From 749ecdf991a1280563f23ba448bfd296c46def37 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 14 Aug 2026 13:10:15 +0800 Subject: [PATCH 1/5] fix: normalize extension skill command references --- extensions/EXTENSION-USER-GUIDE.md | 10 +++++ src/specify_cli/extensions/__init__.py | 61 ++++++++++++++++++++------ tests/test_extension_skills.py | 41 ++++++++++++----- 3 files changed, 87 insertions(+), 25 deletions(-) diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index c3391dbc75..d95872b204 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -202,6 +202,16 @@ Jira Integration (v1.0.0) When an extension is removed, its corresponding skills are also cleaned up automatically. Pre-existing skills that were manually customized are never overwritten. +When one extension command needs to reference another Spec Kit command, prefer the +portable command token form, such as `__SPECKIT_COMMAND_PLAN__`, instead of +hard-coding a slash command. Spec Kit renders these tokens to the active +integration's command style. For skills-based integrations, generated extension +skills also normalize literal slash-dot command references such as +`/speckit.jira.specstoissues` to the active skill invocation form, for example +`$speckit-jira-specstoissues` for Codex or `/speckit-jira-specstoissues` for +slash-skills agents. Avoid using bare prose like `speckit.jira.specstoissues` +when you intend the agent to invoke a command. + --- ## Using Extensions diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index fb4a30519d..cc0f3da644 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1562,7 +1562,6 @@ def _register_extension_skills( from .. import load_init_options from ..agents import CommandRegistrar from ..integrations import get_integration - from ..integrations.base import IntegrationBase written: List[str] = [] opts = load_init_options(self.project_root) @@ -1576,29 +1575,62 @@ def _register_extension_skills( integration = get_integration(selected_ai) ai_skills_enabled = is_ai_skills_enabled(opts) + def _render_skill_command_invocation(command_name: str) -> str: + """Render a command name with the active skill invocation style.""" + + if is_dollar_skills_agent(selected_ai, ai_skills_enabled): + return "$" + command_name.replace("speckit.", "speckit-").replace( + ".", "-" + ) + if is_slash_skills_agent(selected_ai, ai_skills_enabled): + return "/" + command_name.replace("speckit.", "speckit-").replace( + ".", "-" + ) + if integration is not None: + return integration.build_command_invocation(command_name) + + separator = agent_config.get("invoke_separator", ".") + if not isinstance(separator, str) or not separator: + separator = "." + return "/" + command_name.replace(".", separator) + def _resolve_command_ref_tokens(body: str) -> str: """Resolve explicit command-ref tokens with the active skill style.""" def _replacement(match: re.Match[str]) -> str: command_name = "speckit." + match.group(1).lower().replace("_", ".") - if is_dollar_skills_agent(selected_ai, ai_skills_enabled): - return "$" + command_name.replace("speckit.", "speckit-").replace( - ".", "-" - ) - if is_slash_skills_agent(selected_ai, ai_skills_enabled): - return "/" + command_name.replace("speckit.", "speckit-").replace( - ".", "-" - ) - if integration is not None: - return integration.build_command_invocation(command_name) - return IntegrationBase.resolve_command_refs( - match.group(0), agent_config.get("invoke_separator", ".") - ) + return _render_skill_command_invocation(command_name) return re.sub( r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body ) + def _normalize_literal_slash_command_refs(body: str) -> str: + """Normalize literal /speckit.foo refs in generated skill bodies.""" + + def _replacement(match: re.Match[str]) -> str: + command_name = match.group("command") + if command_name.rsplit(".", 1)[-1] in { + "json", + "md", + "toml", + "txt", + "yaml", + "yml", + }: + return match.group(0) + return _render_skill_command_invocation(command_name) + + return re.sub( + ( + r"(?speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)" + r"(?![A-Za-z0-9_.-])" + ), + _replacement, + body, + ) + for cmd_info in manifest.commands: cmd_name = cmd_info["name"] cmd_file_rel = cmd_info["file"] @@ -1678,6 +1710,7 @@ def _replacement(match: re.Match[str]) -> str: selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id ) body = _resolve_command_ref_tokens(body) + body = _normalize_literal_slash_command_refs(body) original_desc = frontmatter.get("description", "") description = original_desc or f"Extension command: {cmd_name}" diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 6eec5e7b47..9c9993e3de 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1166,12 +1166,24 @@ def test_skill_registration_resolves_command_ref_tokens( assert "__SPECKIT_COMMAND_PLAN__" not in content assert expected_invocation in content - def test_skill_registration_does_not_rewrite_literal_speckit_text( - self, project_dir, temp_dir + @pytest.mark.parametrize( + ("ai", "expected_invocation"), + [ + ("claude", "/speckit-foo-bar"), + ("copilot", "/speckit-foo-bar"), + ("codex", "$speckit-foo-bar"), + ("command-code", "$speckit-foo-bar"), + ("kimi", "/skill:speckit-foo-bar"), + ("zcode", "$speckit-foo-bar"), + ("bob", "/speckit-foo-bar"), + ], + ) + def test_skill_registration_rewrites_literal_slash_command_refs( + self, project_dir, temp_dir, ai, expected_invocation ): - """Auto-registered skills should leave literal speckit text untouched.""" - _create_init_options(project_dir, ai="codex", ai_skills=True) - skills_dir = _create_skills_dir(project_dir, ai="codex") + """Auto-registered skills should normalize literal slash-dot refs.""" + _create_init_options(project_dir, ai=ai, ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai=ai) ext_dir = temp_dir / "literal-ref-ext" ext_dir.mkdir() @@ -1202,20 +1214,27 @@ def test_skill_registration_does_not_rewrite_literal_speckit_text( "---\n" "description: Run command\n" "---\n\n" - "Literal slash form: /speckit.foo.bar\n" - "Literal skill form: /speckit-plan\n" + "Literal slash form: /speckit.foo.bar --flag value\n" + "Native slash form: /speckit-foo-bar\n" + "Native dollar form: $speckit-foo-bar\n" + "Native skill form: /skill:speckit-foo-bar\n" "Literal bare form: speckit.foo.bar\n" + "Path-like form: https://example.com/speckit.foo.bar\n" + "File-like form: /speckit.foo.bar.md\n" ) manager = ExtensionManager(project_dir) manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text() - assert "/speckit.foo.bar" in content - assert "/speckit-plan" in content + assert f"Literal slash form: {expected_invocation} --flag value" in content + assert "Literal slash form: /speckit.foo.bar --flag value" not in content + assert "Native slash form: /speckit-foo-bar" in content + assert "Native dollar form: $speckit-foo-bar" in content + assert "Native skill form: /skill:speckit-foo-bar" in content assert "speckit.foo.bar" in content - assert "/speckit-foo-bar" not in content - assert "$speckit-plan" not in content + assert "https://example.com/speckit.foo.bar" in content + assert "/speckit.foo.bar.md" in content def test_missing_command_file_skipped(self, skills_project, temp_dir): """Commands with missing source files should be skipped gracefully.""" From a01e05f39c776bf16be6dd69f514454adcebd6ed Mon Sep 17 00:00:00 2001 From: root Date: Tue, 18 Aug 2026 11:01:20 +0800 Subject: [PATCH 2/5] fix: constrain skill slash ref rewrites to manifest names --- src/specify_cli/extensions/__init__.py | 24 ++++++---- tests/test_extension_skills.py | 61 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index cc0f3da644..452dca122a 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1608,16 +1608,22 @@ def _replacement(match: re.Match[str]) -> str: def _normalize_literal_slash_command_refs(body: str) -> str: """Normalize literal /speckit.foo refs in generated skill bodies.""" + known_command_names = { + cmd["name"] + for cmd in manifest.commands + if isinstance(cmd.get("name"), str) + } + for cmd in manifest.commands: + aliases = cmd.get("aliases", []) + if not isinstance(aliases, list): + continue + known_command_names.update( + alias for alias in aliases if isinstance(alias, str) + ) + def _replacement(match: re.Match[str]) -> str: command_name = match.group("command") - if command_name.rsplit(".", 1)[-1] in { - "json", - "md", - "toml", - "txt", - "yaml", - "yml", - }: + if command_name not in known_command_names: return match.group(0) return _render_skill_command_invocation(command_name) @@ -1625,7 +1631,7 @@ def _replacement(match: re.Match[str]) -> str: ( r"(?speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)" - r"(?![A-Za-z0-9_.-])" + r"(?!/)" ), _replacement, body, diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 9c9993e3de..caa3312df1 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1202,6 +1202,10 @@ def test_skill_registration_rewrites_literal_slash_command_refs( "name": "speckit.literal-ref-ext.run", "file": "commands/run.md", "description": "Run command", + "aliases": [ + "speckit.foo.bar", + "speckit.export.json", + ], } ] }, @@ -1215,6 +1219,9 @@ def test_skill_registration_rewrites_literal_slash_command_refs( "description: Run command\n" "---\n\n" "Literal slash form: /speckit.foo.bar --flag value\n" + "Valid suffix form: /speckit.export.json\n" + "Path continuation form: /speckit.foo.bar/scripts/run.sh\n" + "Sentence punctuation form: Run /speckit.foo.bar.\n" "Native slash form: /speckit-foo-bar\n" "Native dollar form: $speckit-foo-bar\n" "Native skill form: /skill:speckit-foo-bar\n" @@ -1227,8 +1234,13 @@ def test_skill_registration_rewrites_literal_slash_command_refs( manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text() + expected_json_invocation = expected_invocation.replace("foo-bar", "export-json") assert f"Literal slash form: {expected_invocation} --flag value" in content assert "Literal slash form: /speckit.foo.bar --flag value" not in content + assert f"Valid suffix form: {expected_json_invocation}" in content + assert "Valid suffix form: /speckit.export.json" not in content + assert "Path continuation form: /speckit.foo.bar/scripts/run.sh" in content + assert f"Sentence punctuation form: Run {expected_invocation}." in content assert "Native slash form: /speckit-foo-bar" in content assert "Native dollar form: $speckit-foo-bar" in content assert "Native skill form: /skill:speckit-foo-bar" in content @@ -1236,6 +1248,55 @@ def test_skill_registration_rewrites_literal_slash_command_refs( assert "https://example.com/speckit.foo.bar" in content assert "/speckit.foo.bar.md" in content + def test_skill_registration_rewrites_multi_segment_alias_with_punctuation( + self, project_dir, temp_dir + ): + """Aliases can be free-form safe names with multiple dotted segments.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="claude") + + ext_dir = temp_dir / "multi-segment-alias-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "multi-segment-alias-ext", + "name": "Multi Segment Alias Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.multi-segment-alias-ext.run", + "file": "commands/run.md", + "description": "Run command", + "aliases": ["speckit.foo.bar.baz"], + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.safe_dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Run command\n" + "---\n\n" + "Sentence punctuation form: Run /speckit.foo.bar.baz.\n" + ) + + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + content = ( + skills_dir / "speckit-multi-segment-alias-ext-run" / "SKILL.md" + ).read_text() + assert "Sentence punctuation form: Run /speckit-foo-bar-baz." in content + assert "/speckit.foo.bar.baz." not in content + def test_missing_command_file_skipped(self, skills_project, temp_dir): """Commands with missing source files should be skipped gracefully.""" project_dir, skills_dir = skills_project From fb6d234e9bf407e3957c896f98e59903b0f72c8d Mon Sep 17 00:00:00 2001 From: root Date: Wed, 19 Aug 2026 11:48:25 +0800 Subject: [PATCH 3/5] fix: rewrite literal speckit references in generated skills --- src/specify_cli/agents.py | 35 ++++++++++++++++++++++++++++++++++ tests/test_extension_skills.py | 5 ++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index dede50e0b1..69c101acea 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -687,6 +687,39 @@ def register_commands( pass _prefix = get_invocation_prefix(agent_name, registrar_writes_skills) + # The later extension-skill pass normalizes literal slash-dot refs, + # but skills-native agents write SKILL.md through this registrar first. + # That later pass then preserves the file under its existing-file + # guard, so apply the same manifest-scoped normalization here. + known_command_names = { + command["name"] + for command in commands + if isinstance(command.get("name"), str) + } + for command in commands: + aliases = command.get("aliases", []) + if isinstance(aliases, list): + known_command_names.update( + alias for alias in aliases if isinstance(alias, str) + ) + + def _normalize_literal_slash_command_refs(body: str) -> str: + def _replacement(match: re.Match[str]) -> str: + command_name = match.group("command") + if command_name not in known_command_names: + return match.group(0) + return _prefix + command_name.replace(".", _sep) + + return re.sub( + ( + r"(?speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)" + r"(?!/)" + ), + _replacement, + body, + ) + for cmd_info in commands: cmd_name = cmd_info["name"] aliases = cmd_info.get("aliases", []) @@ -789,6 +822,8 @@ def register_commands( from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415 body = IntegrationBase.resolve_command_refs(body, _sep, _prefix) + if registrar_writes_skills: + body = _normalize_literal_slash_command_refs(body) output_name = self._compute_output_name(agent_name, cmd_name, agent_config) diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index caa3312df1..4da40573e3 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1231,7 +1231,10 @@ def test_skill_registration_rewrites_literal_slash_command_refs( ) manager = ExtensionManager(project_dir) - manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + # Exercise normal extension-add registration. Skills-native agents + # write their SKILL.md through CommandRegistrar before the later + # skill mirror reaches its existing-file guard. + manager.install_from_directory(ext_dir, "0.1.0", register_commands=True) content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text() expected_json_invocation = expected_invocation.replace("foo-bar", "export-json") From ffc0e7fb6e1ac6b4585e29e4b615790711f2049f Mon Sep 17 00:00:00 2001 From: root Date: Thu, 20 Aug 2026 11:22:52 +0800 Subject: [PATCH 4/5] fix(extensions): normalize skill command references --- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 13 +-- extensions/EXTENSION-USER-GUIDE.md | 9 +- src/specify_cli/agents.py | 135 ++++++++++++++++------ src/specify_cli/extensions/__init__.py | 93 ++++++++++----- tests/test_extension_skills.py | 73 ++++++++++++ tests/test_extensions.py | 8 ++ 6 files changed, 253 insertions(+), 78 deletions(-) diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index ac78029f2a..e19cb60f7b 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -314,15 +314,10 @@ Once the assessment exists, the next step is `__SPECKIT_COMMAND_BUG_FIX__ slug=< This renders as `/speckit.bug.fix slug=` for a slash-based agent, `/speckit-bug-fix slug=` for a skills-based agent, and so on — the author writes it once and it stays portable. The first-party `bug` and `git` extensions use this token exclusively; see `extensions/bug/commands/` for working examples. -> **Current limitation — skills mode.** Token resolution runs in the -> command-rendering path (`CommandRegistrar`), so it applies when an extension -> installs *command files*. It does **not** yet run when an extension is -> registered as *skills* for a skills-based agent: `_register_extension_skills` -> resolves placeholders and post-processes content but never calls -> `resolve_command_refs`, so a `__SPECKIT_COMMAND___` token reaches -> agents such as Codex, ZCode, and Kimi verbatim in that mode. Until that -> rendering step lands, prefer the token for command-file extensions and avoid -> relying on it inside skill bodies destined for skills-based agents. +Token resolution also applies when an extension is registered as skills for a +skills-based agent. Existing isolated literal slash-dot references are +normalized as a compatibility fallback, but new extension command bodies +should continue to use tokens so their intent is unambiguous across agents. ### Script Path Rewriting diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index d95872b204..6b918c24c7 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -205,12 +205,13 @@ When an extension is removed, its corresponding skills are also cleaned up autom When one extension command needs to reference another Spec Kit command, prefer the portable command token form, such as `__SPECKIT_COMMAND_PLAN__`, instead of hard-coding a slash command. Spec Kit renders these tokens to the active -integration's command style. For skills-based integrations, generated extension -skills also normalize literal slash-dot command references such as +integration's command style. As a compatibility fallback, generated extension +skills also normalize isolated literal slash-dot command references such as `/speckit.jira.specstoissues` to the active skill invocation form, for example `$speckit-jira-specstoissues` for Codex or `/speckit-jira-specstoissues` for -slash-skills agents. Avoid using bare prose like `speckit.jira.specstoissues` -when you intend the agent to invoke a command. +slash-skills agents. URL and path references remain literal. Avoid using bare +prose like `speckit.jira.specstoissues` when you intend the agent to invoke a +command. --- diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 69c101acea..4ffc1abaf0 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -10,7 +10,7 @@ import re from copy import deepcopy from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Callable, Dict, Iterable, List, Optional import yaml @@ -57,6 +57,34 @@ class CommandRegistrar: # Populated lazily via _ensure_configs() on first use. AGENT_CONFIGS: dict[str, dict[str, Any]] = {} _configs_loaded: bool = False + _LITERAL_EXTENSION_SKILL_COMMAND_REF = re.compile( + r"(?speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)" + ) + _FILE_LIKE_COMMAND_SUFFIXES = frozenset( + { + "csv", + "css", + "html", + "js", + "json", + "jsx", + "md", + "mdx", + "ps1", + "psm1", + "py", + "scss", + "sh", + "toml", + "ts", + "tsx", + "txt", + "xml", + "yaml", + "yml", + } + ) def __init__(self) -> None: self._ensure_configs() @@ -584,6 +612,50 @@ def _is_safe_command_name(name: str) -> bool: return False return os.path.normpath(name) == name + @classmethod + def normalize_literal_extension_skill_command_refs( + cls, + body: str, + render_invocation: Callable[[str], str], + known_command_names: Iterable[str], + ) -> str: + """Render isolated literal slash-dot refs in extension skill bodies. + + Tokens remain the portable authoring form. This compatibility fallback + covers existing literal references, including core and cross-extension + commands that are not part of the current extension manifest. + """ + known_names = frozenset( + name for name in known_command_names if isinstance(name, str) + ) + + def _is_url_value(match_start: int) -> bool: + token_start = max( + body.rfind(" ", 0, match_start), + body.rfind("\n", 0, match_start), + body.rfind("\t", 0, match_start), + ) + 1 + prefix = body[token_start:match_start] + return "?" in prefix or "#" in prefix + + def _replacement(match: re.Match[str]) -> str: + command_name = match.group("command") + if _is_url_value(match.start()): + return match.group(0) + if match.start() > 0 and body[match.start() - 1] == ".": + return match.group(0) + if body[match.end() : match.end() + 1] in {"/", "\\"}: + return match.group(0) + if ( + command_name not in known_names + and command_name.rsplit(".", 1)[-1].lower() + in cls._FILE_LIKE_COMMAND_SUFFIXES + ): + return match.group(0) + return render_invocation(command_name) + + return cls._LITERAL_EXTENSION_SKILL_COMMAND_REF.sub(_replacement, body) + @staticmethod def _same_lexical_path(left: Path, right: Path) -> bool: """Compare paths after lexical normalization without resolving symlinks.""" @@ -618,6 +690,7 @@ def register_commands( _resolved_dir: Optional[Path] = None, link_outputs: bool = False, extension_id: Optional[str] = None, + known_extension_command_names: Optional[Iterable[str]] = None, ) -> List[str]: """Register commands for a specific agent. @@ -687,39 +760,29 @@ def register_commands( pass _prefix = get_invocation_prefix(agent_name, registrar_writes_skills) - # The later extension-skill pass normalizes literal slash-dot refs, - # but skills-native agents write SKILL.md through this registrar first. - # That later pass then preserves the file under its existing-file - # guard, so apply the same manifest-scoped normalization here. - known_command_names = { - command["name"] - for command in commands - if isinstance(command.get("name"), str) - } - for command in commands: - aliases = command.get("aliases", []) - if isinstance(aliases, list): + # Skills-native agents write SKILL.md through this registrar before + # the extension skill mirror reaches its existing-file guard. Apply + # the same extension-only compatibility fallback here. + known_command_names: set[str] = set() + if registrar_writes_skills and extension_id is not None: + known_command_names = { + command["name"] + for command in commands + if isinstance(command.get("name"), str) + } + for command in commands: + aliases = command.get("aliases", []) + if isinstance(aliases, list): + known_command_names.update( + alias for alias in aliases if isinstance(alias, str) + ) + if known_extension_command_names is not None: known_command_names.update( - alias for alias in aliases if isinstance(alias, str) + name + for name in known_extension_command_names + if isinstance(name, str) ) - def _normalize_literal_slash_command_refs(body: str) -> str: - def _replacement(match: re.Match[str]) -> str: - command_name = match.group("command") - if command_name not in known_command_names: - return match.group(0) - return _prefix + command_name.replace(".", _sep) - - return re.sub( - ( - r"(?speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)" - r"(?!/)" - ), - _replacement, - body, - ) - for cmd_info in commands: cmd_name = cmd_info["name"] aliases = cmd_info.get("aliases", []) @@ -822,8 +885,12 @@ def _replacement(match: re.Match[str]) -> str: from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415 body = IntegrationBase.resolve_command_refs(body, _sep, _prefix) - if registrar_writes_skills: - body = _normalize_literal_slash_command_refs(body) + if registrar_writes_skills and extension_id is not None: + body = self.normalize_literal_extension_skill_command_refs( + body, + lambda command_name: _prefix + command_name.replace(".", _sep), + known_command_names, + ) output_name = self._compute_output_name(agent_name, cmd_name, agent_config) @@ -1095,6 +1162,7 @@ def register_commands_for_all_agents( create_missing_active_skills_dir: bool = False, extension_id: Optional[str] = None, only_agent: Optional[str] = None, + known_extension_command_names: Optional[Iterable[str]] = None, ) -> Dict[str, List[str]]: """Register commands for all detected agents in the project. @@ -1219,6 +1287,7 @@ def register_commands_for_all_agents( _resolved_dir=agent_dir, link_outputs=link_outputs, extension_id=extension_id, + known_extension_command_names=known_extension_command_names, ) if registered: results[agent_name] = registered diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 452dca122a..85810ffdb3 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1010,6 +1010,53 @@ def list_by_priority(self, include_disabled: bool = False) -> List[tuple]: ) +def _manifest_command_names(manifest: ExtensionManifest) -> set[str]: + """Return validated primary command names and aliases from one manifest.""" + names: set[str] = set() + for command in manifest.commands: + if not isinstance(command, dict): + continue + name = command.get("name") + if isinstance(name, str): + names.add(name) + aliases = command.get("aliases", []) + if isinstance(aliases, list): + names.update(alias for alias in aliases if isinstance(alias, str)) + return names + + +def _available_extension_skill_command_names( + manifest: ExtensionManifest, extensions_dir: Path +) -> set[str]: + """Return known callable names for extension skill compatibility rendering. + + The current manifest is not in the registry during a normal add, so it is + included explicitly. Core names and validated manifests for all registered + extensions make suffix-shaped cross-extension commands distinguishable from + file references. A corrupt registry is not trusted for discovery. + """ + names = {f"speckit.{name}" for name in CORE_COMMAND_NAMES} + names.update(_manifest_command_names(manifest)) + + registry = ExtensionRegistry(extensions_dir) + if registry.is_corrupt(): + return names + + for extension_id in registry.list(): + if not isinstance(extension_id, str) or not VALID_EXTENSION_ARTIFACT_NAME_PATTERN.fullmatch( + extension_id + ): + continue + try: + installed_manifest = ExtensionManifest( + extensions_dir / extension_id / "extension.yml" + ) + except ValidationError: + continue + names.update(_manifest_command_names(installed_manifest)) + return names + + class ExtensionManager: """Manages extension lifecycle: installation, removal, updates.""" @@ -1605,37 +1652,9 @@ def _replacement(match: re.Match[str]) -> str: r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body ) - def _normalize_literal_slash_command_refs(body: str) -> str: - """Normalize literal /speckit.foo refs in generated skill bodies.""" - - known_command_names = { - cmd["name"] - for cmd in manifest.commands - if isinstance(cmd.get("name"), str) - } - for cmd in manifest.commands: - aliases = cmd.get("aliases", []) - if not isinstance(aliases, list): - continue - known_command_names.update( - alias for alias in aliases if isinstance(alias, str) - ) - - def _replacement(match: re.Match[str]) -> str: - command_name = match.group("command") - if command_name not in known_command_names: - return match.group(0) - return _render_skill_command_invocation(command_name) - - return re.sub( - ( - r"(?speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)" - r"(?!/)" - ), - _replacement, - body, - ) + known_command_names = _available_extension_skill_command_names( + manifest, self.extensions_dir + ) for cmd_info in manifest.commands: cmd_name = cmd_info["name"] @@ -1716,7 +1735,9 @@ def _replacement(match: re.Match[str]) -> str: selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id ) body = _resolve_command_ref_tokens(body) - body = _normalize_literal_slash_command_refs(body) + body = registrar.normalize_literal_extension_skill_command_refs( + body, _render_skill_command_invocation, known_command_names + ) original_desc = frontmatter.get("description", "") description = original_desc or f"Extension command: {cmd_name}" @@ -3568,6 +3589,9 @@ def register_commands_for_agent( if agent_name not in self.AGENT_CONFIGS: raise ExtensionError(f"Unsupported agent: {agent_name}") context_note = f"\n\n\n" + known_command_names = _available_extension_skill_command_names( + manifest, project_root / ".specify" / "extensions" + ) return self._registrar.register_commands( agent_name, manifest.commands, @@ -3577,6 +3601,7 @@ def register_commands_for_agent( context_note=context_note, link_outputs=link_outputs, extension_id=manifest.id, + known_extension_command_names=known_command_names, ) def register_commands_for_all_agents( @@ -3590,6 +3615,9 @@ def register_commands_for_all_agents( ) -> Dict[str, List[str]]: """Register extension commands for all detected agents.""" context_note = f"\n\n\n" + known_command_names = _available_extension_skill_command_names( + manifest, project_root / ".specify" / "extensions" + ) return self._registrar.register_commands_for_all_agents( manifest.commands, manifest.id, @@ -3600,6 +3628,7 @@ def register_commands_for_all_agents( create_missing_active_skills_dir=create_missing_active_skills_dir, only_agent=only_agent, extension_id=manifest.id, + known_extension_command_names=known_command_names, ) def unregister_commands( diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 4da40573e3..707b0c09cc 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -175,6 +175,15 @@ def _create_extension_dir_with_aliases( return ext_dir +def _create_cross_extension_with_suffix_alias(temp_dir: Path) -> Path: + """Create an installed extension with a file-like command alias.""" + return _create_extension_dir_with_aliases( + temp_dir, + "other-ext", + [("run", ["speckit.other.export.json"])], + ) + + def _create_unicode_extension_dir(temp_dir: Path, ext_id: str = "uni-ext") -> Path: """Create an extension whose command description contains non-ASCII characters.""" ext_dir = temp_dir / ext_id @@ -1220,6 +1229,9 @@ def test_skill_registration_rewrites_literal_slash_command_refs( "---\n\n" "Literal slash form: /speckit.foo.bar --flag value\n" "Valid suffix form: /speckit.export.json\n" + "Core command form: /speckit.tasks\n" + "Cross-extension command form: /speckit.other-ext.run\n" + "Cross-extension suffix form: /speckit.other.export.json\n" "Path continuation form: /speckit.foo.bar/scripts/run.sh\n" "Sentence punctuation form: Run /speckit.foo.bar.\n" "Native slash form: /speckit-foo-bar\n" @@ -1227,10 +1239,21 @@ def test_skill_registration_rewrites_literal_slash_command_refs( "Native skill form: /skill:speckit-foo-bar\n" "Literal bare form: speckit.foo.bar\n" "Path-like form: https://example.com/speckit.foo.bar\n" + "Relative path form: ./speckit.foo.bar\n" + "Query URL form: https://example.test/redirect?next=/speckit.foo.bar\n" + "Fragment URL form: https://example.test/redirect#next=/speckit.foo.bar\n" + "Relative query URL form: /redirect?next=/speckit.foo.bar\n" + "Relative fragment URL form: /redirect#next=/speckit.foo.bar\n" + "Whitespace prose form: What? /speckit.foo.bar\n" "File-like form: /speckit.foo.bar.md\n" ) manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_cross_extension_with_suffix_alias(temp_dir), + "0.1.0", + register_commands=False, + ) # Exercise normal extension-add registration. Skills-native agents # write their SKILL.md through CommandRegistrar before the later # skill mirror reaches its existing-file guard. @@ -1238,10 +1261,26 @@ def test_skill_registration_rewrites_literal_slash_command_refs( content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text() expected_json_invocation = expected_invocation.replace("foo-bar", "export-json") + expected_core_invocation = expected_invocation.replace("foo-bar", "tasks") + expected_cross_extension_invocation = expected_invocation.replace( + "foo-bar", "other-ext-run" + ) + expected_cross_extension_suffix_invocation = expected_invocation.replace( + "foo-bar", "other-export-json" + ) assert f"Literal slash form: {expected_invocation} --flag value" in content assert "Literal slash form: /speckit.foo.bar --flag value" not in content assert f"Valid suffix form: {expected_json_invocation}" in content assert "Valid suffix form: /speckit.export.json" not in content + assert f"Core command form: {expected_core_invocation}" in content + assert ( + f"Cross-extension command form: {expected_cross_extension_invocation}" + in content + ) + assert ( + "Cross-extension suffix form: " + f"{expected_cross_extension_suffix_invocation}" in content + ) assert "Path continuation form: /speckit.foo.bar/scripts/run.sh" in content assert f"Sentence punctuation form: Run {expected_invocation}." in content assert "Native slash form: /speckit-foo-bar" in content @@ -1249,6 +1288,12 @@ def test_skill_registration_rewrites_literal_slash_command_refs( assert "Native skill form: /skill:speckit-foo-bar" in content assert "speckit.foo.bar" in content assert "https://example.com/speckit.foo.bar" in content + assert "./speckit.foo.bar" in content + assert "https://example.test/redirect?next=/speckit.foo.bar" in content + assert "https://example.test/redirect#next=/speckit.foo.bar" in content + assert "Relative query URL form: /redirect?next=/speckit.foo.bar" in content + assert "Relative fragment URL form: /redirect#next=/speckit.foo.bar" in content + assert f"Whitespace prose form: What? {expected_invocation}" in content assert "/speckit.foo.bar.md" in content def test_skill_registration_rewrites_multi_segment_alias_with_punctuation( @@ -1289,9 +1334,22 @@ def test_skill_registration_rewrites_multi_segment_alias_with_punctuation( "description: Run command\n" "---\n\n" "Sentence punctuation form: Run /speckit.foo.bar.baz.\n" + "Core command form: /speckit.tasks\n" + "Cross-extension command form: /speckit.other-ext.run\n" + "Cross-extension suffix form: /speckit.other.export.json\n" + "Query URL form: https://example.test/redirect?next=/speckit.foo.bar\n" + "Fragment URL form: https://example.test/redirect#next=/speckit.foo.bar\n" + "Relative query URL form: /redirect?next=/speckit.foo.bar\n" + "Relative fragment URL form: /redirect#next=/speckit.foo.bar\n" + "Whitespace prose form: What? /speckit.foo.bar\n" ) manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_cross_extension_with_suffix_alias(temp_dir), + "0.1.0", + register_commands=False, + ) manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) content = ( @@ -1299,6 +1357,14 @@ def test_skill_registration_rewrites_multi_segment_alias_with_punctuation( ).read_text() assert "Sentence punctuation form: Run /speckit-foo-bar-baz." in content assert "/speckit.foo.bar.baz." not in content + assert "Core command form: /speckit-tasks" in content + assert "Cross-extension command form: /speckit-other-ext-run" in content + assert "Cross-extension suffix form: /speckit-other-export-json" in content + assert "https://example.test/redirect?next=/speckit.foo.bar" in content + assert "https://example.test/redirect#next=/speckit.foo.bar" in content + assert "Relative query URL form: /redirect?next=/speckit.foo.bar" in content + assert "Relative fragment URL form: /redirect#next=/speckit.foo.bar" in content + assert "Whitespace prose form: What? /speckit-foo-bar" in content def test_missing_command_file_skipped(self, skills_project, temp_dir): """Commands with missing source files should be skipped gracefully.""" @@ -3109,6 +3175,12 @@ def test_register_enabled_extensions_for_agent_force_flag_threads_through( _create_init_options(project_dir, ai="claude", ai_skills=True) skills_dir = _create_skills_dir(project_dir, ai="claude") ext_dir = _create_extension_dir(temp_dir) + (ext_dir / "commands" / "hello.md").write_text( + "---\n" + "description: Test hello command\n" + "---\n\n" + "Run this to say hello. Next: /speckit.tasks\n" + ) manager = ExtensionManager(project_dir) # Install extension so it is in the registry @@ -3126,6 +3198,7 @@ def test_register_enabled_extensions_for_agent_force_flag_threads_through( "After register_enabled_extensions_for_agent(force=True), the SKILL.md " "must contain the extension body, not just the core-template stub." ) + assert "Next: /speckit-tasks" in content def test_force_true_with_preexisting_dir_but_no_skill_file( self, project_dir, temp_dir diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 36e7d67aab..e6dcebb4cb 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1746,11 +1746,15 @@ def fake_register_all( create_missing_active_skills_dir=False, extension_id=None, only_agent=None, + known_extension_command_names=None, ): captured["create_missing_active_skills_dir"] = ( create_missing_active_skills_dir ) captured["extension_id"] = extension_id + captured["known_extension_command_names"] = ( + known_extension_command_names + ) return {} monkeypatch.setattr( @@ -1765,6 +1769,10 @@ def fake_register_all( assert captured["create_missing_active_skills_dir"] is False assert captured["extension_id"] == manifest.id + assert ( + manifest.commands[0]["name"] + in captured["known_extension_command_names"] + ) def test_install_duplicate(self, extension_dir, project_dir): """Test installing already installed extension.""" From 99a45d9d96494d1eef802d70b3135bc1b87d735e Mon Sep 17 00:00:00 2001 From: root Date: Fri, 21 Aug 2026 11:30:46 +0800 Subject: [PATCH 5/5] fix(extensions): preserve valid skill command references Preserve relative Markdown and HTML link destinations during literal command normalization. Restrict fallback rewrites to primary skill artifacts that exist or can be emitted. --- src/specify_cli/agents.py | 31 ++++- src/specify_cli/extensions/__init__.py | 90 +++++++++++++-- tests/test_extension_skills.py | 152 +++++++++++++++++++++---- 3 files changed, 234 insertions(+), 39 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 4ffc1abaf0..deae15f263 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -61,6 +61,10 @@ class CommandRegistrar: r"(?speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)" ) + _MARKDOWN_INLINE_LINK_DESTINATION_PREFIX = re.compile(r"\]\(\s* str: """Render isolated literal slash-dot refs in extension skill bodies. Tokens remain the portable authoring form. This compatibility fallback covers existing literal references, including core and cross-extension - commands that are not part of the current extension manifest. + commands that are not part of the current extension manifest. Callers + whose output path does not emit aliases can restrict rewriting to the + supplied names so references never target absent skill artifacts. """ known_names = frozenset( name for name in known_command_names if isinstance(name, str) @@ -638,14 +646,33 @@ def _is_url_value(match_start: int) -> bool: prefix = body[token_start:match_start] return "?" in prefix or "#" in prefix + def _is_relative_link_destination(match_start: int) -> bool: + line_start = body.rfind("\n", 0, match_start) + 1 + if cls._MARKDOWN_INLINE_LINK_DESTINATION_PREFIX.search( + body[line_start:match_start] + ): + return True + + tag_start = body.rfind("<", 0, match_start) + if tag_start < 0 or body.rfind(">", 0, match_start) > tag_start: + return False + return ( + cls._HTML_HREF_VALUE_PREFIX.search(body[tag_start:match_start]) + is not None + ) + def _replacement(match: re.Match[str]) -> str: command_name = match.group("command") - if _is_url_value(match.start()): + if _is_url_value(match.start()) or _is_relative_link_destination( + match.start() + ): return match.group(0) if match.start() > 0 and body[match.start() - 1] == ".": return match.group(0) if body[match.end() : match.end() + 1] in {"/", "\\"}: return match.group(0) + if restrict_to_known_commands and command_name not in known_names: + return match.group(0) if ( command_name not in known_names and command_name.rsplit(".", 1)[-1].lower() diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index ff648ad686..6c4c9f1504 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1010,8 +1010,10 @@ def list_by_priority(self, include_disabled: bool = False) -> List[tuple]: ) -def _manifest_command_names(manifest: ExtensionManifest) -> set[str]: - """Return validated primary command names and aliases from one manifest.""" +def _manifest_command_names( + manifest: ExtensionManifest, *, include_aliases: bool = True +) -> set[str]: + """Return validated command names emitted by the requested output path.""" names: set[str] = set() for command in manifest.commands: if not isinstance(command, dict): @@ -1019,24 +1021,29 @@ def _manifest_command_names(manifest: ExtensionManifest) -> set[str]: name = command.get("name") if isinstance(name, str): names.add(name) - aliases = command.get("aliases", []) - if isinstance(aliases, list): - names.update(alias for alias in aliases if isinstance(alias, str)) + if include_aliases: + aliases = command.get("aliases", []) + if isinstance(aliases, list): + names.update(alias for alias in aliases if isinstance(alias, str)) return names def _available_extension_skill_command_names( - manifest: ExtensionManifest, extensions_dir: Path + manifest: ExtensionManifest, + extensions_dir: Path, + *, + include_aliases: bool = True, ) -> set[str]: """Return known callable names for extension skill compatibility rendering. The current manifest is not in the registry during a normal add, so it is included explicitly. Core names and validated manifests for all registered extensions make suffix-shaped cross-extension commands distinguishable from - file references. A corrupt registry is not trusted for discovery. + file references. ``include_aliases=False`` matches output paths that emit + only primary-command skills. A corrupt registry is not trusted for discovery. """ names = {f"speckit.{name}" for name in CORE_COMMAND_NAMES} - names.update(_manifest_command_names(manifest)) + names.update(_manifest_command_names(manifest, include_aliases=include_aliases)) registry = ExtensionRegistry(extensions_dir) if registry.is_corrupt(): @@ -1053,7 +1060,11 @@ def _available_extension_skill_command_names( ) except ValidationError: continue - names.update(_manifest_command_names(installed_manifest)) + names.update( + _manifest_command_names( + installed_manifest, include_aliases=include_aliases + ) + ) return names @@ -1652,10 +1663,62 @@ def _replacement(match: re.Match[str]) -> str: r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body ) - known_command_names = _available_extension_skill_command_names( - manifest, self.extensions_dir + # This mirror emits one skill per primary command. Unlike the + # skills-native registrar path, it does not emit alias skill artifacts, + # so literal aliases must remain unchanged. + # A manifest declaration alone is not sufficient: installed/core + # commands must have an artifact, and current-extension primaries must + # pass the same preflight guards as the generation loop below. + candidate_primary_command_names = _available_extension_skill_command_names( + manifest, self.extensions_dir, include_aliases=False ) + def _skill_file_for_command(command_name: str) -> Path: + skill_name = self._skill_name_for_command(command_name) + return skills_dir / skill_name / "SKILL.md" + + emitted_skill_command_names = { + command_name + for command_name in candidate_primary_command_names + if _skill_file_for_command(command_name).exists() + or _skill_file_for_command(command_name).is_symlink() + } + + try: + ext_root = extension_dir.resolve() + except OSError: + ext_root = None + + if ext_root is not None: + for cmd_info in manifest.commands: + cmd_path = Path(cmd_info["file"]) + if cmd_path.is_absolute(): + continue + try: + source_file = (ext_root / cmd_path).resolve() + source_file.relative_to(ext_root) + except (OSError, ValueError): + continue + if not source_file.is_file(): + continue + + skill_name = self._skill_name_for_command(cmd_info["name"]) + skill_subdir = skills_dir / skill_name + skill_file = skill_subdir / "SKILL.md" + if skill_file.exists() or skill_file.is_symlink(): + emitted_skill_command_names.add(cmd_info["name"]) + continue + skill_dir_preexists = ( + skill_subdir.exists() or skill_subdir.is_symlink() + ) + if skill_dir_preexists and not force: + continue + try: + source_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + emitted_skill_command_names.add(cmd_info["name"]) + for cmd_info in manifest.commands: cmd_name = cmd_info["name"] cmd_file_rel = cmd_info["file"] @@ -1736,7 +1799,10 @@ def _replacement(match: re.Match[str]) -> str: ) body = _resolve_command_ref_tokens(body) body = registrar.normalize_literal_extension_skill_command_refs( - body, _render_skill_command_invocation, known_command_names + body, + _render_skill_command_invocation, + emitted_skill_command_names, + restrict_to_known_commands=True, ) original_desc = frontmatter.get("description", "") diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 707b0c09cc..1c56b6e4d4 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1176,23 +1176,27 @@ def test_skill_registration_resolves_command_ref_tokens( assert expected_invocation in content @pytest.mark.parametrize( - ("ai", "expected_invocation"), + ("ai", "expected_invocation", "aliases_emitted"), [ - ("claude", "/speckit-foo-bar"), - ("copilot", "/speckit-foo-bar"), - ("codex", "$speckit-foo-bar"), - ("command-code", "$speckit-foo-bar"), - ("kimi", "/skill:speckit-foo-bar"), - ("zcode", "$speckit-foo-bar"), - ("bob", "/speckit-foo-bar"), + ("claude", "/speckit-foo-bar", True), + ("copilot", "/speckit-foo-bar", False), + ("codex", "$speckit-foo-bar", True), + ("command-code", "$speckit-foo-bar", True), + ("kimi", "/skill:speckit-foo-bar", True), + ("zcode", "$speckit-foo-bar", True), + ("bob", "/speckit-foo-bar", False), + ("qodercli", "/speckit-foo-bar", True), ], ) - def test_skill_registration_rewrites_literal_slash_command_refs( - self, project_dir, temp_dir, ai, expected_invocation + def test_skill_registration_normalizes_only_emitted_skill_command_refs( + self, project_dir, temp_dir, ai, expected_invocation, aliases_emitted ): - """Auto-registered skills should normalize literal slash-dot refs.""" + """Literal refs normalize only when the active path emits their skill.""" _create_init_options(project_dir, ai=ai, ai_skills=True) skills_dir = _create_skills_dir(project_dir, ai=ai) + core_skill = skills_dir / "speckit-tasks" / "SKILL.md" + core_skill.parent.mkdir(parents=True, exist_ok=True) + core_skill.write_text("core tasks skill", encoding="utf-8") ext_dir = temp_dir / "literal-ref-ext" ext_dir.mkdir() @@ -1229,6 +1233,8 @@ def test_skill_registration_rewrites_literal_slash_command_refs( "---\n\n" "Literal slash form: /speckit.foo.bar --flag value\n" "Valid suffix form: /speckit.export.json\n" + "Primary command form: /speckit.literal-ref-ext.run\n" + "Primary prose form: Run /speckit.literal-ref-ext.run.\n" "Core command form: /speckit.tasks\n" "Cross-extension command form: /speckit.other-ext.run\n" "Cross-extension suffix form: /speckit.other.export.json\n" @@ -1245,6 +1251,10 @@ def test_skill_registration_rewrites_literal_slash_command_refs( "Relative query URL form: /redirect?next=/speckit.foo.bar\n" "Relative fragment URL form: /redirect#next=/speckit.foo.bar\n" "Whitespace prose form: What? /speckit.foo.bar\n" + "Markdown link form: [docs](/speckit.literal-ref-ext.run)\n" + "Quoted HTML href form: docs\n" + "Single-quoted HTML href form: docs\n" + "Unquoted HTML href form: docs\n" "File-like form: /speckit.foo.bar.md\n" ) @@ -1252,7 +1262,7 @@ def test_skill_registration_rewrites_literal_slash_command_refs( manager.install_from_directory( _create_cross_extension_with_suffix_alias(temp_dir), "0.1.0", - register_commands=False, + register_commands=True, ) # Exercise normal extension-add registration. Skills-native agents # write their SKILL.md through CommandRegistrar before the later @@ -1261,6 +1271,9 @@ def test_skill_registration_rewrites_literal_slash_command_refs( content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text() expected_json_invocation = expected_invocation.replace("foo-bar", "export-json") + expected_primary_invocation = expected_invocation.replace( + "foo-bar", "literal-ref-ext-run" + ) expected_core_invocation = expected_invocation.replace("foo-bar", "tasks") expected_cross_extension_invocation = expected_invocation.replace( "foo-bar", "other-ext-run" @@ -1268,34 +1281,119 @@ def test_skill_registration_rewrites_literal_slash_command_refs( expected_cross_extension_suffix_invocation = expected_invocation.replace( "foo-bar", "other-export-json" ) - assert f"Literal slash form: {expected_invocation} --flag value" in content - assert "Literal slash form: /speckit.foo.bar --flag value" not in content - assert f"Valid suffix form: {expected_json_invocation}" in content - assert "Valid suffix form: /speckit.export.json" not in content + assert f"Primary command form: {expected_primary_invocation}" in content + assert f"Primary prose form: Run {expected_primary_invocation}." in content assert f"Core command form: {expected_core_invocation}" in content assert ( f"Cross-extension command form: {expected_cross_extension_invocation}" in content ) - assert ( - "Cross-extension suffix form: " - f"{expected_cross_extension_suffix_invocation}" in content - ) assert "Path continuation form: /speckit.foo.bar/scripts/run.sh" in content - assert f"Sentence punctuation form: Run {expected_invocation}." in content assert "Native slash form: /speckit-foo-bar" in content assert "Native dollar form: $speckit-foo-bar" in content assert "Native skill form: /skill:speckit-foo-bar" in content - assert "speckit.foo.bar" in content + assert "Literal bare form: speckit.foo.bar" in content assert "https://example.com/speckit.foo.bar" in content assert "./speckit.foo.bar" in content assert "https://example.test/redirect?next=/speckit.foo.bar" in content assert "https://example.test/redirect#next=/speckit.foo.bar" in content assert "Relative query URL form: /redirect?next=/speckit.foo.bar" in content assert "Relative fragment URL form: /redirect#next=/speckit.foo.bar" in content - assert f"Whitespace prose form: What? {expected_invocation}" in content + assert "Markdown link form: [docs](/speckit.literal-ref-ext.run)" in content + assert ( + 'Quoted HTML href form: docs' + in content + ) + assert ( + "Single-quoted HTML href form: " + "docs" in content + ) + assert ( + "Unquoted HTML href form: " + "docs" in content + ) assert "/speckit.foo.bar.md" in content + alias_skill = skills_dir / "speckit-foo-bar" / "SKILL.md" + if aliases_emitted: + assert f"Literal slash form: {expected_invocation} --flag value" in content + assert f"Valid suffix form: {expected_json_invocation}" in content + assert ( + "Cross-extension suffix form: " + f"{expected_cross_extension_suffix_invocation}" in content + ) + assert f"Sentence punctuation form: Run {expected_invocation}." in content + assert f"Whitespace prose form: What? {expected_invocation}" in content + assert alias_skill.is_file() + else: + assert "Literal slash form: /speckit.foo.bar --flag value" in content + assert "Valid suffix form: /speckit.export.json" in content + assert ( + "Cross-extension suffix form: /speckit.other.export.json" in content + ) + assert "Sentence punctuation form: Run /speckit.foo.bar." in content + assert "Whitespace prose form: What? /speckit.foo.bar" in content + assert not alias_skill.exists() + + @pytest.mark.parametrize("ai", ["copilot", "bob"]) + def test_fallback_keeps_missing_source_primary_ref_literal( + self, project_dir, temp_dir, ai + ): + """Fallback rewrites only primaries whose skill can be emitted.""" + _create_init_options(project_dir, ai=ai, ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai=ai) + + ext_dir = temp_dir / f"missing-primary-{ai}" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "missing-primary-ext", + "name": "Missing Primary Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.missing-primary-ext.run", + "file": "commands/run.md", + "description": "Run command", + }, + { + "name": "speckit.missing-primary-ext.ghost", + "file": "commands/ghost.md", + "description": "Missing command", + }, + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.safe_dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Run command\n" + "---\n\n" + "Generated primary: /speckit.missing-primary-ext.run\n" + "Missing primary: /speckit.missing-primary-ext.ghost\n", + encoding="utf-8", + ) + + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=True) + + content = ( + skills_dir / "speckit-missing-primary-ext-run" / "SKILL.md" + ).read_text(encoding="utf-8") + assert "Generated primary: /speckit-missing-primary-ext-run" in content + assert "Missing primary: /speckit.missing-primary-ext.ghost" in content + assert not ( + skills_dir / "speckit-missing-primary-ext-ghost" / "SKILL.md" + ).exists() + def test_skill_registration_rewrites_multi_segment_alias_with_punctuation( self, project_dir, temp_dir ): @@ -1348,9 +1446,9 @@ def test_skill_registration_rewrites_multi_segment_alias_with_punctuation( manager.install_from_directory( _create_cross_extension_with_suffix_alias(temp_dir), "0.1.0", - register_commands=False, + register_commands=True, ) - manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=True) content = ( skills_dir / "speckit-multi-segment-alias-ext-run" / "SKILL.md" @@ -1365,6 +1463,7 @@ def test_skill_registration_rewrites_multi_segment_alias_with_punctuation( assert "Relative query URL form: /redirect?next=/speckit.foo.bar" in content assert "Relative fragment URL form: /redirect#next=/speckit.foo.bar" in content assert "Whitespace prose form: What? /speckit-foo-bar" in content + assert (skills_dir / "speckit-foo-bar-baz" / "SKILL.md").is_file() def test_missing_command_file_skipped(self, skills_project, temp_dir): """Commands with missing source files should be skipped gracefully.""" @@ -3174,6 +3273,9 @@ def test_register_enabled_extensions_for_agent_force_flag_threads_through( """ _create_init_options(project_dir, ai="claude", ai_skills=True) skills_dir = _create_skills_dir(project_dir, ai="claude") + core_skill = skills_dir / "speckit-tasks" / "SKILL.md" + core_skill.parent.mkdir(parents=True, exist_ok=True) + core_skill.write_text("core tasks skill", encoding="utf-8") ext_dir = _create_extension_dir(temp_dir) (ext_dir / "commands" / "hello.md").write_text( "---\n"