Skip to content

Commit b60e60e

Browse files
committed
feat: core and community extension catalogs merged seamlessly during search
1 parent 71e6b4d commit b60e60e

File tree

2 files changed

+60
-34
lines changed

2 files changed

+60
-34
lines changed

src/specify_cli/extensions.py

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -969,7 +969,10 @@ def register_commands_for_claude(
969969
class ExtensionCatalog:
970970
"""Manages extension catalog fetching, caching, and searching."""
971971

972-
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
972+
DEFAULT_CATALOG_URLS = [
973+
"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
974+
"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
975+
]
973976
CACHE_DURATION = 3600 # 1 hour in seconds
974977

975978
def __init__(self, project_root: Path):
@@ -984,15 +987,15 @@ def __init__(self, project_root: Path):
984987
self.cache_file = self.cache_dir / "catalog.json"
985988
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
986989

987-
def get_catalog_url(self) -> str:
988-
"""Get catalog URL from config or use default.
990+
def get_catalog_urls(self) -> List[str]:
991+
"""Get catalog URLs from config or use default.
989992
990993
Checks in order:
991-
1. SPECKIT_CATALOG_URL environment variable
992-
2. Default catalog URL
994+
1. SPECKIT_CATALOG_URL environment variable (comma-separated for multiple)
995+
2. Default catalog URLs
993996
994997
Returns:
995-
URL to fetch catalog from
998+
List of URLs to fetch catalogs from
996999
9971000
Raises:
9981001
ValidationError: If custom URL is invalid (non-HTTPS)
@@ -1021,7 +1024,7 @@ def get_catalog_url(self) -> str:
10211024
)
10221025

10231026
# Warn users when using a non-default catalog (once per instance)
1024-
if catalog_url != self.DEFAULT_CATALOG_URL:
1027+
if catalog_url not in self.DEFAULT_CATALOG_URLS:
10251028
if not getattr(self, "_non_default_catalog_warning_shown", False):
10261029
print(
10271030
"Warning: Using non-default extension catalog. "
@@ -1030,39 +1033,50 @@ def get_catalog_url(self) -> str:
10301033
)
10311034
self._non_default_catalog_warning_shown = True
10321035

1033-
return catalog_url
1036+
return [catalog_url]
10341037

10351038
# TODO: Support custom catalogs from .specify/extension-catalogs.yml
1036-
return self.DEFAULT_CATALOG_URL
1039+
return self.DEFAULT_CATALOG_URLS
10371040

10381041
def is_cache_valid(self) -> bool:
1039-
"""Check if cached catalog is still valid.
1042+
"""Check if cached catalog is still valid and matches current URL settings.
10401043
10411044
Returns:
1042-
True if cache exists and is within cache duration
1045+
True if cache exists, is within cache duration, and matches current URLs
10431046
"""
10441047
if not self.cache_file.exists() or not self.cache_metadata_file.exists():
10451048
return False
10461049

10471050
try:
10481051
metadata = json.loads(self.cache_metadata_file.read_text())
1052+
1053+
# Check for schema mismatch (older caches used 'catalog_url' string instead of 'catalog_urls' list)
1054+
cached_urls = metadata.get("catalog_urls")
1055+
if not cached_urls or not isinstance(cached_urls, list):
1056+
return False
1057+
1058+
# Check if the currently requested URLs match the cached configuration
1059+
current_urls = self.get_catalog_urls()
1060+
if cached_urls != current_urls:
1061+
return False
1062+
10491063
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
10501064
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
10511065
return age_seconds < self.CACHE_DURATION
10521066
except (json.JSONDecodeError, ValueError, KeyError):
10531067
return False
10541068

10551069
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
1056-
"""Fetch extension catalog from URL or cache.
1070+
"""Fetch extension catalogs from URLs or cache and merge them.
10571071
10581072
Args:
10591073
force_refresh: If True, bypass cache and fetch from network
10601074
10611075
Returns:
1062-
Catalog data dictionary
1076+
Merged catalog data dictionary
10631077
10641078
Raises:
1065-
ExtensionError: If catalog cannot be fetched
1079+
ExtensionError: If catalogs cannot be fetched
10661080
"""
10671081
# Check cache first unless force refresh
10681082
if not force_refresh and self.is_cache_valid():
@@ -1072,36 +1086,48 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
10721086
pass # Fall through to network fetch
10731087

10741088
# Fetch from network
1075-
catalog_url = self.get_catalog_url()
1089+
catalog_urls = self.get_catalog_urls()
1090+
1091+
merged_catalog = {
1092+
"schema_version": "1.0",
1093+
"extensions": {}
1094+
}
10761095

