// Package config handles the on-disk YAML configuration for cc-switch: // path resolution (CC_SWITCH_CONFIG > XDG_CONFIG_HOME > ~/.config), atomic // read/write, permission warnings, and the CRUD surface used by the CLI. package config import ( "fmt" "os" "path/filepath" "regexp" "sort" "strings" "gopkg.in/yaml.v3" ) // Config is the root document persisted to ~/.config/cc-switch/config.yaml. type Config struct { ClaudePath string `yaml:"claude_path,omitempty"` DefaultProvider string `yaml:"default_provider,omitempty"` Providers map[string]Provider `yaml:"providers,omitempty"` } // Provider is one coding-plan subscription's env payload. type Provider struct { Description string `yaml:"description,omitempty"` Env map[string]string `yaml:"env"` } var ( providerNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) envRefRe = regexp.MustCompile(`^env:([A-Za-z_][A-Za-z0-9_]*)$`) ) // ValidateProviderName returns an error if name is empty or contains // characters outside [A-Za-z0-9_-]. func ValidateProviderName(name string) error { if name == "" { return fmt.Errorf("provider name must not be empty") } if !providerNameRe.MatchString(name) { return fmt.Errorf("provider name %q is invalid (allowed: letters, digits, '-', '_')", name) } return nil } // Validate checks the whole config for structural problems (duplicate or // misspelled fields are already caught by the YAML unmarshaller). func (c *Config) Validate() error { for name, p := range c.Providers { if err := ValidateProviderName(name); err != nil { return err } if len(p.Env) == 0 { return fmt.Errorf("provider %q: env must contain at least one key", name) } } if c.DefaultProvider != "" { if _, ok := c.Providers[c.DefaultProvider]; !ok { return fmt.Errorf("default_provider %q is not among providers", c.DefaultProvider) } } return nil } // IsEnvRef reports whether s is a reference to a parent-env variable using the // `env:VAR_NAME` syntax. When ok is true, varName is the referenced variable. // Non-matching strings are treated as literal values elsewhere in the code. func IsEnvRef(s string) (varName string, ok bool) { m := envRefRe.FindStringSubmatch(s) if m == nil { return "", false } return m[1], true } // SortedProviderNames returns provider names in lexicographic order. func (c *Config) SortedProviderNames() []string { names := make([]string, 0, len(c.Providers)) for n := range c.Providers { names = append(names, n) } sort.Strings(names) return names } // AddProvider inserts a new provider. Returns an error if the name already // exists or is invalid, or if env is empty. func (c *Config) AddProvider(name string, p Provider) error { if err := ValidateProviderName(name); err != nil { return err } if len(p.Env) == 0 { return fmt.Errorf("provider %q: env must contain at least one key", name) } if _, exists := c.Providers[name]; exists { return fmt.Errorf("provider %q already exists (use `edit` or pick a different name)", name) } if c.Providers == nil { c.Providers = map[string]Provider{} } c.Providers[name] = p return nil } // UpdateProvider replaces an existing provider in-place. func (c *Config) UpdateProvider(name string, p Provider) error { if err := ValidateProviderName(name); err != nil { return err } if len(p.Env) == 0 { return fmt.Errorf("provider %q: env must contain at least one key", name) } if _, exists := c.Providers[name]; !exists { return fmt.Errorf("provider %q does not exist", name) } c.Providers[name] = p return nil } // RemoveProvider deletes a provider. If the removed provider was the current // default, default_provider is cleared as well. func (c *Config) RemoveProvider(name string) error { if _, exists := c.Providers[name]; !exists { return fmt.Errorf("provider %q does not exist", name) } delete(c.Providers, name) if c.DefaultProvider == name { c.DefaultProvider = "" } return nil } // SetDefault sets default_provider; the provider must already exist. func (c *Config) SetDefault(name string) error { if _, exists := c.Providers[name]; !exists { return fmt.Errorf("provider %q does not exist", name) } c.DefaultProvider = name return nil } // SetClaudePath validates that path (after ~ expansion) points to an existing, // regular, executable file and stores the expanded absolute form. func (c *Config) SetClaudePath(path string) error { expanded, err := ExpandUser(path) if err != nil { return err } abs, err := filepath.Abs(expanded) if err != nil { return fmt.Errorf("resolve %q: %w", path, err) } info, err := os.Stat(abs) if err != nil { return fmt.Errorf("claude path %q: %w", abs, err) } if !info.Mode().IsRegular() { return fmt.Errorf("claude path %q is not a regular file", abs) } if info.Mode().Perm()&0o111 == 0 { return fmt.Errorf("claude path %q is not executable", abs) } c.ClaudePath = abs return nil } // ExpandUser expands a leading "~" or "~/" to the current user's home // directory. Other paths are returned unchanged. func ExpandUser(p string) (string, error) { if p == "" || (p[0] != '~') { return p, nil } if p == "~" { return os.UserHomeDir() } if strings.HasPrefix(p, "~/") { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, p[2:]), nil } // "~user" isn't supported — no standard POSIX way to resolve it in Go. return p, nil } // Marshal serialises the config to YAML, sorting provider keys for stable // diffs and preserving human-friendly field ordering. func (c *Config) Marshal() ([]byte, error) { // Re-use yaml.v3 to get deterministic output without pulling in another dep. return yaml.Marshal(c) }