98 lines
3.2 KiB
Python
98 lines
3.2 KiB
Python
"""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
|