Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions design/extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 8 additions & 5 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down