""" Configuration for ctxd — context daemon. Environment variables take precedence over ctxd.yaml, which takes precedence over built-in defaults. This allows fully env-driven deployments (Docker, Compose, Kubernetes) while preserving file-based config for local dev. """ import os from pathlib import Path def _env_bool(key: str, fallback: bool = False) -> bool: val = os.environ.get(key, "") if not val: return fallback return val.lower() in ("1", "true", "yes", "on") def _env_int(key: str, fallback: int) -> int: val = os.environ.get(key, "") if not val: return fallback try: return int(val) except ValueError: return fallback def _env_str(key: str, fallback: str = "") -> str: return os.environ.get(key, fallback) # Default home directory (~/.ctx) — overridable via CTXD_HOME env var DEFAULT_HOME = Path(os.environ.get("CTXD_HOME", Path.home() / ".ctx")) # Built-in defaults (lowest precedence) DEFAULT_CONFIG = { "server": { "host": "0.0.0.0", "port": 9091, }, "snapshots": { "min_keep": 5, "max_keep": 25, }, "auth": { "enabled": False, "api_key": "", "external_readonly_key": "", }, "oauth": { "enabled": False, "issuer": "", "approval_key": "", "approval_user_id": "admin", "access_token_ttl_seconds": 3600, "refresh_token_ttl_seconds": 2592000, }, "web_sessions": { "ttl_seconds": 604800, }, "seed": { "admin_user": "admin", "admin_display": "Administrator", "service_user": "hermes-gateway", "service_display": "Hermes Agent", }, } class CtxConfig: """Holds resolved paths and config for a ctxd runtime. Precedence: env var > ctxd.yaml value > built-in default. """ def __init__(self, home: Path | str | None = None, config: dict | None = None): resolved = Path(home) if home else DEFAULT_HOME self.home = resolved.resolve() self._cfg = config or dict(DEFAULT_CONFIG) # ── Directory layout ────────────────────────────────────────── @property def db_path(self) -> Path: return self.home / "ctxd.db" @property def snapshots_dir(self) -> Path: return self.home / "snapshots" @property def projects_dir(self) -> Path: return self.home / "projects" @property def user_dir(self) -> Path: return self.home / "users" @property def config_path(self) -> Path: return self.home / "ctxd.yaml" @property def oauth_state_path(self) -> Path: return self.home / "oauth_state.json" @property def web_sessions_path(self) -> Path: return self.home / "web_sessions.json" # ── Database ────────────────────────────────────────────────── @property def database_url(self) -> str: return _env_str("DATABASE_URL") @property def use_postgres(self) -> bool: return bool(self.database_url) # ── Server ──────────────────────────────────────────────────── @property def host(self) -> str: return _env_str("CTXD_HOST", self._cfg.get("server", {}).get("host", "0.0.0.0")) @property def port(self) -> int: return _env_int("CTXD_PORT", self._cfg.get("server", {}).get("port", 9091)) @property def log_level(self) -> str: return _env_str("LOG_LEVEL", "info") @property def demo_mode(self) -> bool: return _env_bool("CTXD_DEMO_MODE", False) # ── Snapshots ───────────────────────────────────────────────── @property def min_snapshots(self) -> int: return _env_int("SNAPSHOT_MIN_KEEP", self._cfg.get("snapshots", {}).get("min_keep", 5)) @property def max_snapshots(self) -> int: return _env_int("SNAPSHOT_MAX_KEEP", self._cfg.get("snapshots", {}).get("max_keep", 25)) # ── Auth ────────────────────────────────────────────────────── @property def auth_enabled(self) -> bool: return _env_bool("CTXD_AUTH_ENABLED", self._cfg.get("auth", {}).get("enabled", False)) @property def api_key(self) -> str: return _env_str("CTXD_API_KEY", self._cfg.get("auth", {}).get("api_key", "")) @property def external_readonly_key(self) -> str: return _env_str("CTXD_EXTERNAL_READONLY_KEY", self._cfg.get("auth", {}).get("external_readonly_key", "")) # ── OAuth ───────────────────────────────────────────────────── @property def oauth_enabled(self) -> bool: return _env_bool("OAUTH_ENABLED", self._cfg.get("oauth", {}).get("enabled", False)) @property def oauth_issuer(self) -> str: return _env_str("OAUTH_ISSUER", self._cfg.get("oauth", {}).get("issuer", "")) @property def oauth_approval_key(self) -> str: return _env_str("OAUTH_APPROVAL_KEY", self._cfg.get("oauth", {}).get("approval_key", "")) @property def oauth_approval_user_id(self) -> str: return _env_str("OAUTH_APPROVAL_USER_ID", self._cfg.get("oauth", {}).get("approval_user_id", "admin")) @property def oauth_access_token_ttl_seconds(self) -> int: return _env_int("OAUTH_ACCESS_TOKEN_TTL", self._cfg.get("oauth", {}).get("access_token_ttl_seconds", 3600)) @property def oauth_refresh_token_ttl_seconds(self) -> int: return _env_int("OAUTH_REFRESH_TOKEN_TTL", self._cfg.get("oauth", {}).get("refresh_token_ttl_seconds", 2592000)) # ── Web Sessions ─────────────────────────────────────────────── @property def web_session_ttl_seconds(self) -> int: return _env_int("WEB_SESSION_TTL", self._cfg.get("web_sessions", {}).get("ttl_seconds", 604800)) # ── Bootstrap ───────────────────────────────────────────────── def ensure_dirs(self): """Create all required directories if they don't exist.""" for d in [self.home, self.snapshots_dir, self.projects_dir, self.user_dir]: d.mkdir(parents=True, exist_ok=True) @classmethod def from_home(cls, home: Path | str | None = None) -> "CtxConfig": """Load from ctxd.yaml if it exists, otherwise use defaults. Env vars always take precedence over yaml values at read time. """ home = Path(home).resolve() if home else DEFAULT_HOME cfg_path = home / "ctxd.yaml" if cfg_path.exists(): import yaml with open(cfg_path) as f: data = yaml.safe_load(f) or {} return cls(home=str(home), config=data) return cls(home=str(home)) def save(self): """Write config to ctxd.yaml. Rarely needed in env-driven deployments.""" import yaml self.ensure_dirs() with open(self.config_path, "w") as f: yaml.dump(self._cfg, f, default_flow_style=False, sort_keys=False)