Skip to content
Open
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Spec-Driven Development **flips the script** on traditional software development

Choose your preferred installation method:

> **Important:** The only official, maintained packages for Spec Kit are published from this GitHub repository. Any packages with the same name on PyPI are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. Always install directly from GitHub as shown below.

#### Option 1: Persistent Installation (Recommended)

Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
Expand All @@ -62,7 +64,13 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
```

Then use the tool directly:
Then verify the correct version is installed:

```bash
specify version
```

And use the tool directly:

```bash
# Create new project
Expand Down
10 changes: 10 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

## Installation

> **Important:** The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid.

### Initialize a New Project

The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
Expand Down Expand Up @@ -69,6 +71,14 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <proje

## Verification

After installation, run the following command to confirm the correct version is installed:

```bash
specify version
```

This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.

After initialization, you should see the following commands available in your AI agent:

- `/speckit.specify` - Create specifications
Expand Down
19 changes: 17 additions & 2 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3653,6 +3653,10 @@ def extension_add(
console.print("\n[green]✓[/green] Extension installed successfully!")
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
console.print(f" {manifest.description}")

for warning in manifest.warnings:
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")

console.print("\n[bold cyan]Provided commands:[/bold cyan]")
for cmd in manifest.commands:
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
Expand Down Expand Up @@ -3706,15 +3710,26 @@ def extension_remove(

# Get extension info for command and skill counts
ext_manifest = manager.get_extension(extension_id)
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
reg_meta = manager.registry.get(extension_id)
# Derive cmd_count from the registry's registered_commands (includes aliases,
# covers all agents) rather than from the manifest (primary commands only).
# Use get() without a default so we can distinguish "key missing" (fall back
# to manifest) from "key present but empty dict" (zero commands registered).
registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None
if isinstance(registered_commands, dict):
cmd_count = max(
(len(v) for v in registered_commands.values() if isinstance(v, list)),
default=0,
)
Comment on lines +3720 to +3723
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmd_count is computed using max(len(v) ...) across agents, but the removal prompt says "across AI agents". If registered_commands differs per agent (possible if registration partially succeeded or agent dirs changed), max can undercount the total files that will be removed. Consider either summing across agents for an "across agents" total, or clarifying the wording as "up to N commands per agent" if max is intentional to avoid double-counting.

Copilot uses AI. Check for mistakes.
else:
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
raw_skills = reg_meta.get("registered_skills") if reg_meta else None
skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0

# Confirm removal
if not force:
console.print("\n[yellow]⚠ This will remove:[/yellow]")
console.print(f" • {cmd_count} commands from AI agent")
console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} across AI agents")
if skill_count:
console.print(f" • {skill_count} agent skill(s)")
console.print(f" • Extension directory: .specify/extensions/{extension_id}/")
Expand Down
98 changes: 94 additions & 4 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ def __init__(self, manifest_path: Path):
ValidationError: If manifest is invalid
"""
self.path = manifest_path
self.warnings: List[str] = []
self.data = self._load_yaml(manifest_path)
self._validate()

Expand Down Expand Up @@ -186,18 +187,107 @@ def _validate(self):
if "commands" not in provides or not provides["commands"]:
raise ValidationError("Extension must provide at least one command")

# Validate commands
# Validate commands; track renames so hook references can be rewritten.
rename_map: Dict[str, str] = {}
for cmd in provides["commands"]:
if "name" not in cmd or "file" not in cmd:
raise ValidationError("Command missing 'name' or 'file'")

# Validate command name format
if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
if not EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]):
corrected = self._try_correct_command_name(cmd["name"], ext["id"])
if corrected:
self.warnings.append(
f"Command name '{cmd['name']}' does not follow the required pattern "
f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. "
f"The extension author should update the manifest to use this name."
)
rename_map[cmd["name"]] = corrected
cmd["name"] = corrected
else:
raise ValidationError(
f"Invalid command name '{cmd['name']}': "
"must follow pattern 'speckit.{extension}.{command}'"
)

# Validate and auto-correct alias name formats
aliases = cmd.get("aliases")
if aliases is None:
aliases = []
if not isinstance(aliases, list):
raise ValidationError(
f"Aliases for command '{cmd['name']}' must be a list"
)
for i, alias in enumerate(aliases):
if not isinstance(alias, str):
raise ValidationError(
f"Aliases for command '{cmd['name']}' must be strings"
)
if not EXTENSION_COMMAND_NAME_PATTERN.match(alias):
corrected = self._try_correct_command_name(alias, ext["id"])
if corrected:
self.warnings.append(
f"Alias '{alias}' does not follow the required pattern "
f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. "
f"The extension author should update the manifest to use this name."
)
rename_map[alias] = corrected
aliases[i] = corrected
else:
raise ValidationError(
f"Invalid alias '{alias}': "
"must follow pattern 'speckit.{extension}.{command}'"
)

# Rewrite any hook command references that pointed at a renamed command or
# an alias-form ref (ext.cmd → speckit.ext.cmd). Always emit a warning when
# the reference is changed so extension authors know to update the manifest.
for hook_name, hook_data in self.data.get("hooks", {}).items():
if not isinstance(hook_data, dict):
raise ValidationError(
f"Invalid command name '{cmd['name']}': "
"must follow pattern 'speckit.{extension}.{command}'"
f"Hook '{hook_name}' must be a mapping, got {type(hook_data).__name__}"
)
command_ref = hook_data.get("command")
if not isinstance(command_ref, str):
continue
# Step 1: apply any rename from the auto-correction pass.
after_rename = rename_map.get(command_ref, command_ref)
# Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'.
parts = after_rename.split(".")
if len(parts) == 2 and parts[0] == ext["id"]:
final_ref = f"speckit.{ext['id']}.{parts[1]}"
else:
final_ref = after_rename
if final_ref != command_ref:
hook_data["command"] = final_ref
self.warnings.append(
f"Hook '{hook_name}' referenced command '{command_ref}'; "
f"updated to canonical form '{final_ref}'. "
f"The extension author should update the manifest."
)

@staticmethod
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
"""Try to auto-correct a non-conforming command name to the required pattern.

Handles the two legacy formats used by community extensions:
- 'speckit.command' → 'speckit.{ext_id}.command'
- '{ext_id}.command' → 'speckit.{ext_id}.command'

The 'X.Y' form is only corrected when X matches ext_id to ensure the
result passes the install-time namespace check. Any other prefix is
uncorrectable and will produce a ValidationError at the call site.

Returns the corrected name, or None if no safe correction is possible.
"""
parts = name.split('.')
if len(parts) == 2:
if parts[0] == 'speckit' or parts[0] == ext_id:
candidate = f"speckit.{ext_id}.{parts[1]}"
if EXTENSION_COMMAND_NAME_PATTERN.match(candidate):
return candidate
return None

@property
def id(self) -> str:
"""Get extension ID."""
Expand Down
106 changes: 101 additions & 5 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ def test_invalid_version(self, temp_dir, valid_manifest_data):
ExtensionManifest(manifest_path)

def test_invalid_command_name(self, temp_dir, valid_manifest_data):
"""Test manifest with invalid command name format."""
"""Test manifest with command name that cannot be auto-corrected raises ValidationError."""
import yaml

valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name"
Expand All @@ -253,6 +253,85 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data):
with pytest.raises(ValidationError, match="Invalid command name"):
ExtensionManifest(manifest_path)

def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data):
"""Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'."""
import yaml

valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello"

manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)

