From f1081412f7f694ab91b775865bfc136d168737c6 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Jul 2026 21:38:24 +0800 Subject: [PATCH 1/3] fix(extensions): resolve command ref tokens in extension skills --- src/specify_cli/extensions/__init__.py | 4 ++ tests/test_extension_skills.py | 92 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index e00b9c247a..f023b07506 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1004,6 +1004,7 @@ 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) @@ -1086,6 +1087,9 @@ def _register_extension_skills( body = registrar.resolve_skill_placeholders( selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id ) + body = IntegrationBase.resolve_command_refs( + body, agent_config.get("invoke_separator", ".") + ) 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 2ebd3f5e6b..4688dd92ef 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -970,6 +970,98 @@ def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, t assert "Read agents/control" not in content assert "and knowledge-base/" not in content + def test_skill_registration_resolves_command_ref_tokens(self, project_dir, temp_dir): + """Auto-registered skills should resolve explicit command ref tokens.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="claude") + + ext_dir = temp_dir / "command-ref-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "command-ref-ext", + "name": "Command Ref Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.command-ref-ext.run", + "file": "commands/run.md", + "description": "Run 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" + "Use __SPECKIT_COMMAND_PLAN__ before proceeding.\n" + ) + + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + content = (skills_dir / "speckit-command-ref-ext-run" / "SKILL.md").read_text() + assert "__SPECKIT_COMMAND_PLAN__" not in content + assert "/speckit-plan" in content + + def test_skill_registration_does_not_rewrite_literal_speckit_text( + self, project_dir, temp_dir + ): + """Auto-registered skills should leave literal speckit text untouched.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="claude") + + ext_dir = temp_dir / "literal-ref-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "literal-ref-ext", + "name": "Literal Ref Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.literal-ref-ext.run", + "file": "commands/run.md", + "description": "Run 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" + "Literal slash form: /speckit.foo.bar\n" + "Literal bare form: speckit.foo.bar\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.foo.bar" in content + assert "/speckit-foo-bar" 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 e7b09fb9382a5aefd1e46263a2f51c04500d3834 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Jul 2026 23:01:00 +0800 Subject: [PATCH 2/3] fix(extensions): render skill command refs by invocation style Resolve extension skill command-reference tokens with the active skill invocation style so Codex and ZCode use $speckit-* while slash-style agents keep their native forms. Preserve literal command-looking text. --- src/specify_cli/extensions/__init__.py | 24 +++++++++++++++++++++--- tests/test_extension_skills.py | 26 ++++++++++++++++++++------ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index f023b07506..bd182d36b1 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1016,6 +1016,26 @@ def _register_extension_skills( registrar = CommandRegistrar() agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) integration = get_integration(selected_ai) + ai_skills_enabled = is_ai_skills_enabled(opts) + + 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 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 re.sub( + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body + ) for cmd_info in manifest.commands: cmd_name = cmd_info["name"] @@ -1087,9 +1107,7 @@ def _register_extension_skills( body = registrar.resolve_skill_placeholders( selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id ) - body = IntegrationBase.resolve_command_refs( - body, agent_config.get("invoke_separator", ".") - ) + body = _resolve_command_ref_tokens(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 4688dd92ef..d040738ab0 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -970,10 +970,21 @@ def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, t assert "Read agents/control" not in content assert "and knowledge-base/" not in content - def test_skill_registration_resolves_command_ref_tokens(self, project_dir, temp_dir): + @pytest.mark.parametrize( + ("ai", "expected_invocation"), + [ + ("claude", "/speckit-plan"), + ("codex", "$speckit-plan"), + ("kimi", "/skill:speckit-plan"), + ("zcode", "$speckit-plan"), + ], + ) + def test_skill_registration_resolves_command_ref_tokens( + self, project_dir, temp_dir, ai, expected_invocation + ): """Auto-registered skills should resolve explicit command ref tokens.""" - _create_init_options(project_dir, ai="claude", ai_skills=True) - skills_dir = _create_skills_dir(project_dir, ai="claude") + _create_init_options(project_dir, ai=ai, ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai=ai) ext_dir = temp_dir / "command-ref-ext" ext_dir.mkdir() @@ -1012,14 +1023,14 @@ def test_skill_registration_resolves_command_ref_tokens(self, project_dir, temp_ content = (skills_dir / "speckit-command-ref-ext-run" / "SKILL.md").read_text() assert "__SPECKIT_COMMAND_PLAN__" not in content - assert "/speckit-plan" in content + assert expected_invocation in content def test_skill_registration_does_not_rewrite_literal_speckit_text( self, project_dir, temp_dir ): """Auto-registered skills should leave literal speckit text untouched.""" - _create_init_options(project_dir, ai="claude", ai_skills=True) - skills_dir = _create_skills_dir(project_dir, ai="claude") + _create_init_options(project_dir, ai="codex", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="codex") ext_dir = temp_dir / "literal-ref-ext" ext_dir.mkdir() @@ -1051,6 +1062,7 @@ def test_skill_registration_does_not_rewrite_literal_speckit_text( "description: Run command\n" "---\n\n" "Literal slash form: /speckit.foo.bar\n" + "Literal skill form: /speckit-plan\n" "Literal bare form: speckit.foo.bar\n" ) @@ -1059,8 +1071,10 @@ def test_skill_registration_does_not_rewrite_literal_speckit_text( content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text() assert "/speckit.foo.bar" in content + assert "/speckit-plan" in content assert "speckit.foo.bar" in content assert "/speckit-foo-bar" not in content + assert "$speckit-plan" not in content def test_missing_command_file_skipped(self, skills_project, temp_dir): """Commands with missing source files should be skipped gracefully.""" From ab75146c9cd9e92d4c93a455e8b64414cfff7c45 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 09:53:41 +0800 Subject: [PATCH 3/3] fix(extensions): resolve slash skill command refs from init options --- src/specify_cli/extensions/__init__.py | 4 ++++ tests/test_extension_skills.py | 1 + 2 files changed, 5 insertions(+) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index bd182d36b1..05aa35f7fb 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1027,6 +1027,10 @@ def _replacement(match: re.Match[str]) -> str: 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( diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index d040738ab0..dea42a3852 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -974,6 +974,7 @@ def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, t ("ai", "expected_invocation"), [ ("claude", "/speckit-plan"), + ("copilot", "/speckit-plan"), ("codex", "$speckit-plan"), ("kimi", "/skill:speckit-plan"), ("zcode", "$speckit-plan"),