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 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
58 changes: 53 additions & 5 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 @@ -192,11 +193,58 @@ def _validate(self):
raise ValidationError("Command missing 'name' or 'file'")

# Validate command name format
if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
raise ValidationError(
f"Invalid command name '{cmd['name']}': "
"must follow pattern 'speckit.{extension}.{command}'"
)
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."
)
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") or []
for i, alias in enumerate(aliases):
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."
)
aliases[i] = corrected
else:
raise ValidationError(
f"Invalid alias '{alias}': "
"must follow pattern 'speckit.{extension}.{command}'"
)

@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 most common legacy formats used by community extensions:
- 'speckit.command' → 'speckit.{ext_id}.command'
- 'extension.command' → 'speckit.extension.command'

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

@property
def id(self) -> str:
Expand Down
77 changes: 72 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,69 @@ 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_no_speckit_prefix(self, temp_dir, valid_manifest_data):
"""Test that 'extension.command' is auto-corrected to 'speckit.extension.command'."""
import yaml

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_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 +698,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 +730,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
10 changes: 10 additions & 0 deletions tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,16 @@ def test_search_with_cached_data(self, project_dir, monkeypatch):
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)

# Restrict to default catalog only — prevents community catalog network calls
# which would add extra results and make the count assertions flaky.
(project_dir / ".specify" / "preset-catalogs.yml").write_text(
f"catalogs:\n"
f" - url: \"{PresetCatalog.DEFAULT_CATALOG_URL}\"\n"
f" name: default\n"
f" priority: 1\n"
f" install_allowed: true\n"
)

catalog_data = {
"schema_version": "1.0",
"presets": {
Expand Down
Loading