manifest = ExtensionManifest(manifest_path)

assert manifest.commands[0]["name"] == "speckit.test-ext.hello"
assert len(manifest.warnings) == 1
assert "speckit.hello" in manifest.warnings[0]
assert "speckit.test-ext.hello" in manifest.warnings[0]

def test_command_name_autocorrect_matching_ext_id_prefix(self, temp_dir, valid_manifest_data):
"""Test that '{ext_id}.command' is auto-corrected to 'speckit.{ext_id}.command'."""
import yaml

# Set ext_id to match the legacy namespace so correction is valid
valid_manifest_data["extension"]["id"] = "docguard"
valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard"

manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)

manifest = ExtensionManifest(manifest_path)

assert manifest.commands[0]["name"] == "speckit.docguard.guard"
assert len(manifest.warnings) == 1
assert "docguard.guard" in manifest.warnings[0]
assert "speckit.docguard.guard" in manifest.warnings[0]

def test_command_name_mismatched_namespace_not_corrected(self, temp_dir, valid_manifest_data):
"""Test that 'X.command' is NOT corrected when X doesn't match ext_id."""
import yaml

# ext_id is "test-ext" but command uses a different namespace
valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard"

manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)

with pytest.raises(ValidationError, match="Invalid command name"):
ExtensionManifest(manifest_path)

