"""AirContext/ self-check + template installation + config validation.""" from __future__ import annotations import os import re import shutil from pathlib import Path from typing import Any try: import yaml # type: ignore except ImportError: yaml = None # validate_config will surface a clear error REQUIRED_FILES = ("config.yaml", "rules.md") def ensure_aircontext_dir(air: Path, plugin_root: Path) -> bool: """Create AirContext/ from templates if missing. Returns True if templates were just installed (caller should ask user to edit config.yaml). Returns False if the dir already existed. """ templates = plugin_root / "templates" if air.exists() and (air / "config.yaml").exists(): # Make sure subdirs exist even on partial installs. (air / "snapshots").mkdir(exist_ok=True) return False air.mkdir(parents=True, exist_ok=True) (air / "snapshots").mkdir(exist_ok=True) for name in REQUIRED_FILES: src = templates / name dst = air / name if not dst.exists() and src.exists(): shutil.copyfile(src, dst) # README is optional but nice readme_src = templates / "README.md" readme_dst = air / "README.md" if not readme_dst.exists() and readme_src.exists(): shutil.copyfile(readme_src, readme_dst) return True _ENV_RE = re.compile(r"\$\{env:([A-Z_][A-Z0-9_]*)\}") def resolve_env_placeholders(value: Any) -> Any: """Replace ${env:VAR} placeholders inside string values, recursively.""" if isinstance(value, str): def _sub(m: re.Match[str]) -> str: return os.environ.get(m.group(1), "") return _ENV_RE.sub(_sub, value) if isinstance(value, dict): return {k: resolve_env_placeholders(v) for k, v in value.items()} if isinstance(value, list): return [resolve_env_placeholders(v) for v in value] return value def load_config(path: Path) -> dict[str, Any]: if yaml is None: raise RuntimeError("PyYAML not installed. `pip install pyyaml` and retry.") with path.open("r", encoding="utf-8") as f: raw = yaml.safe_load(f) or {} return resolve_env_placeholders(raw) def validate_config(path: Path) -> str | None: """Return None on success, or a human-readable error string.""" if not path.exists(): return f"{path} not found" try: cfg = load_config(path) except Exception as e: # pragma: no cover return f"failed to parse {path.name}: {e}" backend = cfg.get("backend") or {} if not backend.get("endpoint"): return "backend.endpoint is required" if not backend.get("model"): return "backend.model is required" api_key = backend.get("api_key", "") if not api_key: return "backend.api_key is empty (set AIRCONTEXT_API_KEY env var or fill config.yaml)" trig = cfg.get("trigger") or {} threshold = trig.get("threshold") if not isinstance(threshold, (int, float)) or not 0 < float(threshold) < 1: return "trigger.threshold must be a number between 0 and 1" comp = cfg.get("compaction") or {} rules_file = comp.get("rules_file", "rules.md") if not (path.parent / rules_file).exists(): return f"rules file not found: {rules_file}" return None