diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index ac78029f2a..e19cb60f7b 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -314,15 +314,10 @@ Once the assessment exists, the next step is `__SPECKIT_COMMAND_BUG_FIX__ slug=< This renders as `/speckit.bug.fix slug=` for a slash-based agent, `/speckit-bug-fix slug=` for a skills-based agent, and so on — the author writes it once and it stays portable. The first-party `bug` and `git` extensions use this token exclusively; see `extensions/bug/commands/` for working examples. -> **Current limitation — skills mode.** Token resolution runs in the -> command-rendering path (`CommandRegistrar`), so it applies when an extension -> installs *command files*. It does **not** yet run when an extension is -> registered as *skills* for a skills-based agent: `_register_extension_skills` -> resolves placeholders and post-processes content but never calls -> `resolve_command_refs`, so a `__SPECKIT_COMMAND___` token reaches -> agents such as Codex, ZCode, and Kimi verbatim in that mode. Until that -> rendering step lands, prefer the token for command-file extensions and avoid -> relying on it inside skill bodies destined for skills-based agents. +Token resolution also applies when an extension is registered as skills for a +skills-based agent. Existing isolated literal slash-dot references are +normalized as a compatibility fallback, but new extension command bodies +should continue to use tokens so their intent is unambiguous across agents. ### Script Path Rewriting diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index c3391dbc75..6b918c24c7 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -202,6 +202,17 @@ 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. As a compatibility fallback, generated extension +skills also normalize isolated 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. URL and path references remain literal. 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..deae15f263 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -10,7 +10,7 @@ import re from copy import deepcopy from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Callable, Dict, Iterable, List, Optional import yaml @@ -57,6 +57,38 @@ class CommandRegistrar: # Populated lazily via _ensure_configs() on first use. AGENT_CONFIGS: dict[str, dict[str, Any]] = {} _configs_loaded: bool = False + _LITERAL_EXTENSION_SKILL_COMMAND_REF = re.compile( + r"(?speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)" + ) + _MARKDOWN_INLINE_LINK_DESTINATION_PREFIX = re.compile(r"\]\(\s* None: self._ensure_configs() @@ -584,6 +616,73 @@ def _is_safe_command_name(name: str) -> bool: return False return os.path.normpath(name) == name + @classmethod + def normalize_literal_extension_skill_command_refs( + cls, + body: str, + render_invocation: Callable[[str], str], + known_command_names: Iterable[str], + *, + restrict_to_known_commands: bool = False, + ) -> str: + """Render isolated literal slash-dot refs in extension skill bodies. + + Tokens remain the portable authoring form. This compatibility fallback + covers existing literal references, including core and cross-extension + commands that are not part of the current extension manifest. Callers + whose output path does not emit aliases can restrict rewriting to the + supplied names so references never target absent skill artifacts. + """ + known_names = frozenset( + name for name in known_command_names if isinstance(name, str) + ) + + def _is_url_value(match_start: int) -> bool: + token_start = max( + body.rfind(" ", 0, match_start), + body.rfind("\n", 0, match_start), + body.rfind("\t", 0, match_start), + ) + 1 + prefix = body[token_start:match_start] + return "?" in prefix or "#" in prefix + + def _is_relative_link_destination(match_start: int) -> bool: + line_start = body.rfind("\n", 0, match_start) + 1 + if cls._MARKDOWN_INLINE_LINK_DESTINATION_PREFIX.search( + body[line_start:match_start] + ): + return True + + tag_start = body.rfind("<", 0, match_start) + if tag_start < 0 or body.rfind(">", 0, match_start) > tag_start: + return False + return ( + cls._HTML_HREF_VALUE_PREFIX.search(body[tag_start:match_start]) + is not None + ) + + def _replacement(match: re.Match[str]) -> str: + command_name = match.group("command") + if _is_url_value(match.start()) or _is_relative_link_destination( + match.start() + ): + return match.group(0) + if match.start() > 0 and body[match.start() - 1] == ".": + return match.group(0) + if body[match.end() : match.end() + 1] in {"/", "\\"}: + return match.group(0) + if restrict_to_known_commands and command_name not in known_names: + return match.group(0) + if ( + command_name not in known_names + and command_name.rsplit(".", 1)[-1].lower() + in cls._FILE_LIKE_COMMAND_SUFFIXES + ): + return match.group(0) + return render_invocation(command_name) + + return cls._LITERAL_EXTENSION_SKILL_COMMAND_REF.sub(_replacement, body) + @staticmethod def _same_lexical_path(left: Path, right: Path) -> bool: """Compare paths after lexical normalization without resolving symlinks.""" @@ -618,6 +717,7 @@ def register_commands( _resolved_dir: Optional[Path] = None, link_outputs: bool = False, extension_id: Optional[str] = None, + known_extension_command_names: Optional[Iterable[str]] = None, ) -> List[str]: """Register commands for a specific agent. @@ -687,6 +787,29 @@ def register_commands( pass _prefix = get_invocation_prefix(agent_name, registrar_writes_skills) + # Skills-native agents write SKILL.md through this registrar before + # the extension skill mirror reaches its existing-file guard. Apply + # the same extension-only compatibility fallback here. + known_command_names: set[str] = set() + if registrar_writes_skills and extension_id is not None: + 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) + ) + if known_extension_command_names is not None: + known_command_names.update( + name + for name in known_extension_command_names + if isinstance(name, str) + ) + for cmd_info in commands: cmd_name = cmd_info["name"] aliases = cmd_info.get("aliases", []) @@ -789,6 +912,12 @@ def register_commands( from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415 body = IntegrationBase.resolve_command_refs(body, _sep, _prefix) + if registrar_writes_skills and extension_id is not None: + body = self.normalize_literal_extension_skill_command_refs( + body, + lambda command_name: _prefix + command_name.replace(".", _sep), + known_command_names, + ) output_name = self._compute_output_name(agent_name, cmd_name, agent_config) @@ -1060,6 +1189,7 @@ def register_commands_for_all_agents( create_missing_active_skills_dir: bool = False, extension_id: Optional[str] = None, only_agent: Optional[str] = None, + known_extension_command_names: Optional[Iterable[str]] = None, ) -> Dict[str, List[str]]: """Register commands for all detected agents in the project. @@ -1184,6 +1314,7 @@ def register_commands_for_all_agents( _resolved_dir=agent_dir, link_outputs=link_outputs, extension_id=extension_id, + known_extension_command_names=known_extension_command_names, ) if registered: results[agent_name] = registered diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3968e4fcbe..6c4c9f1504 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1010,6 +1010,64 @@ def list_by_priority(self, include_disabled: bool = False) -> List[tuple]: ) +def _manifest_command_names( + manifest: ExtensionManifest, *, include_aliases: bool = True +) -> set[str]: + """Return validated command names emitted by the requested output path.""" + names: set[str] = set() + for command in manifest.commands: + if not isinstance(command, dict): + continue + name = command.get("name") + if isinstance(name, str): + names.add(name) + if include_aliases: + aliases = command.get("aliases", []) + if isinstance(aliases, list): + names.update(alias for alias in aliases if isinstance(alias, str)) + return names + + +def _available_extension_skill_command_names( + manifest: ExtensionManifest, + extensions_dir: Path, + *, + include_aliases: bool = True, +) -> set[str]: + """Return known callable names for extension skill compatibility rendering. + + The current manifest is not in the registry during a normal add, so it is + included explicitly. Core names and validated manifests for all registered + extensions make suffix-shaped cross-extension commands distinguishable from + file references. ``include_aliases=False`` matches output paths that emit + only primary-command skills. A corrupt registry is not trusted for discovery. + """ + names = {f"speckit.{name}" for name in CORE_COMMAND_NAMES} + names.update(_manifest_command_names(manifest, include_aliases=include_aliases)) + + registry = ExtensionRegistry(extensions_dir) + if registry.is_corrupt(): + return names + + for extension_id in registry.list(): + if not isinstance(extension_id, str) or not VALID_EXTENSION_ARTIFACT_NAME_PATTERN.fullmatch( + extension_id + ): + continue + try: + installed_manifest = ExtensionManifest( + extensions_dir / extension_id / "extension.yml" + ) + except ValidationError: + continue + names.update( + _manifest_command_names( + installed_manifest, include_aliases=include_aliases + ) + ) + return names + + class ExtensionManager: """Manages extension lifecycle: installation, removal, updates.""" @@ -1562,7 +1620,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 +1633,92 @@ 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 ) + # This mirror emits one skill per primary command. Unlike the + # skills-native registrar path, it does not emit alias skill artifacts, + # so literal aliases must remain unchanged. + # A manifest declaration alone is not sufficient: installed/core + # commands must have an artifact, and current-extension primaries must + # pass the same preflight guards as the generation loop below. + candidate_primary_command_names = _available_extension_skill_command_names( + manifest, self.extensions_dir, include_aliases=False + ) + + def _skill_file_for_command(command_name: str) -> Path: + skill_name = self._skill_name_for_command(command_name) + return skills_dir / skill_name / "SKILL.md" + + emitted_skill_command_names = { + command_name + for command_name in candidate_primary_command_names + if _skill_file_for_command(command_name).exists() + or _skill_file_for_command(command_name).is_symlink() + } + + try: + ext_root = extension_dir.resolve() + except OSError: + ext_root = None + + if ext_root is not None: + for cmd_info in manifest.commands: + cmd_path = Path(cmd_info["file"]) + if cmd_path.is_absolute(): + continue + try: + source_file = (ext_root / cmd_path).resolve() + source_file.relative_to(ext_root) + except (OSError, ValueError): + continue + if not source_file.is_file(): + continue + + skill_name = self._skill_name_for_command(cmd_info["name"]) + skill_subdir = skills_dir / skill_name + skill_file = skill_subdir / "SKILL.md" + if skill_file.exists() or skill_file.is_symlink(): + emitted_skill_command_names.add(cmd_info["name"]) + continue + skill_dir_preexists = ( + skill_subdir.exists() or skill_subdir.is_symlink() + ) + if skill_dir_preexists and not force: + continue + try: + source_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + emitted_skill_command_names.add(cmd_info["name"]) + for cmd_info in manifest.commands: cmd_name = cmd_info["name"] cmd_file_rel = cmd_info["file"] @@ -1678,6 +1798,12 @@ 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 = registrar.normalize_literal_extension_skill_command_refs( + body, + _render_skill_command_invocation, + emitted_skill_command_names, + restrict_to_known_commands=True, + ) original_desc = frontmatter.get("description", "") description = original_desc or f"Extension command: {cmd_name}" @@ -3606,6 +3732,9 @@ def register_commands_for_agent( if agent_name not in self.AGENT_CONFIGS: raise ExtensionError(f"Unsupported agent: {agent_name}") context_note = f"\n\n\n" + known_command_names = _available_extension_skill_command_names( + manifest, project_root / ".specify" / "extensions" + ) return self._registrar.register_commands( agent_name, manifest.commands, @@ -3615,6 +3744,7 @@ def register_commands_for_agent( context_note=context_note, link_outputs=link_outputs, extension_id=manifest.id, + known_extension_command_names=known_command_names, ) def register_commands_for_all_agents( @@ -3628,6 +3758,9 @@ def register_commands_for_all_agents( ) -> Dict[str, List[str]]: """Register extension commands for all detected agents.""" context_note = f"\n\n\n" + known_command_names = _available_extension_skill_command_names( + manifest, project_root / ".specify" / "extensions" + ) return self._registrar.register_commands_for_all_agents( manifest.commands, manifest.id, @@ -3638,6 +3771,7 @@ def register_commands_for_all_agents( create_missing_active_skills_dir=create_missing_active_skills_dir, only_agent=only_agent, extension_id=manifest.id, + known_extension_command_names=known_command_names, ) def unregister_commands( diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 6eec5e7b47..1c56b6e4d4 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -175,6 +175,15 @@ def _create_extension_dir_with_aliases( return ext_dir +def _create_cross_extension_with_suffix_alias(temp_dir: Path) -> Path: + """Create an installed extension with a file-like command alias.""" + return _create_extension_dir_with_aliases( + temp_dir, + "other-ext", + [("run", ["speckit.other.export.json"])], + ) + + def _create_unicode_extension_dir(temp_dir: Path, ext_id: str = "uni-ext") -> Path: """Create an extension whose command description contains non-ASCII characters.""" ext_dir = temp_dir / ext_id @@ -1166,12 +1175,28 @@ 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", "aliases_emitted"), + [ + ("claude", "/speckit-foo-bar", True), + ("copilot", "/speckit-foo-bar", False), + ("codex", "$speckit-foo-bar", True), + ("command-code", "$speckit-foo-bar", True), + ("kimi", "/skill:speckit-foo-bar", True), + ("zcode", "$speckit-foo-bar", True), + ("bob", "/speckit-foo-bar", False), + ("qodercli", "/speckit-foo-bar", True), + ], + ) + def test_skill_registration_normalizes_only_emitted_skill_command_refs( + self, project_dir, temp_dir, ai, expected_invocation, aliases_emitted ): - """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") + """Literal refs normalize only when the active path emits their skill.""" + _create_init_options(project_dir, ai=ai, ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai=ai) + core_skill = skills_dir / "speckit-tasks" / "SKILL.md" + core_skill.parent.mkdir(parents=True, exist_ok=True) + core_skill.write_text("core tasks skill", encoding="utf-8") ext_dir = temp_dir / "literal-ref-ext" ext_dir.mkdir() @@ -1190,6 +1215,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 +1231,239 @@ 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" + "Primary command form: /speckit.literal-ref-ext.run\n" + "Primary prose form: Run /speckit.literal-ref-ext.run.\n" + "Core command form: /speckit.tasks\n" + "Cross-extension command form: /speckit.other-ext.run\n" + "Cross-extension suffix form: /speckit.other.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" + "Relative path form: ./speckit.foo.bar\n" + "Query URL form: https://example.test/redirect?next=/speckit.foo.bar\n" + "Fragment URL form: https://example.test/redirect#next=/speckit.foo.bar\n" + "Relative query URL form: /redirect?next=/speckit.foo.bar\n" + "Relative fragment URL form: /redirect#next=/speckit.foo.bar\n" + "Whitespace prose form: What? /speckit.foo.bar\n" + "Markdown link form: [docs](/speckit.literal-ref-ext.run)\n" + "Quoted HTML href form: docs\n" + "Single-quoted HTML href form: docs\n" + "Unquoted HTML href form: docs\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) + manager.install_from_directory( + _create_cross_extension_with_suffix_alias(temp_dir), + "0.1.0", + register_commands=True, + ) + # 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 - assert "speckit.foo.bar" in content - assert "/speckit-foo-bar" not in content - assert "$speckit-plan" not in content + expected_json_invocation = expected_invocation.replace("foo-bar", "export-json") + expected_primary_invocation = expected_invocation.replace( + "foo-bar", "literal-ref-ext-run" + ) + expected_core_invocation = expected_invocation.replace("foo-bar", "tasks") + expected_cross_extension_invocation = expected_invocation.replace( + "foo-bar", "other-ext-run" + ) + expected_cross_extension_suffix_invocation = expected_invocation.replace( + "foo-bar", "other-export-json" + ) + assert f"Primary command form: {expected_primary_invocation}" in content + assert f"Primary prose form: Run {expected_primary_invocation}." in content + assert f"Core command form: {expected_core_invocation}" in content + assert ( + f"Cross-extension command form: {expected_cross_extension_invocation}" + in content + ) + assert "Path continuation form: /speckit.foo.bar/scripts/run.sh" 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 "Literal bare form: speckit.foo.bar" in content + assert "https://example.com/speckit.foo.bar" in content + assert "./speckit.foo.bar" in content + assert "https://example.test/redirect?next=/speckit.foo.bar" in content + assert "https://example.test/redirect#next=/speckit.foo.bar" in content + assert "Relative query URL form: /redirect?next=/speckit.foo.bar" in content + assert "Relative fragment URL form: /redirect#next=/speckit.foo.bar" in content + assert "Markdown link form: [docs](/speckit.literal-ref-ext.run)" in content + assert ( + 'Quoted HTML href form: docs' + in content + ) + assert ( + "Single-quoted HTML href form: " + "docs" in content + ) + assert ( + "Unquoted HTML href form: " + "docs" in content + ) + assert "/speckit.foo.bar.md" in content + + alias_skill = skills_dir / "speckit-foo-bar" / "SKILL.md" + if aliases_emitted: + assert f"Literal slash form: {expected_invocation} --flag value" in content + assert f"Valid suffix form: {expected_json_invocation}" in content + assert ( + "Cross-extension suffix form: " + f"{expected_cross_extension_suffix_invocation}" in content + ) + assert f"Sentence punctuation form: Run {expected_invocation}." in content + assert f"Whitespace prose form: What? {expected_invocation}" in content + assert alias_skill.is_file() + else: + assert "Literal slash form: /speckit.foo.bar --flag value" in content + assert "Valid suffix form: /speckit.export.json" in content + assert ( + "Cross-extension suffix form: /speckit.other.export.json" in content + ) + assert "Sentence punctuation form: Run /speckit.foo.bar." in content + assert "Whitespace prose form: What? /speckit.foo.bar" in content + assert not alias_skill.exists() + + @pytest.mark.parametrize("ai", ["copilot", "bob"]) + def test_fallback_keeps_missing_source_primary_ref_literal( + self, project_dir, temp_dir, ai + ): + """Fallback rewrites only primaries whose skill can be emitted.""" + _create_init_options(project_dir, ai=ai, ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai=ai) + + ext_dir = temp_dir / f"missing-primary-{ai}" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "missing-primary-ext", + "name": "Missing Primary Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.missing-primary-ext.run", + "file": "commands/run.md", + "description": "Run command", + }, + { + "name": "speckit.missing-primary-ext.ghost", + "file": "commands/ghost.md", + "description": "Missing 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" + "Generated primary: /speckit.missing-primary-ext.run\n" + "Missing primary: /speckit.missing-primary-ext.ghost\n", + encoding="utf-8", + ) + + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=True) + + content = ( + skills_dir / "speckit-missing-primary-ext-run" / "SKILL.md" + ).read_text(encoding="utf-8") + assert "Generated primary: /speckit-missing-primary-ext-run" in content + assert "Missing primary: /speckit.missing-primary-ext.ghost" in content + assert not ( + skills_dir / "speckit-missing-primary-ext-ghost" / "SKILL.md" + ).exists() + + 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" + "Core command form: /speckit.tasks\n" + "Cross-extension command form: /speckit.other-ext.run\n" + "Cross-extension suffix form: /speckit.other.export.json\n" + "Query URL form: https://example.test/redirect?next=/speckit.foo.bar\n" + "Fragment URL form: https://example.test/redirect#next=/speckit.foo.bar\n" + "Relative query URL form: /redirect?next=/speckit.foo.bar\n" + "Relative fragment URL form: /redirect#next=/speckit.foo.bar\n" + "Whitespace prose form: What? /speckit.foo.bar\n" + ) + + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_cross_extension_with_suffix_alias(temp_dir), + "0.1.0", + register_commands=True, + ) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=True) + + 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 + assert "Core command form: /speckit-tasks" in content + assert "Cross-extension command form: /speckit-other-ext-run" in content + assert "Cross-extension suffix form: /speckit-other-export-json" in content + assert "https://example.test/redirect?next=/speckit.foo.bar" in content + assert "https://example.test/redirect#next=/speckit.foo.bar" in content + assert "Relative query URL form: /redirect?next=/speckit.foo.bar" in content + assert "Relative fragment URL form: /redirect#next=/speckit.foo.bar" in content + assert "Whitespace prose form: What? /speckit-foo-bar" in content + assert (skills_dir / "speckit-foo-bar-baz" / "SKILL.md").is_file() def test_missing_command_file_skipped(self, skills_project, temp_dir): """Commands with missing source files should be skipped gracefully.""" @@ -3025,7 +3273,16 @@ def test_register_enabled_extensions_for_agent_force_flag_threads_through( """ _create_init_options(project_dir, ai="claude", ai_skills=True) skills_dir = _create_skills_dir(project_dir, ai="claude") + core_skill = skills_dir / "speckit-tasks" / "SKILL.md" + core_skill.parent.mkdir(parents=True, exist_ok=True) + core_skill.write_text("core tasks skill", encoding="utf-8") ext_dir = _create_extension_dir(temp_dir) + (ext_dir / "commands" / "hello.md").write_text( + "---\n" + "description: Test hello command\n" + "---\n\n" + "Run this to say hello. Next: /speckit.tasks\n" + ) manager = ExtensionManager(project_dir) # Install extension so it is in the registry @@ -3043,6 +3300,7 @@ def test_register_enabled_extensions_for_agent_force_flag_threads_through( "After register_enabled_extensions_for_agent(force=True), the SKILL.md " "must contain the extension body, not just the core-template stub." ) + assert "Next: /speckit-tasks" in content def test_force_true_with_preexisting_dir_but_no_skill_file( self, project_dir, temp_dir diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6642da2b09..e432ea1018 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1746,11 +1746,15 @@ def fake_register_all( create_missing_active_skills_dir=False, extension_id=None, only_agent=None, + known_extension_command_names=None, ): captured["create_missing_active_skills_dir"] = ( create_missing_active_skills_dir ) captured["extension_id"] = extension_id + captured["known_extension_command_names"] = ( + known_extension_command_names + ) return {} monkeypatch.setattr( @@ -1765,6 +1769,10 @@ def fake_register_all( assert captured["create_missing_active_skills_dir"] is False assert captured["extension_id"] == manifest.id + assert ( + manifest.commands[0]["name"] + in captured["known_extension_command_names"] + ) def test_install_duplicate(self, extension_dir, project_dir): """Test installing already installed extension."""