Files
Holden Salomon a9e8287065
test / test (macos-latest, 3.13) (push) Canceled after 0s
test / test (ubuntu-latest, 3.11) (push) Successful in 17s
test / test (ubuntu-latest, 3.12) (push) Successful in 18s
test / test (ubuntu-latest, 3.13) (push) Successful in 15s
ai-incidents 1.0.0
2026-09-21 17:48:40 +00:00

245 lines
7.9 KiB
Python

"""One TOML file configures everything. Unknown keys are an error, so a typo cannot silently turn a
setting off."""
from __future__ import annotations
import os
import tomllib
from dataclasses import dataclass, field, fields
from .judge import BACKENDS, JudgeConfig
from .sources import SOURCE_TYPES
class ConfigError(ValueError):
pass
def xdg(var: str, fallback: str) -> str:
return os.environ.get(var) or os.path.expanduser(fallback)
def default_config_path() -> str:
return os.environ.get("AI_INCIDENTS_CONFIG") or os.path.join(
xdg("XDG_CONFIG_HOME", "~/.config"), "ai-incidents", "config.toml"
)
def default_state_path() -> str:
return os.path.join(xdg("XDG_STATE_HOME", "~/.local/state"), "ai-incidents", "state.json")
def default_source_path(kind: str) -> str:
if kind == "claude-code":
base = os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude"
return os.path.join(base, "projects")
if kind == "opencode":
return os.path.join(xdg("XDG_DATA_HOME", "~/.local/share"), "opencode", "opencode*.db")
return ""
@dataclass
class SourceConfig:
type: str = ""
path: str = ""
name: str = ""
@dataclass
class ScanConfig:
max_candidates: int = 50
max_per_session: int = 12
first_run: str = "baseline" # "baseline" | "backfill"
exclude: list[str] = field(default_factory=list)
extra_destructive: list[str] = field(default_factory=list)
extra_benign: list[str] = field(default_factory=list)
extra_alarm: list[str] = field(default_factory=list)
@dataclass
class LedgerConfig:
dir: str = ""
order: str = "severity" # "severity" | "newest"
title: str = "AI incidents"
@dataclass
class GitConfig:
enabled: bool = True
push: bool = False
remote: str = "origin"
branch: str = ""
expected_remote_url: str = ""
author_name: str = ""
author_email: str = ""
@dataclass
class StateConfig:
file: str = ""
seen_list: str = ""
@dataclass
class PrivacyConfig:
redact: bool = True
extra_redact_patterns: list[str] = field(default_factory=list)
@dataclass
class HooksConfig:
notify_cmd: str = ""
on_success_cmd: str = ""
on_failure_cmd: str = ""
timeout: int = 60
@dataclass
class Config:
sources: list[SourceConfig] = field(default_factory=list)
scan: ScanConfig = field(default_factory=ScanConfig)
judge: JudgeConfig = field(default_factory=JudgeConfig)
ledger: LedgerConfig = field(default_factory=LedgerConfig)
git: GitConfig = field(default_factory=GitConfig)
state: StateConfig = field(default_factory=StateConfig)
privacy: PrivacyConfig = field(default_factory=PrivacyConfig)
hooks: HooksConfig = field(default_factory=HooksConfig)
path: str = ""
def _section(cls, raw, where: str):
if raw is None:
return cls()
if not isinstance(raw, dict):
raise ConfigError(f"[{where}] must be a table")
known = {f.name: f for f in fields(cls)}
unknown = sorted(set(raw) - set(known))
if unknown:
raise ConfigError(f"[{where}] unknown key(s): {', '.join(unknown)}")
defaults = cls()
for k, v in raw.items():
want = type(getattr(defaults, k))
if want is int and isinstance(v, bool) or not isinstance(v, want):
raise ConfigError(f"[{where}] {k} must be {want.__name__}, got {type(v).__name__}")
if want is list and not all(isinstance(x, str) for x in v):
raise ConfigError(f"[{where}] {k} must be a list of strings")
return cls(**raw)
def _expand(p: str) -> str:
return os.path.abspath(os.path.expanduser(os.path.expandvars(p))) if p else p
def load(path: str | None = None, required: bool = True) -> Config:
path = path or default_config_path()
raw: dict = {}
if os.path.exists(path):
try:
with open(path, "rb") as f:
raw = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise ConfigError(f"{path}: {e}") from e
elif required:
raise ConfigError(f"no config at {path} (create one with `ai-incidents init`)")
return from_dict(raw, path)
def from_dict(raw: dict, path: str = "") -> Config:
top = {"source", "scan", "judge", "ledger", "git", "state", "privacy", "hooks"}
unknown = sorted(set(raw) - top)
if unknown:
raise ConfigError(f"unknown top-level key(s): {', '.join(unknown)}")
cfg = Config(path=path)
srcs = raw.get("source")
if srcs is None:
srcs = [{"type": "claude-code"}, {"type": "opencode"}]
if not isinstance(srcs, list):
raise ConfigError("[[source]] must be an array of tables")
names = set()
for i, s in enumerate(srcs):
sc = _section(SourceConfig, s, f"source #{i + 1}")
if sc.type not in SOURCE_TYPES:
raise ConfigError(f"[[source]] #{i + 1}: type must be one of {sorted(SOURCE_TYPES)}")
sc.name = sc.name or sc.type
if sc.name in names:
raise ConfigError(f"[[source]] name {sc.name!r} is used twice; give one a distinct `name`")
names.add(sc.name)
sc.path = _expand(sc.path or default_source_path(sc.type))
cfg.sources.append(sc)
cfg.scan = _section(ScanConfig, raw.get("scan"), "scan")
cfg.judge = _section(JudgeConfig, raw.get("judge"), "judge")
cfg.ledger = _section(LedgerConfig, raw.get("ledger"), "ledger")
cfg.git = _section(GitConfig, raw.get("git"), "git")
cfg.state = _section(StateConfig, raw.get("state"), "state")
cfg.privacy = _section(PrivacyConfig, raw.get("privacy"), "privacy")
cfg.hooks = _section(HooksConfig, raw.get("hooks"), "hooks")
if cfg.scan.first_run not in ("baseline", "backfill"):
raise ConfigError("[scan] first_run must be 'baseline' or 'backfill'")
if cfg.scan.max_candidates < 2:
raise ConfigError("[scan] max_candidates must be at least 2")
if not 2 <= cfg.scan.max_per_session <= cfg.scan.max_candidates:
raise ConfigError("[scan] max_per_session must be between 2 and max_candidates")
if cfg.ledger.order not in ("severity", "newest"):
raise ConfigError("[ledger] order must be 'severity' or 'newest'")
if cfg.judge.backend not in BACKENDS:
raise ConfigError(f"[judge] backend must be one of {sorted(BACKENDS)}")
if cfg.judge.backend == "command" and not cfg.judge.command:
raise ConfigError("[judge] backend = 'command' needs `command = [...]`")
if cfg.judge.timeout <= 0:
raise ConfigError("[judge] timeout must be positive")
cfg.ledger.dir = _expand(cfg.ledger.dir)
cfg.state.file = _expand(cfg.state.file) or default_state_path()
cfg.state.seen_list = _expand(cfg.state.seen_list)
cfg.judge.prompt_file = _expand(cfg.judge.prompt_file)
return cfg
EXAMPLE = '''\
# ai-incidents configuration. Every key is optional except ledger.dir.
# Reference: https://github.com/sudolulo/ai-incidents#configuration
# Where the transcripts are. Read-only, always. Omit [[source]] entirely to read both defaults.
[[source]]
type = "claude-code"
path = "~/.claude/projects"
[[source]]
type = "opencode"
path = "~/.local/share/opencode/opencode*.db"
[scan]
max_candidates = 50 # most candidates shown to the judge in one run
max_per_session = 12 # most candidates from any one session
first_run = "baseline" # "baseline": mark existing sessions judged; "backfill": judge them
[judge]
backend = "claude" # "claude" | "openai" | "command"
model = "sonnet"
timeout = 900
# For a local model, use an OpenAI-compatible server instead (llama.cpp, Ollama, vLLM):
# backend = "openai"
# base_url = "http://localhost:11434/v1"
# model = "qwen3:32b"
[ledger]
dir = "{ledger}"
order = "severity" # "severity" (most severe first) | "newest"
[git]
enabled = true # commit the ledger if ledger.dir is a git repository
push = false
[state]
file = "{state}"
[privacy]
redact = true
[hooks]
# notify_cmd = "curl -s -H 'Title: ai-incidents' --data-binary @- https://ntfy.sh/your-topic"
'''