From 64ccaa8dba47336cfea35eb2f4afc3fad78a99fc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:54:48 +0000 Subject: [PATCH 1/2] Fix command-token-hyphen-gap: add verbatim token form for hyphenated command names Apply the remediation from the bug assessment on issue #4198. Extends resolve_command_refs() with a new verbatim token form __SPECKIT_COMMAND(speckit.agent-context.update)__ that carries the command ID verbatim. Dots are replaced by the invoke_separator; hyphens within segments are preserved (and collapse naturally to the separator when separator is '-'). The existing uppercase __SPECKIT_COMMAND_NAME__ form is unchanged. Refs #4198 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/base.py | 33 +++++++++++++++++------ tests/integrations/test_base.py | 39 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 03c7a90e74..4967804a0b 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -620,25 +620,42 @@ def install_scripts( def resolve_command_refs( content: str, separator: str = ".", prefix: str = "/" ) -> str: - """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. + """Replace ``__SPECKIT_COMMAND_*__`` placeholders with invocations. - Each placeholder encodes a command name in upper-case with + Two forms are supported: + + **Uppercase form** — 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`` + **Verbatim form** — carries the canonical command ID verbatim inside + parentheses (e.g. ``__SPECKIT_COMMAND(speckit.agent-context.update)__``). + This form correctly handles command names that contain hyphens, which + the uppercase form cannot represent unambiguously. The canonical dots + are replaced by *separator*; hyphens within a segment are kept as-is + (they become *separator* when *separator* is ``"-"``): + + * ``separator="."`` → ``/speckit.agent-context.update`` + * ``separator="-"`` → ``/speckit-agent-context-update`` + *prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose native skills invocation uses dollar-prefixed chat commands. """ + + def _replace(m: re.Match) -> str: + if m.group(1) is not None: + # Uppercase form: replace underscores with separator. + return prefix + "speckit" + separator + m.group(1).lower().replace("_", separator) + # Verbatim form: replace dots with separator; hyphens are kept. + return prefix + m.group(2).replace(".", separator) + return re.sub( - r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", - lambda m: prefix - + "speckit" - + separator - + m.group(1).lower().replace("_", separator), + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__|__SPECKIT_COMMAND\(([^)]+)\)__", + _replace, content, ) diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 5f99961804..3003909c8f 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -410,6 +410,45 @@ def test_placeholder_with_digits(self): result = IntegrationBase.resolve_command_refs(text, ".") assert result == "/speckit.v2.plan" + # -- Verbatim form tests -------------------------------------------------- + + def test_verbatim_dot_separator_with_hyphen(self): + """Verbatim form correctly handles a hyphen in command name with dot separator.""" + text = "__SPECKIT_COMMAND(speckit.agent-context.update)__" + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "/speckit.agent-context.update" + + def test_verbatim_hyphen_separator_with_hyphen(self): + """Verbatim form correctly handles a hyphen in command name with hyphen separator.""" + text = "__SPECKIT_COMMAND(speckit.agent-context.update)__" + result = IntegrationBase.resolve_command_refs(text, "-") + assert result == "/speckit-agent-context-update" + + def test_verbatim_dollar_prefix(self): + text = "__SPECKIT_COMMAND(speckit.agent-context.update)__" + result = IntegrationBase.resolve_command_refs(text, "-", "$") + assert result == "$speckit-agent-context-update" + + def test_verbatim_simple_command_no_hyphens(self): + """Verbatim form works for commands without hyphens too.""" + text = "__SPECKIT_COMMAND(speckit.git.commit)__" + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "/speckit.git.commit" + + def test_verbatim_and_uppercase_mixed(self): + """Both forms can coexist in the same string.""" + text = "__SPECKIT_COMMAND_PLAN__ and __SPECKIT_COMMAND(speckit.agent-context.update)__" + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "/speckit.plan and /speckit.agent-context.update" + + def test_uppercase_form_unaffected_by_verbatim_support(self): + """Regression: existing uppercase form still works correctly.""" + text = "__SPECKIT_COMMAND_AGENT_CONTEXT_UPDATE__" + result_dot = IntegrationBase.resolve_command_refs(text, ".") + result_hyphen = IntegrationBase.resolve_command_refs(text, "-") + assert result_dot == "/speckit.agent.context.update" + assert result_hyphen == "/speckit-agent-context-update" + class TestResolvePythonInterpreter: def test_returns_python_on_path(self, monkeypatch): From dcb5f365eced1b2cbe02d51f0509de93db9b7bb6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:47:00 -0500 Subject: [PATCH 2/2] fix(extensions): resolve verbatim command-ref tokens in skills rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #4204 added the verbatim `__SPECKIT_COMMAND(...)__` token form to the central `resolve_command_refs()` (used for command-file agents and presets), but the parallel resolver in `_register_extension_skills()` — the sole writer of extension SKILL.md content when the active integration is in skills mode — still matched only the uppercase form. A verbatim token therefore leaked into SKILL.md as a raw literal. Teach that resolver the verbatim form as well, carrying the command id verbatim so hyphenated names (e.g. `speckit.agent-context.update`) survive intact. Adds regression tests exercising `_register_extension_skills` directly for both dollar- and slash-skills agents. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7f0535c-26ca-4f4a-af95-d6b240dfd0e4 --- src/specify_cli/extensions/__init__.py | 18 +++++++- tests/test_extensions.py | 59 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index fb4a30519d..bf6035dbc4 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1580,7 +1580,18 @@ def _resolve_command_ref_tokens(body: str) -> str: """Resolve explicit command-ref tokens with the active skill style.""" def _replacement(match: re.Match[str]) -> str: - command_name = "speckit." + match.group(1).lower().replace("_", ".") + if match.group(1) is not None: + # Uppercase form: reconstruct the id from underscores. This + # cannot represent a hyphen, so a hyphenated segment is + # silently split into extra segments. + command_name = ( + "speckit." + match.group(1).lower().replace("_", ".") + ) + else: + # Verbatim form: the canonical id is carried literally, so + # hyphenated command names (e.g. speckit.agent-context.update) + # survive intact. + command_name = match.group(2) if is_dollar_skills_agent(selected_ai, ai_skills_enabled): return "$" + command_name.replace("speckit.", "speckit-").replace( ".", "-" @@ -1596,7 +1607,10 @@ 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_]*)__" + r"|__SPECKIT_COMMAND\(([^)]+)\)__", + _replacement, + body, ) for cmd_info in manifest.commands: diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6642da2b09..9f217155e1 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3714,6 +3714,65 @@ 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_verbatim_command_ref( + self, extension_dir, project_dir + ): + """Verbatim __SPECKIT_COMMAND(...)__ tokens resolve in skills mode too. + + Regression for the resolver in ``_register_extension_skills`` (the sole + writer of extension SKILL.md content when the active integration is in + skills mode). It previously matched only the uppercase token form, + leaving the verbatim form (used for hyphenated command names) as a raw + literal in SKILL.md. + """ + (project_dir / ".specify" / "init-options.json").write_text( + '{"ai":"codex","ai_skills":true,"script":"sh"}', encoding="utf-8" + ) + (project_dir / ".agents" / "skills").mkdir(parents=True) + command = extension_dir / "commands" / "hello.md" + command.write_text( + "---\ndescription: Test hello command\n---\n\n" + "Run __SPECKIT_COMMAND(speckit.agent-context.update)__.", + encoding="utf-8", + ) + + manager = ExtensionManager(project_dir) + manifest = ExtensionManifest(extension_dir / "extension.yml") + manager._register_extension_skills(manifest, extension_dir) + + skill_file = ( + project_dir / ".agents" / "skills" / "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_verbatim_command_ref( + self, extension_dir, project_dir + ): + """Verbatim tokens with hyphens resolve for slash-skills agents.""" + (project_dir / ".specify" / "init-options.json").write_text( + '{"ai":"claude","ai_skills":true,"script":"sh"}', encoding="utf-8" + ) + (project_dir / ".claude" / "skills").mkdir(parents=True) + command = extension_dir / "commands" / "hello.md" + command.write_text( + "---\ndescription: Test hello command\n---\n\n" + "Run __SPECKIT_COMMAND(speckit.agent-context.update)__.", + encoding="utf-8", + ) + + manager = ExtensionManager(project_dir) + manifest = ExtensionManifest(extension_dir / "extension.yml") + manager._register_extension_skills(manifest, extension_dir) + + skill_file = ( + project_dir / ".claude" / "skills" / "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