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/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/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index fb4a30519d..452dca122a 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,68 @@ 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.""" + + 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, + ) + for cmd_info in manifest.commands: cmd_name = cmd_info["name"] cmd_file_rel = cmd_info["file"] @@ -1678,6 +1716,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..4da40573e3 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() @@ -1190,6 +1202,10 @@ def test_skill_registration_does_not_rewrite_literal_speckit_text( "name": "speckit.literal-ref-ext.run", "file": "commands/run.md", "description": "Run command", + "aliases": [ + "speckit.foo.bar", + "speckit.export.json", + ], } ] }, @@ -1202,20 +1218,87 @@ 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" + "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" "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) + # 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() - assert "/speckit.foo.bar" in content - assert "/speckit-plan" in content + 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 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_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."""