150 lines
5.1 KiB
Python
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
|