"""Configuration loader: env vars -> config.yaml -> defaults.""" from __future__ import annotations import logging import os from dataclasses import dataclass, field from pathlib import Path import yaml from dotenv import load_dotenv log = logging.getLogger(__name__) ROOT_DIR = Path(__file__).resolve().parent.parent load_dotenv(ROOT_DIR / ".env") @dataclass class ClickUpConfig: api_token: str = "" space_id: str = "" task_type_field_name: str = "Work Category" # Custom field names (must match ClickUp exactly) delegate_field_name: str = "Delegate to Claude" stage_field_name: str = "Stage" error_field_name: str = "Error" # Statuses ai_working_status: str = "ai working" review_status: str = "review" client_review_status: str = "client review" complete_status: str = "complete" @dataclass class AutoCoraConfig: jobs_dir: str = "//PennQnap1/SHARE1/AutoCora/jobs" results_dir: str = "//PennQnap1/SHARE1/AutoCora/results" xlsx_dir: str = "//PennQnap1/SHARE1/Cora72-for-macro" poll_interval_seconds: int = 120 @dataclass class NASConfig: generated_dir: str = "//PennQnap1/SHARE1/generated" @dataclass class RunnerConfig: poll_interval_seconds: int = 720 claude_timeout_seconds: int = 2700 # 45 minutes max_turns_default: int = 10 temp_dir: str = "" # empty = system temp @dataclass class NtfyConfig: enabled: bool = False server: str = "https://ntfy.sh" error_topic: str = "" success_topic: str = "" @dataclass class Config: clickup: ClickUpConfig = field(default_factory=ClickUpConfig) autocora: AutoCoraConfig = field(default_factory=AutoCoraConfig) nas: NASConfig = field(default_factory=NASConfig) runner: RunnerConfig = field(default_factory=RunnerConfig) ntfy: NtfyConfig = field(default_factory=NtfyConfig) # Derived paths root_dir: Path = field(default_factory=lambda: ROOT_DIR) skills_dir: Path = field(default_factory=lambda: ROOT_DIR / "skills") db_path: Path = field(default_factory=lambda: ROOT_DIR / "data" / "clickup_runner.db") def _apply_section(cfg_obj, data: dict): """Apply a dict of values to a dataclass instance, skipping unknown keys.""" for k, v in data.items(): if hasattr(cfg_obj, k): setattr(cfg_obj, k, v) def load_config(yaml_path: Path | None = None) -> Config: """Load config from env vars -> config.yaml -> defaults.""" cfg = Config() # Load YAML if exists if yaml_path is None: yaml_path = ROOT_DIR / "clickup_runner.yaml" if yaml_path.exists(): with open(yaml_path) as f: data = yaml.safe_load(f) or {} for section_name in ("clickup", "autocora", "nas", "runner", "ntfy"): if section_name in data and isinstance(data[section_name], dict): _apply_section(getattr(cfg, section_name), data[section_name]) # Env var overrides if token := os.getenv("CLICKUP_API_TOKEN"): cfg.clickup.api_token = token if space := os.getenv("CLICKUP_SPACE_ID"): cfg.clickup.space_id = space # ntfy topics from env vars if topic := os.getenv("NTFY_ERROR_TOPIC"): cfg.ntfy.error_topic = topic if topic := os.getenv("NTFY_SUCCESS_TOPIC"): cfg.ntfy.success_topic = topic # Validate required fields if not cfg.clickup.api_token: log.warning("CLICKUP_API_TOKEN not set -- runner will not be able to poll") if not cfg.clickup.space_id: log.warning("CLICKUP_SPACE_ID not set -- runner will not be able to poll") # Ensure data dir exists cfg.db_path.parent.mkdir(parents=True, exist_ok=True) return cfg