config.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. // Package config handles the on-disk YAML configuration for cc-switch:
  2. // path resolution (CC_SWITCH_CONFIG > XDG_CONFIG_HOME > ~/.config), atomic
  3. // read/write, permission warnings, and the CRUD surface used by the CLI.
  4. package config
  5. import (
  6. "fmt"
  7. "os"
  8. "path/filepath"
  9. "regexp"
  10. "sort"
  11. "strings"
  12. "gopkg.in/yaml.v3"
  13. )
  14. // Config is the root document persisted to ~/.config/cc-switch/config.yaml.
  15. type Config struct {
  16. ClaudePath string `yaml:"claude_path,omitempty"`
  17. DefaultProvider string `yaml:"default_provider,omitempty"`
  18. Providers map[string]Provider `yaml:"providers,omitempty"`
  19. }
  20. // Provider is one coding-plan subscription's env payload.
  21. type Provider struct {
  22. Description string `yaml:"description,omitempty"`
  23. Env map[string]string `yaml:"env"`
  24. }
  25. var (
  26. providerNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
  27. envRefRe = regexp.MustCompile(`^env:([A-Za-z_][A-Za-z0-9_]*)$`)
  28. )
  29. // ValidateProviderName returns an error if name is empty or contains
  30. // characters outside [A-Za-z0-9_-].
  31. func ValidateProviderName(name string) error {
  32. if name == "" {
  33. return fmt.Errorf("provider name must not be empty")
  34. }
  35. if !providerNameRe.MatchString(name) {
  36. return fmt.Errorf("provider name %q is invalid (allowed: letters, digits, '-', '_')", name)
  37. }
  38. return nil
  39. }
  40. // Validate checks the whole config for structural problems (duplicate or
  41. // misspelled fields are already caught by the YAML unmarshaller).
  42. func (c *Config) Validate() error {
  43. for name, p := range c.Providers {
  44. if err := ValidateProviderName(name); err != nil {
  45. return err
  46. }
  47. if len(p.Env) == 0 {
  48. return fmt.Errorf("provider %q: env must contain at least one key", name)
  49. }
  50. }
  51. if c.DefaultProvider != "" {
  52. if _, ok := c.Providers[c.DefaultProvider]; !ok {
  53. return fmt.Errorf("default_provider %q is not among providers", c.DefaultProvider)
  54. }
  55. }
  56. return nil
  57. }
  58. // IsEnvRef reports whether s is a reference to a parent-env variable using the
  59. // `env:VAR_NAME` syntax. When ok is true, varName is the referenced variable.
  60. // Non-matching strings are treated as literal values elsewhere in the code.
  61. func IsEnvRef(s string) (varName string, ok bool) {
  62. m := envRefRe.FindStringSubmatch(s)
  63. if m == nil {
  64. return "", false
  65. }
  66. return m[1], true
  67. }
  68. // SortedProviderNames returns provider names in lexicographic order.
  69. func (c *Config) SortedProviderNames() []string {
  70. names := make([]string, 0, len(c.Providers))
  71. for n := range c.Providers {
  72. names = append(names, n)
  73. }
  74. sort.Strings(names)
  75. return names
  76. }
  77. // AddProvider inserts a new provider. Returns an error if the name already
  78. // exists or is invalid, or if env is empty.
  79. func (c *Config) AddProvider(name string, p Provider) error {
  80. if err := ValidateProviderName(name); err != nil {
  81. return err
  82. }
  83. if len(p.Env) == 0 {
  84. return fmt.Errorf("provider %q: env must contain at least one key", name)
  85. }
  86. if _, exists := c.Providers[name]; exists {
  87. return fmt.Errorf("provider %q already exists (use `edit` or pick a different name)", name)
  88. }
  89. if c.Providers == nil {
  90. c.Providers = map[string]Provider{}
  91. }
  92. c.Providers[name] = p
  93. return nil
  94. }
  95. // UpdateProvider replaces an existing provider in-place.
  96. func (c *Config) UpdateProvider(name string, p Provider) error {
  97. if err := ValidateProviderName(name); err != nil {
  98. return err
  99. }
  100. if len(p.Env) == 0 {
  101. return fmt.Errorf("provider %q: env must contain at least one key", name)
  102. }
  103. if _, exists := c.Providers[name]; !exists {
  104. return fmt.Errorf("provider %q does not exist", name)
  105. }
  106. c.Providers[name] = p
  107. return nil
  108. }
  109. // RemoveProvider deletes a provider. If the removed provider was the current
  110. // default, default_provider is cleared as well.
  111. func (c *Config) RemoveProvider(name string) error {
  112. if _, exists := c.Providers[name]; !exists {
  113. return fmt.Errorf("provider %q does not exist", name)
  114. }
  115. delete(c.Providers, name)
  116. if c.DefaultProvider == name {
  117. c.DefaultProvider = ""
  118. }
  119. return nil
  120. }
  121. // SetDefault sets default_provider; the provider must already exist.
  122. func (c *Config) SetDefault(name string) error {
  123. if _, exists := c.Providers[name]; !exists {
  124. return fmt.Errorf("provider %q does not exist", name)
  125. }
  126. c.DefaultProvider = name
  127. return nil
  128. }
  129. // SetClaudePath validates that path (after ~ expansion) points to an existing,
  130. // regular, executable file and stores the expanded absolute form.
  131. func (c *Config) SetClaudePath(path string) error {
  132. expanded, err := ExpandUser(path)
  133. if err != nil {
  134. return err
  135. }
  136. abs, err := filepath.Abs(expanded)
  137. if err != nil {
  138. return fmt.Errorf("resolve %q: %w", path, err)
  139. }
  140. info, err := os.Stat(abs)
  141. if err != nil {
  142. return fmt.Errorf("claude path %q: %w", abs, err)
  143. }
  144. if !info.Mode().IsRegular() {
  145. return fmt.Errorf("claude path %q is not a regular file", abs)
  146. }
  147. if info.Mode().Perm()&0o111 == 0 {
  148. return fmt.Errorf("claude path %q is not executable", abs)
  149. }
  150. c.ClaudePath = abs
  151. return nil
  152. }
  153. // ExpandUser expands a leading "~" or "~/" to the current user's home
  154. // directory. Other paths are returned unchanged.
  155. func ExpandUser(p string) (string, error) {
  156. if p == "" || (p[0] != '~') {
  157. return p, nil
  158. }
  159. if p == "~" {
  160. return os.UserHomeDir()
  161. }
  162. if strings.HasPrefix(p, "~/") {
  163. home, err := os.UserHomeDir()
  164. if err != nil {
  165. return "", err
  166. }
  167. return filepath.Join(home, p[2:]), nil
  168. }
  169. // "~user" isn't supported — no standard POSIX way to resolve it in Go.
  170. return p, nil
  171. }
  172. // Marshal serialises the config to YAML, sorting provider keys for stable
  173. // diffs and preserving human-friendly field ordering.
  174. func (c *Config) Marshal() ([]byte, error) {
  175. // Re-use yaml.v3 to get deterministic output without pulling in another dep.
  176. return yaml.Marshal(c)
  177. }