Initial release: aircontext

This commit is contained in:
admin
2026-05-18 11:45:08 +08:00
commit 5914cfb9cd
32 changed files with 1981 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
"""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