fix: address PR review feedback for multi-catalog support

- Rename 'org-approved' catalog to 'default'
- Move 'catalogs' command to 'catalog list' for consistency
- Add 'description' field to CatalogEntry dataclass
- Add --description option to 'catalog add' CLI command
- Align install_allowed default to False in _load_catalog_config
- Add user-level config detection in catalog list footer
- Fix _load_catalog_config docstring (document ValidationError)
- Fix test isolation for test_search_by_tag, test_search_by_query,
  test_search_verified_only, test_get_extension_info
- Update version to 0.1.14 and CHANGELOG
- Update all docs (RFC, User Guide, API Reference)
This commit is contained in:
Manfred Riem
2026-03-09 13:04:55 -05:00
parent 9a82f098e8
commit 990a1513c2
7 changed files with 204 additions and 59 deletions

View File

@@ -12,6 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- feat: add Tabnine CLI agent support
- **Multi-Catalog Support (#1707)**: Extension catalog system now supports multiple active catalogs simultaneously via a catalog stack
- New `specify extension catalog list` command lists all active catalogs with name, URL, priority, and `install_allowed` status
- New `specify extension catalog add` and `specify extension catalog remove` commands for project-scoped catalog management
- Default built-in stack includes `catalog.json` (default, installable) and `catalog.community.json` (community, discovery only) — community extensions are now surfaced in search results out of the box
- `specify extension search` aggregates results across all active catalogs, annotating each result with source catalog
- `specify extension add` enforces `install_allowed` policy — extensions from discovery-only catalogs cannot be installed directly
- Project-level `.specify/extension-catalogs.yml` and user-level `~/.specify/extension-catalogs.yml` config files supported, with project-level taking precedence
- `SPECKIT_CATALOG_URL` environment variable still works for backward compatibility (replaces full stack with single catalog)
- All catalog URLs require HTTPS (HTTP allowed for localhost development)
- New `CatalogEntry` dataclass in `extensions.py` for catalog stack representation
- Per-URL hash-based caching for non-default catalogs; legacy cache preserved for default catalog
- Higher-priority catalogs win on merge conflicts (same extension id in multiple catalogs)
- 13 new tests covering catalog stack resolution, merge conflicts, URL validation, and `install_allowed` enforcement
- Updated RFC, Extension User Guide, and Extension API Reference documentation
## [0.1.13] - 2026-03-03

View File

@@ -254,9 +254,10 @@ from specify_cli.extensions import CatalogEntry
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="org-approved",
name="default",
priority=1,
install_allowed=True,
description="Built-in catalog of installable extensions",
)
```
@@ -268,6 +269,7 @@ entry = CatalogEntry(
| `name` | `str` | Human-readable catalog name |
| `priority` | `int` | Sort order (lower = higher priority, wins on conflicts) |
| `install_allowed` | `bool` | Whether extensions from this catalog can be installed |
| `description` | `str` | Optional human-readable description of the catalog (default: empty) |
### ExtensionCatalog
@@ -282,7 +284,7 @@ catalog = ExtensionCatalog(project_root)
**Class attributes**:
```python
ExtensionCatalog.DEFAULT_CATALOG_URL # org-approved catalog URL
ExtensionCatalog.DEFAULT_CATALOG_URL # default catalog URL
ExtensionCatalog.COMMUNITY_CATALOG_URL # community catalog URL
```
@@ -328,14 +330,16 @@ Each extension dict returned by `search()` and `get_extension_info()` includes:
```yaml
catalogs:
- name: "org-approved"
url: "https://example.com/catalog.json"
- name: "default"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
priority: 1
install_allowed: true
description: "Built-in catalog of installable extensions"
- name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 2
install_allowed: false
description: "Community-contributed extensions (discovery only)"
```
### HookExecutor
@@ -604,11 +608,11 @@ EXECUTE_COMMAND: {command}
**Output**: List of installed extensions with metadata
### extension catalogs
### extension catalog list
**Usage**: `specify extension catalogs`
**Usage**: `specify extension catalog list`
Lists all active catalogs in the current catalog stack, showing name, URL, priority, and `install_allowed` status.
Lists all active catalogs in the current catalog stack, showing name, description, URL, priority, and `install_allowed` status.
### extension catalog add
@@ -619,6 +623,7 @@ Lists all active catalogs in the current catalog stack, showing name, URL, prior
- `--name NAME` - Catalog name (required)
- `--priority INT` - Priority (lower = higher priority, default: 10)
- `--install-allowed / --no-install-allowed` - Allow installs from this catalog (default: false)
- `--description TEXT` - Optional description of the catalog
**Arguments**:

View File

@@ -84,7 +84,7 @@ vim .specify/extensions/jira/jira-config.yml
specify extension search
```
Shows all extensions across all active catalogs (org-approved and community by default).
Shows all extensions across all active catalogs (default and community by default).
### Search by Keyword
@@ -423,13 +423,13 @@ Spec Kit uses a **catalog stack** — an ordered list of catalogs searched simul
| Priority | Catalog | Install Allowed | Purpose |
|----------|---------|-----------------|---------|
| 1 | `catalog.json` (org-approved) | ✅ Yes | Extensions your org approves for installation |
| 1 | `catalog.json` (default) | ✅ Yes | Curated extensions available for installation |
| 2 | `catalog.community.json` (community) | ❌ No (discovery only) | Browse community extensions |
### Listing Active Catalogs
```bash
specify extension catalogs
specify extension catalog list
```
### Adding a Catalog (Project-scoped)
@@ -463,23 +463,26 @@ You can also edit `.specify/extension-catalogs.yml` directly:
```yaml
catalogs:
- name: "org-approved"
- name: "default"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
priority: 1
install_allowed: true
description: "Built-in catalog of installable extensions"
- name: "internal"
url: "https://internal.company.com/spec-kit/catalog.json"
priority: 2
install_allowed: true
description: "Internal company extensions"
- name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 3
install_allowed: false
description: "Community-contributed extensions (discovery only)"
```
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. Project-level config takes full precedence when present.
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. Project-level config takes full precedence when it contains one or more catalog entries. An empty `catalogs: []` list falls back to built-in defaults.
## Organization Catalog Customization
@@ -569,7 +572,7 @@ Add to `.specify/extension-catalogs.yml` in your project:
```yaml
catalogs:
- name: "org-approved"
- name: "my-org"
url: "https://your-org.com/spec-kit/catalog.json"
priority: 1
install_allowed: true
@@ -579,7 +582,7 @@ Or use the CLI:
```bash
specify extension catalog add \
--name "org-approved" \
--name "my-org" \
--install-allowed \
https://your-org.com/spec-kit/catalog.json
```
@@ -595,7 +598,7 @@ export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json"
```bash
# List active catalogs
specify extension catalogs
specify extension catalog list
# Search should now show your catalog's extensions
specify extension search

