mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 07:04:44 +08:00
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
This commit is contained in:
@@ -706,6 +706,17 @@ class CommandRegistrar:
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
# -- Post-process for non-skills agents -----------------------
|
||||
_integration = None
|
||||
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)
|
||||
@@ -766,6 +777,9 @@ class CommandRegistrar:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
245
tests/test_post_process.py
Normal file
245
tests/test_post_process.py
Normal file
@@ -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 = "<!-- POST_PROCESSED -->"
|
||||
|
||||
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 = "<!-- SHOULD_NOT_APPEAR -->"
|
||||
|
||||
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"
|
||||
Reference in New Issue
Block a user