// Package claudecfg reads and writes Claude Code's settings.json. // CrossRelay only touches the env keys it manages; every other key // (theme, permissions, unrelated env vars) round-trips untouched. package claudecfg import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" "gitea.rutua.cn/kate522/CrossRelay/internal/config" ) // ManagedEnvKeys lists the settings.json env keys CrossRelay owns. var ManagedEnvKeys = []string{ "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL", "ANTHROPIC_MODEL_NAME", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", } // DefaultPath returns the location of Claude Code's settings.json. // CROSSRELAY_CLAUDE_SETTINGS overrides it (used by tests). func DefaultPath() (string, error) { if v := strings.TrimSpace(os.Getenv("CROSSRELAY_CLAUDE_SETTINGS")); v != "" { return v, nil } dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR")) if dir == "" { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("locate home directory: %w", err) } dir = filepath.Join(home, ".claude") } return filepath.Join(dir, "settings.json"), nil } // Manager performs guarded edits on one settings.json file. type Manager struct { path string backupDir string mu sync.Mutex } // New builds a manager for settingsPath, storing backups in backupDir. func New(settingsPath, backupDir string) *Manager { return &Manager{path: settingsPath, backupDir: backupDir} } // ApplyOptions describes what gets injected into settings.json. type ApplyOptions struct { BaseURL string Token string Models config.Models } // Settings is a parsed settings.json. Raw keeps every top-level key. type Settings struct { Raw map[string]json.RawMessage `json:"-"` Env map[string]string `json:"env"` } // UnmarshalJSON parses settings.json while keeping unknown keys. func (s *Settings) UnmarshalJSON(data []byte) error { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return err } s.Raw = raw s.Env = nil if envRaw, ok := raw["env"]; ok { _ = json.Unmarshal(envRaw, &s.Env) } return nil } // MarshalJSON writes every original key back, env included. func (s Settings) MarshalJSON() ([]byte, error) { out := make(map[string]json.RawMessage, len(s.Raw)+1) for k, v := range s.Raw { out[k] = v } env, err := json.Marshal(s.Env) if err != nil { return nil, err } out["env"] = env return json.Marshal(out) } // Apply injects the relay configuration, backing up the current file // first. It returns the resulting settings. func (m *Manager) Apply(opts ApplyOptions) (*Settings, error) { m.mu.Lock() defer m.mu.Unlock() settings, err := m.load() if err != nil { return nil, err } if _, err := m.backupLocked(settings); err != nil { return nil, err } if settings.Env == nil { settings.Env = map[string]string{} } settings.Env["ANTHROPIC_BASE_URL"] = opts.BaseURL settings.Env["ANTHROPIC_AUTH_TOKEN"] = opts.Token applyModelEnv(settings.Env, opts.Models) if err := m.saveLocked(settings); err != nil { return nil, err } return settings, nil } // Restore copies the newest backup over settings.json and returns the // backup file name. func (m *Manager) Restore() (string, error) { m.mu.Lock() defer m.mu.Unlock() backups, err := m.listBackupsLocked() if err != nil { return "", err } if len(backups) == 0 { return "", fmt.Errorf("no backup found in %s", m.backupDir) } newest := backups[0] data, err := os.ReadFile(filepath.Join(m.backupDir, newest.Name)) if err != nil { return "", err } if err := m.writeFile(data); err != nil { return "", err } return newest.Name, nil } // BackupInfo describes one backup file. type BackupInfo struct { Name string `json:"name"` Size int64 `json:"size"` Modified time.Time `json:"modified"` } // Backups lists backup files, newest first. func (m *Manager) Backups() ([]BackupInfo, error) { m.mu.Lock() defer m.mu.Unlock() return m.listBackupsLocked() } // CurrentEnv returns the env block of the live settings.json. func (m *Manager) CurrentEnv() (map[string]string, error) { m.mu.Lock() defer m.mu.Unlock() settings, err := m.load() if err != nil { return nil, err } return settings.Env, nil } func (m *Manager) load() (*Settings, error) { data, err := os.ReadFile(m.path) if err != nil { if os.IsNotExist(err) { return &Settings{Raw: map[string]json.RawMessage{}, Env: map[string]string{}}, nil } return nil, err } if len(data) == 0 { return &Settings{Raw: map[string]json.RawMessage{}, Env: map[string]string{}}, nil } var settings Settings if err := json.Unmarshal(data, &settings); err != nil { return nil, fmt.Errorf("parse %s: %w", m.path, err) } if settings.Raw == nil { settings.Raw = map[string]json.RawMessage{} } if settings.Env == nil { settings.Env = map[string]string{} } return &settings, nil } func (m *Manager) saveLocked(settings *Settings) error { data, err := json.MarshalIndent(settings, "", " ") if err != nil { return err } data = append(data, '\n') return m.writeFile(data) } func (m *Manager) writeFile(data []byte) error { if err := os.MkdirAll(filepath.Dir(m.path), 0o700); err != nil { return err } tmp := m.path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } if err := os.Rename(tmp, m.path); err != nil { _ = os.Remove(tmp) return err } return nil } func (m *Manager) backupLocked(settings *Settings) (string, error) { data, err := json.MarshalIndent(settings, "", " ") if err != nil { return "", err } data = append(data, '\n') if err := os.MkdirAll(m.backupDir, 0o700); err != nil { return "", err } name := fmt.Sprintf("settings-%s.json", time.Now().Format("20060102-150405")) path := filepath.Join(m.backupDir, name) if err := os.WriteFile(path, data, 0o600); err != nil { return "", err } m.pruneBackupsLocked(20) return name, nil } func (m *Manager) listBackupsLocked() ([]BackupInfo, error) { entries, err := os.ReadDir(m.backupDir) if err != nil { if os.IsNotExist(err) { return []BackupInfo{}, nil } return nil, err } out := []BackupInfo{} for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue } info, err := e.Info() if err != nil { continue } out = append(out, BackupInfo{Name: e.Name(), Size: info.Size(), Modified: info.ModTime()}) } sort.Slice(out, func(i, j int) bool { return out[i].Modified.After(out[j].Modified) }) return out, nil } func (m *Manager) pruneBackupsLocked(keep int) { backups, err := m.listBackupsLocked() if err != nil || len(backups) <= keep { return } for _, b := range backups[keep:] { _ = os.Remove(filepath.Join(m.backupDir, b.Name)) } } // applyModelEnv writes the model env vars for the roles the provider // defines and removes the ones it does not. func applyModelEnv(env map[string]string, models config.Models) { main := firstNonEmpty(models.Sonnet, models.Default) setOrDelete(env, "ANTHROPIC_MODEL", main) setOrDelete(env, "ANTHROPIC_MODEL_NAME", main) setOrDelete(env, "ANTHROPIC_DEFAULT_SONNET_MODEL", firstNonEmpty(models.Sonnet, models.Default)) setOrDelete(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", firstNonEmpty(models.Sonnet, models.Default)) setOrDelete(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", firstNonEmpty(models.Haiku, models.Default)) setOrDelete(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", firstNonEmpty(models.Haiku, models.Default)) setOrDelete(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", firstNonEmpty(models.Opus, models.Default)) setOrDelete(env, "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", firstNonEmpty(models.Opus, models.Default)) } func setOrDelete(env map[string]string, key, value string) { if value == "" { delete(env, key) return } env[key] = value } func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return v } } return "" }