From d37d1c36d45340692505973928a22c03486d4ce5 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:02:38 +0900 Subject: [PATCH 1/2] feat(integrations): add process_registered_command hook with source_id post_process_command_content() only sees rendered text, so an integration cannot tell a core command from an extension or preset command, and any rewrite that depends on the source has to live as an agent-name branch in CommandRegistrar.register_commands(). Add IntegrationBase .process_registered_command(frontmatter, body, *, source_id), called for every registered command after name injection and before placeholder resolution and rendering. The default returns both values unchanged, so no output changes. Assisted-by: Grok Bot (model: unknown, autonomous) Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com> --- src/specify_cli/agents.py | 6 ++ src/specify_cli/integrations/base.py | 20 +++++ tests/test_registered_command_hook.py | 123 ++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 tests/test_registered_command_hook.py 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..eb0b4682b6 --- /dev/null +++ b/tests/test_registered_command_hook.py @@ -0,0 +1,123 @@ +"""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" + # The hook runs before $ARGUMENTS is converted for the agent. + 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_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") From 9c0441386cda88cd8ca06261a5d2927bc5d59d8b Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:10:07 +0900 Subject: [PATCH 2/2] test(integrations): assert hook runs before placeholder resolution OpenCode keeps $ARGUMENTS unchanged and the old fixture had no __SPECKIT_COMMAND_*__ token, so the existing test still passed if process_registered_command() ran after argument conversion or command reference resolution. Add a Gemini case whose source contains both placeholders: the hook must receive them unresolved, and tokens the hook adds must come out resolved ({{args}}, /speckit.tasks) in the written TOML command. Assisted-by: Grok Bot (model: unknown, autonomous) Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com> --- tests/test_registered_command_hook.py | 42 ++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/test_registered_command_hook.py b/tests/test_registered_command_hook.py index eb0b4682b6..d8cda0fe83 100644 --- a/tests/test_registered_command_hook.py +++ b/tests/test_registered_command_hook.py @@ -69,7 +69,8 @@ def hook(self, frontmatter, body, *, source_id): frontmatter, body, source_id = calls[0] assert source_id == "test-ext" assert frontmatter["description"] == "Review the code" - # The hook runs before $ARGUMENTS is converted for the agent. + # 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" @@ -80,6 +81,45 @@ def hook(self, frontmatter, body, *, source_id): 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 = []