"""State helpers for installed AI agent integrations.""" from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path from typing import Any INTEGRATION_JSON = ".specify/integration.json" INTEGRATION_STATE_SCHEMA = 1 @dataclass(frozen=True) class IntegrationReadError: """Structured failure from :func:`try_read_integration_json`. Callers map ``kind`` to whatever surface they need (loud CLI error, silent fallback, etc.) without re-implementing the parse/validation logic. """ kind: str # "decode", "os", "not_object", "schema_too_new" detail: str = "" schema: int | None = None def _read_integration_json_data( project_root: Path, ) -> tuple[dict[str, Any] | None, IntegrationReadError | None]: """Read raw integration state without normalizing or raising. Returns ``(data, None)`` when the JSON object is readable and supported, ``(None, None)`` when the file is absent, and ``(None, error)`` for parse, schema, encoding, or filesystem failures. """ path = project_root / INTEGRATION_JSON # Avoid Path.exists() / Path.is_file() as a pre-check: both return False # on some OSErrors (e.g. permission errors during stat), which would # silently treat an unreadable-but-present file as missing. Attempt the # read directly and distinguish FileNotFoundError (genuinely absent) from # other OSErrors (which become loud errors via the IntegrationReadError # path). try: raw = path.read_text(encoding="utf-8") except FileNotFoundError: return None, None except IsADirectoryError as exc: return None, IntegrationReadError( kind="os", detail=f"{path} exists but is not a regular file: {exc}", ) except UnicodeDecodeError as exc: return None, IntegrationReadError(kind="decode", detail=str(exc)) except OSError as exc: return None, IntegrationReadError(kind="os", detail=str(exc)) try: data = json.loads(raw) except json.JSONDecodeError as exc: return None, IntegrationReadError(kind="decode", detail=str(exc)) if not isinstance(data, dict): return None, IntegrationReadError(kind="not_object", detail=type(data).__name__) schema = data.get("integration_state_schema") if ( isinstance(schema, int) and not isinstance(schema, bool) and schema > INTEGRATION_STATE_SCHEMA ): return None, IntegrationReadError(kind="schema_too_new", schema=schema) return data, None def try_read_integration_json( project_root: Path, ) -> tuple[dict[str, Any] | None, IntegrationReadError | None]: """Parse ``.specify/integration.json`` without raising. Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the file does not exist, or ``(None, error)`` for any parse / validation failure. This helper delegates file I/O and raw JSON validation to ``_read_integration_json_data`` so callers that need raw state can share the same low-level reader instead of duplicating parse logic. """ data, error = _read_integration_json_data(project_root) if data is None: return None, error return normalize_integration_state(data), None def try_read_integration_json_with_raw( project_root: Path, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None, IntegrationReadError | None]: """Parse ``integration.json`` and return normalized plus raw state. Returns ``(normalized_state, raw_state, None)`` when the file is readable, ``(None, None, None)`` when it is absent, and ``(None, None, error)`` for parse, schema, encoding, or filesystem failures. """ data, error = _read_integration_json_data(project_root) if data is None: return None, None, error return normalize_integration_state(data), data, None def clean_integration_key(key: Any) -> str | None: """Return a stripped integration key, or None for empty/non-string values.""" if not isinstance(key, str) or not key.strip(): return None return key.strip() def dedupe_integration_keys(keys: list[Any]) -> list[str]: """Return a de-duplicated list of non-empty integration keys.""" seen: set[str] = set() deduped: list[str] = [] for key in keys: clean = clean_integration_key(key) if clean is None: continue if clean in seen: continue seen.add(clean) deduped.append(clean) return deduped def normalize_integration_settings(settings: Any) -> dict[str, dict[str, Any]]: """Return JSON-safe per-integration runtime settings.""" if not isinstance(settings, dict): return {} normalized: dict[str, dict[str, Any]] = {} for key, value in settings.items(): if not isinstance(key, str) or not key.strip() or not isinstance(value, dict): continue clean: dict[str, Any] = {} script = value.get("script") if isinstance(script, str) and script.strip(): clean["script"] = script.strip() raw_options = value.get("raw_options") if isinstance(raw_options, str): clean["raw_options"] = raw_options parsed_options = value.get("parsed_options") if isinstance(parsed_options, dict): clean["parsed_options"] = parsed_options invoke_separator = value.get("invoke_separator") if isinstance(invoke_separator, str) and invoke_separator.strip(): clean["invoke_separator"] = invoke_separator.strip() if clean: normalized[key.strip()] = clean return normalized def _normalized_integration_state_schema(value: Any) -> int: if isinstance(value, int) and not isinstance(value, bool) and value > INTEGRATION_STATE_SCHEMA: return value return INTEGRATION_STATE_SCHEMA def normalize_integration_state(data: dict[str, Any]) -> dict[str, Any]: """Normalize legacy and multi-install integration metadata.""" legacy_key = clean_integration_key(data.get("integration")) default_key = clean_integration_key(data.get("default_integration")) or legacy_key installed = data.get("installed_integrations") installed_keys = dedupe_integration_keys(installed if isinstance(installed, list) else []) if not default_key and installed_keys: default_key = installed_keys[0] if default_key and default_key not in installed_keys: installed_keys.insert(0, default_key) settings = normalize_integration_settings(data.get("integration_settings")) normalized = dict(data) normalized["integration_state_schema"] = _normalized_integration_state_schema( data.get("integration_state_schema") ) if default_key: normalized["integration"] = default_key normalized["default_integration"] = default_key else: normalized.pop("integration", None) normalized.pop("default_integration", None) normalized["installed_integrations"] = installed_keys normalized["integration_settings"] = { key: settings[key] for key in installed_keys if key in settings } return normalized def default_integration_key(state: dict[str, Any]) -> str | None: """Return the default integration key from normalized state.""" key = state.get("default_integration") or state.get("integration") return clean_integration_key(key) def installed_integration_keys(state: dict[str, Any]) -> list[str]: """Return installed integration keys from normalized state.""" return dedupe_integration_keys(state.get("installed_integrations", [])) def integration_settings(state: dict[str, Any]) -> dict[str, dict[str, Any]]: """Return normalized per-integration settings from state.""" return normalize_integration_settings(state.get("integration_settings")) def integration_setting(state: dict[str, Any], key: str) -> dict[str, Any]: """Return stored runtime settings for *key*.""" return dict(integration_settings(state).get(key, {})) def write_integration_json( project_root: Path, *, version: str, integration_key: str | None, installed_integrations: list[str] | None = None, settings: dict[str, dict[str, Any]] | None = None, ) -> None: """Write ``.specify/integration.json`` with legacy-compatible state.""" dest = project_root / INTEGRATION_JSON dest.parent.mkdir(parents=True, exist_ok=True) integration_key = clean_integration_key(integration_key) installed = dedupe_integration_keys(installed_integrations or []) if integration_key and integration_key not in installed: installed.insert(0, integration_key) if not integration_key and installed: integration_key = installed[0] normalized_settings = normalize_integration_settings(settings or {}) normalized_settings = { key: normalized_settings[key] for key in installed if key in normalized_settings } data: dict[str, Any] = { "version": version, "integration_state_schema": INTEGRATION_STATE_SCHEMA, "installed_integrations": installed, "integration_settings": normalized_settings, } if integration_key: data["integration"] = integration_key data["default_integration"] = integration_key dest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")