From 70d165e7ddea504690cf8178f79031f4c9058191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Thu, 2 Jul 2026 19:52:49 +0200 Subject: [PATCH] feat(integrations): add post_process_command_content() hook for all format types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add post_process_command_content(self, content: str) -> str to IntegrationBase with a no-op default. Wire it into register_commands() for non-skills format types (Markdown, TOML, YAML) after format rendering, before writing to disk. Also applies to aliases rendered via the inject_name path (cline, forge). Skills-format agents are excluded to preserve the existing post_process_skill_content() path and avoid double-processing. This gives extension authors a clean per-agent content transformation seam for all 21 non-skills integrations that previously had no post-processing hook. Ref: #3303 Assisted-By: 🤖 Claude Code --- src/specify_cli/agents.py | 13 ++ src/specify_cli/integrations/base.py | 13 ++ tests/test_post_process.py | 245 +++++++++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 tests/test_post_process.py diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 7864260a99..14ce371737 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -679,6 +679,16 @@ def register_commands( else: raise ValueError(f"Unsupported format: {agent_config['format']}") + # -- Post-process for non-skills agents ----------------------- + if agent_config["extension"] != "/SKILL.md": + from specify_cli.integrations import ( # noqa: PLC0415 + get_integration, + ) + + _integration = get_integration(agent_name) + if _integration is not None: + output = _integration.post_process_command_content(output) + dest_file = commands_dir / f"{output_name}{agent_config['extension']}" self._ensure_inside(dest_file, commands_dir) dest_file.parent.mkdir(parents=True, exist_ok=True) @@ -738,6 +748,9 @@ def register_commands( raise ValueError( f"Unsupported format: {agent_config['format']}" ) + + if agent_config["extension"] != "/SKILL.md" and _integration is not None: + alias_output = _integration.post_process_command_content(alias_output) else: # For other agents, reuse the primary output alias_output = output diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index d5ebce78e2..c5724d1a7d 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -123,6 +123,19 @@ class IntegrationBase(ABC): integration that sets this flag. """ + def post_process_command_content(self, content: str) -> str: + """Transform command content after format rendering. + + Called by ``register_commands()`` for non-skills format types + (Markdown, TOML, YAML) after the command has been rendered into + its target format and before writing to disk. Skills-format + agents use ``post_process_skill_content()`` instead. + + Subclasses may override to inject agent-specific content. + The default implementation returns *content* unchanged. + """ + return content + # -- Public API ------------------------------------------------------- @classmethod diff --git a/tests/test_post_process.py b/tests/test_post_process.py new file mode 100644 index 0000000000..34e947e9fb --- /dev/null +++ b/tests/test_post_process.py @@ -0,0 +1,245 @@ +"""Tests for post_process_command_content() hook on IntegrationBase. + +Verifies that the generalized post-processing hook: +- Runs for non-skills format types (Markdown, TOML, YAML) +- Does NOT run for skills-format agents +- Default no-op returns content unchanged +- Exceptions propagate to caller +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from specify_cli.agents import CommandRegistrar +from specify_cli.integrations.base import IntegrationBase + + +@pytest.fixture +def registrar(): + return CommandRegistrar() + + +@pytest.fixture +def ext_dir(tmp_path): + """Create a mock extension with a simple command template.""" + ext = tmp_path / "extension" + ext.mkdir() + cmd_dir = ext / "commands" + cmd_dir.mkdir() + return ext, cmd_dir + + +def _write_cmd(cmd_dir, name="review.md", body="Review the code.\n"): + cmd_file = cmd_dir / name + cmd_file.write_text( + f"---\ndescription: Test command\n---\n\n{body}", + encoding="utf-8", + ) + return cmd_file + + +class TestDefaultNoOp: + def test_returns_content_unchanged(self): + base = IntegrationBase() + content = "Some command content\nwith multiple lines." + assert base.post_process_command_content(content) == content + + def test_empty_string(self): + base = IntegrationBase() + assert base.post_process_command_content("") == "" + + +class TestMarkdownAgentPostProcess: + def test_opencode_post_process_applied( + self, tmp_path, registrar, ext_dir, monkeypatch + ): + ext, cmd_dir = ext_dir + _write_cmd(cmd_dir) + + from specify_cli.integrations import get_integration + + opencode = get_integration("opencode") + marker = "" + + def _inject_marker(self, content): + return content + marker + + monkeypatch.setattr( + opencode.__class__, "post_process_command_content", _inject_marker + ) + + commands = [{"name": "speckit.test.review", "file": "commands/review.md"}] + registrar.register_commands( + "opencode", commands, "test-ext", ext, tmp_path + ) + + cmd_output = tmp_path / ".opencode" / "commands" / "speckit.test.review.md" + assert cmd_output.exists() + content = cmd_output.read_text(encoding="utf-8") + assert marker in content + + +class TestTomlAgentPostProcess: + def test_gemini_post_process_applied( + self, tmp_path, registrar, ext_dir, monkeypatch + ): + ext, cmd_dir = ext_dir + _write_cmd(cmd_dir) + + from specify_cli.integrations import get_integration + + gemini = get_integration("gemini") + marker = "# POST_PROCESSED" + + def _inject_marker(self, content): + return content + f"\n{marker}\n" + + monkeypatch.setattr( + gemini.__class__, "post_process_command_content", _inject_marker + ) + + commands = [{"name": "speckit.test.review", "file": "commands/review.md"}] + registrar.register_commands( + "gemini", commands, "test-ext", ext, tmp_path + ) + + cmd_output = tmp_path / ".gemini" / "commands" / "speckit.test.review.toml" + assert cmd_output.exists() + content = cmd_output.read_text(encoding="utf-8") + assert marker in content + + +class TestYamlAgentPostProcess: + def test_goose_post_process_applied( + self, tmp_path, registrar, ext_dir, monkeypatch + ): + ext, cmd_dir = ext_dir + _write_cmd(cmd_dir) + + from specify_cli.integrations import get_integration + + goose = get_integration("goose") + marker = "# POST_PROCESSED" + + def _inject_marker(self, content): + return content + f"\n{marker}\n" + + monkeypatch.setattr( + goose.__class__, "post_process_command_content", _inject_marker + ) + + commands = [{"name": "speckit.test.review", "file": "commands/review.md"}] + registrar.register_commands( + "goose", commands, "test-ext", ext, tmp_path + ) + + cmd_output = tmp_path / ".goose" / "recipes" / "speckit.test.review.yaml" + assert cmd_output.exists() + content = cmd_output.read_text(encoding="utf-8") + assert marker in content + + +class TestSkillsAgentExcluded: + def test_claude_post_process_not_called( + self, tmp_path, registrar, ext_dir, monkeypatch + ): + ext, cmd_dir = ext_dir + _write_cmd(cmd_dir) + + from specify_cli.integrations import get_integration + + claude = get_integration("claude") + marker = "" + + def _inject_marker(self, content): + return content + marker + + monkeypatch.setattr( + claude.__class__, "post_process_command_content", _inject_marker + ) + + commands = [{"name": "speckit.test.review", "file": "commands/review.md"}] + registrar.register_commands( + "claude", commands, "test-ext", ext, tmp_path + ) + + skill_file = ( + tmp_path / ".claude" / "skills" / "speckit-test-review" / "SKILL.md" + ) + assert skill_file.exists() + content = skill_file.read_text(encoding="utf-8") + assert marker not in content + + def test_skills_agent_method_never_called( + self, tmp_path, registrar, ext_dir + ): + ext, cmd_dir = ext_dir + _write_cmd(cmd_dir) + + from specify_cli.integrations import get_integration + + claude = get_integration("claude") + commands = [{"name": "speckit.test.review", "file": "commands/review.md"}] + + with patch.object( + claude.__class__, "post_process_command_content", wraps=claude.post_process_command_content + ) as mock_method: + registrar.register_commands( + "claude", commands, "test-ext", ext, tmp_path + ) + mock_method.assert_not_called() + + +class TestExceptionPropagation: + def test_hook_exception_propagates( + self, tmp_path, registrar, ext_dir, monkeypatch + ): + ext, cmd_dir = ext_dir + _write_cmd(cmd_dir) + + from specify_cli.integrations import get_integration + + opencode = get_integration("opencode") + + def _raise(self, content): + raise RuntimeError("Hook failed") + + monkeypatch.setattr( + opencode.__class__, "post_process_command_content", _raise + ) + + commands = [{"name": "speckit.test.review", "file": "commands/review.md"}] + with pytest.raises(RuntimeError, match="Hook failed"): + registrar.register_commands( + "opencode", commands, "test-ext", ext, tmp_path + ) + + +class TestRegressionPlainTemplate: + @pytest.mark.parametrize( + "agent,path_pattern", + [ + ("claude", ".claude/skills/speckit-test-plain/SKILL.md"), + ("opencode", ".opencode/commands/speckit.test.plain.md"), + ], + ids=["skills", "markdown"], + ) + def test_plain_template_unchanged( + self, tmp_path, registrar, ext_dir, agent, path_pattern + ): + ext, cmd_dir = ext_dir + body_text = "This is a plain command with no special content.\n" + _write_cmd(cmd_dir, name="plain.md", body=body_text) + + commands = [{"name": "speckit.test.plain", "file": "commands/plain.md"}] + registrar.register_commands( + agent, commands, "test-ext", ext, tmp_path + ) + + output_file = tmp_path / path_pattern + assert output_file.exists(), f"Output file missing for {agent}" + content = output_file.read_text(encoding="utf-8") + assert body_text.strip() in content, f"Body text missing in {agent} output"