Skip to content

Commit dad07b1

Browse files
committed
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)
1 parent d513881 commit dad07b1

File tree

8 files changed

+148
-30
lines changed

8 files changed

+148
-30
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ Recent changes to the Specify CLI and templates are documented here.
77
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
88
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

10+
## [0.1.14] - 2026-03-09
11+
12+
### Added
13+
14+
- **Multi-Catalog Support (#1707)**: Extension catalog system now supports multiple active catalogs simultaneously via a catalog stack
15+
- New `specify extension catalog list` command lists all active catalogs with name, URL, priority, and `install_allowed` status
16+
- New `specify extension catalog add` and `specify extension catalog remove` commands for project-scoped catalog management
17+
- 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
18+
- `specify extension search` aggregates results across all active catalogs, annotating each result with source catalog
19+
- `specify extension add` enforces `install_allowed` policy — extensions from discovery-only catalogs cannot be installed directly
20+
- Project-level `.specify/extension-catalogs.yml` and user-level `~/.specify/extension-catalogs.yml` config files supported, with project-level taking precedence
21+
- `SPECKIT_CATALOG_URL` environment variable still works for backward compatibility (replaces full stack with single catalog)
22+
- All catalog URLs require HTTPS (HTTP allowed for localhost development)
23+
- New `CatalogEntry` dataclass in `extensions.py` for catalog stack representation
24+
- Per-URL hash-based caching for non-default catalogs; legacy cache preserved for default catalog
25+
- Higher-priority catalogs win on merge conflicts (same extension id in multiple catalogs)
26+
- 13 new tests covering catalog stack resolution, merge conflicts, URL validation, and `install_allowed` enforcement
27+
- Updated RFC, Extension User Guide, and Extension API Reference documentation
28+
1029
## [0.1.13] - 2026-03-03
1130

1231
### Changed

extensions/EXTENSION-API-REFERENCE.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,10 @@ from specify_cli.extensions import CatalogEntry
254254
255255
entry = CatalogEntry(
256256
url="https://example.com/catalog.json",
257-
name="org-approved",
257+
name="default",
258258
priority=1,
259259
install_allowed=True,
260+
description="Built-in catalog of installable extensions",
260261
)
261262
```
262263

@@ -268,6 +269,7 @@ entry = CatalogEntry(
268269
| `name` | `str` | Human-readable catalog name |
269270
| `priority` | `int` | Sort order (lower = higher priority, wins on conflicts) |
270271
| `install_allowed` | `bool` | Whether extensions from this catalog can be installed |
272+
| `description` | `str` | Optional human-readable description of the catalog (default: empty) |
271273

272274
### ExtensionCatalog
273275

@@ -282,7 +284,7 @@ catalog = ExtensionCatalog(project_root)
282284
**Class attributes**:
283285

284286
```python
285-
ExtensionCatalog.DEFAULT_CATALOG_URL # org-approved catalog URL
287+
ExtensionCatalog.DEFAULT_CATALOG_URL # default catalog URL
286288
ExtensionCatalog.COMMUNITY_CATALOG_URL # community catalog URL
287289
```
288290

@@ -328,14 +330,16 @@ Each extension dict returned by `search()` and `get_extension_info()` includes:
328330

329331
```yaml
330332
catalogs:
331-
- name: "org-approved"
332-
url: "https://example.com/catalog.json"
333+
- name: "default"
334+
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
333335
priority: 1
334336
install_allowed: true
337+
description: "Built-in catalog of installable extensions"
335338
- name: "community"
336339
url: "https://v-raw-githubusercontent-com.miaizhe.xyz/github/spec-kit/main/extensions/catalog.community.json"
337340
priority: 2
338341
install_allowed: false
342+
description: "Community-contributed extensions (discovery only)"
339343
```
340344

341345
### HookExecutor
@@ -604,11 +608,11 @@ EXECUTE_COMMAND: {command}
604608

605609
**Output**: List of installed extensions with metadata
606610

607-
### extension catalogs
611+
### extension catalog list
608612

609-
**Usage**: `specify extension catalogs`
613+
**Usage**: `specify extension catalog list`
610614

611-
Lists all active catalogs in the current catalog stack, showing name, URL, priority, and `install_allowed` status.
615+
Lists all active catalogs in the current catalog stack, showing name, description, URL, priority, and `install_allowed` status.
612616

613617
### extension catalog add
614618

@@ -619,6 +623,7 @@ Lists all active catalogs in the current catalog stack, showing name, URL, prior
619623
- `--name NAME` - Catalog name (required)
620624
- `--priority INT` - Priority (lower = higher priority, default: 10)
621625
- `--install-allowed / --no-install-allowed` - Allow installs from this catalog (default: false)
626+
- `--description TEXT` - Optional description of the catalog
622627

623628
**Arguments**:
624629

extensions/EXTENSION-USER-GUIDE.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ vim .specify/extensions/jira/jira-config.yml
8484
specify extension search
8585
```
8686

87-
Shows all extensions across all active catalogs (org-approved and community by default).
87+
Shows all extensions across all active catalogs (default and community by default).
8888

8989
### Search by Keyword
9090

@@ -423,13 +423,13 @@ Spec Kit uses a **catalog stack** — an ordered list of catalogs searched simul
423423

424424
| Priority | Catalog | Install Allowed | Purpose |
425425
|----------|---------|-----------------|---------|
426-
| 1 | `catalog.json` (org-approved) | ✅ Yes | Extensions your org approves for installation |
426+
| 1 | `catalog.json` (default) | ✅ Yes | Curated extensions available for installation |
427427
| 2 | `catalog.community.json` (community) | ❌ No (discovery only) | Browse community extensions |
428428

429429
### Listing Active Catalogs
430430

431431
```bash
432-
specify extension catalogs
432+
specify extension catalog list
433433
```
434434

435435
### Adding a Catalog (Project-scoped)
@@ -463,20 +463,23 @@ You can also edit `.specify/extension-catalogs.yml` directly:
463463

464464
```yaml
465465
catalogs:
466-
- name: "org-approved"
466+
- name: "default"
467467
url: "https://v-raw-githubusercontent-com.miaizhe.xyz/github/spec-kit/main/extensions/catalog.json"
468468
priority: 1
469469
install_allowed: true
470+
description: "Built-in catalog of installable extensions"
470471
471472
- name: "internal"
472473
url: "https://internal.company.com/spec-kit/catalog.json"
473474
priority: 2
474475
install_allowed: true
476+
description: "Internal company extensions"
475477
476478
- name: "community"
477479
url: "https://v-raw-githubusercontent-com.miaizhe.xyz/github/spec-kit/main/extensions/catalog.community.json"
478480
priority: 3
479481
install_allowed: false
482+
description: "Community-contributed extensions (discovery only)"
480483
```
481484

482485
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. Project-level config takes full precedence when present.
@@ -569,7 +572,7 @@ Add to `.specify/extension-catalogs.yml` in your project:
569572

570573
```yaml
571574
catalogs:
572-
- name: "org-approved"
575+
- name: "my-org"
573576
url: "https://your-org.com/spec-kit/catalog.json"
574577
priority: 1
575578
install_allowed: true
@@ -579,7 +582,7 @@ Or use the CLI:
579582

580583
```bash
581584
specify extension catalog add \
582-
--name "org-approved" \
585+
--name "my-org" \
583586
--install-allowed \
584587
https://your-org.com/spec-kit/catalog.json
585588
```
@@ -595,7 +598,7 @@ export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json"
595598

596599
```bash
597600
# List active catalogs
598-
specify extension catalogs
601+
specify extension catalog list
599602
600603
# Search should now show your catalog's extensions
601604
specify extension search

extensions/RFC-EXTENSION-SYSTEM.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -961,7 +961,7 @@ specify extension info jira
961961

962962
### Custom Catalogs
963963

964-
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.
964+
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.
965965

966966
#### Catalog Stack Resolution
967967

@@ -978,29 +978,32 @@ When no config file exists, the CLI uses:
978978

979979
| Priority | Catalog | install_allowed | Purpose |
980980
|----------|---------|-----------------|---------|
981-
| 1 | `catalog.json` (org-approved) | `true` | Extensions your org approves for installation |
981+
| 1 | `catalog.json` (default) | `true` | Curated extensions available for installation |
982982
| 2 | `catalog.community.json` (community) | `false` | Discovery only — browse but not install |
983983

984-
This means `specify extension search` surfaces community extensions out of the box, while `specify extension add` is still restricted to org-approved entries.
984+
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`.
985985

986986
#### `.specify/extension-catalogs.yml` Config File
987987

988988
```yaml
989989
catalogs:
990-
- name: "org-approved"
990+
- name: "default"
991991
url: "https://v-raw-githubusercontent-com.miaizhe.xyz/github/spec-kit/main/extensions/catalog.json"
992992
priority: 1 # Highest — only approved entries can be installed
993993
install_allowed: true
994+
description: "Built-in catalog of installable extensions"
994995
995996
- name: "internal"
996997
url: "https://internal.company.com/spec-kit/catalog.json"
997998
priority: 2
998999
install_allowed: true
1000+
description: "Internal company extensions"
9991001
10001002
- name: "community"
10011003
url: "https://v-raw-githubusercontent-com.miaizhe.xyz/github/spec-kit/main/extensions/catalog.community.json"
10021004
priority: 3 # Lowest — discovery only, not installable
10031005
install_allowed: false
1006+
description: "Community-contributed extensions (discovery only)"
10041007
```
10051008

10061009
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.
@@ -1009,7 +1012,7 @@ A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. When a pro
10091012

10101013
```bash
10111014
# List active catalogs with name, URL, priority, and install_allowed
1012-
specify extension catalogs
1015+
specify extension catalog list
10131016
10141017
# Add a catalog (project-scoped)
10151018
specify extension catalog add --name "internal" --install-allowed \
@@ -1024,7 +1027,7 @@ specify extension catalog remove internal
10241027
10251028
# Show which catalog an extension came from
10261029
specify extension info jira
1027-
# → Source catalog: org-approved
1030+
# → Source catalog: default
10281031
```
10291032

10301033
#### Merge Conflict Resolution

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "specify-cli"
3-
version = "0.1.13"
3+
version = "0.1.14"
44
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
55
requires-python = ">=3.11"
66
dependencies = [

src/specify_cli/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1837,8 +1837,8 @@ def extension_list(
18371837
console.print(" [cyan]specify extension add <name>[/cyan]")
18381838

18391839

1840-
@extension_app.command("catalogs")
1841-
def extension_catalogs():
1840+
@catalog_app.command("list")
1841+
def catalog_list():
18421842
"""List all active extension catalogs."""
18431843
from .extensions import ExtensionCatalog, ValidationError
18441844

@@ -1866,15 +1866,20 @@ def extension_catalogs():
18661866
else "[yellow]discovery only[/yellow]"
18671867
)
18681868
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
1869+
if entry.description:
1870+
console.print(f" {entry.description}")
18691871
console.print(f" URL: {entry.url}")
18701872
console.print(f" Install: {install_str}")
18711873
console.print()
18721874

18731875
config_path = project_root / ".specify" / "extension-catalogs.yml"
1876+
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
18741877
if config_path.exists() and catalog._load_catalog_config(config_path) is not None:
18751878
console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]")
18761879
elif os.environ.get("SPECKIT_CATALOG_URL"):
18771880
console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]")
1881+
elif user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None:
1882+
console.print(f"[dim]Config: ~/.specify/extension-catalogs.yml[/dim]")
18781883
else:
18791884
console.print("[dim]Using built-in default catalog stack.[/dim]")
18801885
console.print(
@@ -1891,6 +1896,7 @@ def catalog_add(
18911896
False, "--install-allowed/--no-install-allowed",
18921897
help="Allow extensions from this catalog to be installed",
18931898
),
1899+
description: str = typer.Option("", "--description", help="Description of the catalog"),
18941900
):
18951901
"""Add a catalog to .specify/extension-catalogs.yml."""
18961902
from .extensions import ExtensionCatalog, ValidationError
@@ -1936,6 +1942,7 @@ def catalog_add(
19361942
"url": url,
19371943
"priority": priority,
19381944
"install_allowed": install_allowed,
1945+
"description": description,
19391946
})
19401947

19411948
config["catalogs"] = catalogs

src/specify_cli/extensions.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class CatalogEntry:
4545
name: str
4646
priority: int
4747
install_allowed: bool
48+
description: str = ""
4849

4950

5051
class ExtensionManifest:
@@ -1026,6 +1027,9 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]
10261027
Returns:
10271028
Ordered list of CatalogEntry objects, or None if file doesn't exist
10281029
or contains no valid catalog entries.
1030+
1031+
Raises:
1032+
ValidationError: If any catalog entry has an invalid URL.
10291033
"""
10301034
if not config_path.exists():
10311035
return None
@@ -1044,7 +1048,8 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]
10441048
url=url,
10451049
name=str(item.get("name", f"catalog-{idx + 1}")),
10461050
priority=int(item.get("priority", idx + 1)),
1047-
install_allowed=bool(item.get("install_allowed", True)),
1051+
install_allowed=bool(item.get("install_allowed", False)),
1052+
description=str(item.get("description", "")),
10481053
))
10491054
entries.sort(key=lambda e: e.priority)
10501055
return entries if entries else None
@@ -1058,7 +1063,7 @@ def get_active_catalogs(self) -> List[CatalogEntry]:
10581063
1. SPECKIT_CATALOG_URL env var — single catalog replacing all defaults
10591064
2. Project-level .specify/extension-catalogs.yml
10601065
3. User-level ~/.specify/extension-catalogs.yml
1061-
4. Built-in default stack (org-approved + community)
1066+
4. Built-in default stack (default + community)
10621067
10631068
Returns:
10641069
List of CatalogEntry objects sorted by priority (ascending)
@@ -1080,7 +1085,7 @@ def get_active_catalogs(self) -> List[CatalogEntry]:
10801085
file=sys.stderr,
10811086
)
10821087
self._non_default_catalog_warning_shown = True
1083-
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True)]
1088+
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")]
10841089

10851090
# 2. Project-level config overrides all defaults
10861091
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
@@ -1096,8 +1101,8 @@ def get_active_catalogs(self) -> List[CatalogEntry]:
10961101

10971102
# 4. Built-in default stack
10981103
return [
1099-
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="org-approved", priority=1, install_allowed=True),
1100-
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False),
1104+
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"),
1105+
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"),
11011106
]
11021107

11031108
def get_catalog_url(self) -> str:

0 commit comments

Comments
 (0)