2525from packaging import version as pkg_version
2626from packaging .specifiers import SpecifierSet , InvalidSpecifier
2727
28+ from .catalogs import CatalogEntry as BaseCatalogEntry , CatalogStackBase
29+
2830_FALLBACK_CORE_COMMAND_NAMES = frozenset ({
2931 "analyze" ,
3032 "checklist" ,
@@ -107,13 +109,8 @@ def normalize_priority(value: Any, default: int = 10) -> int:
107109
108110
109111@dataclass
110- class CatalogEntry :
112+ class CatalogEntry ( BaseCatalogEntry ) :
111113 """Represents a single catalog entry in the catalog stack."""
112- url : str
113- name : str
114- priority : int
115- install_allowed : bool
116- description : str = ""
117114
118115
119116class ExtensionManifest :
@@ -1666,12 +1663,16 @@ def register_commands_for_claude(
16661663 return self .register_commands_for_agent ("claude" , manifest , extension_dir , project_root )
16671664
16681665
1669- class ExtensionCatalog :
1666+ class ExtensionCatalog ( CatalogStackBase ) :
16701667 """Manages extension catalog fetching, caching, and searching."""
16711668
16721669 DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
16731670 COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
16741671 CACHE_DURATION = 3600 # 1 hour in seconds
1672+ CONFIG_FILENAME = "extension-catalogs.yml"
1673+ ENTRY_CLASS = CatalogEntry
1674+ ERROR_TYPE = ValidationError
1675+ VALIDATION_ERROR_TYPE = ValidationError
16751676
16761677 def __init__ (self , project_root : Path ):
16771678 """Initialize extension catalog manager.
@@ -1685,27 +1686,6 @@ def __init__(self, project_root: Path):
16851686 self .cache_file = self .cache_dir / "catalog.json"
16861687 self .cache_metadata_file = self .cache_dir / "catalog-metadata.json"
16871688
1688- def _validate_catalog_url (self , url : str ) -> None :
1689- """Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
1690-
1691- Args:
1692- url: URL to validate
1693-
1694- Raises:
1695- ValidationError: If URL is invalid or uses non-HTTPS scheme
1696- """
1697- from urllib .parse import urlparse
1698-
1699- parsed = urlparse (url )
1700- is_localhost = parsed .hostname in ("localhost" , "127.0.0.1" , "::1" )
1701- if parsed .scheme != "https" and not (parsed .scheme == "http" and is_localhost ):
1702- raise ValidationError (
1703- f"Catalog URL must use HTTPS (got { parsed .scheme } ://). "
1704- "HTTP is only allowed for localhost."
1705- )
1706- if not parsed .netloc :
1707- raise ValidationError ("Catalog URL must be a valid URL with a host." )
1708-
17091689 def _make_request (self , url : str ):
17101690 """Build a urllib Request, adding auth headers when a provider matches.
17111691
@@ -1722,81 +1702,6 @@ def _open_url(self, url: str, timeout: int = 10):
17221702 from specify_cli .authentication .http import open_url
17231703 return open_url (url , timeout )
17241704
1725- def _load_catalog_config (self , config_path : Path ) -> Optional [List [CatalogEntry ]]:
1726- """Load catalog stack configuration from a YAML file.
1727-
1728- Args:
1729- config_path: Path to extension-catalogs.yml
1730-
1731- Returns:
1732- Ordered list of CatalogEntry objects, or None if file doesn't exist.
1733-
1734- Raises:
1735- ValidationError: If any catalog entry has an invalid URL,
1736- the file cannot be parsed, a priority value is invalid,
1737- or the file exists but contains no valid catalog entries
1738- (fail-closed for security).
1739- """
1740- if not config_path .exists ():
1741- return None
1742- try :
1743- data = yaml .safe_load (config_path .read_text (encoding = "utf-8" )) or {}
1744- except (yaml .YAMLError , OSError , UnicodeError ) as e :
1745- raise ValidationError (
1746- f"Failed to read catalog config { config_path } : { e } "
1747- )
1748- catalogs_data = data .get ("catalogs" , [])
1749- if not catalogs_data :
1750- # File exists but has no catalogs key or empty list - fail closed
1751- raise ValidationError (
1752- f"Catalog config { config_path } exists but contains no 'catalogs' entries. "
1753- f"Remove the file to use built-in defaults, or add valid catalog entries."
1754- )
1755- if not isinstance (catalogs_data , list ):
1756- raise ValidationError (
1757- f"Invalid catalog config: 'catalogs' must be a list, got { type (catalogs_data ).__name__ } "
1758- )
1759- entries : List [CatalogEntry ] = []
1760- skipped_entries : List [int ] = []
1761- for idx , item in enumerate (catalogs_data ):
1762- if not isinstance (item , dict ):
1763- raise ValidationError (
1764- f"Invalid catalog entry at index { idx } : expected a mapping, got { type (item ).__name__ } "
1765- )
1766- url = str (item .get ("url" , "" )).strip ()
1767- if not url :
1768- skipped_entries .append (idx )
1769- continue
1770- self ._validate_catalog_url (url )
1771- try :
1772- priority = int (item .get ("priority" , idx + 1 ))
1773- except (TypeError , ValueError ):
1774- raise ValidationError (
1775- f"Invalid priority for catalog '{ item .get ('name' , idx + 1 )} ': "
1776- f"expected integer, got { item .get ('priority' )!r} "
1777- )
1778- raw_install = item .get ("install_allowed" , False )
1779- if isinstance (raw_install , str ):
1780- install_allowed = raw_install .strip ().lower () in ("true" , "yes" , "1" )
1781- else :
1782- install_allowed = bool (raw_install )
1783- entries .append (CatalogEntry (
1784- url = url ,
1785- name = str (item .get ("name" , f"catalog-{ idx + 1 } " )),
1786- priority = priority ,
1787- install_allowed = install_allowed ,
1788- description = str (item .get ("description" , "" )),
1789- ))
1790- entries .sort (key = lambda e : e .priority )
1791- if not entries :
1792- # All entries were invalid (missing URLs) - fail closed for security
1793- raise ValidationError (
1794- f"Catalog config { config_path } contains { len (catalogs_data )} entries but none have valid URLs "
1795- f"(entries at indices { skipped_entries } were skipped). "
1796- f"Each catalog entry must have a 'url' field."
1797- )
1798- return entries
1799-
18001705 def get_active_catalogs (self ) -> List [CatalogEntry ]:
18011706 """Get the ordered list of active catalogs.
18021707
@@ -1826,24 +1731,44 @@ def get_active_catalogs(self) -> List[CatalogEntry]:
18261731 file = sys .stderr ,
18271732 )
18281733 self ._non_default_catalog_warning_shown = True
1829- return [CatalogEntry (url = catalog_url , name = "custom" , priority = 1 , install_allowed = True , description = "Custom catalog via SPECKIT_CATALOG_URL" )]
1734+ return [
1735+ self ._entry (
1736+ url = catalog_url ,
1737+ name = "custom" ,
1738+ priority = 1 ,
1739+ install_allowed = True ,
1740+ description = "Custom catalog via SPECKIT_CATALOG_URL" ,
1741+ )
1742+ ]
18301743
18311744 # 2. Project-level config overrides all defaults
1832- project_config_path = self .project_root / ".specify" / "extension-catalogs.yml"
1745+ project_config_path = self .project_root / ".specify" / self . CONFIG_FILENAME
18331746 catalogs = self ._load_catalog_config (project_config_path )
18341747 if catalogs is not None :
18351748 return catalogs
18361749
18371750 # 3. User-level config
1838- user_config_path = Path .home () / ".specify" / "extension-catalogs.yml"
1751+ user_config_path = Path .home () / ".specify" / self . CONFIG_FILENAME
18391752 catalogs = self ._load_catalog_config (user_config_path )
18401753 if catalogs is not None :
18411754 return catalogs
18421755
18431756 # 4. Built-in default stack
18441757 return [
1845- CatalogEntry (url = self .DEFAULT_CATALOG_URL , name = "default" , priority = 1 , install_allowed = True , description = "Built-in catalog of installable extensions" ),
1846- CatalogEntry (url = self .COMMUNITY_CATALOG_URL , name = "community" , priority = 2 , install_allowed = False , description = "Community-contributed extensions (discovery only)" ),
1758+ self ._entry (
1759+ url = self .DEFAULT_CATALOG_URL ,
1760+ name = "default" ,
1761+ priority = 1 ,
1762+ install_allowed = True ,
1763+ description = "Built-in catalog of installable extensions" ,
1764+ ),
1765+ self ._entry (
1766+ url = self .COMMUNITY_CATALOG_URL ,
1767+ name = "community" ,
1768+ priority = 2 ,
1769+ install_allowed = False ,
1770+ description = "Community-contributed extensions (discovery only)" ,
1771+ ),
18471772 ]
18481773
18491774 def get_catalog_url (self ) -> str :
0 commit comments