diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index e00b9c247a..05aa35f7fb 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) @@ -1015,6 +1016,30 @@ 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 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 re.sub( + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body + ) for cmd_info in manifest.commands: cmd_name = cmd_info["name"] @@ -1086,6 +1111,7 @@ def _register_extension_skills( body = registrar.resolve_skill_placeholders( selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id ) + 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 2ebd3f5e6b..dea42a3852 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -970,6 +970,113 @@ 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 + @pytest.mark.parametrize( + ("ai", "expected_invocation"), + [ + ("claude", "/speckit-plan"), + ("copilot", "/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=ai, ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai=ai) + + 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 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="codex", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="codex") + + 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 skill form: /speckit-plan\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-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.""" project_dir, skills_dir = skills_project