10771096
try:
10781097
import urllib.request
10791098
import urllib.error
1080-
1081-
with urllib.request.urlopen(catalog_url, timeout=10) as response:
1082-
catalog_data = json.loads(response.read())
1083-
1084-
# Validate catalog structure
1085-
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
1086-
raise ExtensionError("Invalid catalog format")
1099+
1100+
for catalog_url in catalog_urls:
1101+
with urllib.request.urlopen(catalog_url, timeout=10) as response:
1102+
catalog_data = json.loads(response.read())
1103+
1104+
# Validate catalog structure
1105+
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
1106+
raise ExtensionError(f"Invalid catalog format from {catalog_url}")
1107+
1108+
# Merge extensions into the aggregated catalog, preserving precedence for the first catalog URL
1109+
# that defines a given extension ID.
1110+
for ext_id, ext_data in catalog_data.get("extensions", {}).items():
1111+
if ext_id not in merged_catalog["extensions"]:
1112+
merged_catalog["extensions"][ext_id] = ext_data
10871113

10881114
# Save to cache
10891115
self.cache_dir.mkdir(parents=True, exist_ok=True)
1090-
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
1116+
self.cache_file.write_text(json.dumps(merged_catalog, indent=2))
10911117

10921118
# Save cache metadata
10931119
metadata = {
10941120
"cached_at": datetime.now(timezone.utc).isoformat(),
1095-
"catalog_url": catalog_url,
1121+
"catalog_urls": catalog_urls,
10961122
}
10971123
self.cache_metadata_file.write_text(json.dumps(metadata, indent=2))
10981124

1099-
return catalog_data
1125+
return merged_catalog
11001126

11011127
except urllib.error.URLError as e:
1102-
raise ExtensionError(f"Failed to fetch catalog from {catalog_url}: {e}")
1128+
raise ExtensionError(f"Failed to fetch catalog from network: {e}")
11031129
except json.JSONDecodeError as e:
1104-
raise ExtensionError(f"Invalid JSON in catalog: {e}")
1130+
raise ExtensionError(f"Invalid JSON in catalog payload: {e}")
11051131

11061132
def search(
11071133
self,

tests/test_extensions.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -841,7 +841,7 @@ def test_cache_directory_creation(self, temp_dir):
841841
json.dumps(
842842
{
843843
"cached_at": datetime.now(timezone.utc).isoformat(),
844-
"catalog_url": "http://test.com/catalog.json",
844+
"catalog_urls": catalog.get_catalog_urls(),
845845
}
846846
)
847847
)
@@ -870,7 +870,7 @@ def test_cache_expiration(self, temp_dir):
870870
json.dumps(
871871
{
872872
"cached_at": expired_datetime.isoformat(),
873-
"catalog_url": "http://test.com/catalog.json",
873+
"catalog_urls": catalog.get_catalog_urls(),
874874
}
875875
)
876876
)
@@ -918,7 +918,7 @@ def test_search_all_extensions(self, temp_dir):
918918
json.dumps(
919919
{
920920
"cached_at": datetime.now(timezone.utc).isoformat(),
921-
"catalog_url": "http://test.com",
921+
"catalog_urls": catalog.get_catalog_urls(),
922922
}
923923
)
924924
)
@@ -962,7 +962,7 @@ def test_search_by_query(self, temp_dir):
962962
json.dumps(
963963
{
964964
"cached_at": datetime.now(timezone.utc).isoformat(),
965-
"catalog_url": "http://test.com",
965+
"catalog_urls": catalog.get_catalog_urls(),
966966
}
967967
)
968968
)
@@ -1014,7 +1014,7 @@ def test_search_by_tag(self, temp_dir):
10141014
json.dumps(
10151015
{
10161016
"cached_at": datetime.now(timezone.utc).isoformat(),
1017-
"catalog_url": "http://test.com",
1017+
"catalog_urls": catalog.get_catalog_urls(),
10181018
}
10191019
)
10201020
)
@@ -1059,7 +1059,7 @@ def test_search_verified_only(self, temp_dir):
10591059
json.dumps(
10601060
{
10611061
"cached_at": datetime.now(timezone.utc).isoformat(),
1062-
"catalog_url": "http://test.com",
1062+
"catalog_urls": catalog.get_catalog_urls(),
10631063
}
10641064
)
10651065
)
@@ -1097,7 +1097,7 @@ def test_get_extension_info(self, temp_dir):
10971097
json.dumps(
10981098
{
10991099
"cached_at": datetime.now(timezone.utc).isoformat(),
1100-
"catalog_url": "http://test.com",
1100+
"catalog_urls": catalog.get_catalog_urls(),
11011101
}
11021102
)
11031103
)

0 commit comments

Comments
 (0)