Initial release: aircontext
This commit is contained in:
149
scripts/core/auto_init.py
Normal file
149
scripts/core/auto_init.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user