// 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"` } // 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"` } // 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 } } // 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 }