Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
166 changes: 166 additions & 0 deletions 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-]+)$")

Comment thread
afurm marked this conversation as resolved.

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 @@ -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