Skip to main content

cc_switch/
claude_settings.rs

1use anyhow::{Context, Result};
2use std::collections::BTreeMap;
3use std::fs;
4
5use crate::config::types::{ClaudeSettings, Configuration, StorageMode};
6use crate::utils::get_claude_settings_path;
7
8/// Remove trailing commas from JSON content to make it more lenient
9///
10/// Handles trailing commas before `}` and `]` characters, which are common
11/// in hand-edited JSON files but not valid in standard JSON.
12fn strip_trailing_commas(json: &str) -> String {
13    // Simple approach: remove commas that appear before closing braces/brackets
14    // This handles the most common case of trailing commas
15    let mut result = String::with_capacity(json.len());
16    let chars: Vec<char> = json.chars().collect();
17    let mut i = 0;
18
19    while i < chars.len() {
20        let c = chars[i];
21
22        // Check if this is a comma followed by optional whitespace and then } or ]
23        if c == ',' {
24            // Look ahead to see if the next non-whitespace char is } or ]
25            let mut j = i + 1;
26            while j < chars.len() && chars[j].is_whitespace() {
27                j += 1;
28            }
29
30            if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
31                // Skip this trailing comma
32                i += 1;
33                continue;
34            }
35        }
36
37        result.push(c);
38        i += 1;
39    }
40
41    result
42}
43
44impl ClaudeSettings {
45    /// Load Claude settings from disk
46    ///
47    /// Reads the JSON file from the configured Claude settings directory
48    /// Returns default empty settings if file doesn't exist
49    /// Creates the file with default structure if it doesn't exist
50    ///
51    /// # Arguments
52    /// * `custom_dir` - Optional custom directory for Claude settings
53    ///
54    /// # Errors
55    /// Returns error if file exists but cannot be read or parsed
56    pub fn load(custom_dir: Option<&str>) -> Result<Self> {
57        let path = get_claude_settings_path(custom_dir)?;
58
59        if !path.exists() {
60            // Create default settings file if it doesn't exist
61            let default_settings = ClaudeSettings::default();
62            default_settings.save(custom_dir)?;
63            return Ok(default_settings);
64        }
65
66        let content = fs::read_to_string(&path)
67            .with_context(|| format!("Failed to read Claude settings from {}", path.display()))?;
68
69        // Parse with better error handling for missing env field
70        let mut settings: ClaudeSettings = if content.trim().is_empty() {
71            ClaudeSettings::default()
72        } else {
73            // Strip trailing commas to handle lenient JSON
74            let cleaned_content = strip_trailing_commas(&content);
75
76            // Try to parse the cleaned content first
77            match serde_json::from_str(&cleaned_content) {
78                Ok(s) => s,
79                Err(e) => {
80                    // Provide helpful error message with the actual parse error
81                    let error_msg = format!(
82                        "Failed to parse Claude settings JSON at {}:\n  {}\n\n\
83                         This usually means the JSON file has invalid syntax.\n\
84                         Common issues:\n\
85                         - Trailing commas (e.g., {{\"key\": \"value\",}})\n\
86                         - Missing quotes around keys or values\n\
87                         - Unescaped special characters in strings\n\n\
88                         Please fix the JSON syntax in the file.",
89                        path.display(),
90                        e
91                    );
92                    return Err(anyhow::anyhow!("{}", error_msg));
93                }
94            }
95        };
96
97        // Ensure env field exists (handle case where it might be missing from JSON)
98        if settings.env.is_empty() && !content.contains("\"env\"") {
99            settings.env = BTreeMap::new();
100        }
101
102        Ok(settings)
103    }
104
105    /// Save Claude settings to disk
106    ///
107    /// Writes the current state to the configured Claude settings directory
108    /// Creates the directory structure if it doesn't exist
109    /// Ensures the env field is properly serialized
110    ///
111    /// # Arguments
112    /// * `custom_dir` - Optional custom directory for Claude settings
113    ///
114    /// # Errors
115    /// Returns error if directory cannot be created or file cannot be written
116    pub fn save(&self, custom_dir: Option<&str>) -> Result<()> {
117        let path = get_claude_settings_path(custom_dir)?;
118
119        // Create directory if it doesn't exist
120        if let Some(parent) = path.parent() {
121            fs::create_dir_all(parent)
122                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
123        }
124
125        // The custom Serialize implementation handles env field inclusion automatically
126        let settings_to_save = self;
127
128        let json = serde_json::to_string_pretty(&settings_to_save)
129            .with_context(|| "Failed to serialize Claude settings")?;
130
131        fs::write(&path, json).with_context(|| format!("Failed to write to {}", path.display()))?;
132
133        Ok(())
134    }
135
136    /// Switch to a specific API configuration
137    ///
138    /// Updates the environment variables with the provided configuration
139    /// Ensures env field exists before updating
140    ///
141    /// # Arguments
142    /// * `config` - Configuration containing token, URL, and optional model settings to apply
143    pub fn switch_to_config(&mut self, config: &Configuration) {
144        // Ensure env field exists
145        if self.env.is_empty() {
146            self.env = BTreeMap::new();
147        }
148
149        // Remove all Anthropic environment variables to ensure clean state
150        let env_fields = Configuration::get_env_field_names();
151        for field in &env_fields {
152            self.env.remove(*field);
153        }
154
155        // Set required environment variables
156        self.env
157            .insert("ANTHROPIC_AUTH_TOKEN".to_string(), config.token.clone());
158        self.env
159            .insert("ANTHROPIC_BASE_URL".to_string(), config.url.clone());
160
161        // Set model configurations only if provided (don't set empty values)
162        if let Some(model) = &config.model
163            && !model.is_empty()
164        {
165            self.env
166                .insert("ANTHROPIC_MODEL".to_string(), model.clone());
167        }
168
169        if let Some(small_fast_model) = &config.small_fast_model
170            && !small_fast_model.is_empty()
171        {
172            self.env.insert(
173                "ANTHROPIC_SMALL_FAST_MODEL".to_string(),
174                small_fast_model.clone(),
175            );
176        }
177
178        // Set additional configuration values that should not be removed
179        if let Some(max_thinking_tokens) = config.max_thinking_tokens {
180            self.env.insert(
181                "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
182                max_thinking_tokens.to_string(),
183            );
184        }
185
186        if let Some(timeout) = config.api_timeout_ms {
187            self.env
188                .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
189        }
190
191        if let Some(flag) = config.claude_code_disable_nonessential_traffic {
192            self.env.insert(
193                "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
194                flag.to_string(),
195            );
196        }
197
198        if let Some(model) = &config.anthropic_default_sonnet_model
199            && !model.is_empty()
200        {
201            self.env
202                .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
203        }
204
205        if let Some(model) = &config.anthropic_default_opus_model
206            && !model.is_empty()
207        {
208            self.env
209                .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
210        }
211
212        if let Some(model) = &config.anthropic_default_haiku_model
213            && !model.is_empty()
214        {
215            self.env
216                .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
217        }
218    }
219
220    /// Remove Anthropic environment variables
221    ///
222    /// Clears all Anthropic-related environment variables from settings
223    /// Used to reset to default Claude behavior
224    pub fn remove_anthropic_env(&mut self) {
225        // Ensure env field exists
226        if self.env.is_empty() {
227            self.env = BTreeMap::new();
228        }
229
230        // Remove all environment variables that can be set by configurations
231        let env_fields = Configuration::get_env_field_names();
232        for field in &env_fields {
233            self.env.remove(*field);
234        }
235    }
236
237    /// Switch to a specific API configuration with specified storage mode
238    ///
239    /// Updates the settings.json file based on the storage mode:
240    /// - Env mode: Launch Claude with environment variables (cleans settings.json)
241    /// - Config mode: Write to env field in settings.json (settings file persistence)
242    ///
243    /// # Arguments
244    /// * `config` - Configuration containing token, URL, and optional model settings to apply
245    /// * `mode` - Storage mode to use (Env or Config)
246    /// * `custom_dir` - Optional custom directory for Claude settings
247    ///
248    /// # Errors
249    /// Returns error if settings cannot be saved
250    pub fn switch_to_config_with_mode(
251        &mut self,
252        config: &Configuration,
253        mode: StorageMode,
254        custom_dir: Option<&str>,
255    ) -> Result<()> {
256        match mode {
257            StorageMode::Env => {
258                // Env mode: Check for conflicts with existing configurable fields
259                // Automatically remove them from settings.json if found
260                // Note: User preference fields are preserved (not cleared)
261
262                // Get environment variable names that should be cleared
263                // This excludes user preference fields like DISABLE_NONESSENTIAL_TRAFFIC
264                let clearable_env_fields = Configuration::get_clearable_env_field_names();
265
266                let mut removed_fields = Vec::new();
267
268                // Check and remove Anthropic variables from env field
269                for field in &clearable_env_fields {
270                    if self.env.remove(*field).is_some() {
271                        removed_fields.push(field.to_string());
272                    }
273                }
274
275                // If fields were removed, report what was cleaned and save
276                if !removed_fields.is_empty() {
277                    eprintln!("🧹 Cleaning settings.json for env mode:");
278                    eprintln!("   Removed configurable fields:");
279                    for field in &removed_fields {
280                        eprintln!("   - {}", field);
281                    }
282                    eprintln!();
283                    eprintln!(
284                        "   Settings.json cleaned. Environment variables will be used instead."
285                    );
286
287                    // Save the cleaned settings
288                    self.save(custom_dir)?;
289                }
290
291                // Env mode: Environment variables will be set directly when launching Claude
292            }
293            StorageMode::Config => {
294                // Config mode: Write Anthropic settings to env field with UPPERCASE names
295                // Check for conflicts with system environment variables (not settings.json)
296                // settings.json env field is managed by this tool, so it's expected to have values
297
298                // Get all environment variable names that can be set by configurations
299                let anthropic_env_fields = Configuration::get_env_field_names();
300
301                let mut conflicts = Vec::new();
302
303                // Check system environment variables for Anthropic variables
304                // This is the real conflict - user may have set these in their shell
305                for field in &anthropic_env_fields {
306                    if std::env::var(field).is_ok() {
307                        conflicts.push(format!("system env: {}", field));
308                    }
309                }
310
311                // If conflicts found, report error and exit
312                if !conflicts.is_empty() {
313                    eprintln!("❌ Conflict detected in config mode:");
314                    eprintln!("   Found existing Anthropic configuration in system environment:");
315                    for conflict in &conflicts {
316                        eprintln!("   - {}", conflict);
317                    }
318                    eprintln!();
319                    eprintln!(
320                        "   Config mode cannot work when Anthropic environment variables are set in system env."
321                    );
322                    eprintln!("   Please:");
323                    eprintln!("   1. Unset system environment variables, or");
324                    eprintln!("   2. Use 'env' mode instead");
325                    return Err(anyhow::anyhow!(
326                        "Config mode conflict: Anthropic environment variables exist in system env"
327                    ));
328                }
329
330                // Apply the new configuration to env field (overwrite existing values)
331                self.switch_to_config(config);
332
333                // Add the additional fields that switch_to_config doesn't handle
334                if let Some(max_thinking_tokens) = config.max_thinking_tokens {
335                    self.env.insert(
336                        "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
337                        max_thinking_tokens.to_string(),
338                    );
339                }
340
341                if let Some(timeout) = config.api_timeout_ms {
342                    self.env
343                        .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
344                }
345
346                if let Some(flag) = config.claude_code_disable_nonessential_traffic {
347                    self.env.insert(
348                        "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
349                        flag.to_string(),
350                    );
351                }
352
353                if let Some(model) = &config.anthropic_default_sonnet_model
354                    && !model.is_empty()
355                {
356                    self.env
357                        .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
358                }
359
360                if let Some(model) = &config.anthropic_default_opus_model
361                    && !model.is_empty()
362                {
363                    self.env
364                        .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
365                }
366
367                if let Some(model) = &config.anthropic_default_haiku_model
368                    && !model.is_empty()
369                {
370                    self.env
371                        .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
372                }
373
374                self.save(custom_dir)?;
375            }
376        }
377
378        Ok(())
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn test_strip_trailing_commas_simple() {
388        let input = r#"{"a": 1,}"#;
389        let expected = r#"{"a": 1}"#;
390        assert_eq!(strip_trailing_commas(input), expected);
391    }
392
393    #[test]
394    fn test_strip_trailing_commas_nested_object() {
395        let input = r#"{"env": {"KEY": "value",},}"#;
396        let expected = r#"{"env": {"KEY": "value"}}"#;
397        assert_eq!(strip_trailing_commas(input), expected);
398    }
399
400    #[test]
401    fn test_strip_trailing_commas_array() {
402        let input = r#"{"items": [1, 2, 3,],}"#;
403        let expected = r#"{"items": [1, 2, 3]}"#;
404        assert_eq!(strip_trailing_commas(input), expected);
405    }
406
407    #[test]
408    fn test_strip_trailing_commas_multiline() {
409        let input = r#"{
410  "env": {
411    "KEY": "value",
412  },
413}"#;
414        let expected = r#"{
415  "env": {
416    "KEY": "value"
417  }
418}"#;
419        assert_eq!(strip_trailing_commas(input), expected);
420    }
421
422    #[test]
423    fn test_strip_trailing_commas_no_trailing() {
424        let input = r#"{"a": 1, "b": 2}"#;
425        assert_eq!(strip_trailing_commas(input), input);
426    }
427
428    #[test]
429    fn test_strip_trailing_commas_complex() {
430        let input = r#"{
431  "env": {
432    "ANTHROPIC_AUTH_TOKEN": "token",
433    "ANTHROPIC_BASE_URL": "https://api.example.com",
434  },
435  "model": "claude-3-opus",
436}"#;
437        let expected = r#"{
438  "env": {
439    "ANTHROPIC_AUTH_TOKEN": "token",
440    "ANTHROPIC_BASE_URL": "https://api.example.com"
441  },
442  "model": "claude-3-opus"
443}"#;
444        assert_eq!(strip_trailing_commas(input), expected);
445    }
446
447    #[test]
448    fn test_strip_trailing_commas_preserves_inner_commas() {
449        let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
450        let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
451        assert_eq!(strip_trailing_commas(input), expected);
452    }
453}