"""Auto-fill non-private fields when AirContext/ is freshly created. Privacy boundary: - backend.api_key and backend.endpoint are left for the USER to fill. - Everything else (model_context_window, threshold, cooldown, compaction rules, etc.) is auto-configured here using the host machine's environment. Inputs we read: - ~/.claude/settings.json (specifically `model` and `env` sections) - os.environ (overrides settings.json env when both set) Outputs: - Mutated AirContext/config.yaml on disk - Side-effect: settings.json's `env` keys are copied into os.environ so the wrapper's own validate_config call resolves ${env:VAR} placeholders consistently with what claude (and thus its hooks/compactor) will see. """ from __future__ import annotations import json import os from pathlib import Path from typing import Any try: import yaml # type: ignore except ImportError: # pragma: no cover — pyyaml is a hard dep declared in pyproject yaml = None # Approximate context windows for known Claude model IDs / aliases. _MODEL_WINDOWS = { "opus[1m]": 1_000_000, "claude-opus-4-7[1m]": 1_000_000, "claude-opus-4-7": 200_000, "claude-sonnet-4-6": 200_000, "claude-sonnet-4-5": 200_000, "claude-haiku-4-5": 200_000, "claude-haiku-4-5-20251001": 200_000, } def _read_user_settings() -> dict: p = Path.home() / ".claude" / "settings.json" if not p.exists(): return {} try: return json.loads(p.read_text(encoding="utf-8")) except json.JSONDecodeError: return {} def propagate_settings_env() -> dict[str, str]: """Copy ~/.claude/settings.json `env` block into os.environ if not already set. Claude Code injects these into its own subprocess environment, but the wrapper itself is launched from the user's shell and doesn't inherit them. Without this propagation, the wrapper's validate_config sees empty ${env:ANTHROPIC_AUTH_TOKEN} and bails out, even though the env var WILL be set when the compactor actually runs (since it's a child of claude). Returns the env dict that was applied. """ settings = _read_user_settings() env_block = settings.get("env") or {} if not isinstance(env_block, dict): return {} for k, v in env_block.items(): if isinstance(v, str): os.environ.setdefault(k, v) return env_block def detect_claude_context_window() -> int | None: """Infer the active claude model's context window from settings.json.""" settings = _read_user_settings() model = settings.get("model") if not isinstance(model, str): return None if model in _MODEL_WINDOWS: return _MODEL_WINDOWS[model] if "[1m]" in model.lower(): return 1_000_000 return 200_000 # safe default for any modern Claude model def auto_configure(config_path: Path) -> dict[str, Any]: """Fill non-private fields in the freshly-created config. Returns the merged config dict (also written back to disk). Raises RuntimeError if PyYAML is missing. """ if yaml is None: raise RuntimeError("PyYAML required (pip install pyyaml)") propagate_settings_env() cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} cfg.setdefault("backend", {}) cfg.setdefault("trigger", {}) cfg.setdefault("compaction", {}) cfg.setdefault("safety", {}) window = detect_claude_context_window() if window: cfg["trigger"]["model_context_window"] = window # If user has ANTHROPIC_AUTH_TOKEN in settings.json/env, prefer # anthropic_native as the default backend type (most likely match). # api_key/endpoint themselves stay user-fillable. has_anthropic = bool( os.environ.get("ANTHROPIC_AUTH_TOKEN") or os.environ.get("ANTHROPIC_API_KEY") ) has_openai = bool(os.environ.get("OPENAI_API_KEY")) if has_anthropic and not has_openai: cfg["backend"].setdefault("type", "anthropic_native") elif has_openai and not has_anthropic: cfg["backend"].setdefault("type", "openai_compat") # Otherwise leave whatever the template specified. config_path.write_text( yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False), encoding="utf-8", ) return cfg def required_fields_missing(config_path: Path) -> list[str]: """Return the names of REQUIRED-USER-FILL fields that are still empty. These are the privacy-boundary fields: endpoint and api_key. Any auto-configurable field is NOT in this list. """ if yaml is None: return ["[pyyaml not installed]"] cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} backend = cfg.get("backend") or {} missing: list[str] = [] endpoint = (backend.get("endpoint") or "").strip() if not endpoint: missing.append("backend.endpoint") api_key_raw = backend.get("api_key") or "" # api_key may be a ${env:VAR} placeholder. Resolve via current env. from .config_loader import resolve_env_placeholders resolved = resolve_env_placeholders(api_key_raw) if isinstance(resolved, str) and not resolved.strip(): missing.append("backend.api_key") return missing