| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- // 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)
- }
|