Skip to content
Merged
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
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; namespace must match extension.id and must not shadow core or installed extension commands

config: # Optional, array of config files
- name: string # Config file name
Expand Down
4 changes: 2 additions & 2 deletions 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 Expand Up @@ -186,7 +186,7 @@ What the extension provides.
- `name`: Command name (must match `speckit.{ext-id}.{command}`)
- `file`: Path to command file (relative to extension root)
- `description`: Command description (optional)
- `aliases`: Alternative command names (optional, array)
- `aliases`: Alternative command names (optional, array; each must match `speckit.{ext-id}.{command}`)

### Optional Fields

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
8 changes: 4 additions & 4 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 @@ -1517,18 +1517,18 @@ specify extension add github-projects
/speckit.github.taskstoissues
```

**Compatibility shim** (if needed):
**Migration alias** (if needed):

```yaml
# extension.yml
provides:
commands:
- name: "speckit.github.taskstoissues"
file: "commands/taskstoissues.md"
aliases: ["speckit.taskstoissues"] # Backward compatibility
aliases: ["speckit.github.sync-taskstoissues"] # Alternate namespaced entry point
```

AI agent registers both names, so old scripts work.
AI agents register both names, so callers can migrate to the alternate alias without relying on deprecated global shortcuts like `/speckit.taskstoissues`.

---

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
168 changes: 167 additions & 1 deletion src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,49 @@
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier

_FALLBACK_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-]+)$")


def _load_core_command_names() -> frozenset[str]:
"""Discover bundled core command names from the packaged templates.

Prefer the wheel-time ``core_pack`` bundle when present, and fall back to
the source checkout when running from the repository. If neither is
available, use the baked-in fallback set so validation still works.
"""
candidate_dirs = [
Path(__file__).parent / "core_pack" / "commands",
Path(__file__).resolve().parent.parent.parent / "templates" / "commands",
]

for commands_dir in candidate_dirs:
if not commands_dir.is_dir():
continue

command_names = {
command_file.stem
for command_file in commands_dir.iterdir()
if command_file.is_file() and command_file.suffix == ".md"
}
if command_names:
return frozenset(command_names)

return _FALLBACK_CORE_COMMAND_NAMES


CORE_COMMAND_NAMES = _load_core_command_names()


class ExtensionError(Exception):
"""Base exception for extension-related errors."""
Expand Down Expand Up @@ -149,7 +192,7 @@ def _validate(self):
raise ValidationError("Command missing 'name' or 'file'")

# Validate command name format
if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]):
if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
raise ValidationError(
f"Invalid command name '{cmd['name']}': "
"must follow pattern 'speckit.{extension}.{command}'"
Expand Down Expand Up @@ -446,6 +489,126 @@ 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:
- commands and aliases must use the canonical `speckit.{extension}.{command}` shape
- commands and aliases must use this extension's namespace
- 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 != manifest.id:
raise ValidationError(
f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'"
)

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 +1024,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