mirror of
https://github.com/github/spec-kit.git
synced 2026-03-29 08:43:08 +00:00
fix: prevent extension command shadowing (#1994)
* fix: prevent extension command shadowing * Validate extension command namespaces * Reuse extension command name pattern
This commit is contained in:
@@ -44,7 +44,7 @@ provides:
|
|||||||
- name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$
|
- name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$
|
||||||
file: string # Required, relative path to command file
|
file: string # Required, relative path to command file
|
||||||
description: string # Required
|
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
|
config: # Optional, array of config files
|
||||||
- name: string # Config file name
|
- name: string # Config file name
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ provides:
|
|||||||
- name: "speckit.my-ext.hello" # Must follow pattern: speckit.{ext-id}.{cmd}
|
- name: "speckit.my-ext.hello" # Must follow pattern: speckit.{ext-id}.{cmd}
|
||||||
file: "commands/hello.md"
|
file: "commands/hello.md"
|
||||||
description: "Say hello"
|
description: "Say hello"
|
||||||
aliases: ["speckit.hello"] # Optional aliases
|
aliases: ["speckit.my-ext.hi"] # Optional aliases, same pattern
|
||||||
|
|
||||||
config: # Optional: Config files
|
config: # Optional: Config files
|
||||||
- name: "my-ext-config.yml"
|
- name: "my-ext-config.yml"
|
||||||
@@ -186,7 +186,7 @@ What the extension provides.
|
|||||||
- `name`: Command name (must match `speckit.{ext-id}.{command}`)
|
- `name`: Command name (must match `speckit.{ext-id}.{command}`)
|
||||||
- `file`: Path to command file (relative to extension root)
|
- `file`: Path to command file (relative to extension root)
|
||||||
- `description`: Command description (optional)
|
- `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
|
### Optional Fields
|
||||||
|
|
||||||
|
|||||||
@@ -214,8 +214,8 @@ Extensions add commands that appear in your AI agent (Claude Code):
|
|||||||
# In Claude Code
|
# In Claude Code
|
||||||
> /speckit.jira.specstoissues
|
> /speckit.jira.specstoissues
|
||||||
|
|
||||||
# Or use short alias (if provided)
|
# Or use a namespaced alias (if provided)
|
||||||
> /speckit.specstoissues
|
> /speckit.jira.sync
|
||||||
```
|
```
|
||||||
|
|
||||||
### Extension Configuration
|
### Extension Configuration
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ provides:
|
|||||||
- name: "speckit.jira.specstoissues"
|
- name: "speckit.jira.specstoissues"
|
||||||
file: "commands/specstoissues.md"
|
file: "commands/specstoissues.md"
|
||||||
description: "Create Jira hierarchy from spec and tasks"
|
description: "Create Jira hierarchy from spec and tasks"
|
||||||
aliases: ["speckit.specstoissues"] # Alternate names
|
aliases: ["speckit.jira.sync"] # Alternate names
|
||||||
|
|
||||||
- name: "speckit.jira.discover-fields"
|
- name: "speckit.jira.discover-fields"
|
||||||
file: "commands/discover-fields.md"
|
file: "commands/discover-fields.md"
|
||||||
@@ -1517,7 +1517,7 @@ specify extension add github-projects
|
|||||||
/speckit.github.taskstoissues
|
/speckit.github.taskstoissues
|
||||||
```
|
```
|
||||||
|
|
||||||
**Compatibility shim** (if needed):
|
**Migration alias** (if needed):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# extension.yml
|
# extension.yml
|
||||||
@@ -1525,10 +1525,10 @@ provides:
|
|||||||
commands:
|
commands:
|
||||||
- name: "speckit.github.taskstoissues"
|
- name: "speckit.github.taskstoissues"
|
||||||
file: "commands/taskstoissues.md"
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ provides:
|
|||||||
- name: "speckit.my-extension.example"
|
- name: "speckit.my-extension.example"
|
||||||
file: "commands/example.md"
|
file: "commands/example.md"
|
||||||
description: "Example command that demonstrates functionality"
|
description: "Example command that demonstrates functionality"
|
||||||
# Optional: Add aliases for shorter command names
|
# Optional: Add aliases in the same namespaced format
|
||||||
aliases: ["speckit.example"]
|
aliases: ["speckit.my-extension.example-short"]
|
||||||
|
|
||||||
# ADD MORE COMMANDS: Copy this block for each command
|
# ADD MORE COMMANDS: Copy this block for each command
|
||||||
# - name: "speckit.my-extension.another-command"
|
# - name: "speckit.my-extension.another-command"
|
||||||
|
|||||||
@@ -25,6 +25,49 @@ import yaml
|
|||||||
from packaging import version as pkg_version
|
from packaging import version as pkg_version
|
||||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
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):
|
class ExtensionError(Exception):
|
||||||
"""Base exception for extension-related errors."""
|
"""Base exception for extension-related errors."""
|
||||||
@@ -149,7 +192,7 @@ class ExtensionManifest:
|
|||||||
raise ValidationError("Command missing 'name' or 'file'")
|
raise ValidationError("Command missing 'name' or 'file'")
|
||||||
|
|
||||||
# Validate command name format
|
# 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(
|
raise ValidationError(
|
||||||
f"Invalid command name '{cmd['name']}': "
|
f"Invalid command name '{cmd['name']}': "
|
||||||
"must follow pattern 'speckit.{extension}.{command}'"
|
"must follow pattern 'speckit.{extension}.{command}'"
|
||||||
@@ -446,6 +489,126 @@ class ExtensionManager:
|
|||||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||||
self.registry = ExtensionRegistry(self.extensions_dir)
|
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
|
@staticmethod
|
||||||
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
|
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
|
||||||
"""Load .extensionignore and return an ignore function for shutil.copytree.
|
"""Load .extensionignore and return an ignore function for shutil.copytree.
|
||||||
@@ -861,6 +1024,9 @@ class ExtensionManager:
|
|||||||
f"Use 'specify extension remove {manifest.id}' first."
|
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
|
# Install extension
|
||||||
dest_dir = self.extensions_dir / manifest.id
|
dest_dir = self.extensions_dir / manifest.id
|
||||||
if dest_dir.exists():
|
if dest_dir.exists():
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from specify_cli.extensions import (
|
from specify_cli.extensions import (
|
||||||
CatalogEntry,
|
CatalogEntry,
|
||||||
|
CORE_COMMAND_NAMES,
|
||||||
ExtensionManifest,
|
ExtensionManifest,
|
||||||
ExtensionRegistry,
|
ExtensionRegistry,
|
||||||
ExtensionManager,
|
ExtensionManager,
|
||||||
@@ -63,7 +64,7 @@ def valid_manifest_data():
|
|||||||
"provides": {
|
"provides": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"name": "speckit.test.hello",
|
"name": "speckit.test-ext.hello",
|
||||||
"file": "commands/hello.md",
|
"file": "commands/hello.md",
|
||||||
"description": "Test command",
|
"description": "Test command",
|
||||||
}
|
}
|
||||||
@@ -71,7 +72,7 @@ def valid_manifest_data():
|
|||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"after_tasks": {
|
"after_tasks": {
|
||||||
"command": "speckit.test.hello",
|
"command": "speckit.test-ext.hello",
|
||||||
"optional": True,
|
"optional": True,
|
||||||
"prompt": "Run test?",
|
"prompt": "Run test?",
|
||||||
}
|
}
|
||||||
@@ -189,7 +190,18 @@ class TestExtensionManifest:
|
|||||||
assert manifest.version == "1.0.0"
|
assert manifest.version == "1.0.0"
|
||||||
assert manifest.description == "A test extension"
|
assert manifest.description == "A test extension"
|
||||||
assert len(manifest.commands) == 1
|
assert len(manifest.commands) == 1
|
||||||
assert manifest.commands[0]["name"] == "speckit.test.hello"
|
assert manifest.commands[0]["name"] == "speckit.test-ext.hello"
|
||||||
|
|
||||||
|
def test_core_command_names_match_bundled_templates(self):
|
||||||
|
"""Core command reservations should stay aligned with bundled templates."""
|
||||||
|
commands_dir = Path(__file__).resolve().parent.parent / "templates" / "commands"
|
||||||
|
expected = {
|
||||||
|
command_file.stem
|
||||||
|
for command_file in commands_dir.iterdir()
|
||||||
|
if command_file.is_file() and command_file.suffix == ".md"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert CORE_COMMAND_NAMES == expected
|
||||||
|
|
||||||
def test_missing_required_field(self, temp_dir):
|
def test_missing_required_field(self, temp_dir):
|
||||||
"""Test manifest missing required field."""
|
"""Test manifest missing required field."""
|
||||||
@@ -589,6 +601,172 @@ class TestExtensionManager:
|
|||||||
with pytest.raises(ExtensionError, match="already installed"):
|
with pytest.raises(ExtensionError, match="already installed"):
|
||||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir):
|
||||||
|
"""Install should reject extension IDs that shadow core commands."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "analyze-ext"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
(ext_dir / "commands").mkdir()
|
||||||
|
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "analyze",
|
||||||
|
"name": "Analyze Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.analyze.extra",
|
||||||
|
"file": "commands/cmd.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
(ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
|
||||||
|
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
with pytest.raises(ValidationError, match="conflicts with core command namespace"):
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
def test_install_rejects_alias_without_extension_namespace(self, temp_dir, project_dir):
|
||||||
|
"""Install should reject legacy short aliases that can shadow core commands."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "alias-shortcut"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
(ext_dir / "commands").mkdir()
|
||||||
|
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "alias-shortcut",
|
||||||
|
"name": "Alias Shortcut",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.alias-shortcut.cmd",
|
||||||
|
"file": "commands/cmd.md",
|
||||||
|
"aliases": ["speckit.shortcut"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
(ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
|
||||||
|
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
with pytest.raises(ValidationError, match="Invalid alias 'speckit.shortcut'"):
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
def test_install_rejects_namespace_squatting(self, temp_dir, project_dir):
|
||||||
|
"""Install should reject commands and aliases outside the extension namespace."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "squat-ext"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
(ext_dir / "commands").mkdir()
|
||||||
|
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "squat-ext",
|
||||||
|
"name": "Squat Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.other-ext.cmd",
|
||||||
|
"file": "commands/cmd.md",
|
||||||
|
"aliases": ["speckit.squat-ext.ok"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
(ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
|
||||||
|
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
with pytest.raises(ValidationError, match="must use extension namespace 'squat-ext'"):
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
def test_install_rejects_command_collision_with_installed_extension(self, temp_dir, project_dir):
|
||||||
|
"""Install should reject names already claimed by an installed legacy extension."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
first_dir = temp_dir / "ext-one"
|
||||||
|
first_dir.mkdir()
|
||||||
|
(first_dir / "commands").mkdir()
|
||||||
|
first_manifest = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "ext-one",
|
||||||
|
"name": "Extension One",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.ext-one.sync",
|
||||||
|
"file": "commands/cmd.md",
|
||||||
|
"aliases": ["speckit.shared.sync"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(first_dir / "extension.yml").write_text(yaml.dump(first_manifest))
|
||||||
|
(first_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
|
||||||
|
installed_ext_dir = project_dir / ".specify" / "extensions" / "ext-one"
|
||||||
|
installed_ext_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copytree(first_dir, installed_ext_dir)
|
||||||
|
|
||||||
|
second_dir = temp_dir / "ext-two"
|
||||||
|
second_dir.mkdir()
|
||||||
|
(second_dir / "commands").mkdir()
|
||||||
|
second_manifest = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "shared",
|
||||||
|
"name": "Shared Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.shared.sync",
|
||||||
|
"file": "commands/cmd.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(second_dir / "extension.yml").write_text(yaml.dump(second_manifest))
|
||||||
|
(second_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manager.registry.add("ext-one", {"version": "1.0.0", "source": "local"})
|
||||||
|
|
||||||
|
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_remove_extension(self, extension_dir, project_dir):
|
def test_remove_extension(self, extension_dir, project_dir):
|
||||||
"""Test removing an installed extension."""
|
"""Test removing an installed extension."""
|
||||||
manager = ExtensionManager(project_dir)
|
manager = ExtensionManager(project_dir)
|
||||||
@@ -852,10 +1030,10 @@ $ARGUMENTS
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert len(registered) == 1
|
assert len(registered) == 1
|
||||||
assert "speckit.test.hello" in registered
|
assert "speckit.test-ext.hello" in registered
|
||||||
|
|
||||||
# Check command file was created
|
# Check command file was created
|
||||||
cmd_file = claude_dir / "speckit.test.hello.md"
|
cmd_file = claude_dir / "speckit.test-ext.hello.md"
|
||||||
assert cmd_file.exists()
|
assert cmd_file.exists()
|
||||||
|
|
||||||
content = cmd_file.read_text()
|
content = cmd_file.read_text()
|
||||||
@@ -885,9 +1063,9 @@ $ARGUMENTS
|
|||||||
"provides": {
|
"provides": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"name": "speckit.alias.cmd",
|
"name": "speckit.ext-alias.cmd",
|
||||||
"file": "commands/cmd.md",
|
"file": "commands/cmd.md",
|
||||||
"aliases": ["speckit.shortcut"],
|
"aliases": ["speckit.ext-alias.shortcut"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -907,10 +1085,10 @@ $ARGUMENTS
|
|||||||
registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir)
|
registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir)
|
||||||
|
|
||||||
assert len(registered) == 2
|
assert len(registered) == 2
|
||||||
assert "speckit.alias.cmd" in registered
|
assert "speckit.ext-alias.cmd" in registered
|
||||||
assert "speckit.shortcut" in registered
|
assert "speckit.ext-alias.shortcut" in registered
|
||||||
assert (claude_dir / "speckit.alias.cmd.md").exists()
|
assert (claude_dir / "speckit.ext-alias.cmd.md").exists()
|
||||||
assert (claude_dir / "speckit.shortcut.md").exists()
|
assert (claude_dir / "speckit.ext-alias.shortcut.md").exists()
|
||||||
|
|
||||||
def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):
|
def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):
|
||||||
"""Codex skill cleanup should use the same mapped names as registration."""
|
"""Codex skill cleanup should use the same mapped names as registration."""
|
||||||
@@ -951,11 +1129,11 @@ $ARGUMENTS
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir)
|
registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir)
|
||||||
|
|
||||||
skill_file = skills_dir / "speckit-test-hello" / "SKILL.md"
|
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||||
assert skill_file.exists()
|
assert skill_file.exists()
|
||||||
|
|
||||||
content = skill_file.read_text()
|
content = skill_file.read_text()
|
||||||
assert "name: speckit-test-hello" in content
|
assert "name: speckit-test-ext-hello" in content
|
||||||
assert "description: Test hello command" in content
|
assert "description: Test hello command" in content
|
||||||
assert "compatibility:" in content
|
assert "compatibility:" in content
|
||||||
assert "metadata:" in content
|
assert "metadata:" in content
|
||||||
@@ -982,7 +1160,7 @@ $ARGUMENTS
|
|||||||
"provides": {
|
"provides": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"name": "speckit.test.plan",
|
"name": "speckit.ext-scripted.plan",
|
||||||
"file": "commands/plan.md",
|
"file": "commands/plan.md",
|
||||||
"description": "Scripted command",
|
"description": "Scripted command",
|
||||||
}
|
}
|
||||||
@@ -1020,7 +1198,7 @@ Agent __AGENT__
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||||
|
|
||||||
skill_file = skills_dir / "speckit-test-plan" / "SKILL.md"
|
skill_file = skills_dir / "speckit-ext-scripted-plan" / "SKILL.md"
|
||||||
assert skill_file.exists()
|
assert skill_file.exists()
|
||||||
|
|
||||||
content = skill_file.read_text()
|
content = skill_file.read_text()
|
||||||
@@ -1051,9 +1229,9 @@ Agent __AGENT__
|
|||||||
"provides": {
|
"provides": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"name": "speckit.alias.cmd",
|
"name": "speckit.ext-alias-skill.cmd",
|
||||||
"file": "commands/cmd.md",
|
"file": "commands/cmd.md",
|
||||||
"aliases": ["speckit.shortcut"],
|
"aliases": ["speckit.ext-alias-skill.shortcut"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1070,13 +1248,13 @@ Agent __AGENT__
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||||
|
|
||||||
primary = skills_dir / "speckit-alias-cmd" / "SKILL.md"
|
primary = skills_dir / "speckit-ext-alias-skill-cmd" / "SKILL.md"
|
||||||
alias = skills_dir / "speckit-shortcut" / "SKILL.md"
|
alias = skills_dir / "speckit-ext-alias-skill-shortcut" / "SKILL.md"
|
||||||
|
|
||||||
assert primary.exists()
|
assert primary.exists()
|
||||||
assert alias.exists()
|
assert alias.exists()
|
||||||
assert "name: speckit-alias-cmd" in primary.read_text()
|
assert "name: speckit-ext-alias-skill-cmd" in primary.read_text()
|
||||||
assert "name: speckit-shortcut" in alias.read_text()
|
assert "name: speckit-ext-alias-skill-shortcut" in alias.read_text()
|
||||||
|
|
||||||
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
|
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
|
||||||
self, project_dir, temp_dir
|
self, project_dir, temp_dir
|
||||||
@@ -1100,7 +1278,7 @@ Agent __AGENT__
|
|||||||
"provides": {
|
"provides": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"name": "speckit.fallback.plan",
|
"name": "speckit.ext-script-fallback.plan",
|
||||||
"file": "commands/plan.md",
|
"file": "commands/plan.md",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1132,7 +1310,7 @@ Then {AGENT_SCRIPT}
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||||
|
|
||||||
skill_file = skills_dir / "speckit-fallback-plan" / "SKILL.md"
|
skill_file = skills_dir / "speckit-ext-script-fallback-plan" / "SKILL.md"
|
||||||
assert skill_file.exists()
|
assert skill_file.exists()
|
||||||
|
|
||||||
content = skill_file.read_text()
|
content = skill_file.read_text()
|
||||||
@@ -1163,7 +1341,7 @@ Then {AGENT_SCRIPT}
|
|||||||
"provides": {
|
"provides": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"name": "speckit.list.plan",
|
"name": "speckit.ext-script-list-init.plan",
|
||||||
"file": "commands/plan.md",
|
"file": "commands/plan.md",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1194,7 +1372,7 @@ Run {SCRIPT}
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||||
|
|
||||||
content = (skills_dir / "speckit-list-plan" / "SKILL.md").read_text()
|
content = (skills_dir / "speckit-ext-script-list-init-plan" / "SKILL.md").read_text()
|
||||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||||
|
|
||||||
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
|
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
|
||||||
@@ -1221,7 +1399,7 @@ Run {SCRIPT}
|
|||||||
"provides": {
|
"provides": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"name": "speckit.windows.plan",
|
"name": "speckit.ext-script-windows-fallback.plan",
|
||||||
"file": "commands/plan.md",
|
"file": "commands/plan.md",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1253,7 +1431,7 @@ Then {AGENT_SCRIPT}
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||||
|
|
||||||
skill_file = skills_dir / "speckit-windows-plan" / "SKILL.md"
|
skill_file = skills_dir / "speckit-ext-script-windows-fallback-plan" / "SKILL.md"
|
||||||
assert skill_file.exists()
|
assert skill_file.exists()
|
||||||
|
|
||||||
content = skill_file.read_text()
|
content = skill_file.read_text()
|
||||||
@@ -1275,14 +1453,14 @@ Then {AGENT_SCRIPT}
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert len(registered) == 1
|
assert len(registered) == 1
|
||||||
assert "speckit.test.hello" in registered
|
assert "speckit.test-ext.hello" in registered
|
||||||
|
|
||||||
# Verify command file uses .agent.md extension
|
# Verify command file uses .agent.md extension
|
||||||
cmd_file = agents_dir / "speckit.test.hello.agent.md"
|
cmd_file = agents_dir / "speckit.test-ext.hello.agent.md"
|
||||||
assert cmd_file.exists()
|
assert cmd_file.exists()
|
||||||
|
|
||||||
# Verify NO plain .md file was created
|
# Verify NO plain .md file was created
|
||||||
plain_md_file = agents_dir / "speckit.test.hello.md"
|
plain_md_file = agents_dir / "speckit.test-ext.hello.md"
|
||||||
assert not plain_md_file.exists()
|
assert not plain_md_file.exists()
|
||||||
|
|
||||||
content = cmd_file.read_text()
|
content = cmd_file.read_text()
|
||||||
@@ -1302,12 +1480,12 @@ Then {AGENT_SCRIPT}
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify companion .prompt.md file exists
|
# Verify companion .prompt.md file exists
|
||||||
prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md"
|
prompt_file = project_dir / ".github" / "prompts" / "speckit.test-ext.hello.prompt.md"
|
||||||
assert prompt_file.exists()
|
assert prompt_file.exists()
|
||||||
|
|
||||||
# Verify content has correct agent frontmatter
|
# Verify content has correct agent frontmatter
|
||||||
content = prompt_file.read_text()
|
content = prompt_file.read_text()
|
||||||
assert content == "---\nagent: speckit.test.hello\n---\n"
|
assert content == "---\nagent: speckit.test-ext.hello\n---\n"
|
||||||
|
|
||||||
def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir):
|
def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir):
|
||||||
"""Test that aliases also get companion .prompt.md files for Copilot."""
|
"""Test that aliases also get companion .prompt.md files for Copilot."""
|
||||||
@@ -1328,9 +1506,9 @@ Then {AGENT_SCRIPT}
|
|||||||
"provides": {
|
"provides": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"name": "speckit.alias-copilot.cmd",
|
"name": "speckit.ext-alias-copilot.cmd",
|
||||||
"file": "commands/cmd.md",
|
"file": "commands/cmd.md",
|
||||||
"aliases": ["speckit.shortcut-copilot"],
|
"aliases": ["speckit.ext-alias-copilot.shortcut"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1357,8 +1535,8 @@ Then {AGENT_SCRIPT}
|
|||||||
|
|
||||||
# Both primary and alias get companion .prompt.md
|
# Both primary and alias get companion .prompt.md
|
||||||
prompts_dir = project_dir / ".github" / "prompts"
|
prompts_dir = project_dir / ".github" / "prompts"
|
||||||
assert (prompts_dir / "speckit.alias-copilot.cmd.prompt.md").exists()
|
assert (prompts_dir / "speckit.ext-alias-copilot.cmd.prompt.md").exists()
|
||||||
assert (prompts_dir / "speckit.shortcut-copilot.prompt.md").exists()
|
assert (prompts_dir / "speckit.ext-alias-copilot.shortcut.prompt.md").exists()
|
||||||
|
|
||||||
def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir):
|
def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir):
|
||||||
"""Test that non-copilot agents do NOT create .prompt.md files."""
|
"""Test that non-copilot agents do NOT create .prompt.md files."""
|
||||||
@@ -1431,7 +1609,7 @@ class TestIntegration:
|
|||||||
assert installed[0]["id"] == "test-ext"
|
assert installed[0]["id"] == "test-ext"
|
||||||
|
|
||||||
# Verify command registered
|
# Verify command registered
|
||||||
cmd_file = project_dir / ".claude" / "commands" / "speckit.test.hello.md"
|
cmd_file = project_dir / ".claude" / "commands" / "speckit.test-ext.hello.md"
|
||||||
assert cmd_file.exists()
|
assert cmd_file.exists()
|
||||||
|
|
||||||
# Verify registry has registered commands (now a dict keyed by agent)
|
# Verify registry has registered commands (now a dict keyed by agent)
|
||||||
@@ -1439,7 +1617,7 @@ class TestIntegration:
|
|||||||
registered_commands = metadata["registered_commands"]
|
registered_commands = metadata["registered_commands"]
|
||||||
# Check that the command is registered for at least one agent
|
# Check that the command is registered for at least one agent
|
||||||
assert any(
|
assert any(
|
||||||
"speckit.test.hello" in cmds
|
"speckit.test-ext.hello" in cmds
|
||||||
for cmds in registered_commands.values()
|
for cmds in registered_commands.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1465,8 +1643,8 @@ class TestIntegration:
|
|||||||
assert "copilot" in metadata["registered_commands"]
|
assert "copilot" in metadata["registered_commands"]
|
||||||
|
|
||||||
# Verify files exist before cleanup
|
# Verify files exist before cleanup
|
||||||
agent_file = agents_dir / "speckit.test.hello.agent.md"
|
agent_file = agents_dir / "speckit.test-ext.hello.agent.md"
|
||||||
prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md"
|
prompt_file = project_dir / ".github" / "prompts" / "speckit.test-ext.hello.prompt.md"
|
||||||
assert agent_file.exists()
|
assert agent_file.exists()
|
||||||
assert prompt_file.exists()
|
assert prompt_file.exists()
|
||||||
|
|
||||||
@@ -2776,7 +2954,7 @@ class TestExtensionUpdateCLI:
|
|||||||
"provides": {
|
"provides": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"name": "speckit.test.hello",
|
"name": "speckit.test-ext.hello",
|
||||||
"file": "commands/hello.md",
|
"file": "commands/hello.md",
|
||||||
"description": "Test command",
|
"description": "Test command",
|
||||||
}
|
}
|
||||||
@@ -2784,7 +2962,7 @@ class TestExtensionUpdateCLI:
|
|||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"after_tasks": {
|
"after_tasks": {
|
||||||
"command": "speckit.test.hello",
|
"command": "speckit.test-ext.hello",
|
||||||
"optional": True,
|
"optional": True,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2813,7 +2991,7 @@ class TestExtensionUpdateCLI:
|
|||||||
"description": "A test extension",
|
"description": "A test extension",
|
||||||
},
|
},
|
||||||
"requires": {"speckit_version": ">=0.1.0"},
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
"provides": {"commands": [{"name": "speckit.test.hello", "file": "commands/hello.md"}]},
|
"provides": {"commands": [{"name": "speckit.test-ext.hello", "file": "commands/hello.md"}]},
|
||||||
}
|
}
|
||||||
|
|
||||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||||
@@ -3442,15 +3620,15 @@ class TestHookInvocationRendering:
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"extension": "test-ext",
|
"extension": "test-ext",
|
||||||
"command": "speckit.test.hello",
|
"command": "speckit.test-ext.hello",
|
||||||
"optional": False,
|
"optional": False,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "Executing: `/skill:speckit-test-hello`" in message
|
assert "Executing: `/skill:speckit-test-ext-hello`" in message
|
||||||
assert "EXECUTE_COMMAND: speckit.test.hello" in message
|
assert "EXECUTE_COMMAND: speckit.test-ext.hello" in message
|
||||||
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-test-hello" in message
|
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-test-ext-hello" in message
|
||||||
|
|
||||||
def test_hook_executor_caches_init_options_lookup(self, project_dir, monkeypatch):
|
def test_hook_executor_caches_init_options_lookup(self, project_dir, monkeypatch):
|
||||||
"""Init options should be loaded once per executor instance."""
|
"""Init options should be loaded once per executor instance."""
|
||||||
|
|||||||
Reference in New Issue
Block a user