Initial release: aircontext
This commit is contained in:
97
scripts/core/config_loader.py
Normal file
97
scripts/core/config_loader.py
Normal 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
|
||||
Reference in New Issue
Block a user