View File

@@ -961,7 +961,7 @@ specify extension info jira
### Custom Catalogs
Spec Kit supports a **catalog stack** — an ordered list of catalogs that the CLI merges and searches across. This allows organizations to benefit from org-approved extensions, an internal catalog, and community discovery all at once.
Spec Kit supports a **catalog stack** — an ordered list of catalogs that the CLI merges and searches across. This allows organizations to maintain their own org-approved extensions alongside an internal catalog and community discovery, all at once.
#### Catalog Stack Resolution
@@ -978,38 +978,41 @@ When no config file exists, the CLI uses:
| Priority | Catalog | install_allowed | Purpose |
|----------|---------|-----------------|---------|
| 1 | `catalog.json` (org-approved) | `true` | Extensions your org approves for installation |
| 1 | `catalog.json` (default) | `true` | Curated extensions available for installation |
| 2 | `catalog.community.json` (community) | `false` | Discovery only — browse but not install |
This means `specify extension search` surfaces community extensions out of the box, while `specify extension add` is still restricted to org-approved entries.
This means `specify extension search` surfaces community extensions out of the box, while `specify extension add` is still restricted to entries from catalogs with `install_allowed: true`.
#### `.specify/extension-catalogs.yml` Config File
```yaml
catalogs:
- name: "org-approved"
- name: "default"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
priority: 1 # Highest — only approved entries can be installed
install_allowed: true
description: "Built-in catalog of installable extensions"
- name: "internal"
url: "https://internal.company.com/spec-kit/catalog.json"
priority: 2
install_allowed: true
description: "Internal company extensions"
- name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 3 # Lowest — discovery only, not installable
install_allowed: false
description: "Community-contributed extensions (discovery only)"
```
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. When a project-level config is present, it takes full control and the built-in defaults are not applied.
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. When a project-level config is present with one or more catalog entries, it takes full control and the built-in defaults are not applied. An empty `catalogs: []` list is treated the same as no config file, falling back to defaults.
#### Catalog CLI Commands
```bash
# List active catalogs with name, URL, priority, and install_allowed
specify extension catalogs
specify extension catalog list
# Add a catalog (project-scoped)
specify extension catalog add --name "internal" --install-allowed \
@@ -1024,7 +1027,7 @@ specify extension catalog remove internal
# Show which catalog an extension came from
specify extension info jira
# → Source catalog: org-approved
# → Source catalog: default
```
#### Merge Conflict Resolution

