Files
aircontext/scripts/core/auto_init.py
2026-05-18 11:45:08 +08:00

150 lines
5.1 KiB
Python

"""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