diff --git a/docs/integrations/cline.md b/docs/integrations/cline.md index 1c948b8b95..d849280715 100644 --- a/docs/integrations/cline.md +++ b/docs/integrations/cline.md @@ -25,7 +25,14 @@ Invoke them in Cline's chat as `/speckit-specify` or `/speckit-bug-assess` for the installed bug extension, not dotted commands. Handoff references and extension/preset command content are also normalized -to hyphenated command names. +to hyphenated command names, so `/speckit.git.commit` in an extension command +becomes `/speckit-git-commit`. A `speckit.*.md` file name inside a path, such +as `.specify/extensions/git/commands/speckit.git.commit.md`, +`C:\speckit.foo.md`, `C:\Program Files (x86)\speckit.foo.md`, +`%TEMP%\speckit.foo.md`, or `./commands[old]/speckit.foo.md`, keeps its dots +and `.md` extension. A bare or +slash-prefixed token such as `/speckit.foo.md` is treated as a command +reference and hyphenated. Core command content is not rewritten this way. ## Workflow Limitation diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 72966998a5..8f3668812f 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -92,33 +92,6 @@ def _ensure_configs(cls) -> None: except ImportError: pass # Circular import during module init; retry on next access - @staticmethod - def _hyphenate_frontmatter_refs(val: Any) -> Any: - """Recursively find any dotted references starting with speckit. and hyphenate them.""" - if isinstance(val, dict): - return { - k: CommandRegistrar._hyphenate_frontmatter_refs(v) - for k, v in val.items() - } - elif isinstance(val, list): - return [CommandRegistrar._hyphenate_frontmatter_refs(x) for x in val] - elif isinstance(val, str): - return re.sub( - r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b", - lambda m: m.group(0).replace(".", "-"), - val, - ) - return val - - @staticmethod - def _hyphenate_body_refs(body: str) -> str: - """Hyphenate dotted speckit references in command body text.""" - return re.sub( - r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b", - lambda m: m.group(0).replace(".", "-"), - body, - ) - @staticmethod def parse_frontmatter(content: str) -> tuple[dict, str]: """Parse YAML frontmatter from Markdown content. @@ -715,7 +688,6 @@ def register_commands( commands_dir.mkdir(parents=True, exist_ok=True) registered = [] - is_cline_ext = agent_name == "cline" and source_id != "core" source_root = source_dir.resolve() # Resolve the command-reference separator for the file THIS registrar @@ -738,6 +710,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 @@ -828,9 +801,10 @@ def register_commands( format_name = agent_config.get("format_name") frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name - if is_cline_ext: - 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/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index db6961b5eb..91ec2c5a74 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -50,6 +50,110 @@ def format_cline_command_name(cmd_name: str) -> str: return cmd_name +# Dotted ``speckit.*`` references in extension and preset command content. +_SPECKIT_DOTTED_REF = re.compile(r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b") + + +def _is_path_char(ch: str) -> bool: + """Whether *ch* can end the directory part of a path before a separator.""" + return ch.isalnum() or ch in "._-~/\\}$" + + +# A ``%VAR%`` environment variable reference ending a directory name +# (``%TEMP%``, ``%ProgramFiles(x86)%``). +_ENV_VAR_DIR = re.compile(r"%[A-Za-z_][A-Za-z0-9_()]*%\Z") + +_BRACKET_OPENERS = {")": "(", "]": "["} + + +def _ends_directory(text: str, sep: int) -> bool: + """Whether the text before the separator at *sep* ends a directory name. + + Besides plain path-name characters this accepts a closing bracket that + closes a whitespace-free group (``Program Files (x86)``, + ``commands[old]``) and a ``%VAR%`` environment variable reference + (``%TEMP%``). An opening bracket, or a closing one around prose such as + ``(see docs)``, does not end a directory, so ``[/speckit.foo.md]`` and + ``(/speckit.foo.md)`` stay command references. + """ + ch = text[sep - 1] + if _is_path_char(ch): + return True + if ch in _BRACKET_OPENERS: + opener = text.rfind(_BRACKET_OPENERS[ch], 0, sep - 1) + group = text[opener + 1 : sep - 1] + return ( + opener >= 0 + and bool(group) + and ch not in group + and not any(c.isspace() for c in group) + ) + if ch == "%": + return _ENV_VAR_DIR.search(text, 0, sep) is not None + return False + + +def _is_drive_prefix(text: str, sep: int) -> bool: + """Whether a Windows drive (``C:``) ends right before the separator at *sep*. + + The drive letter must stand alone, so ``C:\\speckit.foo.md`` and + ``C:/speckit.foo.md`` count as root-level paths while ``ab:/speckit.foo.md`` + does not. + """ + return ( + sep >= 2 + and text[sep - 1] == ":" + and text[sep - 2].isascii() + and text[sep - 2].isalpha() + and (sep == 2 or not _is_path_char(text[sep - 3])) + ) + + +def _hyphenate_ref_match(match: re.Match[str]) -> str: + """Hyphenate one dotted speckit reference, leaving ``.md`` file names alone. + + Command names may themselves end in an ``md`` segment: extension + commands are ``speckit..`` and preset command names + may contain further dots, so ``/speckit.foo.md`` and + ``speckit.foo.bar.md`` can both be valid command references. The + ``.md`` suffix and the segment count therefore cannot tell a file name + apart from a command. A ``.md`` token is kept as a file name only when + it sits inside a path (``commands/speckit.git.commit.md``), that is, + when the separator before it ends a directory name (see + ``_ends_directory``: ``commands/``, ``C:\\Program Files (x86)\\``, + ``%TEMP%\\``, ``commands[old]/``) or follows a Windows drive + (``C:\\speckit.foo.md``, ``C:/speckit.foo.md``). A bare or + slash-prefixed token is treated as a command reference, including after + an opening bracket or other punctuation (``[/speckit.foo.md]``, + ``(/speckit.foo.md)``). + """ + ref = match.group(0) + if ref.endswith(".md"): + start = match.start() + text = match.string + if start >= 2 and text[start - 1] in "/\\" and ( + _ends_directory(text, start - 1) or _is_drive_prefix(text, start - 1) + ): + return ref + return ref.replace(".", "-") + + +def hyphenate_command_refs(text: str) -> str: + """Hyphenate dotted ``speckit.*`` command references in *text*.""" + return _SPECKIT_DOTTED_REF.sub(_hyphenate_ref_match, text) + + +def _hyphenate_frontmatter_refs(val: Any) -> Any: + """Recursively hyphenate dotted ``speckit.*`` references in frontmatter values.""" + if isinstance(val, dict): + return {k: _hyphenate_frontmatter_refs(v) for k, v in val.items()} + if isinstance(val, list): + return [_hyphenate_frontmatter_refs(x) for x in val] + if isinstance(val, str): + return hyphenate_command_refs(val) + return val + + class ClineIntegration(MarkdownIntegration): """Integration for Cline IDE.""" @@ -154,6 +258,22 @@ def _rewrite_handoff_references(content: str) -> str: content, ) + def process_registered_command( + self, frontmatter: dict[str, Any], body: str, *, source_id: str + ) -> tuple[dict[str, Any], str]: + """Hyphenate dotted command references in extension and preset commands. + + Cline only registers hyphenated slash commands, so references such as + ``/speckit.git.commit`` in extension and preset command content are + rewritten to ``/speckit-git-commit``. ``speckit.*.md`` file names + inside a path keep their extension. Core commands (``source_id == + "core"``) are left unchanged; their references are rendered through + ``invoke_separator`` instead. + """ + if source_id == "core": + return frontmatter, body + return _hyphenate_frontmatter_refs(frontmatter), hyphenate_command_refs(body) + def post_process_command_content(self, content: str) -> str: """Apply Cline-specific transformations to command content. diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index d7350a9633..3608f754e4 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -4,7 +4,11 @@ import pytest from specify_cli.integrations import get_integration -from specify_cli.integrations.cline import format_cline_command_name +from specify_cli.integrations.cline import ( + _hyphenate_frontmatter_refs, + format_cline_command_name, + hyphenate_command_refs, +) from .test_integration_base_markdown import MarkdownIntegrationTests @@ -82,6 +86,189 @@ def test_cline_handoff_rewrite(self): rewritten = cline._rewrite_handoff_references(content) assert rewritten == "---\nagent: speckit-plan\n---\n" + def test_cline_body_refs_keep_md_file_names(self): + """Command references are hyphenated but ``speckit.*.md`` file names keep ``.md``.""" + body = ( + "Edit `.specify/extensions/git/commands/speckit.git.commit.md`, " + "then run /speckit.git.commit." + ) + assert hyphenate_command_refs(body) == ( + "Edit `.specify/extensions/git/commands/speckit.git.commit.md`, " + "then run /speckit-git-commit." + ) + + def test_cline_body_refs_hyphenate_a_command_named_md(self): + """An extension command whose name is ``md`` is still a command reference.""" + body = ( + "Run /speckit.foo.md, or open commands/speckit.plan.md " + "and agents/speckit.foo.md." + ) + assert hyphenate_command_refs(body) == ( + "Run /speckit-foo-md, or open commands/speckit.plan.md " + "and agents/speckit.foo.md." + ) + refs = _hyphenate_frontmatter_refs( + {"agent": "speckit.foo.md", "next": "/speckit.foo.md"} + ) + assert refs == {"agent": "speckit-foo-md", "next": "/speckit-foo-md"} + + def test_cline_refs_hyphenate_dotted_preset_command_ending_in_md(self): + """A dotted preset command name ending in ``md`` is a command, not a file.""" + body = ( + "Run speckit.foo.bar.md or /speckit.foo.bar.md, " + "then edit presets/foo/commands/speckit.foo.bar.md." + ) + assert hyphenate_command_refs(body) == ( + "Run speckit-foo-bar-md or /speckit-foo-bar-md, " + "then edit presets/foo/commands/speckit.foo.bar.md." + ) + refs = _hyphenate_frontmatter_refs( + {"agent": "speckit.foo.bar.md", "file": "commands/speckit.foo.bar.md"} + ) + assert refs == {"agent": "speckit-foo-bar-md", "file": "commands/speckit.foo.bar.md"} + + def test_cline_refs_hyphenate_bracketed_slash_command_named_md(self): + """A slash command after Markdown punctuation is still a command reference.""" + body = ( + "See [/speckit.foo.md] and {/speckit.foo.md}, " + "then edit ~/commands/speckit.foo.md or ${DIR}/speckit.foo.md." + ) + assert hyphenate_command_refs(body) == ( + "See [/speckit-foo-md] and {/speckit-foo-md}, " + "then edit ~/commands/speckit.foo.md or ${DIR}/speckit.foo.md." + ) + refs = _hyphenate_frontmatter_refs( + {"next": "[/speckit.foo.md]", "file": "[commands/speckit.foo.md]"} + ) + assert refs == {"next": "[/speckit-foo-md]", "file": "[commands/speckit.foo.md]"} + + def test_cline_refs_keep_md_file_names_at_a_windows_drive_root(self): + """``C:\\speckit.foo.md`` and ``C:/speckit.foo.md`` are file names, not commands.""" + body = ( + "Open C:\\speckit.foo.md or d:/speckit.foo.md, " + "then run /speckit.foo.md or ab:/speckit.foo.md." + ) + assert hyphenate_command_refs(body) == ( + "Open C:\\speckit.foo.md or d:/speckit.foo.md, " + "then run /speckit-foo-md or ab:/speckit-foo-md." + ) + refs = _hyphenate_frontmatter_refs( + {"file": "C:\\speckit.foo.md", "other": "C:/speckit.foo.md"} + ) + assert refs == {"file": "C:\\speckit.foo.md", "other": "C:/speckit.foo.md"} + + @pytest.mark.parametrize( + "path", + [ + "C:\\Program Files (x86)\\speckit.git.commit.md", + "C:/Program Files (x86)/speckit.git.commit.md", + "%TEMP%\\speckit.git.commit.md", + "%ProgramFiles(x86)%\\speckit.git.commit.md", + "./commands[old]/speckit.git.commit.md", + "commands(1)/speckit.git.commit.md", + ], + ) + def test_cline_refs_keep_md_file_names_after_bracket_or_env_var(self, path): + """A directory may end in ``)``, ``]`` or a ``%VAR%`` reference.""" + body = f"Edit {path}, then run /speckit.git.status." + assert hyphenate_command_refs(body) == ( + f"Edit {path}, then run /speckit-git-status." + ) + assert _hyphenate_frontmatter_refs({"file": path}) == {"file": path} + + def test_cline_refs_hyphenate_commands_after_closing_punctuation(self): + """A closing bracket or ``%`` only counts as a directory ending when it + closes a bracketed or ``%VAR%`` name; other prose stays a command.""" + body = ( + "Run (see docs)/speckit.foo.md, 100%/speckit.foo.md, " + "[/speckit.foo.md], ([/speckit.foo.md]) or [Run](/speckit.foo.md)." + ) + assert hyphenate_command_refs(body) == ( + "Run (see docs)/speckit-foo-md, 100%/speckit-foo-md, " + "[/speckit-foo-md], ([/speckit-foo-md]) or [Run](/speckit-foo-md)." + ) + + def test_cline_frontmatter_refs_keep_md_file_names(self): + """Frontmatter string values get the same ``.md`` file name protection.""" + refs = _hyphenate_frontmatter_refs( + {"agent": "speckit.plan", "file": "commands/speckit.git.commit.md"} + ) + assert refs == {"agent": "speckit-plan", "file": "commands/speckit.git.commit.md"} + + def _register_cline_command(self, tmp_path, source_id, body, frontmatter=""): + from specify_cli.agents import CommandRegistrar + + source = tmp_path / "src" + (source / "commands").mkdir(parents=True) + (source / "commands" / "commit.md").write_text( + f"---\ndescription: Commit\n{frontmatter}---\n\n{body}", + encoding="utf-8", + ) + project = tmp_path / "project" + project.mkdir() + CommandRegistrar().register_commands( + "cline", + [{"name": "speckit.git.commit", "file": "commands/commit.md"}], + source_id, + source, + project, + ) + out = project / ".clinerules" / "workflows" / "speckit-git-commit.md" + return out.read_text(encoding="utf-8") + + def test_registered_extension_command_keeps_md_file_names(self, tmp_path): + """Registering an extension command for Cline hyphenates command + references in the body and frontmatter but keeps ``.md`` file names.""" + text = self._register_cline_command( + tmp_path, + "git", + "Edit `.specify/extensions/git/commands/speckit.git.commit.md`, " + "then run /speckit.git.status.\n", + frontmatter="handoff: speckit.git.status\nsource_file: commands/speckit.git.commit.md\n", + ) + assert "`.specify/extensions/git/commands/speckit.git.commit.md`" in text + assert "then run /speckit-git-status." in text + assert "handoff: speckit-git-status" in text + assert "source_file: commands/speckit.git.commit.md" in text + assert "speckit-git-commit-md" not in text + + def test_registered_extension_command_keeps_bracketed_and_env_paths(self, tmp_path): + """Registration keeps ``.md`` file names under ``(x86)``, ``[old]`` and + ``%TEMP%`` directories in the body and frontmatter.""" + text = self._register_cline_command( + tmp_path, + "git", + "Copy C:\\Program Files (x86)\\speckit.git.commit.md and " + "./commands[old]/speckit.git.commit.md, then run /speckit.git.status.\n", + frontmatter="source_file: '%TEMP%\\speckit.git.commit.md'\n", + ) + assert "C:\\Program Files (x86)\\speckit.git.commit.md" in text + assert "./commands[old]/speckit.git.commit.md" in text + assert "%TEMP%\\speckit.git.commit.md" in text + assert "then run /speckit-git-status." in text + assert "speckit-git-commit-md" not in text + + def test_registered_core_command_is_not_rewritten(self, tmp_path): + """The rewrite stays limited to extension and preset commands.""" + text = self._register_cline_command( + tmp_path, "core", "Then run /speckit.git.status.\n" + ) + assert "Then run /speckit.git.status." in text + + def test_process_registered_command_hook(self): + cline = get_integration("cline") + frontmatter = {"handoff": "speckit.plan", "file": "commands/speckit.plan.md"} + body = "Run /speckit.plan; see commands/speckit.plan.md." + assert cline.process_registered_command( + frontmatter, body, source_id="core" + ) == (frontmatter, body) + assert cline.process_registered_command( + frontmatter, body, source_id="my-preset" + ) == ( + {"handoff": "speckit-plan", "file": "commands/speckit.plan.md"}, + "Run /speckit-plan; see commands/speckit.plan.md.", + ) + def test_cline_hook_instruction_injection(self): """Verify Cline injects the dot-to-hyphen note for hooks.""" cline = get_integration("cline") 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")