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: Clean settings.json and use environment variables
280                // NOTE: This will affect existing Claude sessions
281
282                // Get environment variable names that should be cleared
283                // This excludes user preference fields like DISABLE_NONESSENTIAL_TRAFFIC
284                let clearable_env_fields = Configuration::get_clearable_env_field_names();
285
286                let mut removed_fields = Vec::new();
287
288                // Check and remove Anthropic variables from env field
289                for field in &clearable_env_fields {
290                    if self.env.remove(*field).is_some() {
291                        removed_fields.push(field.to_string());
292                    }
293                }
294
295                // If fields were removed, report what was cleaned and save
296                if !removed_fields.is_empty() {
297                    eprintln!("🧹 Cleaning settings.json for env mode:");
298                    eprintln!("   Removed configurable fields:");
299                    for field in &removed_fields {
300                        eprintln!("   - {}", field);
301                    }
302                    eprintln!();
303                    eprintln!(
304                        "   Settings.json cleaned. Environment variables will be used instead."
305                    );
306
307                    // Save the cleaned settings
308                    self.save(custom_dir)?;
309                }
310
311                // Env mode: Environment variables will be set directly when launching Claude
312            }
313            StorageMode::Config => {
314                // Config mode: Write Anthropic settings to env field with UPPERCASE names
315                // Check for conflicts with system environment variables (not settings.json)
316                // settings.json env field is managed by this tool, so it's expected to have values
317
318                // Get all environment variable names that can be set by configurations
319                let anthropic_env_fields = Configuration::get_env_field_names();
320
321                let mut conflicts = Vec::new();
322
323                // Check system environment variables for Anthropic variables
324                // This is the real conflict - user may have set these in their shell
325                for field in &anthropic_env_fields {
326                    if std::env::var(field).is_ok() {
327                        conflicts.push(format!("system env: {}", field));
328                    }
329                }
330
331                // If conflicts found, report error and exit
332                if !conflicts.is_empty() {
333                    eprintln!("❌ Conflict detected in config mode:");
334                    eprintln!("   Found existing Anthropic configuration in system environment:");
335                    for conflict in &conflicts {
336                        eprintln!("   - {}", conflict);
337                    }
338                    eprintln!();
339                    eprintln!(
340                        "   Config mode cannot work when Anthropic environment variables are set in system env."
341                    );
342                    eprintln!("   Please:");
343                    eprintln!("   1. Unset system environment variables, or");
344                    eprintln!("   2. Use 'env' mode instead");
345                    return Err(anyhow::anyhow!(
346                        "Config mode conflict: Anthropic environment variables exist in system env"
347                    ));
348                }
349
350                // Apply the new configuration to env field (overwrite existing values)
351                self.switch_to_config(config);
352
353                // Add the additional fields that switch_to_config doesn't handle
354                if let Some(max_thinking_tokens) = config.max_thinking_tokens {
355                    self.env.insert(
356                        "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
357                        max_thinking_tokens.to_string(),
358                    );
359                }
360
361                if let Some(timeout) = config.api_timeout_ms {
362                    self.env
363                        .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
364                }
365
366                if let Some(flag) = config.claude_code_disable_nonessential_traffic {
367                    self.env.insert(
368                        "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
369                        flag.to_string(),
370                    );
371                }
372
373                if let Some(model) = &config.anthropic_default_sonnet_model
374                    && !model.is_empty()
375                {
376                    self.env
377                        .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
378                }
379
380                if let Some(model) = &config.anthropic_default_opus_model
381                    && !model.is_empty()
382                {
383                    self.env
384                        .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
385                }
386
387                if let Some(model) = &config.anthropic_default_haiku_model
388                    && !model.is_empty()
389                {
390                    self.env
391                        .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
392                }
393
394                self.save(custom_dir)?;
395            }
396        }
397
398        Ok(())
399    }
400
401    /// Write the current active alias name to a file
402    ///
403    /// Stores the alias name in `~/.cc-switch/current_alias` so that
404    /// external tools (like statusLine scripts) can read the current configuration.
405    ///
406    /// # Arguments
407    /// * `alias` - The alias name to write
408    ///
409    /// # Errors
410    /// Returns error if the file cannot be written
411    pub fn write_current_alias(alias: &str) -> Result<()> {
412        let path = Self::get_current_alias_path()?;
413
414        // Create directory if it doesn't exist
415        if let Some(parent) = path.parent() {
416            fs::create_dir_all(parent)
417                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
418        }
419
420        fs::write(&path, alias)
421            .with_context(|| format!("Failed to write current alias to {}", path.display()))?;
422
423        Ok(())
424    }
425
426    /// Read the current active alias name from file
427    ///
428    /// Returns `None` if the file doesn't exist or is empty.
429    pub fn read_current_alias() -> Option<String> {
430        let path = Self::get_current_alias_path().ok()?;
431        let content = fs::read_to_string(&path).ok()?;
432        let trimmed = content.trim();
433        if trimmed.is_empty() {
434            None
435        } else {
436            Some(trimmed.to_string())
437        }
438    }
439
440    /// Clear the current active alias file
441    ///
442    /// Removes the file if it exists. Does not error if file doesn't exist.
443    pub fn clear_current_alias() -> Result<()> {
444        let path = Self::get_current_alias_path()?;
445        if path.exists() {
446            fs::remove_file(&path)
447                .with_context(|| format!("Failed to remove {}", path.display()))?;
448        }
449        Ok(())
450    }
451
452    /// Get the path to the current alias file
453    ///
454    /// Returns `~/.claude/cc_auto_switch_current_alias`
455    fn get_current_alias_path() -> Result<std::path::PathBuf> {
456        let config_file = crate::config::get_config_storage_path()?;
457        let config_dir = config_file
458            .parent()
459            .context("Could not get config directory")?;
460        Ok(config_dir.join("cc_auto_switch_current_alias"))
461    }
462
463    /// Write the current alias for a specific session (per-PID file)
464    ///
465    /// Creates `~/.claude/cc_auto_switch_alias_<PID>` for per-session isolation.
466    /// This allows multiple Claude sessions to display different aliases.
467    ///
468    /// # Arguments
469    /// * `alias` - The alias name to write
470    ///
471    /// # Errors
472    /// Returns error if the file cannot be written
473    pub fn write_current_alias_for_pid(alias: &str) -> Result<()> {
474        let pid = std::process::id();
475        let path = Self::get_current_alias_for_pid(pid)?;
476
477        if let Some(parent) = path.parent() {
478            fs::create_dir_all(parent)
479                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
480        }
481
482        fs::write(&path, alias)
483            .with_context(|| format!("Failed to write current alias to {}", path.display()))?;
484
485        Ok(())
486    }
487
488    /// Clear the per-PID alias file
489    ///
490    /// Removes `~/.claude/cc_auto_switch_alias_<PID>` if it exists.
491    pub fn clear_current_alias_for_pid() -> Result<()> {
492        let pid = std::process::id();
493        let path = Self::get_current_alias_for_pid(pid)?;
494        if path.exists() {
495            fs::remove_file(&path)
496                .with_context(|| format!("Failed to remove {}", path.display()))?;
497        }
498        Ok(())
499    }
500
501    /// Get the path to the per-PID alias file
502    fn get_current_alias_for_pid(pid: u32) -> Result<std::path::PathBuf> {
503        let config_file = crate::config::get_config_storage_path()?;
504        let config_dir = config_file
505            .parent()
506            .context("Could not get config directory")?;
507        Ok(config_dir.join(format!("cc_auto_switch_alias_{}", pid)))
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn test_strip_trailing_commas_simple() {
517        let input = r#"{"a": 1,}"#;
518        let expected = r#"{"a": 1}"#;
519        assert_eq!(strip_trailing_commas(input), expected);
520    }
521
522    #[test]
523    fn test_strip_trailing_commas_nested_object() {
524        let input = r#"{"env": {"KEY": "value",},}"#;
525        let expected = r#"{"env": {"KEY": "value"}}"#;
526        assert_eq!(strip_trailing_commas(input), expected);
527    }
528
529    #[test]
530    fn test_strip_trailing_commas_array() {
531        let input = r#"{"items": [1, 2, 3,],}"#;
532        let expected = r#"{"items": [1, 2, 3]}"#;
533        assert_eq!(strip_trailing_commas(input), expected);
534    }
535
536    #[test]
537    fn test_strip_trailing_commas_multiline() {
538        let input = r#"{
539  "env": {
540    "KEY": "value",
541  },
542}"#;
543        let expected = r#"{
544  "env": {
545    "KEY": "value"
546  }
547}"#;
548        assert_eq!(strip_trailing_commas(input), expected);
549    }
550
551    #[test]
552    fn test_strip_trailing_commas_no_trailing() {
553        let input = r#"{"a": 1, "b": 2}"#;
554        assert_eq!(strip_trailing_commas(input), input);
555    }
556
557    #[test]
558    fn test_strip_trailing_commas_complex() {
559        let input = r#"{
560  "env": {
561    "ANTHROPIC_AUTH_TOKEN": "token",
562    "ANTHROPIC_BASE_URL": "https://api.example.com",
563  },
564  "model": "claude-3-opus",
565}"#;
566        let expected = r#"{
567  "env": {
568    "ANTHROPIC_AUTH_TOKEN": "token",
569    "ANTHROPIC_BASE_URL": "https://api.example.com"
570  },
571  "model": "claude-3-opus"
572}"#;
573        assert_eq!(strip_trailing_commas(input), expected);
574    }
575
576    #[test]
577    fn test_strip_trailing_commas_preserves_inner_commas() {
578        let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
579        let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
580        assert_eq!(strip_trailing_commas(input), expected);
581    }
582
583    #[test]
584    fn test_per_pid_alias_write_and_clear() {
585        use std::process;
586
587        let pid = process::id();
588        let test_alias = "test-alias-123";
589
590        // Write per-PID alias
591        ClaudeSettings::write_current_alias_for_pid(test_alias).unwrap();
592
593        // Verify file exists and contains correct alias
594        let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
595        assert!(path.exists());
596        let content = fs::read_to_string(&path).unwrap();
597        assert_eq!(content, test_alias);
598
599        // Clear per-PID alias
600        ClaudeSettings::clear_current_alias_for_pid().unwrap();
601
602        // Verify file is removed
603        assert!(!path.exists());
604    }
605
606    #[test]
607    fn test_per_pid_alias_path_format() {
608        let pid = 12345u32;
609        let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
610        let filename = path.file_name().unwrap().to_str().unwrap();
611        assert_eq!(filename, "cc_auto_switch_alias_12345");
612    }
613}