From 7473aa1ad77052f452adbff17d721bd6fe9bf339 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:38:40 +0900 Subject: [PATCH 1/9] fix(cline): keep .md extension on speckit.* file names in command bodies Cline's dot-to-hyphen rewrite matched `speckit.git.commit.md` as a command reference and produced `speckit-git-commit-md`, dropping the extension. Leave matches that end in `.md` untouched in both body and frontmatter. Fixes #5097 Assisted-by: Grok Bot (model: unknown, autonomous) Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com> --- src/specify_cli/agents.py | 24 +++++++++++++------- tests/integrations/test_integration_cline.py | 22 ++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 72966998a5..31981efbc0 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -92,6 +92,18 @@ def _ensure_configs(cls) -> None: except ImportError: pass # Circular import during module init; retry on next access + _SPECKIT_DOTTED_REF = re.compile(r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b") + + @staticmethod + def _hyphenate_ref_match(match: "re.Match[str]") -> str: + """Hyphenate one dotted speckit reference, leaving ``.md`` file names alone.""" + ref = match.group(0) + if ref.endswith(".md"): + # ``speckit.git.commit.md`` is a command file name, not a command + # reference; rewriting it would drop the extension. + return ref + return ref.replace(".", "-") + @staticmethod def _hyphenate_frontmatter_refs(val: Any) -> Any: """Recursively find any dotted references starting with speckit. and hyphenate them.""" @@ -103,20 +115,16 @@ def _hyphenate_frontmatter_refs(val: Any) -> Any: 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 CommandRegistrar._SPECKIT_DOTTED_REF.sub( + CommandRegistrar._hyphenate_ref_match, 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, + return CommandRegistrar._SPECKIT_DOTTED_REF.sub( + CommandRegistrar._hyphenate_ref_match, body ) @staticmethod diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index d7350a9633..81aceec87a 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -82,6 +82,28 @@ 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``.""" + from specify_cli.agents import CommandRegistrar + + body = ( + "Edit `.specify/extensions/git/commands/speckit.git.commit.md`, " + "then run /speckit.git.commit." + ) + assert CommandRegistrar._hyphenate_body_refs(body) == ( + "Edit `.specify/extensions/git/commands/speckit.git.commit.md`, " + "then run /speckit-git-commit." + ) + + def test_cline_frontmatter_refs_keep_md_file_names(self): + """Frontmatter string values get the same ``.md`` file name protection.""" + from specify_cli.agents import CommandRegistrar + + refs = CommandRegistrar._hyphenate_frontmatter_refs( + {"agent": "speckit.plan", "file": "commands/speckit.git.commit.md"} + ) + assert refs == {"agent": "speckit-plan", "file": "commands/speckit.git.commit.md"} + def test_cline_hook_instruction_injection(self): """Verify Cline injects the dot-to-hyphen note for hooks.""" cline = get_integration("cline") From c901e965742b97720088ea6eaf11034aeea0f7c4 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:20:55 +0900 Subject: [PATCH 2/9] fix(cline): still hyphenate a command named md outside a path A speckit..md token is a command reference unless it has more segments than a command can have or sits inside a path. Assisted-by: Grok Bot (model: unknown, autonomous) Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com> --- src/specify_cli/agents.py | 24 ++++++++++++++++---- tests/integrations/test_integration_cline.py | 17 ++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 31981efbc0..f75fc9c207 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -96,12 +96,28 @@ def _ensure_configs(cls) -> None: @staticmethod def _hyphenate_ref_match(match: "re.Match[str]") -> str: - """Hyphenate one dotted speckit reference, leaving ``.md`` file names alone.""" + """Hyphenate one dotted speckit reference, leaving ``.md`` file names alone. + + ``speckit..`` may legitimately end in ``.md`` when the + command itself is named ``md`` (``/speckit.foo.md``), so the suffix alone + is not enough. A ``.md`` token is treated as a file name only when it has + more segments than any command reference can have + (``speckit.git.commit.md``) or sits inside a path + (``commands/speckit.plan.md``). + """ ref = match.group(0) if ref.endswith(".md"): - # ``speckit.git.commit.md`` is a command file name, not a command - # reference; rewriting it would drop the extension. - return ref + if ref.count(".") > 2: + return ref + start = match.start() + text = match.string + if ( + start >= 2 + and text[start - 1] in "/\\" + and not text[start - 2].isspace() + and text[start - 2] not in "`'\"(" + ): + return ref return ref.replace(".", "-") @staticmethod diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index 81aceec87a..850b8026c6 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -95,6 +95,23 @@ def test_cline_body_refs_keep_md_file_names(self): "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.""" + from specify_cli.agents import CommandRegistrar + + body = ( + "Run /speckit.foo.md, or open commands/speckit.plan.md " + "and agents/speckit.foo.md." + ) + assert CommandRegistrar._hyphenate_body_refs(body) == ( + "Run /speckit-foo-md, or open commands/speckit.plan.md " + "and agents/speckit.foo.md." + ) + refs = CommandRegistrar._hyphenate_frontmatter_refs( + {"agent": "speckit.foo.md", "next": "/speckit.foo.md"} + ) + assert refs == {"agent": "speckit-foo-md", "next": "/speckit-foo-md"} + def test_cline_frontmatter_refs_keep_md_file_names(self): """Frontmatter string values get the same ``.md`` file name protection.""" from specify_cli.agents import CommandRegistrar From a5afd73b516ee0282b320542c48f6c1125490166 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:20:43 +0900 Subject: [PATCH 3/9] fix(cline): treat bare speckit.*.md tokens as command references Preset command names may contain dots, so speckit.foo.bar.md can be a valid command whose last segment is md. The segment count therefore cannot identify a file name. Keep a .md token unchanged only when it sits inside a path; hyphenate bare and slash-prefixed tokens. Assisted-by: Grok Bot (model: unknown, autonomous) Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com> --- src/specify_cli/agents.py | 16 ++++++++-------- tests/integrations/test_integration_cline.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index f75fc9c207..ec1e9d471f 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -98,17 +98,17 @@ def _ensure_configs(cls) -> None: def _hyphenate_ref_match(match: "re.Match[str]") -> str: """Hyphenate one dotted speckit reference, leaving ``.md`` file names alone. - ``speckit..`` may legitimately end in ``.md`` when the - command itself is named ``md`` (``/speckit.foo.md``), so the suffix alone - is not enough. A ``.md`` token is treated as a file name only when it has - more segments than any command reference can have - (``speckit.git.commit.md``) or sits inside a path - (``commands/speckit.plan.md``). + 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``); a bare or + slash-prefixed token is treated as a command reference. """ ref = match.group(0) if ref.endswith(".md"): - if ref.count(".") > 2: - return ref start = match.start() text = match.string if ( diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index 850b8026c6..556783b59a 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -112,6 +112,23 @@ def test_cline_body_refs_hyphenate_a_command_named_md(self): ) 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.""" + from specify_cli.agents import CommandRegistrar + + body = ( + "Run speckit.foo.bar.md or /speckit.foo.bar.md, " + "then edit presets/foo/commands/speckit.foo.bar.md." + ) + assert CommandRegistrar._hyphenate_body_refs(body) == ( + "Run speckit-foo-bar-md or /speckit-foo-bar-md, " + "then edit presets/foo/commands/speckit.foo.bar.md." + ) + refs = CommandRegistrar._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_frontmatter_refs_keep_md_file_names(self): """Frontmatter string values get the same ``.md`` file name protection.""" from specify_cli.agents import CommandRegistrar From d00721f947f0706da2a2ab518c107e536fbd1007 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:27:13 +0900 Subject: [PATCH 4/9] fix(cline): hyphenate slash commands after Markdown punctuation like [ --- src/specify_cli/agents.py | 15 +++++++++++---- tests/integrations/test_integration_cline.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index ec1e9d471f..2dbe48abbb 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -104,8 +104,11 @@ def _hyphenate_ref_match(match: "re.Match[str]") -> str: ``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``); a bare or - slash-prefixed token is treated as a command reference. + it sits inside a path (``commands/speckit.git.commit.md``), that is, + when the separator before it follows a path-name character. A bare or + slash-prefixed token is treated as a command reference, whatever + punctuation precedes the slash (``[/speckit.foo.md]``, + ``(/speckit.foo.md)``). """ ref = match.group(0) if ref.endswith(".md"): @@ -114,12 +117,16 @@ def _hyphenate_ref_match(match: "re.Match[str]") -> str: if ( start >= 2 and text[start - 1] in "/\\" - and not text[start - 2].isspace() - and text[start - 2] not in "`'\"(" + and CommandRegistrar._is_path_char(text[start - 2]) ): return ref return ref.replace(".", "-") + @staticmethod + 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 "._-~/\\}$" + @staticmethod def _hyphenate_frontmatter_refs(val: Any) -> Any: """Recursively find any dotted references starting with speckit. and hyphenate them.""" diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index 556783b59a..dfb9c1f94a 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -129,6 +129,23 @@ def test_cline_refs_hyphenate_dotted_preset_command_ending_in_md(self): ) 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.""" + from specify_cli.agents import CommandRegistrar + + body = ( + "See [/speckit.foo.md] and {/speckit.foo.md}, " + "then edit ~/commands/speckit.foo.md or ${DIR}/speckit.foo.md." + ) + assert CommandRegistrar._hyphenate_body_refs(body) == ( + "See [/speckit-foo-md] and {/speckit-foo-md}, " + "then edit ~/commands/speckit.foo.md or ${DIR}/speckit.foo.md." + ) + refs = CommandRegistrar._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_frontmatter_refs_keep_md_file_names(self): """Frontmatter string values get the same ``.md`` file name protection.""" from specify_cli.agents import CommandRegistrar From b9216586e0cb4949c10f2661a882cfc006d69d91 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:58:49 +0900 Subject: [PATCH 5/9] fix(cline): keep .md file names at a Windows drive root C:\speckit.foo.md and C:/speckit.foo.md were hyphenated because the drive colon before the separator is not a path-name character. A lone drive letter and colon before the separator now count as path context, so these stay file names; ab:/speckit.foo.md is still treated as a command reference. Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com> --- src/specify_cli/agents.py | 26 ++++++++++++++++---- tests/integrations/test_integration_cline.py | 17 +++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 2dbe48abbb..fafbef1649 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -105,7 +105,8 @@ def _hyphenate_ref_match(match: "re.Match[str]") -> str: ``.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 follows a path-name character. A bare or + when the separator before it follows a path-name character or a Windows + drive (``C:\\speckit.foo.md``, ``C:/speckit.foo.md``). A bare or slash-prefixed token is treated as a command reference, whatever punctuation precedes the slash (``[/speckit.foo.md]``, ``(/speckit.foo.md)``). @@ -114,14 +115,29 @@ def _hyphenate_ref_match(match: "re.Match[str]") -> str: if ref.endswith(".md"): start = match.start() text = match.string - if ( - start >= 2 - and text[start - 1] in "/\\" - and CommandRegistrar._is_path_char(text[start - 2]) + if start >= 2 and text[start - 1] in "/\\" and ( + CommandRegistrar._is_path_char(text[start - 2]) + or CommandRegistrar._is_drive_prefix(text, start - 1) ): return ref return ref.replace(".", "-") + @staticmethod + 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 CommandRegistrar._is_path_char(text[sep - 3])) + ) + @staticmethod def _is_path_char(ch: str) -> bool: """Whether *ch* can end the directory part of a path before a separator.""" diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index dfb9c1f94a..226a0a4110 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -146,6 +146,23 @@ def test_cline_refs_hyphenate_bracketed_slash_command_named_md(self): ) 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.""" + from specify_cli.agents import CommandRegistrar + + body = ( + "Open C:\\speckit.foo.md or d:/speckit.foo.md, " + "then run /speckit.foo.md or ab:/speckit.foo.md." + ) + assert CommandRegistrar._hyphenate_body_refs(body) == ( + "Open C:\\speckit.foo.md or d:/speckit.foo.md, " + "then run /speckit-foo-md or ab:/speckit-foo-md." + ) + refs = CommandRegistrar._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"} + def test_cline_frontmatter_refs_keep_md_file_names(self): """Frontmatter string values get the same ``.md`` file name protection.""" from specify_cli.agents import CommandRegistrar 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 6/9] 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 ab5d147e9a773424394c82e4bd11a81e9c2c588c Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:10:04 +0900 Subject: [PATCH 7/9] refactor(cline): move speckit.* reference rewrite into the Cline integration The dotted-reference rewrite for extension and preset commands, including the .md file-name handling from this PR, lived in CommandRegistrar as an is_cline_ext branch. Move it into src/specify_cli/integrations/cline/ and apply it through ClineIntegration.process_registered_command(), keeping the non-core-only scope. Add registration-level tests that go through CommandRegistrar.register_commands() for an extension and a core command, and document the behavior in docs/integrations/cline.md. Assisted-by: Grok Bot (model: unknown, autonomous) Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com> --- docs/integrations/cline.md | 7 +- src/specify_cli/agents.py | 79 --------------- .../integrations/cline/__init__.py | 84 ++++++++++++++++ tests/integrations/test_integration_cline.py | 96 ++++++++++++++----- 4 files changed, 163 insertions(+), 103 deletions(-) diff --git a/docs/integrations/cline.md b/docs/integrations/cline.md index 1c948b8b95..1d695df314 100644 --- a/docs/integrations/cline.md +++ b/docs/integrations/cline.md @@ -25,7 +25,12 @@ 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` or +`C:\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 6233e1c731..8f3668812f 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -92,80 +92,6 @@ def _ensure_configs(cls) -> None: except ImportError: pass # Circular import during module init; retry on next access - _SPECKIT_DOTTED_REF = re.compile(r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b") - - @staticmethod - 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 follows a path-name character or a Windows - drive (``C:\\speckit.foo.md``, ``C:/speckit.foo.md``). A bare or - slash-prefixed token is treated as a command reference, whatever - punctuation precedes the slash (``[/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 ( - CommandRegistrar._is_path_char(text[start - 2]) - or CommandRegistrar._is_drive_prefix(text, start - 1) - ): - return ref - return ref.replace(".", "-") - - @staticmethod - 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 CommandRegistrar._is_path_char(text[sep - 3])) - ) - - @staticmethod - 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 "._-~/\\}$" - - @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 CommandRegistrar._SPECKIT_DOTTED_REF.sub( - CommandRegistrar._hyphenate_ref_match, val - ) - return val - - @staticmethod - def _hyphenate_body_refs(body: str) -> str: - """Hyphenate dotted speckit references in command body text.""" - return CommandRegistrar._SPECKIT_DOTTED_REF.sub( - CommandRegistrar._hyphenate_ref_match, body - ) - @staticmethod def parse_frontmatter(content: str) -> tuple[dict, str]: """Parse YAML frontmatter from Markdown content. @@ -762,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 @@ -876,10 +801,6 @@ 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 diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index db6961b5eb..200ae7a3e9 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -50,6 +50,74 @@ 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 "._-~/\\}$" + + +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 follows a path-name character or a Windows + drive (``C:\\speckit.foo.md``, ``C:/speckit.foo.md``). A bare or + slash-prefixed token is treated as a command reference, whatever + punctuation precedes the slash (``[/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 ( + _is_path_char(text[start - 2]) 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 +222,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 226a0a4110..6723566962 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 @@ -84,94 +88,140 @@ def test_cline_handoff_rewrite(self): def test_cline_body_refs_keep_md_file_names(self): """Command references are hyphenated but ``speckit.*.md`` file names keep ``.md``.""" - from specify_cli.agents import CommandRegistrar - body = ( "Edit `.specify/extensions/git/commands/speckit.git.commit.md`, " "then run /speckit.git.commit." ) - assert CommandRegistrar._hyphenate_body_refs(body) == ( + 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.""" - from specify_cli.agents import CommandRegistrar - body = ( "Run /speckit.foo.md, or open commands/speckit.plan.md " "and agents/speckit.foo.md." ) - assert CommandRegistrar._hyphenate_body_refs(body) == ( + assert hyphenate_command_refs(body) == ( "Run /speckit-foo-md, or open commands/speckit.plan.md " "and agents/speckit.foo.md." ) - refs = CommandRegistrar._hyphenate_frontmatter_refs( + 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.""" - from specify_cli.agents import CommandRegistrar - body = ( "Run speckit.foo.bar.md or /speckit.foo.bar.md, " "then edit presets/foo/commands/speckit.foo.bar.md." ) - assert CommandRegistrar._hyphenate_body_refs(body) == ( + 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 = CommandRegistrar._hyphenate_frontmatter_refs( + 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.""" - from specify_cli.agents import CommandRegistrar - body = ( "See [/speckit.foo.md] and {/speckit.foo.md}, " "then edit ~/commands/speckit.foo.md or ${DIR}/speckit.foo.md." ) - assert CommandRegistrar._hyphenate_body_refs(body) == ( + 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 = CommandRegistrar._hyphenate_frontmatter_refs( + 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.""" - from specify_cli.agents import CommandRegistrar - body = ( "Open C:\\speckit.foo.md or d:/speckit.foo.md, " "then run /speckit.foo.md or ab:/speckit.foo.md." ) - assert CommandRegistrar._hyphenate_body_refs(body) == ( + 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 = CommandRegistrar._hyphenate_frontmatter_refs( + 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"} def test_cline_frontmatter_refs_keep_md_file_names(self): """Frontmatter string values get the same ``.md`` file name protection.""" - from specify_cli.agents import CommandRegistrar - - refs = CommandRegistrar._hyphenate_frontmatter_refs( + 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_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") 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 8/9] 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 = [] From 422d44c923693cb53f1cdfa6d14f0b760165b5e2 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:12:29 +0900 Subject: [PATCH 9/9] fix(cline): keep .md file names after (x86), [old] and %VAR% directories The path check only accepted a separator after a plain path-name character, so valid paths such as C:\Program Files (x86)\speckit.git.commit.md, %TEMP%\speckit.git.commit.md and ./commands[old]/speckit.git.commit.md were rewritten to speckit-git-commit-md in the body and frontmatter. Treat a closing ) or ] that closes a whitespace-free group, and a %VAR% reference, as a directory ending. Opening brackets and prose groups such as "(see docs)" still precede command references, so [/speckit.foo.md] and (/speckit.foo.md) keep being hyphenated. Assisted-by: Grok Bot (model: unknown, autonomous) Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com> --- docs/integrations/cline.md | 6 ++- .../integrations/cline/__init__.py | 46 ++++++++++++++++-- tests/integrations/test_integration_cline.py | 47 +++++++++++++++++++ 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/docs/integrations/cline.md b/docs/integrations/cline.md index 1d695df314..d849280715 100644 --- a/docs/integrations/cline.md +++ b/docs/integrations/cline.md @@ -27,8 +27,10 @@ Invoke them in Cline's chat as `/speckit-specify` or Handoff references and extension/preset command content are also normalized 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` or -`C:\speckit.foo.md`, keeps its dots and `.md` extension. A bare or +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. diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index 200ae7a3e9..91ec2c5a74 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -59,6 +59,40 @@ def _is_path_char(ch: str) -> bool: 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*. @@ -85,10 +119,12 @@ def _hyphenate_ref_match(match: re.Match[str]) -> str: ``.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 follows a path-name character or a Windows - drive (``C:\\speckit.foo.md``, ``C:/speckit.foo.md``). A bare or - slash-prefixed token is treated as a command reference, whatever - punctuation precedes the slash (``[/speckit.foo.md]``, + 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) @@ -96,7 +132,7 @@ def _hyphenate_ref_match(match: re.Match[str]) -> str: start = match.start() text = match.string if start >= 2 and text[start - 1] in "/\\" and ( - _is_path_char(text[start - 2]) or _is_drive_prefix(text, start - 1) + _ends_directory(text, start - 1) or _is_drive_prefix(text, start - 1) ): return ref return ref.replace(".", "-") diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index 6723566962..3608f754e4 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -157,6 +157,37 @@ def test_cline_refs_keep_md_file_names_at_a_windows_drive_root(self): ) 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( @@ -201,6 +232,22 @@ def test_registered_extension_command_keeps_md_file_names(self, tmp_path): 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(