From 749ecdf991a1280563f23ba448bfd296c46def37 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 14 Aug 2026 13:10:15 +0800 Subject: [PATCH] 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."""