// Package config loads and validates the claude-failover YAML configuration. package config import ( "fmt" "os" "path/filepath" "strings" "time" "gopkg.in/yaml.v3" ) // Config is the top-level configuration structure, mapping config.example.yaml. type Config struct { Accounts []AccountConfig `yaml:"accounts"` Pool PoolConfig `yaml:"pool"` Quota QuotaConfig `yaml:"quota"` Checkpoint CheckpointConfig `yaml:"checkpoint"` MCPHTTP MCPHTTPConfig `yaml:"mcp_http"` Notifications NotificationsConfig `yaml:"notifications"` Dispatcher DispatcherConfig `yaml:"dispatcher"` Watcher WatcherConfig `yaml:"watcher"` Janitor JanitorConfig `yaml:"janitor"` } // AccountConfig describes a single Anthropic account available to the daemon. type AccountConfig struct { Name string `yaml:"name"` Home string `yaml:"home"` Limits AccountLimits `yaml:"limits"` Priority int `yaml:"priority"` } // AccountLimits defines soft usage thresholds for an account. type AccountLimits struct { HourlyMsgs int `yaml:"hourly_msgs"` WeeklyMsgs int `yaml:"weekly_msgs"` } // PoolConfig describes the session pool configuration. type PoolConfig struct { Dedicated []DedicatedSession `yaml:"dedicated"` Autonomous AutonomousConfig `yaml:"autonomous"` SharedProjectsDir string `yaml:"shared_projects_dir"` } // DedicatedSession is a named tmux session bound to a specific project directory. type DedicatedSession struct { Name string `yaml:"name"` Project string `yaml:"project"` } // AutonomousConfig controls the autoscaling inbox-dispatcher session pool. type AutonomousConfig struct { Prefix string `yaml:"prefix"` Min int `yaml:"min"` Max int `yaml:"max"` } // QuotaConfig defines quota monitoring parameters. type QuotaConfig struct { PollInterval Duration `yaml:"poll_interval"` Window5hThreshold float64 `yaml:"window_5h_threshold"` WindowWeekThreshold float64 `yaml:"window_week_threshold"` ReactivateCooldown Duration `yaml:"reactivate_cooldown"` } // CheckpointConfig controls session context snapshotting. type CheckpointConfig struct { Dir string `yaml:"dir"` Interval Duration `yaml:"interval"` Keep int `yaml:"keep"` } // MCPHTTPConfig defines the HTTP control-plane endpoint. type MCPHTTPConfig struct { Listen string `yaml:"listen"` BearerTokenEnv string `yaml:"bearer_token_env"` EnableTrigger bool `yaml:"enable_trigger"` } // NotificationsConfig holds environment variable names for alert credentials. // Actual secrets are read from env at runtime — never stored in config files. type NotificationsConfig struct { TelegramTokenEnv string `yaml:"telegram_token_env"` TelegramChatIDEnv string `yaml:"telegram_chat_id_env"` ResendAPIKeyEnv string `yaml:"resend_api_key_env"` NotifyEmailEnv string `yaml:"notify_email_env"` } // DispatcherConfig controls the task dispatcher behaviour. type DispatcherConfig struct { ProjectsDir string `yaml:"projects_dir"` IdleTimeout Duration `yaml:"idle_timeout"` PromptTimeout Duration `yaml:"prompt_timeout"` MaxDispatchPerTask int `yaml:"max_dispatch_per_task"` } // WatcherConfig controls the session watcher behaviour. type WatcherConfig struct { Interval Duration `yaml:"interval"` DoneSignalDir string `yaml:"done_signal_dir"` IdleTimeout Duration `yaml:"idle_timeout"` } // JanitorConfig controls the periodic housekeeping goroutine. type JanitorConfig struct { Interval Duration `yaml:"interval"` } // Duration is a time.Duration that unmarshals from YAML strings like "30s", "1h". type Duration struct { time.Duration } func (d *Duration) UnmarshalYAML(value *yaml.Node) error { var s string if err := value.Decode(&s); err != nil { return err } dur, err := time.ParseDuration(s) if err != nil { return fmt.Errorf("invalid duration %q: %w", s, err) } d.Duration = dur return nil } // expandHome replaces a leading "~" with the current user's home directory. func expandHome(path string) string { if strings.HasPrefix(path, "~/") { home, err := os.UserHomeDir() if err != nil { return path } return filepath.Join(home, path[2:]) } return path } // expandPaths resolves ~ in paths that may reference the operator's home dir. func (c *Config) expandPaths() { for i := range c.Accounts { c.Accounts[i].Home = expandHome(c.Accounts[i].Home) } for i := range c.Pool.Dedicated { c.Pool.Dedicated[i].Project = expandHome(c.Pool.Dedicated[i].Project) } c.Pool.SharedProjectsDir = expandHome(c.Pool.SharedProjectsDir) c.Checkpoint.Dir = expandHome(c.Checkpoint.Dir) } // defaults sets sensible fallback values when fields are zero. func (c *Config) defaults() { if c.MCPHTTP.Listen == "" { c.MCPHTTP.Listen = "127.0.0.1:9090" } if c.Quota.PollInterval.Duration == 0 { c.Quota.PollInterval.Duration = 30 * time.Second } if c.Checkpoint.Interval.Duration == 0 { c.Checkpoint.Interval.Duration = 60 * time.Second } if c.Checkpoint.Keep == 0 { c.Checkpoint.Keep = 20 } if c.Pool.Autonomous.Min == 0 { c.Pool.Autonomous.Min = 2 } if c.Pool.Autonomous.Max == 0 { c.Pool.Autonomous.Max = 10 } if c.Watcher.Interval.Duration == 0 { c.Watcher.Interval.Duration = 30 * time.Second } if c.Watcher.IdleTimeout.Duration == 0 { c.Watcher.IdleTimeout.Duration = 60 * time.Minute } if c.Watcher.DoneSignalDir == "" { c.Watcher.DoneSignalDir = "/tmp" } if c.Janitor.Interval.Duration == 0 { c.Janitor.Interval.Duration = 5 * time.Minute } if c.Dispatcher.IdleTimeout.Duration == 0 { c.Dispatcher.IdleTimeout.Duration = 60 * time.Minute } if c.Dispatcher.PromptTimeout.Duration == 0 { c.Dispatcher.PromptTimeout.Duration = 30 * time.Second } if c.Dispatcher.MaxDispatchPerTask == 0 { c.Dispatcher.MaxDispatchPerTask = 3 } } // Load reads the YAML file at path, expands home paths, and applies defaults. func Load(path string) (*Config, error) { path = expandHome(path) data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config %s: %w", path, err) } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parsing config %s: %w", path, err) } cfg.expandPaths() cfg.defaults() return &cfg, nil }