Skip to content

Commit d4e7d2b

Browse files
authored
feat(integrations): generalize post-processing to all format types (#3311)
* feat(integrations): add post_process_command_content() hook for all format types 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 * fix: initialize _integration before conditional branch Prevents potential UnboundLocalError if the non-skills guard is refactored without updating the alias path reference. Assisted-By: 🤖 Claude Code
1 parent abaed10 commit d4e7d2b

3 files changed

Lines changed: 272 additions & 0 deletions

File tree

src/specify_cli/agents.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,17 @@ def register_commands(
706706
else:
707707
raise ValueError(f"Unsupported format: {agent_config['format']}")
708708

709+
# -- Post-process for non-skills agents -----------------------
710+
_integration = None
711+
if agent_config["extension"] != "/SKILL.md":
712+
from specify_cli.integrations import ( # noqa: PLC0415
713+
get_integration,
714+
)
715+
716+
_integration = get_integration(agent_name)
717+
if _integration is not None:
718+
output = _integration.post_process_command_content(output)
719+
709720
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
710721
self._ensure_inside(dest_file, commands_dir)
711722
dest_file.parent.mkdir(parents=True, exist_ok=True)
@@ -766,6 +777,9 @@ def register_commands(
766777
raise ValueError(
767778
f"Unsupported format: {agent_config['format']}"
768779
)
780+
781+
if agent_config["extension"] != "/SKILL.md" and _integration is not None:
782+
alias_output = _integration.post_process_command_content(alias_output)
769783
else:
770784
# For other agents, reuse the primary output
771785
alias_output = output

src/specify_cli/integrations/base.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ class IntegrationBase(ABC):
123123
integration that sets this flag.
124124
"""
125125

126+
def post_process_command_content(self, content: str) -> str:
127+
"""Transform command content after format rendering.
128+
129+
Called by ``register_commands()`` for non-skills format types
130+
(Markdown, TOML, YAML) after the command has been rendered into
131+
its target format and before writing to disk. Skills-format
132+
agents use ``post_process_skill_content()`` instead.
133+
134+
Subclasses may override to inject agent-specific content.
135+
The default implementation returns *content* unchanged.
136+
"""
137+
return content
138+
126139
# -- Public API -------------------------------------------------------
127140

128141
@classmethod

tests/test_post_process.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""Tests for post_process_command_content() hook on IntegrationBase.
2+
3+
Verifies that the generalized post-processing hook:
4+
- Runs for non-skills format types (Markdown, TOML, YAML)
5+
- Does NOT run for skills-format agents
6+
- Default no-op returns content unchanged
7+
- Exceptions propagate to caller
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from unittest.mock import patch
13+
14+
import pytest
15+
16+
from specify_cli.agents import CommandRegistrar
17+
from specify_cli.integrations.base import IntegrationBase
18+
19+
20+
@pytest.fixture
21+
def registrar():
22+
return CommandRegistrar()
23+
24+
25+
@pytest.fixture
26+
def ext_dir(tmp_path):
27+
"""Create a mock extension with a simple command template."""
28+
ext = tmp_path / "extension"
29+
ext.mkdir()
30+
cmd_dir = ext / "commands"
31+
cmd_dir.mkdir()
32+
return ext, cmd_dir
33+
34+
35+
def _write_cmd(cmd_dir, name="review.md", body="Review the code.\n"):
36+
cmd_file = cmd_dir / name
37+
cmd_file.write_text(
38+
f"---\ndescription: Test command\n---\n\n{body}",
39+
encoding="utf-8",
40+
)
41+
return cmd_file
42+
43+
44+
class TestDefaultNoOp:
45+
def test_returns_content_unchanged(self):
46+
base = IntegrationBase()
47+
content = "Some command content\nwith multiple lines."
48+
assert base.post_process_command_content(content) == content
49+
50+
def test_empty_string(self):
51+
base = IntegrationBase()
52+
assert base.post_process_command_content("") == ""
53+
54+
55+
class TestMarkdownAgentPostProcess:
56+
def test_opencode_post_process_applied(
57+
self, tmp_path, registrar, ext_dir, monkeypatch
58+
):
59+
ext, cmd_dir = ext_dir
60+
_write_cmd(cmd_dir)
61+
62+
from specify_cli.integrations import get_integration
63+
64+
opencode = get_integration("opencode")
65+
marker = "<!-- POST_PROCESSED -->"
66+
67+
def _inject_marker(self, content):
68+
return content + marker
69+
70+
monkeypatch.setattr(
71+
opencode.__class__, "post_process_command_content", _inject_marker
72+
)
73+
74+
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
75+
registrar.register_commands(
76+
"opencode", commands, "test-ext", ext, tmp_path
77+
)
78+
79+
cmd_output = tmp_path / ".opencode" / "commands" / "speckit.test.review.md"
80+
assert cmd_output.exists()
81+
content = cmd_output.read_text(encoding="utf-8")
82+
assert marker in content
83+
84+
85+
class TestTomlAgentPostProcess:
86+
def test_gemini_post_process_applied(
87+
self, tmp_path, registrar, ext_dir, monkeypatch
88+
):
89+
ext, cmd_dir = ext_dir
90+
_write_cmd(cmd_dir)
91+
92+
from specify_cli.integrations import get_integration
93+
94+
gemini = get_integration("gemini")
95+
marker = "# POST_PROCESSED"
96+
97+
def _inject_marker(self, content):
98+
return content + f"\n{marker}\n"
99+
100+
monkeypatch.setattr(
101+
gemini.__class__, "post_process_command_content", _inject_marker
102+
)
103+
104+
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
105+
registrar.register_commands(
106+
"gemini", commands, "test-ext", ext, tmp_path
107+
)
108+
109+
cmd_output = tmp_path / ".gemini" / "commands" / "speckit.test.review.toml"
110+
assert cmd_output.exists()
111+
content = cmd_output.read_text(encoding="utf-8")
112+
assert marker in content
113+
114+
115+
class TestYamlAgentPostProcess:
116+
def test_goose_post_process_applied(
117+
self, tmp_path, registrar, ext_dir, monkeypatch
118+
):
119+
ext, cmd_dir = ext_dir
120+
_write_cmd(cmd_dir)
121+
122+
from specify_cli.integrations import get_integration
123+
124+
goose = get_integration("goose")
125+
marker = "# POST_PROCESSED"
126+
127+
def _inject_marker(self, content):
128+
return content + f"\n{marker}\n"
129+
130+
monkeypatch.setattr(
131+
goose.__class__, "post_process_command_content", _inject_marker
132+
)
133+
134+
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
135+
registrar.register_commands(
136+
"goose", commands, "test-ext", ext, tmp_path
137+
)
138+
139+
cmd_output = tmp_path / ".goose" / "recipes" / "speckit.test.review.yaml"
140+
assert cmd_output.exists()
141+
content = cmd_output.read_text(encoding="utf-8")
142+
assert marker in content
143+
144+
145+
class TestSkillsAgentExcluded:
146+
def test_claude_post_process_not_called(
147+
self, tmp_path, registrar, ext_dir, monkeypatch
148+
):
149+
ext, cmd_dir = ext_dir
150+
_write_cmd(cmd_dir)
151+
152+
from specify_cli.integrations import get_integration
153+
154+
claude = get_integration("claude")
155+
marker = "<!-- SHOULD_NOT_APPEAR -->"
156+
157+
def _inject_marker(self, content):
158+
return content + marker
159+
160+
monkeypatch.setattr(
161+
claude.__class__, "post_process_command_content", _inject_marker
162+
)
163+
164+
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
165+
registrar.register_commands(
166+
"claude", commands, "test-ext", ext, tmp_path
167+
)
168+
169+
skill_file = (
170+
tmp_path / ".claude" / "skills" / "speckit-test-review" / "SKILL.md"
171+
)
172+
assert skill_file.exists()
173+
content = skill_file.read_text(encoding="utf-8")
174+
assert marker not in content
175+
176+
def test_skills_agent_method_never_called(
177+
self, tmp_path, registrar, ext_dir
178+
):
179+
ext, cmd_dir = ext_dir
180+
_write_cmd(cmd_dir)
181+
182+
from specify_cli.integrations import get_integration
183+
184+
claude = get_integration("claude")
185+
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
186+
187+
with patch.object(
188+
claude.__class__, "post_process_command_content", wraps=claude.post_process_command_content
189+
) as mock_method:
190+
registrar.register_commands(
191+
"claude", commands, "test-ext", ext, tmp_path
192+
)
193+
mock_method.assert_not_called()
194+
195+
196+
class TestExceptionPropagation:
197+
def test_hook_exception_propagates(
198+
self, tmp_path, registrar, ext_dir, monkeypatch
199+
):
200+
ext, cmd_dir = ext_dir
201+
_write_cmd(cmd_dir)
202+
203+
from specify_cli.integrations import get_integration
204+
205+
opencode = get_integration("opencode")
206+
207+
def _raise(self, content):
208+
raise RuntimeError("Hook failed")
209+
210+
monkeypatch.setattr(
211+
opencode.__class__, "post_process_command_content", _raise
212+
)
213+
214+
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
215+
with pytest.raises(RuntimeError, match="Hook failed"):
216+
registrar.register_commands(
217+
"opencode", commands, "test-ext", ext, tmp_path
218+
)
219+
220+
221+
class TestRegressionPlainTemplate:
222+
@pytest.mark.parametrize(
223+
"agent,path_pattern",
224+
[
225+
("claude", ".claude/skills/speckit-test-plain/SKILL.md"),
226+
("opencode", ".opencode/commands/speckit.test.plain.md"),
227+
],
228+
ids=["skills", "markdown"],
229+
)
230+
def test_plain_template_unchanged(
231+
self, tmp_path, registrar, ext_dir, agent, path_pattern
232+
):
233+
ext, cmd_dir = ext_dir
234+
body_text = "This is a plain command with no special content.\n"
235+
_write_cmd(cmd_dir, name="plain.md", body=body_text)
236+
237+
commands = [{"name": "speckit.test.plain", "file": "commands/plain.md"}]
238+
registrar.register_commands(
239+
agent, commands, "test-ext", ext, tmp_path
240+
)
241+
242+
output_file = tmp_path / path_pattern
243+
assert output_file.exists(), f"Output file missing for {agent}"
244+
content = output_file.read_text(encoding="utf-8")
245+
assert body_text.strip() in content, f"Body text missing in {agent} output"

0 commit comments

Comments
 (0)