Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions extensions/EXTENSION-API-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
41 changes: 36 additions & 5 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}``.
Expand Down Expand Up @@ -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(
Expand Down
49 changes: 49 additions & 0 deletions tests/integrations/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 61 additions & 0 deletions tests/integrations/test_integration_codex.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Loading