def test_alias_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data):
"""Test that a legacy 'speckit.command' alias is auto-corrected."""
import yaml

valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.hello"]

manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)

manifest = ExtensionManifest(manifest_path)

assert manifest.commands[0]["aliases"] == ["speckit.test-ext.hello"]
assert len(manifest.warnings) == 1
assert "speckit.hello" in manifest.warnings[0]
assert "speckit.test-ext.hello" in manifest.warnings[0]

def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data):
"""Test that a correctly-named command produces no warnings."""
import yaml

manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)

manifest = ExtensionManifest(manifest_path)

assert manifest.warnings == []

def test_no_commands(self, temp_dir, valid_manifest_data):
"""Test manifest with no commands provided."""
import yaml
Expand All @@ -266,6 +345,19 @@ def test_no_commands(self, temp_dir, valid_manifest_data):
with pytest.raises(ValidationError, match="must provide at least one command"):
ExtensionManifest(manifest_path)

def test_non_dict_hook_entry_raises_validation_error(self, temp_dir, valid_manifest_data):
"""Non-mapping hook entries must raise ValidationError, not silently skip."""
import yaml

valid_manifest_data["hooks"]["after_tasks"] = "speckit.test-ext.hello"

manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)

with pytest.raises(ValidationError, match="Hook 'after_tasks' must be a mapping"):
ExtensionManifest(manifest_path)

def test_manifest_hash(self, extension_dir):
"""Test manifest hash calculation."""
manifest_path = extension_dir / "extension.yml"
Expand Down Expand Up @@ -635,8 +727,8 @@ def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_
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."""
def test_install_autocorrects_alias_without_extension_namespace(self, temp_dir, project_dir):
"""Legacy short aliases are auto-corrected to 'speckit.{ext_id}.{cmd}' with a warning."""
import yaml

ext_dir = temp_dir / "alias-shortcut"
Expand Down Expand Up @@ -667,8 +759,12 @@ def test_install_rejects_alias_without_extension_namespace(self, temp_dir, proje
(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)
manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)

assert manifest.commands[0]["aliases"] == ["speckit.alias-shortcut.shortcut"]
assert len(manifest.warnings) == 1
assert "speckit.shortcut" in manifest.warnings[0]
assert "speckit.alias-shortcut.shortcut" in manifest.warnings[0]

def test_install_rejects_namespace_squatting(self, temp_dir, project_dir):
"""Install should reject commands and aliases outside the extension namespace."""
Expand Down
3 changes: 1 addition & 2 deletions tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1174,8 +1174,7 @@ def test_search_with_cached_data(self, project_dir, monkeypatch):
"""Test search with cached catalog data."""
from unittest.mock import patch

# Only use the default catalog to prevent fetching the community catalog from the network
monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", PresetCatalog.DEFAULT_CATALOG_URL)
monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False)
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)

Expand Down
Loading