diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 72966998a5..ec1e9d471f 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -92,6 +92,34 @@ 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``); a bare or + slash-prefixed token is treated as a command reference. + """ + ref = match.group(0) + if ref.endswith(".md"): + 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 def _hyphenate_frontmatter_refs(val: Any) -> Any: """Recursively find any dotted references starting with speckit. and hyphenate them.""" @@ -103,20 +131,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..556783b59a 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -82,6 +82,62 @@ 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_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_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 + + 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")