Skip to content
Draft
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
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 are published 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. Always install directly from GitHub as shown below.

### 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
4 changes: 4 additions & 0 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
82 changes: 78 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,91 @@ 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):
Comment on lines +213 to +217
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

aliases: null (or an empty aliases: key) will currently be treated as an empty list for validation, but cmd['aliases'] is not normalized back to a list. Downstream code iterates cmd_info.get('aliases', []) (e.g., command registration), which will return None if the key exists and can crash with TypeError: 'NoneType' object is not iterable. Consider either (a) rejecting None as invalid, or (b) normalizing by setting cmd['aliases'] = [] when the value is None so later consumers always see a list.

Copilot uses AI. Check for mistakes.
raise ValidationError(
f"Invalid command name '{cmd['name']}': "
"must follow pattern 'speckit.{extension}.{command}'"
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.
for hook_name, hook_data in self.data.get("hooks", {}).items():
if isinstance(hook_data, dict) and hook_data.get("command") in rename_map:
old_ref = hook_data["command"]
hook_data["command"] = rename_map[old_ref]
self.warnings.append(
f"Hook '{hook_name}' referenced renamed command '{old_ref}'; "
f"updated to '{rename_map[old_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
93 changes: 88 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 Down Expand Up @@ -635,8 +714,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 +746,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)
Comment on lines +1177 to 1178
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

PR description says this test writes a project-level .specify/preset-catalogs.yml to restrict catalogs, but the test actually patches PresetCatalog.get_active_catalogs at runtime. If the intent changed, please update the PR description/test comment to match the implemented approach (or implement the config-file approach if that’s preferred).

Copilot uses AI. Check for mistakes.
catalog.cache_dir.mkdir(parents=True, exist_ok=True)

Expand Down
Loading