From 63b596674f8f54879ccbf4f8dc3ca37c1b1ad67b Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Sun, 12 Jul 2026 01:43:44 +0700 Subject: [PATCH] fix: rewrite extension skill command references Closes #3451 Assisted-by: Codex (model: GPT-5, autonomous) --- extensions/EXTENSION-USER-GUIDE.md | 2 + src/specify_cli/extensions/__init__.py | 99 +++++++++++++++++++------- tests/test_extension_skills.py | 49 +++++++++++++ 3 files changed, 124 insertions(+), 26 deletions(-) diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index c3391dbc75..80df9367a2 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -202,6 +202,8 @@ 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. +Extension command bodies can keep portable slash-dot references such as `/speckit.jira.sync-status`. During skill registration, spec-kit rewrites standalone command references to the active agent's invocation style—for example, `$speckit-jira-sync-status` for Codex, `/skill:speckit-jira-sync-status` for Kimi, or `/speckit-jira-sync-status` for slash-based skills integrations. URL and path-like references are left unchanged. + --- ## Using Extensions diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index b84b836545..25a65ee03f 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -49,6 +49,15 @@ } ) EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") +_SPECKIT_SLASH_REF_PATTERN = re.compile( + r"(? str: + """Map a command id like ``speckit.plan`` to its installed skill name.""" + if not isinstance(command, str): + return "" + command_id = command.strip() + if not command_id.startswith("speckit."): + return "" + return f"speckit-{command_id[len('speckit.') :].replace('.', '-')}" + + +def _render_agent_command_invocation( + command: Any, selected_ai: Any, ai_skills_enabled: bool +) -> str: + """Render a command using the active agent's invocation syntax.""" + if not isinstance(command, str): + return "" + + command_id = command.strip() + if not command_id: + return "" + + skill_name = _skill_name_from_command(command_id) + if is_dollar_skills_agent(selected_ai, ai_skills_enabled) and skill_name: + return f"${skill_name}" + if selected_ai == "kimi" and skill_name: + return f"/skill:{skill_name}" + if selected_ai == "cline": + from ..integrations.cline import format_cline_command_name + + return f"/{format_cline_command_name(command_id)}" + if is_slash_skills_agent(selected_ai, ai_skills_enabled) and skill_name: + return f"/{skill_name}" + return f"/{command_id}" + + +def _rewrite_skill_body_command_refs( + body: str, selected_ai: str, ai_skills_enabled: bool +) -> str: + """Rewrite slash-dot command references for an agent skill body.""" + def replace(match: re.Match[str]) -> str: + line_start = body.rfind("\n", 0, match.start()) + 1 + line_prefix = body[line_start : match.start()] + if _MARKDOWN_LINK_DESTINATION_PREFIX_PATTERN.search( + line_prefix + ) or _HTML_LINK_ATTRIBUTE_PREFIX_PATTERN.search(line_prefix): + return match.group(0) + return _render_agent_command_invocation( + match.group(1), selected_ai, ai_skills_enabled + ) + + return _SPECKIT_SLASH_REF_PATTERN.sub(replace, body) + + def _load_core_command_names() -> frozenset[str]: """Discover bundled core command names from the packaged templates. @@ -1081,6 +1143,13 @@ def _register_extension_skills( body = registrar.resolve_skill_placeholders( selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id ) + # Extension authors commonly reference sibling commands using the + # portable slash-dot spelling (for example, + # ``/speckit.memory.prepare``). Rewrite those references to the + # active skills integration's native invocation form. + body = _rewrite_skill_body_command_refs( + body, selected_ai, is_ai_skills_enabled(opts) + ) original_desc = frontmatter.get("description", "") description = original_desc or f"Extension command: {cmd_name}" @@ -2898,12 +2967,7 @@ def _load_init_options(self) -> Dict[str, Any]: @staticmethod def _skill_name_from_command(command: Any) -> str: """Map a command id like speckit.plan to speckit-plan skill name.""" - if not isinstance(command, str): - return "" - command_id = command.strip() - if not command_id.startswith("speckit."): - return "" - return f"speckit-{command_id[len('speckit.') :].replace('.', '-')}" + return _skill_name_from_command(command) def _render_hook_invocation(self, command: Any) -> str: """Render an agent-specific invocation string for a hook command.""" @@ -2918,26 +2982,9 @@ def _render_hook_invocation(self, command: Any) -> str: selected_ai = init_options.get("ai") ai_skills_enabled = is_ai_skills_enabled(init_options) - dollar_skill_mode = is_dollar_skills_agent(selected_ai, ai_skills_enabled) - kimi_skill_mode = selected_ai == "kimi" - cline_mode = selected_ai == "cline" - - skill_name = self._skill_name_from_command(command_id) - if dollar_skill_mode and skill_name: - return f"${skill_name}" - if kimi_skill_mode and skill_name: - return f"/skill:{skill_name}" - if cline_mode: - from ..integrations.cline import format_cline_command_name - - return f"/{format_cline_command_name(command_id)}" - - use_slash = is_slash_skills_agent(selected_ai, ai_skills_enabled) - - if skill_name and use_slash: - return f"/{skill_name}" - - return f"/{command_id}" + return _render_agent_command_invocation( + command_id, selected_ai, ai_skills_enabled + ) def get_project_config(self) -> Dict[str, Any]: """Load project-level extension configuration. diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index c2d9bb439e..6b3d9f59cd 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -292,6 +292,55 @@ def test_returns_none_for_non_dict_init_options(self, project_dir): class TestExtensionSkillRegistration: """Test _register_extension_skills() on ExtensionManager.""" + @pytest.mark.parametrize( + ("ai", "expected_invocation"), + [ + ("codex", "$speckit-test-ext-world"), + ("kimi", "/skill:speckit-test-ext-world"), + ("claude", "/speckit-test-ext-world"), + ], + ) + def test_dotted_command_refs_use_skill_invocation( + self, project_dir, extension_dir, ai, expected_invocation + ): + """Extension cross-command refs should match each skills integration.""" + _create_init_options(project_dir, ai=ai, ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai=ai) + hello_command = extension_dir / "commands" / "hello.md" + hello_command.write_text( + hello_command.read_text(encoding="utf-8") + + ( + "\nContinue with /speckit.test-ext.world.\n" + "Documentation: https://speckit.test-ext.world/docs\n" + "Local path: ./speckit.test-ext.world/docs\n" + "Parent path: ../speckit.test-ext.world/docs\n" + "Root path: /speckit.test-ext.world/docs\n" + "Markdown link: [guide](/speckit.test-ext.world.md)\n" + "Spaced link: [guide]( /speckit.test-ext.world.md )\n" + 'HTML link: guide\n' + "Query path: /speckit.test-ext.world?raw=1\n" + ), + encoding="utf-8", + ) + + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + content = ( + skills_dir / "speckit-test-ext-hello" / "SKILL.md" + ).read_text(encoding="utf-8") + assert f"Continue with {expected_invocation}." in content + assert "Documentation: https://speckit.test-ext.world/docs" in content + assert "Local path: ./speckit.test-ext.world/docs" in content + assert "Parent path: ../speckit.test-ext.world/docs" in content + assert "Root path: /speckit.test-ext.world/docs" in content + assert "Markdown link: [guide](/speckit.test-ext.world.md)" in content + assert "Spaced link: [guide]( /speckit.test-ext.world.md )" in content + assert 'HTML link: ' in content + assert "Query path: /speckit.test-ext.world?raw=1" in content + def test_skills_created_when_ai_skills_active(self, skills_project, extension_dir): """Skills should be created when ai_skills is enabled.""" project_dir, skills_dir = skills_project