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        if let Some(model) = &config.claude_code_subagent_model
220            && !model.is_empty()
221        {
222            self.env
223                .insert("CLAUDE_CODE_SUBAGENT_MODEL".to_string(), model.clone());
224        }
225
226        if let Some(flag) = config.claude_code_disable_nonstreaming_fallback {
227            self.env.insert(
228                "CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK".to_string(),
229                flag.to_string(),
230            );
231        }
232
233        if let Some(level) = &config.claude_code_effort_level
234            && !level.is_empty()
235        {
236            self.env
237                .insert("CLAUDE_CODE_EFFORT_LEVEL".to_string(), level.clone());
238        }
239    }
240
241    /// Remove Anthropic environment variables
242    ///
243    /// Clears all Anthropic-related environment variables from settings
244    /// Used to reset to default Claude behavior
245    pub fn remove_anthropic_env(&mut self) {
246        // Ensure env field exists
247        if self.env.is_empty() {
248            self.env = BTreeMap::new();
249        }
250
251        // Remove all environment variables that can be set by configurations
252        let env_fields = Configuration::get_env_field_names();
253        for field in &env_fields {
254            self.env.remove(*field);
255        }
256    }
257
258    /// Switch to a specific API configuration with specified storage mode
259    ///
260    /// Updates the settings.json file based on the storage mode:
261    /// - Env mode: Launch Claude with environment variables (cleans settings.json)
262    /// - Config mode: Write to env field in settings.json (settings file persistence)
263    ///
264    /// # Arguments
265    /// * `config` - Configuration containing token, URL, and optional model settings to apply
266    /// * `mode` - Storage mode to use (Env or Config)
267    /// * `custom_dir` - Optional custom directory for Claude settings
268    ///
269    /// # Errors
270    /// Returns error if settings cannot be saved
271    pub fn switch_to_config_with_mode(
272        &mut self,
273        config: &Configuration,
274        mode: StorageMode,
275        custom_dir: Option<&str>,
276    ) -> Result<()> {
277        match mode {
278            StorageMode::Env => {
279                // Env mode: Check for conflicts with existing configurable fields
280                // Automatically remove them from settings.json if found
281                // Note: User preference fields are preserved (not cleared)
282
283                // Get environment variable names that should be cleared
284                // This excludes user preference fields like DISABLE_NONESSENTIAL_TRAFFIC
285                let clearable_env_fields = Configuration::get_clearable_env_field_names();
286
287                let mut removed_fields = Vec::new();
288
289                // Check and remove Anthropic variables from env field
290                for field in &clearable_env_fields {
291                    if self.env.remove(*field).is_some() {
292                        removed_fields.push(field.to_string());
293                    }
294                }
295
296                // If fields were removed, report what was cleaned and save
297                if !removed_fields.is_empty() {
298                    eprintln!("🧹 Cleaning settings.json for env mode:");
299                    eprintln!("   Removed configurable fields:");
300                    for field in &removed_fields {
301                        eprintln!("   - {}", field);
302                    }
303                    eprintln!();
304                    eprintln!(
305                        "   Settings.json cleaned. Environment variables will be used instead."
306                    );
307
308                    // Save the cleaned settings
309                    self.save(custom_dir)?;
310                }
311
312                // Env mode: Environment variables will be set directly when launching Claude
313            }
314            StorageMode::Config => {
315                // Config mode: Write Anthropic settings to env field with UPPERCASE names
316                // Check for conflicts with system environment variables (not settings.json)
317                // settings.json env field is managed by this tool, so it's expected to have values
318
319                // Get all environment variable names that can be set by configurations
320                let anthropic_env_fields = Configuration::get_env_field_names();
321
322                let mut conflicts = Vec::new();
323
324                // Check system environment variables for Anthropic variables
325                // This is the real conflict - user may have set these in their shell
326                for field in &anthropic_env_fields {
327                    if std::env::var(field).is_ok() {
328                        conflicts.push(format!("system env: {}", field));
329                    }
330                }
331
332                // If conflicts found, report error and exit
333                if !conflicts.is_empty() {
334                    eprintln!("❌ Conflict detected in config mode:");
335                    eprintln!("   Found existing Anthropic configuration in system environment:");
336                    for conflict in &conflicts {
337                        eprintln!("   - {}", conflict);
338                    }
339                    eprintln!();
340                    eprintln!(
341                        "   Config mode cannot work when Anthropic environment variables are set in system env."
342                    );
343                    eprintln!("   Please:");
344                    eprintln!("   1. Unset system environment variables, or");
345                    eprintln!("   2. Use 'env' mode instead");
346                    return Err(anyhow::anyhow!(
347                        "Config mode conflict: Anthropic environment variables exist in system env"
348                    ));
349                }
350
351                // Apply the new configuration to env field (overwrite existing values)
352                self.switch_to_config(config);
353
354                // Add the additional fields that switch_to_config doesn't handle
355                if let Some(max_thinking_tokens) = config.max_thinking_tokens {
356                    self.env.insert(
357                        "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
358                        max_thinking_tokens.to_string(),
359                    );
360                }
361
362                if let Some(timeout) = config.api_timeout_ms {
363                    self.env
364                        .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
365                }
366
367                if let Some(flag) = config.claude_code_disable_nonessential_traffic {
368                    self.env.insert(
369                        "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
370                        flag.to_string(),
371                    );
372                }
373
374                if let Some(model) = &config.anthropic_default_sonnet_model
375                    && !model.is_empty()
376                {
377                    self.env
378                        .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
379                }
380
381                if let Some(model) = &config.anthropic_default_opus_model
382                    && !model.is_empty()
383                {
384                    self.env
385                        .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
386                }
387
388                if let Some(model) = &config.anthropic_default_haiku_model
389                    && !model.is_empty()
390                {
391                    self.env
392                        .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
393                }
394
395                self.save(custom_dir)?;
396            }
397        }
398
399        Ok(())
400    }
401
402    /// Write the current active alias name to a file
403    ///
404    /// Stores the alias name in `~/.cc-switch/current_alias` so that
405    /// external tools (like statusLine scripts) can read the current configuration.
406    ///
407    /// # Arguments
408    /// * `alias` - The alias name to write
409    ///
410    /// # Errors
411    /// Returns error if the file cannot be written
412    pub fn write_current_alias(alias: &str) -> Result<()> {
413        let path = Self::get_current_alias_path()?;
414
415        // Create directory if it doesn't exist
416        if let Some(parent) = path.parent() {
417            fs::create_dir_all(parent)
418                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
419        }
420
421        fs::write(&path, alias)
422            .with_context(|| format!("Failed to write current alias to {}", path.display()))?;
423
424        Ok(())
425    }
426
427    /// Read the current active alias name from file
428    ///
429    /// Returns `None` if the file doesn't exist or is empty.
430    pub fn read_current_alias() -> Option<String> {
431        let path = Self::get_current_alias_path().ok()?;
432        let content = fs::read_to_string(&path).ok()?;
433        let trimmed = content.trim();
434        if trimmed.is_empty() {
435            None
436        } else {
437            Some(trimmed.to_string())
438        }
439    }
440
441    /// Clear the current active alias file
442    ///
443    /// Removes the file if it exists. Does not error if file doesn't exist.
444    pub fn clear_current_alias() -> Result<()> {
445        let path = Self::get_current_alias_path()?;
446        if path.exists() {
447            fs::remove_file(&path)
448                .with_context(|| format!("Failed to remove {}", path.display()))?;
449        }
450        Ok(())
451    }
452
453    /// Get the path to the current alias file
454    ///
455    /// Returns `~/.claude/cc_auto_switch_current_alias`
456    fn get_current_alias_path() -> Result<std::path::PathBuf> {
457        let config_file = crate::config::get_config_storage_path()?;
458        let config_dir = config_file
459            .parent()
460            .context("Could not get config directory")?;
461        Ok(config_dir.join("cc_auto_switch_current_alias"))
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn test_strip_trailing_commas_simple() {
471        let input = r#"{"a": 1,}"#;
472        let expected = r#"{"a": 1}"#;
473        assert_eq!(strip_trailing_commas(input), expected);
474    }
475
476    #[test]
477    fn test_strip_trailing_commas_nested_object() {
478        let input = r#"{"env": {"KEY": "value",},}"#;
479        let expected = r#"{"env": {"KEY": "value"}}"#;
480        assert_eq!(strip_trailing_commas(input), expected);
481    }
482
483    #[test]
484    fn test_strip_trailing_commas_array() {
485        let input = r#"{"items": [1, 2, 3,],}"#;
486        let expected = r#"{"items": [1, 2, 3]}"#;
487        assert_eq!(strip_trailing_commas(input), expected);
488    }
489
490    #[test]
491    fn test_strip_trailing_commas_multiline() {
492        let input = r#"{
493  "env": {
494    "KEY": "value",
495  },
496}"#;
497        let expected = r#"{
498  "env": {
499    "KEY": "value"
500  }
501}"#;
502        assert_eq!(strip_trailing_commas(input), expected);
503    }
504
505    #[test]
506    fn test_strip_trailing_commas_no_trailing() {
507        let input = r#"{"a": 1, "b": 2}"#;
508        assert_eq!(strip_trailing_commas(input), input);
509    }
510
511    #[test]
512    fn test_strip_trailing_commas_complex() {
513        let input = r#"{
514  "env": {
515    "ANTHROPIC_AUTH_TOKEN": "token",
516    "ANTHROPIC_BASE_URL": "https://api.example.com",
517  },
518  "model": "claude-3-opus",
519}"#;
520        let expected = r#"{
521  "env": {
522    "ANTHROPIC_AUTH_TOKEN": "token",
523    "ANTHROPIC_BASE_URL": "https://api.example.com"
524  },
525  "model": "claude-3-opus"
526}"#;
527        assert_eq!(strip_trailing_commas(input), expected);
528    }
529
530    #[test]
531    fn test_strip_trailing_commas_preserves_inner_commas() {
532        let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
533        let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
534        assert_eq!(strip_trailing_commas(input), expected);
535    }
536}