diff --git a/design/extension.md b/design/extension.md index 8eb6a29199..1a7ddbb32d 100644 --- a/design/extension.md +++ b/design/extension.md @@ -91,13 +91,12 @@ always have `replace` semantics when they participate in artifact resolution; extensions cannot author preset composition strategies. Primary command names must use the extension's namespace. Installation rejects -names and aliases that exactly match a name from another installed extension, -and rejects names whose normalized agent output form conflicts with a core -command. Installed-extension comparisons are not normalized, so differently -spelled names can still converge on the same output path for a particular -renderer. A small set of legacy two-segment command names is normalized to the -canonical form with a warning; hook and event command references are rewritten -consistently. +names and aliases whose normalized agent output form matches a name or alias +from another installed extension or a core command. For example, `shared.sync` +and `shared-sync` cannot both be installed because they produce the same skill +output name. A small set of legacy two-segment command names is normalized to +the canonical form with a warning; hook and event command references are +rewritten consistently. ## Project state and filesystem layout diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index c22445ebe0..e4cea95677 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1224,24 +1224,27 @@ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: aliases are intentionally free-form (see the comment there) and so can only be caught here, by comparing declared names' normalized on-disk form (see ``_normalize_shadow_name``) against core command - names rather than relying on ``_get_installed_command_name_map``, - which only knows about installed extensions. + names and names from installed extensions. """ declared_names = self._collect_manifest_command_names(manifest) installed_names = self._get_installed_command_name_map( exclude_extension_id=manifest.id ) + installed_shadow_names: dict[str, str] = {} + for name, ext_id in installed_names.items(): + installed_shadow_names.setdefault(self._normalize_shadow_name(name), ext_id) core_shadow_names = { self._normalize_shadow_name(f"speckit.{name}") for name in CORE_COMMAND_NAMES } collisions = [] for name in sorted(declared_names): - if name in installed_names: + shadow_name = self._normalize_shadow_name(name) + if shadow_name in installed_shadow_names: collisions.append( - f"{name} (already provided by extension '{installed_names[name]}')" + f"{name} (already provided by extension '{installed_shadow_names[shadow_name]}')" ) - elif self._normalize_shadow_name(name) in core_shadow_names: + elif shadow_name in core_shadow_names: collisions.append(f"{name} (conflicts with core command)") if collisions: diff --git a/tests/test_extensions.py b/tests/test_extensions.py index f2a75d08a4..651ce2b5d5 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3125,6 +3125,45 @@ def test_install_rejects_command_collision_with_installed_extension(self, temp_d with pytest.raises(ValidationError, match="already provided by extension 'ext-one'"): manager.install_from_directory(second_dir, "0.1.0", register_commands=False) + def test_install_rejects_alias_with_same_skill_output_name(self, temp_dir, project_dir): + """A second extension must not overwrite an installed alias skill (#5081).""" + import yaml + + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True, "script": "py"}) + ) + (project_dir / ".claude" / "skills").mkdir(parents=True) + + def extension_source(ext_id, alias): + source = temp_dir / ext_id + (source / "commands").mkdir(parents=True) + (source / "commands" / "hello.md").write_text( + f"---\ndescription: {ext_id}\n---\n\nHello from {ext_id}\n" + ) + (source / "extension.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "extension": {"id": ext_id, "name": ext_id, "version": "1.0.0", "description": "Test"}, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"commands": [{ + "name": f"speckit.{ext_id}.hello", "file": "commands/hello.md", + "aliases": [alias], + }]}, + })) + return source + + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_source("alpha", "shared.sync"), "0.1.0") + skill = project_dir / ".claude" / "skills" / "speckit-shared-sync" / "SKILL.md" + original = skill.read_bytes() + assert b"alpha" in original + + with pytest.raises(ValidationError, match="already provided by extension 'alpha'"): + manager.install_from_directory(extension_source("beta", "shared-sync"), "0.1.0") + + assert manager.registry.is_installed("alpha") + assert not manager.registry.is_installed("beta") + assert skill.read_bytes() == original + def test_install_rejects_alias_shadowing_core_command(self, temp_dir, project_dir): """An alias equal to a core command's qualified name must not install.