View File

@@ -1844,8 +1844,8 @@ def extension_list(
console.print(" [cyan]specify extension add <name>[/cyan]")
@extension_app.command("catalogs")
def extension_catalogs():
@catalog_app.command("list")
def catalog_list():
"""List all active extension catalogs."""
from .extensions import ExtensionCatalog, ValidationError
@@ -1873,15 +1873,20 @@ def extension_catalogs():
else "[yellow]discovery only[/yellow]"
)
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
if entry.description:
console.print(f" {entry.description}")
console.print(f" URL: {entry.url}")
console.print(f" Install: {install_str}")
console.print()
config_path = project_root / ".specify" / "extension-catalogs.yml"
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
if config_path.exists() and catalog._load_catalog_config(config_path) is not None:
console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]")
elif os.environ.get("SPECKIT_CATALOG_URL"):
console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]")
elif user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None:
console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]")
else:
console.print("[dim]Using built-in default catalog stack.[/dim]")
console.print(
@@ -1898,6 +1903,7 @@ def catalog_add(
False, "--install-allowed/--no-install-allowed",
help="Allow extensions from this catalog to be installed",
),
description: str = typer.Option("", "--description", help="Description of the catalog"),
):
"""Add a catalog to .specify/extension-catalogs.yml."""
from .extensions import ExtensionCatalog, ValidationError
@@ -1924,16 +1930,20 @@ def catalog_add(
if config_path.exists():
try:
config = yaml.safe_load(config_path.read_text()) or {}
except Exception:
config = {}
except Exception as e:
console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
raise typer.Exit(1)
else:
config = {}
catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
# Check for duplicate name
for existing in catalogs:
if existing.get("name") == name:
if isinstance(existing, dict) and existing.get("name") == name:
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
console.print("Use 'specify extension catalog remove' first, or choose a different name.")
raise typer.Exit(1)
@@ -1943,6 +1953,7 @@ def catalog_add(
"url": url,
"priority": priority,
"install_allowed": install_allowed,
"description": description,
})
config["catalogs"] = catalogs
@@ -1980,8 +1991,11 @@ def catalog_remove(
raise typer.Exit(1)
catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
original_count = len(catalogs)
catalogs = [c for c in catalogs if c.get("name") != name]
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
if len(catalogs) == original_count:
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
@@ -2268,8 +2282,8 @@ def extension_search(
else:
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
console.print(
" Add to an approved catalog with install_allowed: true, "
"or use: specify extension add --from <url>"
f" Add to an approved catalog with install_allowed: true, "
f"or install from a ZIP URL: specify extension add {ext['id']} --from <zip-url>"
)
console.print()

View File

@@ -45,6 +45,7 @@ class CatalogEntry:
name: str
priority: int
install_allowed: bool
description: str = ""
class ExtensionManifest:
@@ -1032,30 +1033,57 @@ class ExtensionCatalog:
Returns:
Ordered list of CatalogEntry objects, or None if file doesn't exist
or contains no valid catalog entries.
Raises:
ValidationError: If any catalog entry has an invalid URL,
the file cannot be parsed, or a priority value is invalid.
"""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text()) or {}
except (yaml.YAMLError, OSError) as e:
raise ValidationError(
f"Failed to read catalog config {config_path}: {e}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
return None
if not isinstance(catalogs_data, list):
raise ValidationError(
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
)
entries: List[CatalogEntry] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
raise ValidationError(
f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}"
)
url = str(item.get("url", "")).strip()
if not url:
continue
self._validate_catalog_url(url)
try:
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raise ValidationError(
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
else:
install_allowed = bool(raw_install)
entries.append(CatalogEntry(
url=url,
name=str(item.get("name", f"catalog-{idx + 1}")),
priority=int(item.get("priority", idx + 1)),
install_allowed=bool(item.get("install_allowed", True)),
priority=priority,
install_allowed=install_allowed,
description=str(item.get("description", "")),
))
entries.sort(key=lambda e: e.priority)
return entries if entries else None
except (yaml.YAMLError, OSError):
return None
def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs.
@@ -1064,7 +1092,7 @@ class ExtensionCatalog:
1. SPECKIT_CATALOG_URL env var — single catalog replacing all defaults
2. Project-level .specify/extension-catalogs.yml
3. User-level ~/.specify/extension-catalogs.yml
4. Built-in default stack (org-approved + community)
4. Built-in default stack (default + community)
Returns:
List of CatalogEntry objects sorted by priority (ascending)
@@ -1086,7 +1114,7 @@ class ExtensionCatalog:
file=sys.stderr,
)
self._non_default_catalog_warning_shown = True
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True)]
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")]
# 2. Project-level config overrides all defaults
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
@@ -1102,8 +1130,8 @@ class ExtensionCatalog:
# 4. Built-in default stack
return [
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="org-approved", priority=1, install_allowed=True),
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False),
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"),
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"),
]
def get_catalog_url(self) -> str:
@@ -1155,9 +1183,11 @@ class ExtensionCatalog:
try:
metadata = json.loads(cache_meta_file.read_text())
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
is_valid = age < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError):
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
# If metadata is invalid or missing expected fields, treat cache as invalid
pass
@@ -1231,8 +1261,8 @@ class ExtensionCatalog:
for ext_id, ext_data in catalog_data.get("extensions", {}).items():
if ext_id not in merged: # Higher-priority catalog wins
merged[ext_id] = {
"id": ext_id,
**ext_data,
"id": ext_id,
"_catalog_name": catalog_entry.name,
"_install_allowed": catalog_entry.install_allowed,
}
@@ -1254,9 +1284,11 @@ class ExtensionCatalog:
try:
metadata = json.loads(self.cache_metadata_file.read_text())
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
return age_seconds < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError):
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
return False
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:

View File

@@ -950,10 +950,29 @@ class TestExtensionCatalog:
def test_search_by_query(self, temp_dir):
"""Test searching by query text."""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Create mock catalog
@@ -995,10 +1014,29 @@ class TestExtensionCatalog:
def test_search_by_tag(self, temp_dir):
"""Test searching by tag."""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Create mock catalog
@@ -1047,10 +1085,29 @@ class TestExtensionCatalog:
def test_search_verified_only(self, temp_dir):
"""Test searching verified extensions only."""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Create mock catalog
@@ -1092,10 +1149,29 @@ class TestExtensionCatalog:
def test_get_extension_info(self, temp_dir):
"""Test getting specific extension info."""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Create mock catalog
@@ -1214,7 +1290,7 @@ class TestCatalogStack:
# --- get_active_catalogs ---
def test_default_stack(self, temp_dir):
"""Default stack includes org-approved and community catalogs."""
"""Default stack includes default and community catalogs."""
project_dir = self._make_project(temp_dir)
catalog = ExtensionCatalog(project_dir)
@@ -1222,7 +1298,7 @@ class TestCatalogStack:
assert len(entries) == 2
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
assert entries[0].name == "org-approved"
assert entries[0].name == "default"
assert entries[0].priority == 1
assert entries[0].install_allowed is True
assert entries[1].url == ExtensionCatalog.COMMUNITY_CATALOG_URL
@@ -1372,8 +1448,6 @@ class TestCatalogStack:
def test_merge_conflict_higher_priority_wins(self, temp_dir):
"""When same extension id is in two catalogs, higher priority wins."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
# Write project config with two catalogs