Skip to content
Merged
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
18 changes: 2 additions & 16 deletions extensions/catalog.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-06T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
"extensions": {
"git": {
Expand All @@ -10,27 +10,13 @@
"description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip",
"bundled": true,
"tags": [
"git",
"branching",
"workflow",
"core"
]
},
"selftest": {
"name": "Spec Kit Self-Test Utility",
"id": "selftest",
"version": "1.0.0",
"description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip",
"tags": [
"testing",
"core",
"utility"
]
}
}
}
15 changes: 14 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3001,7 +3001,7 @@ def extension_add(
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install an extension."""
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND

project_root = Path.cwd()

Expand Down Expand Up @@ -3103,6 +3103,19 @@ def extension_add(
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)

if bundled_path is None:
# Bundled extensions without a download URL must come from the local package
if ext_info.get("bundled") and not ext_info.get("download_url"):
console.print(
f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit "
f"but could not be found in the installed package."
)
console.print(
"\nThis usually means the spec-kit installation is incomplete or corrupted."
)
console.print("Try reinstalling spec-kit:")
console.print(f" {REINSTALL_COMMAND}")
raise typer.Exit(1)

# Enforce install_allowed policy
if not ext_info.get("_install_allowed", True):
catalog_name = ext_info.get("_catalog_name", "community")
Expand Down
10 changes: 10 additions & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
})
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")

REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"


def _load_core_command_names() -> frozenset[str]:
"""Discover bundled core command names from the packaged templates.
Expand Down Expand Up @@ -1870,6 +1872,14 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non
if not ext_info:
raise ExtensionError(f"Extension '{extension_id}' not found in catalog")

# Bundled extensions without a download URL must be installed locally
if ext_info.get("bundled") and not ext_info.get("download_url"):
raise ExtensionError(
f"Extension '{extension_id}' is bundled with spec-kit and has no download URL. "
f"It should be installed from the local package. "
f"Try reinstalling: {REINSTALL_COMMAND}"
)

download_url = ext_info.get("download_url")
if not download_url:
raise ExtensionError(f"Extension '{extension_id}' has no download URL")
Expand Down
116 changes: 116 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2995,6 +2995,122 @@ def mock_download(extension_id):
f"but was called with '{download_called_with[0]}'"
)

def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path):
"""extension add should give a clear error when a bundled extension is not found locally."""
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from specify_cli import app

runner = CliRunner()

# Create project structure
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".specify" / "extensions").mkdir(parents=True)

# Mock catalog that returns a bundled extension without download_url
mock_catalog = MagicMock()
mock_catalog.get_extension_info.return_value = {
"id": "git",
"name": "Git Branching Workflow",
"version": "1.0.0",
"description": "Git branching extension",
"bundled": True,
"_install_allowed": True,
}
mock_catalog.search.return_value = []

with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
patch("specify_cli._locate_bundled_extension", return_value=None), \
patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", "git"],
catch_exceptions=True,
)

assert result.exit_code != 0
assert "bundled with spec-kit" in result.output
assert "reinstall" in result.output.lower()


class TestDownloadExtensionBundled:
"""Tests for download_extension handling of bundled extensions."""

def test_download_extension_raises_for_bundled(self, temp_dir):
"""download_extension should raise a clear error for bundled extensions without a URL."""
from unittest.mock import patch

project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()

catalog = ExtensionCatalog(project_dir)

bundled_ext_info = {
"name": "Git Branching Workflow",
"id": "git",
"version": "1.0.0",
"description": "Git workflow",
"bundled": True,
}

with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info):
with pytest.raises(ExtensionError, match="bundled with spec-kit"):
catalog.download_extension("git")

def test_download_extension_allows_bundled_with_url(self, temp_dir):
"""download_extension should allow bundled extensions that have a download_url (newer version)."""
from unittest.mock import patch, MagicMock
import urllib.request

project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()

catalog = ExtensionCatalog(project_dir)

bundled_with_url = {
"name": "Git Branching Workflow",
"id": "git",
"version": "2.0.0",
"description": "Git workflow",
"bundled": True,
"download_url": "https://example.com/git-2.0.0.zip",
}

mock_response = MagicMock()
mock_response.read.return_value = b"fake zip data"
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)

with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \
patch.object(urllib.request, "urlopen", return_value=mock_response):
result = catalog.download_extension("git")
assert result.name == "git-2.0.0.zip"

def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir):
"""download_extension should raise 'no download URL' for non-bundled extensions without URL."""
from unittest.mock import patch

project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()

catalog = ExtensionCatalog(project_dir)

non_bundled_ext_info = {
"name": "Some Extension",
"id": "some-ext",
"version": "1.0.0",
"description": "Test",
}

with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info):
with pytest.raises(ExtensionError, match="has no download URL"):
catalog.download_extension("some-ext")


class TestExtensionUpdateCLI:
"""CLI integration tests for extension update command."""
Expand Down
Loading