diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 72966998a5..8a6d3ac8b0 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -738,6 +738,7 @@ def register_commands( # ``.bob/commands``. _sep = agent_config.get("invoke_separator", ".") registrar_writes_skills = agent_config.get("extension") == "/SKILL.md" + _integ = None try: from specify_cli.integrations import get_integration # noqa: PLC0415 @@ -832,6 +833,11 @@ def register_commands( frontmatter = self._hyphenate_frontmatter_refs(frontmatter) body = self._hyphenate_body_refs(body) + if _integ is not None: + frontmatter, body = _integ.process_registered_command( + frontmatter, body, source_id=source_id + ) + body = self._convert_argument_placeholder( body, "$ARGUMENTS", agent_config["args"] ) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index f789b183c8..09069ffffc 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -170,6 +170,26 @@ def post_process_command_content(self, content: str) -> str: """ return content + def process_registered_command( + self, frontmatter: dict[str, Any], body: str, *, source_id: str + ) -> tuple[dict[str, Any], str]: + """Transform a command's frontmatter and body before it is rendered. + + Called by ``CommandRegistrar.register_commands()`` for every command it + registers for this integration, after script paths are adjusted and the + ``name`` field is injected, and before ``$ARGUMENTS`` and + ``__SPECKIT_COMMAND_*__`` tokens are resolved and the command is + rendered. Unlike ``post_process_command_content()``, which only sees + the rendered text, this hook receives the parsed frontmatter and the + *source_id* (``"core"`` or an extension/preset id), so an integration + can apply rewrites that depend on where a command came from. + + Subclasses may override it; they should return new objects rather + than mutating *frontmatter* in place. The default implementation + returns both values unchanged. + """ + return frontmatter, body + # -- Public API ------------------------------------------------------- @classmethod diff --git a/tests/test_registered_command_hook.py b/tests/test_registered_command_hook.py new file mode 100644 index 0000000000..d8cda0fe83 --- /dev/null +++ b/tests/test_registered_command_hook.py @@ -0,0 +1,163 @@ +"""Tests for the process_registered_command() hook on IntegrationBase. + +The hook lets an integration rewrite a command's parsed frontmatter and body +during ``CommandRegistrar.register_commands()``, with the command's +``source_id`` available, before placeholders are resolved and the command is +rendered. +""" + +from __future__ import annotations + +import pytest + +from specify_cli.agents import CommandRegistrar +from specify_cli.integrations import get_integration +from specify_cli.integrations.base import IntegrationBase + + +@pytest.fixture +def ext_dir(tmp_path): + ext = tmp_path / "extension" + (ext / "commands").mkdir(parents=True) + (ext / "commands" / "review.md").write_text( + "---\ndescription: Review the code\n---\n\nReview $ARGUMENTS now.\n", + encoding="utf-8", + ) + return ext + + +COMMANDS = [{"name": "speckit.test.review", "file": "commands/review.md"}] + + +class TestDefault: + def test_returns_inputs_unchanged(self): + frontmatter = {"description": "x", "tags": ["a"]} + body = "Body text.\n" + result = IntegrationBase().process_registered_command( + frontmatter, body, source_id="test-ext" + ) + assert result == (frontmatter, body) + + def test_default_registration_output_is_unchanged(self, tmp_path, ext_dir): + CommandRegistrar().register_commands( + "opencode", COMMANDS, "test-ext", ext_dir, tmp_path + ) + out = tmp_path / ".opencode" / "commands" / "speckit.test.review.md" + text = out.read_text(encoding="utf-8") + assert "description: Review the code" in text + assert "Review $ARGUMENTS now." in text + + +class TestRegistrationUsesHook: + def test_hook_output_is_rendered(self, tmp_path, ext_dir, monkeypatch): + calls = [] + + def hook(self, frontmatter, body, *, source_id): + calls.append((dict(frontmatter), body, source_id)) + updated = dict(frontmatter) + updated["description"] = f"[{source_id}] {frontmatter['description']}" + return updated, body.replace("Review", "Inspect") + + monkeypatch.setattr( + get_integration("opencode").__class__, "process_registered_command", hook + ) + CommandRegistrar().register_commands( + "opencode", COMMANDS, "test-ext", ext_dir, tmp_path + ) + + assert len(calls) == 1 + frontmatter, body, source_id = calls[0] + assert source_id == "test-ext" + assert frontmatter["description"] == "Review the code" + # OpenCode keeps $ARGUMENTS unchanged, so ordering against placeholder + # conversion is asserted by the Gemini case below. + assert "Review $ARGUMENTS now." in body + + out = tmp_path / ".opencode" / "commands" / "speckit.test.review.md" + text = out.read_text(encoding="utf-8") + assert "description: '[test-ext] Review the code'" in text or ( + 'description: "[test-ext] Review the code"' in text + ) + assert "Inspect" in text + assert "Review $ARGUMENTS" not in text + + def test_hook_runs_before_placeholder_resolution(self, tmp_path, monkeypatch): + """Gemini rewrites ``$ARGUMENTS`` to ``{{args}}`` and resolves + ``__SPECKIT_COMMAND_*__`` tokens, so this case fails if the hook runs + after either conversion.""" + ext = tmp_path / "extension" + (ext / "commands").mkdir(parents=True) + (ext / "commands" / "review.md").write_text( + "---\ndescription: Review the code\n---\n\n" + "Review $ARGUMENTS, then run __SPECKIT_COMMAND_PLAN__.\n", + encoding="utf-8", + ) + calls = [] + + def hook(self, frontmatter, body, *, source_id): + calls.append(body) + return frontmatter, body + "Next: __SPECKIT_COMMAND_TASKS__ $ARGUMENTS\n" + + monkeypatch.setattr( + get_integration("gemini").__class__, "process_registered_command", hook + ) + project = tmp_path / "project" + project.mkdir() + CommandRegistrar().register_commands( + "gemini", COMMANDS, "test-ext", ext, project + ) + + assert len(calls) == 1 + assert "$ARGUMENTS" in calls[0] + assert "__SPECKIT_COMMAND_PLAN__" in calls[0] + assert "{{args}}" not in calls[0] + assert "/speckit.plan" not in calls[0] + + out = project / ".gemini" / "commands" / "speckit.test.review.toml" + text = out.read_text(encoding="utf-8") + assert "Review {{args}}, then run /speckit.plan." in text + assert "Next: /speckit.tasks {{args}}" in text + assert "$ARGUMENTS" not in text + assert "__SPECKIT_COMMAND_" not in text + + def test_hook_sees_core_source_id(self, tmp_path, ext_dir, monkeypatch): + seen = [] + + def hook(self, frontmatter, body, *, source_id): + seen.append(source_id) + return frontmatter, body + + monkeypatch.setattr( + get_integration("opencode").__class__, "process_registered_command", hook + ) + CommandRegistrar().register_commands( + "opencode", COMMANDS, "core", ext_dir, tmp_path + ) + assert seen == ["core"] + + def test_hook_applies_to_skills_layout(self, tmp_path, ext_dir, monkeypatch): + def hook(self, frontmatter, body, *, source_id): + return frontmatter, body + "\nHOOKED\n" + + integ = get_integration("claude") + monkeypatch.setattr(integ.__class__, "process_registered_command", hook) + CommandRegistrar().register_commands( + "claude", COMMANDS, "test-ext", ext_dir, tmp_path + ) + skill_files = list((tmp_path / ".claude").rglob("SKILL.md")) + assert skill_files, "expected a skill file to be written" + assert "HOOKED" in skill_files[0].read_text(encoding="utf-8") + + def test_hook_output_reaches_aliases(self, tmp_path, ext_dir, monkeypatch): + def hook(self, frontmatter, body, *, source_id): + return frontmatter, body.replace("Review", "Inspect") + + monkeypatch.setattr( + get_integration("opencode").__class__, "process_registered_command", hook + ) + commands = [dict(COMMANDS[0], aliases=["speckit.test.rv"])] + CommandRegistrar().register_commands( + "opencode", commands, "test-ext", ext_dir, tmp_path + ) + alias = tmp_path / ".opencode" / "commands" / "speckit.test.rv.md" + assert "Inspect" in alias.read_text(encoding="utf-8")