{"record":{"id":"e584ca42869876e2","repo":"github/spec-kit","slug":"invalid-catalog-format-from-url-expected-a-json","errorCode":null,"errorMessage":"Invalid catalog format from {url}: expected a JSON object","messagePattern":"Invalid catalog format from (.+?): expected a JSON object","errorType":"exception","errorClass":"ExtensionError","httpStatus":null,"severity":"error","filePath":"src/specify_cli/extensions/__init__.py","lineNumber":3690,"sourceCode":"        ``{\"extensions\": []}`` or ``{\"extensions\": null}`` slip through\n        here and then crash with ``AttributeError: 'list' object has no\n        attribute 'items'`` deep inside ``_get_merged_extensions``. The\n        sibling integration catalog reader already guards both the root\n        object and the nested mapping (see ``integrations/catalog.py``);\n        the extension catalog must stay consistent so a malformed payload\n        surfaces as the user-facing ``Invalid catalog format`` error\n        instead of a raw Python traceback.\n\n        Args:\n            catalog_data: Parsed JSON payload from the catalog source.\n            url: Source URL — used in the error message so the user can\n                tell which catalog in a multi-catalog stack is malformed.\n\n        Raises:\n            ExtensionError: If the payload's shape is invalid.\n        \"\"\"\n        if not isinstance(catalog_data, dict):\n            raise ExtensionError(\n                f\"Invalid catalog format from {url}: expected a JSON object\"\n            )\n        if \"schema_version\" not in catalog_data or \"extensions\" not in catalog_data:\n            raise ExtensionError(f\"Invalid catalog format from {url}\")\n        if not isinstance(catalog_data.get(\"extensions\"), dict):\n            raise ExtensionError(\n                f\"Invalid catalog format from {url}: 'extensions' must be a JSON object\"\n            )\n\n    def get_active_catalogs(self) -> List[CatalogEntry]:\n        \"\"\"Get the ordered list of active catalogs.\n\n        Resolution order:\n        1. SPECKIT_CATALOG_URL env var — single catalog replacing all defaults\n        2. Project-level .specify/extension-catalogs.yml\n        3. User-level ~/.specify/extension-catalogs.yml\n        4. Built-in default stack (default + community)\n","sourceCodeStart":3672,"sourceCodeEnd":3708,"githubUrl":"https://github.com/github/spec-kit/blob/bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c/src/specify_cli/extensions/__init__.py#L3672-L3708","documentation":"Catalog payload validation (_validate_catalog_data) rejects a fetched catalog whose parsed JSON is not an object — e.g. a JSON array, string, or number. The catalog format requires a top-level object with schema_version and extensions, so any other top-level type fails immediately with the 'expected a JSON object' variant.","triggerScenarios":"A catalog URL (from SPECKIT_CATALOG_URL, .specify/extension-catalogs.yml, or the user-level catalog config) returning valid JSON that is not an object, e.g. `[...]` or `\"ok\"` — such as an API returning a bare list of extensions.","commonSituations":"Pointing the catalog URL at a JSON array endpoint instead of the catalog document; a proxy or CDN serving a JSON status string; hand-written catalog files with the braces omitted.","solutions":["Fetch the URL manually (`curl -s <catalog-url> | python -m json.tool`) and confirm the top level is `{...}`","Wrap array data into the catalog shape: {\"schema_version\": 1, \"extensions\": {\"id\": {...}}}","If the URL is wrong, correct it in .specify/extension-catalogs.yml / ~/.specify/extension-catalogs.yml / SPECKIT_CATALOG_URL","Fall back to the default catalog stack by removing the custom catalog entry"],"exampleFix":"# before: catalog URL returns a bare array\n[ {\"id\": \"my-ext\", \"name\": \"My Ext\"} ]\n\n# after: proper catalog object\n{\n  \"schema_version\": 1,\n  \"extensions\": {\n    \"my-ext\": {\"name\": \"My Ext\", \"description\": \"...\"}\n  }\n}","handlingStrategy":"validation","validationCode":"import json, urllib.request\n\ndef fetch_catalog_object(url: str) -> dict:\n    with urllib.request.urlopen(url, timeout=10) as r:\n        data = json.load(r)\n    if not isinstance(data, dict):\n        raise ValueError(f'{url}: top-level JSON must be an object, got {type(data).__name__}')\n    return data","typeGuard":"def is_catalog_shaped(data: object) -> bool:\n    return (\n        isinstance(data, dict)\n        and 'schema_version' in data\n        and 'extensions' in data\n        and isinstance(data['extensions'], dict)\n    )","tryCatchPattern":"from specify_cli.extensions import ExtensionError\n\ntry:\n    ...catalog fetch...\nexcept ExtensionError as e:\n    if 'expected a JSON object' in str(e):\n        # URL returns non-object JSON; fix endpoint or wrap array data","preventionTips":["Validate catalog JSON shape (object + schema_version + extensions) in CI before publishing","Prefer raw-file endpoints over API endpoints whose top level may be an array"],"tags":["extensions","catalog","json","validation"],"backgroundTag":null,"analyzedSha":"bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c","analyzedAt":"2026-08-14T19:43:37.150Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}