fix(integrations): use the agent's invocation prefix in hook blocks by shreyanshj10 · Pull Request #4958 · github/spec-kit · GitHub
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
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
Loading