From d4a0a4c822469b41a7d0524ec823a958f4747b74 Mon Sep 17 00:00:00 2001 From: minzzang144 Date: Sat, 22 Aug 2026 17:15:14 +0900 Subject: [PATCH 1/3] fix: allow literal hyphens in __SPECKIT_COMMAND___ tokens Widens the uppercase command-ref token's character class from [A-Z0-9_] to [A-Z0-9_-] in both resolve_command_refs() and the extension-skills resolver, so a hyphenated command name (e.g. speckit.agent-context.update) round-trips without a second token grammar. Decode logic is unchanged since replace("_", separator) already leaves literal hyphens untouched. Implements the "Option 2" direction agreed with @mnriem in the review discussion on #4204, as an alternative to that PR's verbatim __SPECKIT_COMMAND(...)__ form. Fixes #4198 This change was implemented with AI assistance (Claude Code); I reviewed the diff and ran the full test suite myself before opening this PR. --- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 22 +++++------ src/specify_cli/extensions/__init__.py | 2 +- src/specify_cli/integrations/base.py | 12 +++++- tests/integrations/test_base.py | 25 ++++++++++++ tests/test_extensions.py | 48 +++++++++++++++++++++++ 5 files changed, 95 insertions(+), 14 deletions(-) diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index ac78029f2a..9d4c0fe427 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -296,15 +296,16 @@ A command body is a *template* that Spec Kit renders once per agent. Different a Instead use the agent-neutral token `__SPECKIT_COMMAND___`. Spec Kit resolves it to a `/speckit...` invocation using the active integration's `invoke_separator` (and integrations may post-process that further in skills output). -Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore: +Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore. A hyphen within a segment is written verbatim — command names are constrained to `[a-z0-9-]+` per segment and never contain an underscore, so there's no ambiguity between a dot and a hyphen: | Command file | Token | | --- | --- | | `speckit.plan.md` | `__SPECKIT_COMMAND_PLAN__` | | `speckit.bug.fix.md` | `__SPECKIT_COMMAND_BUG_FIX__` | | `speckit.git.commit.md` | `__SPECKIT_COMMAND_GIT_COMMIT__` | +| `speckit.agent-context.update.md` | `__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__` | -The resolver maps each underscore back to the active agent's separator, so use tokens to reference commands whose name segments are single words. (Command names are dotted segments like `git.commit`; the token scheme rebuilds those dots and does not carry hyphens within a segment.) +The resolver maps each underscore back to the active agent's separator and leaves hyphens untouched, so both single-word and hyphenated segments round-trip correctly. **Example** — a command body that points the user at the next step: @@ -314,15 +315,14 @@ Once the assessment exists, the next step is `__SPECKIT_COMMAND_BUG_FIX__ slug=< This renders as `/speckit.bug.fix slug=` for a slash-based agent, `/speckit-bug-fix slug=` for a skills-based agent, and so on — the author writes it once and it stays portable. The first-party `bug` and `git` extensions use this token exclusively; see `extensions/bug/commands/` for working examples. -> **Current limitation — skills mode.** Token resolution runs in the -> command-rendering path (`CommandRegistrar`), so it applies when an extension -> installs *command files*. It does **not** yet run when an extension is -> registered as *skills* for a skills-based agent: `_register_extension_skills` -> resolves placeholders and post-processes content but never calls -> `resolve_command_refs`, so a `__SPECKIT_COMMAND___` token reaches -> agents such as Codex, ZCode, and Kimi verbatim in that mode. Until that -> rendering step lands, prefer the token for command-file extensions and avoid -> relying on it inside skill bodies destined for skills-based agents. +> **Note — skills mode.** When an extension is registered as *skills* for a +> skills-based agent, tokens are resolved by a separate resolver in +> `ExtensionManager._register_extension_skills`, not by the shared +> `resolve_command_refs()` used for command-file extensions. Both resolvers +> share the same token grammar, so `__SPECKIT_COMMAND___` (including +> hyphenated segments) resolves correctly in either mode — but a fix to one +> resolver does not automatically apply to the other, so keep both in sync +> when touching this token's grammar or resolution logic. ### Script Path Rewriting diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3968e4fcbe..a440b6da9b 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1596,7 +1596,7 @@ def _replacement(match: re.Match[str]) -> str: ) return re.sub( - r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_-]*)__", _replacement, body ) for cmd_info in manifest.commands: diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 27c43582b0..9b4fc3be04 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -630,17 +630,25 @@ def resolve_command_refs( Each placeholder encodes a command name in upper-case with underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``, - ``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses + ``__SPECKIT_COMMAND_GIT_COMMIT__``). Each underscore is replaced by *separator* to join the segments: * ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit`` * ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit`` + A segment may itself contain a literal hyphen (command names are + constrained to ``[a-z0-9-]+`` per segment and never contain an + underscore, see ``EXTENSION_COMMAND_NAME_PATTERN``), so hyphens are + written verbatim in the placeholder and pass through unchanged: + + * ``__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__`` with + ``separator="."`` → ``/speckit.agent-context.update`` + *prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose native skills invocation uses dollar-prefixed chat commands. """ return re.sub( - r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_-]*)__", lambda m: prefix + "speckit" + separator diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 5f99961804..b2ca139501 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -410,6 +410,31 @@ def test_placeholder_with_digits(self): result = IntegrationBase.resolve_command_refs(text, ".") assert result == "/speckit.v2.plan" + # -- Hyphenated segment tests --------------------------------------------- + + def test_hyphen_in_segment_dot_separator(self): + """A literal hyphen in a segment survives with the dot separator.""" + text = "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__" + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "/speckit.agent-context.update" + + def test_hyphen_in_segment_hyphen_separator(self): + """A literal hyphen in a segment survives with the hyphen separator.""" + text = "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__" + result = IntegrationBase.resolve_command_refs(text, "-") + assert result == "/speckit-agent-context-update" + + def test_hyphen_in_segment_dollar_prefix(self): + text = "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__" + result = IntegrationBase.resolve_command_refs(text, "-", "$") + assert result == "$speckit-agent-context-update" + + def test_hyphen_in_multiple_segments(self): + """Every segment may independently contain a hyphen.""" + text = "__SPECKIT_COMMAND_CODE-REVIEW_REQUEST-CHANGES__" + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "/speckit.code-review.request-changes" + class TestResolvePythonInterpreter: def test_returns_python_on_path(self, monkeypatch): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6642da2b09..f5c042e3c0 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3714,6 +3714,54 @@ def test_codex_skill_registration_uses_dollar_command_refs( assert "$speckit-plan" in content assert "/speckit-plan" not in content + def test_codex_skill_registration_resolves_hyphenated_command_ref( + self, extension_dir, project_dir + ): + """A hyphenated segment in a command-ref token resolves for dollar-skills agents.""" + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + command = extension_dir / "commands" / "hello.md" + command.write_text( + "---\ndescription: Test hello command\n---\n\n" + "Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__.", + encoding="utf-8", + ) + + manifest = ExtensionManifest(extension_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent( + "codex", manifest, extension_dir, project_dir + ) + + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" + content = skill_file.read_text(encoding="utf-8") + assert "$speckit-agent-context-update" in content + assert "__SPECKIT_COMMAND_" not in content + + def test_claude_skill_registration_resolves_hyphenated_command_ref( + self, extension_dir, project_dir + ): + """A hyphenated segment in a command-ref token resolves for slash-skills agents.""" + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True) + command = extension_dir / "commands" / "hello.md" + command.write_text( + "---\ndescription: Test hello command\n---\n\n" + "Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__.", + encoding="utf-8", + ) + + manifest = ExtensionManifest(extension_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent( + "claude", manifest, extension_dir, project_dir + ) + + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" + content = skill_file.read_text(encoding="utf-8") + assert "/speckit-agent-context-update" in content + assert "__SPECKIT_COMMAND_" not in content + def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir): """Codex SKILL.md overrides should resolve script placeholders.""" import yaml From 919714d689ff1ac5742151bda362607673aa2857 Mon Sep 17 00:00:00 2001 From: minzzang144 Date: Wed, 26 Aug 2026 10:37:26 +0900 Subject: [PATCH 2/3] test: cover multi-segment and multi-hyphen command-ref tokens Adds cases for a hyphen adjacent to a digit, multiple hyphens within one segment, and more than two dotted segments, confirming the widened character class has no arbitrary limit on segment count. --- tests/integrations/test_base.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index b2ca139501..5a8e81211b 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -435,6 +435,24 @@ def test_hyphen_in_multiple_segments(self): result = IntegrationBase.resolve_command_refs(text, ".") assert result == "/speckit.code-review.request-changes" + def test_hyphen_adjacent_to_digit(self): + """A hyphen directly next to a digit (e.g. a version suffix) round-trips.""" + text = "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE-V2__" + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "/speckit.agent-context.update-v2" + + def test_multiple_hyphens_within_one_segment(self): + """A segment with more than one hyphen (a multi-word compound) round-trips.""" + text = "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE-WITH-EXAMPLE__" + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "/speckit.agent-context.update-with-example" + + def test_no_limit_on_segment_count(self): + """The token isn't limited to two dotted segments; any number works.""" + text = "__SPECKIT_COMMAND_A_B-C_D-E-F__" + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "/speckit.a.b-c.d-e-f" + class TestResolvePythonInterpreter: def test_returns_python_on_path(self, monkeypatch): From 36dfdb72bc970388c87622e28e924ef4515008b3 Mon Sep 17 00:00:00 2001 From: minzzang144 Date: Wed, 26 Aug 2026 10:49:01 +0900 Subject: [PATCH 3/3] test: use clearer synthetic values for hyphen edge cases STEP-2/RUN and MULTI-WORD-SEGMENT read unambiguously as synthetic test data, instead of awkwardly extending the real agent-context extension's command name with made-up suffixes. --- tests/integrations/test_base.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 5a8e81211b..fffdfec545 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -436,16 +436,16 @@ def test_hyphen_in_multiple_segments(self): assert result == "/speckit.code-review.request-changes" def test_hyphen_adjacent_to_digit(self): - """A hyphen directly next to a digit (e.g. a version suffix) round-trips.""" - text = "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE-V2__" + """A hyphen directly next to a digit round-trips.""" + text = "__SPECKIT_COMMAND_STEP-2_RUN__" result = IntegrationBase.resolve_command_refs(text, ".") - assert result == "/speckit.agent-context.update-v2" + assert result == "/speckit.step-2.run" def test_multiple_hyphens_within_one_segment(self): - """A segment with more than one hyphen (a multi-word compound) round-trips.""" - text = "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE-WITH-EXAMPLE__" + """A segment with more than one hyphen round-trips.""" + text = "__SPECKIT_COMMAND_MULTI-WORD-SEGMENT__" result = IntegrationBase.resolve_command_refs(text, ".") - assert result == "/speckit.agent-context.update-with-example" + assert result == "/speckit.multi-word-segment" def test_no_limit_on_segment_count(self): """The token isn't limited to two dotted segments; any number works."""