diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 2be2f43123..594c3e734a 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -684,6 +684,8 @@ Executing: `/{command}` EXECUTE_COMMAND: {command} ``` +Write the invocation in the generic `/{command}` form shown above. For agents whose native invocation prefix is not `/`, Spec Kit rewrites that prefix when it generates the agent's skill files. That rewriting is a temporary compatibility fix rather than a supported extension API, and it may change or be removed without backward compatibility, so do not introduce new dependencies on it. + --- ## CLI Commands diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 72966998a5..69e2043ace 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -850,6 +850,7 @@ def register_commands( from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415 body = IntegrationBase.resolve_command_refs(body, _sep, _prefix) + body = IntegrationBase.resolve_hook_invocation_prefix(body, _prefix) output_name = self._compute_output_name(agent_name, cmd_name, agent_config) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 6eee0513d6..82ed1dcf22 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -739,6 +739,35 @@ def resolve_command_refs( content, ) + @staticmethod + def resolve_hook_invocation_prefix( + content: str, prefix: str = "/" + ) -> str: + """Resolve the hard-coded ``/`` in hook-block ``{command}`` invocations. + + The command templates and the bundled extension commands spell the + hook output block with a literal slash (``Command: `/{command}```, + ``Executing: `/{command}```, ``To execute: `/{command}```). Agents + whose native skills invocation is not a bare slash -- ``$`` for + Codex, ``/skill:`` for Kimi -- are then told to emit an invocation + they cannot run, contradicting the dot-to-hyphen note injected + alongside it. Only the prefix is resolved; ``{command}`` stays a + placeholder for the agent to fill from ``extensions.yml``. + + Idempotent, because the rewritten text no longer carries the bare + ``/`` form that is matched. + + This is a temporary compatibility shim for agents whose invocation + prefix is not ``/``, not a supported extension API. Rewriting + ``/{command}`` may change or be removed without backward + compatibility, so extension authors should not introduce new + dependencies on this behaviour: keep writing the generic + ``/{command}`` form in hook blocks. + """ + if prefix == "/": + return content + return content.replace("`/{command}`", f"`{prefix}{{command}}`") + @staticmethod def resolve_python_interpreter(project_root: Path | None = None) -> str: """Resolve a portable Python interpreter command for ``{SCRIPT}``. @@ -1774,13 +1803,15 @@ def post_process_skill_content(self, content: str) -> str: Called by external skill generators (presets, extensions) to let the integration inject agent-specific frontmatter or body - transformations. The base implementation injects shared skills - guidance for converting dotted hook command names to the agent-native - hyphenated command invocation (e.g. ``/speckit-git-commit`` or - ``$speckit-git-commit``). Subclasses may override -- see - ``ClaudeIntegration``. + transformations. The base implementation rewrites the hook blocks' + hard-coded ``/{command}`` invocations to the agent's native prefix + and injects shared skills guidance for converting dotted hook + command names to the agent-native hyphenated command invocation + (e.g. ``/speckit-git-commit`` or ``$speckit-git-commit``). + Subclasses may override -- see ``ClaudeIntegration``. """ invocation_prefix = get_invocation_prefix(self.key, True) + content = self.resolve_hook_invocation_prefix(content, invocation_prefix) return self._inject_hook_command_note(content, invocation_prefix) def setup( diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 6383cead59..966aa34913 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -458,6 +458,55 @@ def test_placeholder_with_digits(self): assert result == "/speckit.v2.plan" +class TestResolveHookInvocationPrefix: + """Tests for resolve_hook_invocation_prefix.""" + + HOOK_BLOCK = ( + " Command: `/{command}`\n" + " Prompt: {prompt}\n" + " To execute: `/{command}`\n" + " Executing: `/{command}`\n" + " EXECUTE_COMMAND: {command}\n" + ) + + def test_slash_prefix_leaves_content_untouched(self): + assert ( + IntegrationBase.resolve_hook_invocation_prefix(self.HOOK_BLOCK, "/") + == self.HOOK_BLOCK + ) + + def test_dollar_prefix(self): + result = IntegrationBase.resolve_hook_invocation_prefix( + self.HOOK_BLOCK, "$" + ) + assert "`/{command}`" not in result + assert result.count("`${command}`") == 3 + + def test_skill_colon_prefix(self): + result = IntegrationBase.resolve_hook_invocation_prefix( + self.HOOK_BLOCK, "/skill:" + ) + assert "`/{command}`" not in result + assert result.count("`/skill:{command}`") == 3 + + def test_bare_command_token_is_not_rewritten(self): + """``EXECUTE_COMMAND`` carries the raw hook id, not an invocation.""" + result = IntegrationBase.resolve_hook_invocation_prefix( + self.HOOK_BLOCK, "$" + ) + assert "EXECUTE_COMMAND: {command}" in result + assert "Prompt: {prompt}" in result + + @pytest.mark.parametrize("prefix", ["$", "/skill:"]) + def test_idempotent(self, prefix): + once = IntegrationBase.resolve_hook_invocation_prefix( + self.HOOK_BLOCK, prefix + ) + assert ( + IntegrationBase.resolve_hook_invocation_prefix(once, prefix) == once + ) + + class TestResolvePythonInterpreter: def test_returns_python_on_path(self, monkeypatch): # Positive: when python3 is on PATH it is preferred over python. diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index 9a4dc5442b..0f2526491a 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -1,5 +1,7 @@ """Tests for CodexIntegration.""" +from pathlib import Path + from specify_cli.integrations import get_integration from specify_cli.integrations.manifest import IntegrationManifest @@ -191,3 +193,62 @@ def test_hook_note_when_instruction_is_final_line_without_newline(self): assert "For each executable hook" not in lines[note_line_idx], ( "Note and instruction must not be on the same line" ) + + +class TestCodexHookInvocationPrefix: + """Hook blocks in generated skills must carry Codex's ``$`` prefix. + + The command templates and the bundled extension commands spell the hook + output block with a literal slash, so Codex was told to report + ``To execute: `/speckit-git-commit``` -- an invocation it cannot run, + and the opposite of the dot-to-hyphen note sitting right above it. + """ + + def _hook_invocations(self, skill: Path) -> list[str]: + return [ + line.strip() + for line in skill.read_text(encoding="utf-8").splitlines() + if "`/{command}`" in line or "`${command}`" in line + ] + + def test_core_skill_hook_invocations_use_dollar_prefix(self, tmp_path): + i = get_integration("codex") + i.setup(tmp_path, IntegrationManifest("codex", tmp_path), script_type="sh") + + skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md" + invocations = self._hook_invocations(skill) + assert invocations, "speckit-specify should render hook output blocks" + assert all("`${command}`" in line for line in invocations), invocations + assert "To execute: `${command}`" in invocations + + def test_slash_agent_skill_hook_invocations_keep_slash(self, tmp_path): + """Agents whose native invocation is a bare slash must not change.""" + i = get_integration("claude") + i.setup(tmp_path, IntegrationManifest("claude", tmp_path), script_type="sh") + + skill = next(tmp_path.rglob("speckit-specify/SKILL.md")) + invocations = self._hook_invocations(skill) + assert invocations, "speckit-specify should render hook output blocks" + assert all("`/{command}`" in line for line in invocations), invocations + + def test_extension_skill_hook_invocations_use_dollar_prefix(self, tmp_path): + """Bundled extension commands render through the command registrar.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.extensions import ExtensionManager + + target = tmp_path / "proj" + result = CliRunner().invoke( + app, + ["init", str(target), "--integration", "codex", + "--ignore-agent-tools", "--script", "sh"], + ) + assert result.exit_code == 0, result.output + + ext_dir = Path(__file__).resolve().parents[2] / "extensions" / "bug" + ExtensionManager(target).install_from_directory(ext_dir, "0.9.0") + + skill = target / ".agents/skills/speckit-bug-fix/SKILL.md" + invocations = self._hook_invocations(skill) + assert invocations, "speckit-bug-fix should render hook output blocks" + assert all("`${command}`" in line for line in invocations), invocations