Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion extensions/EXTENSION-API-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ provides:
- name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$
file: string # Required, relative path to command file
description: string # Required
aliases: [string] # Optional, array of alternate names
aliases: [string] # Optional, same pattern as name; must not shadow core commands
Comment thread
afurm marked this conversation as resolved.
Outdated

config: # Optional, array of config files
- name: string # Config file name
Expand Down
2 changes: 1 addition & 1 deletion extensions/EXTENSION-DEVELOPMENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ provides:
- name: "speckit.my-ext.hello" # Must follow pattern: speckit.{ext-id}.{cmd}
file: "commands/hello.md"
description: "Say hello"
aliases: ["speckit.hello"] # Optional aliases
aliases: ["speckit.my-ext.hi"] # Optional aliases, same pattern

config: # Optional: Config files
- name: "my-ext-config.yml"
Expand Down
4 changes: 2 additions & 2 deletions extensions/EXTENSION-USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ Extensions add commands that appear in your AI agent (Claude Code):
# In Claude Code
> /speckit.jira.specstoissues

# Or use short alias (if provided)
> /speckit.specstoissues
# Or use a namespaced alias (if provided)
> /speckit.jira.sync
```

### Extension Configuration
Expand Down
4 changes: 2 additions & 2 deletions extensions/RFC-EXTENSION-SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ provides:
- name: "speckit.jira.specstoissues"
file: "commands/specstoissues.md"
description: "Create Jira hierarchy from spec and tasks"
aliases: ["speckit.specstoissues"] # Alternate names
aliases: ["speckit.jira.sync"] # Alternate names

- name: "speckit.jira.discover-fields"
file: "commands/discover-fields.md"
Expand Down Expand Up @@ -1525,7 +1525,7 @@ provides:
commands:
- name: "speckit.github.taskstoissues"
file: "commands/taskstoissues.md"
aliases: ["speckit.taskstoissues"] # Backward compatibility
aliases: ["speckit.github.sync-taskstoissues"] # Backward compatibility
```

AI agent registers both names, so old scripts work.
Comment thread
afurm marked this conversation as resolved.
Outdated
Expand Down
4 changes: 2 additions & 2 deletions extensions/template/extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ provides:
- name: "speckit.my-extension.example"
file: "commands/example.md"
description: "Example command that demonstrates functionality"
# Optional: Add aliases for shorter command names
aliases: ["speckit.example"]
# Optional: Add aliases in the same namespaced format
aliases: ["speckit.my-extension.example-short"]

# ADD MORE COMMANDS: Copy this block for each command
# - name: "speckit.my-extension.another-command"
Expand Down
131 changes: 131 additions & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier

CORE_COMMAND_NAMES = frozenset({
"analyze",
"checklist",
"clarify",
"constitution",
"implement",
"plan",
"specify",
"tasks",
"taskstoissues",
})
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
Comment thread
afurm marked this conversation as resolved.
Outdated

Comment thread
afurm marked this conversation as resolved.

class ExtensionError(Exception):
"""Base exception for extension-related errors."""
Expand Down Expand Up @@ -446,6 +459,121 @@ def __init__(self, project_root: Path):
self.extensions_dir = project_root / ".specify" / "extensions"
self.registry = ExtensionRegistry(self.extensions_dir)

@staticmethod
def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]:
"""Collect command and alias names declared by a manifest.

Performs install-time validation for extension-specific constraints that
should not invalidate already-installed legacy manifests:
- aliases must use the canonical `speckit.{extension}.{command}` shape
- command namespaces must not shadow core commands
- duplicate command/alias names inside one manifest are rejected

Args:
manifest: Parsed extension manifest

Returns:
Mapping of declared command/alias name -> kind ("command"/"alias")

Raises:
ValidationError: If any declared name is invalid
"""
if manifest.id in CORE_COMMAND_NAMES:
raise ValidationError(
f"Extension ID '{manifest.id}' conflicts with core command namespace '{manifest.id}'"
)

declared_names: Dict[str, str] = {}

for cmd in manifest.commands:
primary_name = cmd["name"]
aliases = cmd.get("aliases", [])

if aliases is None:
aliases = []
if not isinstance(aliases, list):
raise ValidationError(
f"Aliases for command '{primary_name}' must be a list"
)

for kind, name in [("command", primary_name)] + [
("alias", alias) for alias in aliases
]:
if not isinstance(name, str):
raise ValidationError(
f"{kind.capitalize()} for command '{primary_name}' must be a string"
)

match = EXTENSION_COMMAND_NAME_PATTERN.match(name)
if match is None:
raise ValidationError(
f"Invalid {kind} '{name}': "
"must follow pattern 'speckit.{extension}.{command}'"
)

namespace = match.group(1)
if namespace in CORE_COMMAND_NAMES:
raise ValidationError(
f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'"
)

if name in declared_names:
raise ValidationError(
f"Duplicate command or alias '{name}' in extension manifest"
)

declared_names[name] = kind

return declared_names

def _get_installed_command_name_map(
self,
exclude_extension_id: Optional[str] = None,
) -> Dict[str, str]:
"""Return registered command and alias names for installed extensions."""
installed_names: Dict[str, str] = {}

for ext_id in self.registry.keys():
if ext_id == exclude_extension_id:
continue

manifest = self.get_extension(ext_id)
if manifest is None:
continue

for cmd in manifest.commands:
cmd_name = cmd.get("name")
if isinstance(cmd_name, str):
installed_names.setdefault(cmd_name, ext_id)

aliases = cmd.get("aliases", [])
if not isinstance(aliases, list):
continue

for alias in aliases:
if isinstance(alias, str):
installed_names.setdefault(alias, ext_id)

return installed_names

def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None:
"""Reject installs that would shadow core or installed extension commands."""
declared_names = self._collect_manifest_command_names(manifest)
installed_names = self._get_installed_command_name_map(
exclude_extension_id=manifest.id
)

collisions = [
f"{name} (already provided by extension '{installed_names[name]}')"
for name in sorted(declared_names)
if name in installed_names
]
if collisions:
raise ValidationError(
"Extension commands conflict with installed extensions:\n- "
+ "\n- ".join(collisions)
)

@staticmethod
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
"""Load .extensionignore and return an ignore function for shutil.copytree.
Expand Down Expand Up @@ -861,6 +989,9 @@ def install_from_directory(
f"Use 'specify extension remove {manifest.id}' first."
)

# Reject manifests that would shadow core commands or installed extensions.
self._validate_install_conflicts(manifest)

# Install extension
dest_dir = self.extensions_dir / manifest.id
if dest_dir.exists():
Expand Down
Loading
Loading