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
8const PER_PID_ALIAS_PREFIX: &str = "cc_auto_switch_alias_";
9
10/// Remove trailing commas from JSON content to make it more lenient
11///
12/// Handles trailing commas before `}` and `]` characters, which are common
13/// in hand-edited JSON files but not valid in standard JSON.
14fn strip_trailing_commas(json: &str) -> String {
15    // Simple approach: remove commas that appear before closing braces/brackets
16    // This handles the most common case of trailing commas
17    let mut result = String::with_capacity(json.len());
18    let chars: Vec<char> = json.chars().collect();
19    let mut i = 0;
20
21    while i < chars.len() {
22        let c = chars[i];
23
24        // Check if this is a comma followed by optional whitespace and then } or ]
25        if c == ',' {
26            // Look ahead to see if the next non-whitespace char is } or ]
27            let mut j = i + 1;
28            while j < chars.len() && chars[j].is_whitespace() {
29                j += 1;
30            }
31
32            if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
33                // Skip this trailing comma
34                i += 1;
35                continue;
36            }
37        }
38
39        result.push(c);
40        i += 1;
41    }
42
43    result
44}
45
46impl ClaudeSettings {
47    /// Load Claude settings from disk
48    ///
49    /// Reads the JSON file from the configured Claude settings directory
50    /// Returns default empty settings if file doesn't exist
51    /// Creates the file with default structure if it doesn't exist
52    ///
53    /// # Arguments
54    /// * `custom_dir` - Optional custom directory for Claude settings
55    ///
56    /// # Errors
57    /// Returns error if file exists but cannot be read or parsed
58    pub fn load(custom_dir: Option<&str>) -> Result<Self> {
59        let path = get_claude_settings_path(custom_dir)?;
60
61        if !path.exists() {
62            // Create default settings file if it doesn't exist
63            let default_settings = ClaudeSettings::default();
64            default_settings.save(custom_dir)?;
65            return Ok(default_settings);
66        }
67
68        let content = fs::read_to_string(&path)
69            .with_context(|| format!("Failed to read Claude settings from {}", path.display()))?;
70
71        // Parse with better error handling for missing env field
72        let mut settings: ClaudeSettings = if content.trim().is_empty() {
73            ClaudeSettings::default()
74        } else {
75            // Strip trailing commas to handle lenient JSON
76            let cleaned_content = strip_trailing_commas(&content);
77
78            // Try to parse the cleaned content first
79            match serde_json::from_str(&cleaned_content) {
80                Ok(s) => s,
81                Err(e) => {
82                    // Provide helpful error message with the actual parse error
83                    let error_msg = format!(
84                        "Failed to parse Claude settings JSON at {}:\n  {}\n\n\
85                         This usually means the JSON file has invalid syntax.\n\
86                         Common issues:\n\
87                         - Trailing commas (e.g., {{\"key\": \"value\",}})\n\
88                         - Missing quotes around keys or values\n\
89                         - Unescaped special characters in strings\n\n\
90                         Please fix the JSON syntax in the file.",
91                        path.display(),
92                        e
93                    );
94                    return Err(anyhow::anyhow!("{}", error_msg));
95                }
96            }
97        };
98
99        // Ensure env field exists (handle case where it might be missing from JSON)
100        if settings.env.is_empty() && !content.contains("\"env\"") {
101            settings.env = BTreeMap::new();
102        }
103
104        Ok(settings)
105    }
106
107    /// Save Claude settings to disk
108    ///
109    /// Writes the current state to the configured Claude settings directory
110    /// Creates the directory structure if it doesn't exist
111    /// Ensures the env field is properly serialized
112    ///
113    /// # Arguments
114    /// * `custom_dir` - Optional custom directory for Claude settings
115    ///
116    /// # Errors
117    /// Returns error if directory cannot be created or file cannot be written
118    pub fn save(&self, custom_dir: Option<&str>) -> Result<()> {
119        let path = get_claude_settings_path(custom_dir)?;
120
121        // Create directory if it doesn't exist
122        if let Some(parent) = path.parent() {
123            fs::create_dir_all(parent)
124                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
125        }
126
127        // The custom Serialize implementation handles env field inclusion automatically
128        let settings_to_save = self;
129
130        let json = serde_json::to_string_pretty(&settings_to_save)
131            .with_context(|| "Failed to serialize Claude settings")?;
132
133        fs::write(&path, json).with_context(|| format!("Failed to write to {}", path.display()))?;
134
135        Ok(())
136    }
137
138    /// Switch to a specific API configuration
139    ///
140    /// Updates the environment variables with the provided configuration
141    /// Ensures env field exists before updating
142    ///
143    /// # Arguments
144    /// * `config` - Configuration containing token, URL, and optional model settings to apply
145    pub fn switch_to_config(&mut self, config: &Configuration) {
146        // Ensure env field exists
147        if self.env.is_empty() {
148            self.env = BTreeMap::new();
149        }
150
151        // Remove all Anthropic environment variables to ensure clean state
152        let env_fields = Configuration::get_env_field_names();
153        for field in &env_fields {
154            self.env.remove(*field);
155        }
156
157        // Set required environment variables
158        self.env
159            .insert("ANTHROPIC_AUTH_TOKEN".to_string(), config.token.clone());
160        self.env
161            .insert("ANTHROPIC_BASE_URL".to_string(), config.url.clone());
162
163        // Set model configurations only if provided (don't set empty values)
164        if let Some(model) = &config.model
165            && !model.is_empty()
166        {
167            self.env
168                .insert("ANTHROPIC_MODEL".to_string(), model.clone());
169        }
170
171        if let Some(small_fast_model) = &config.small_fast_model
172            && !small_fast_model.is_empty()
173        {
174            self.env.insert(
175                "ANTHROPIC_SMALL_FAST_MODEL".to_string(),
176                small_fast_model.clone(),
177            );
178        }
179
180        // Set additional configuration values that should not be removed
181        if let Some(max_thinking_tokens) = config.max_thinking_tokens {
182            self.env.insert(
183                "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
184                max_thinking_tokens.to_string(),
185            );
186        }
187
188        if let Some(timeout) = config.api_timeout_ms {
189            self.env
190                .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
191        }
192
193        if let Some(flag) = config.claude_code_disable_nonessential_traffic {
194            self.env.insert(
195                "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
196                flag.to_string(),
197            );
198        }
199
200        if let Some(model) = &config.anthropic_default_sonnet_model
201            && !model.is_empty()
202        {
203            self.env
204                .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
205        }
206
207        if let Some(model) = &config.anthropic_default_opus_model
208            && !model.is_empty()
209        {
210            self.env
211                .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
212        }
213
214        if let Some(model) = &config.anthropic_default_haiku_model
215            && !model.is_empty()
216        {
217            self.env
218                .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
219        }
220
221        if let Some(model) = &config.claude_code_subagent_model
222            && !model.is_empty()
223        {
224            self.env
225                .insert("CLAUDE_CODE_SUBAGENT_MODEL".to_string(), model.clone());
226        }
227
228        if let Some(flag) = config.claude_code_disable_nonstreaming_fallback {
229            self.env.insert(
230                "CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK".to_string(),
231                flag.to_string(),
232            );
233        }
234
235        if let Some(level) = &config.claude_code_effort_level
236            && !level.is_empty()
237        {
238            self.env
239                .insert("CLAUDE_CODE_EFFORT_LEVEL".to_string(), level.clone());
240        }
241    }
242
243    /// Remove Anthropic environment variables
244    ///
245    /// Clears all Anthropic-related environment variables from settings
246    /// Used to reset to default Claude behavior
247    pub fn remove_anthropic_env(&mut self) {
248        // Ensure env field exists
249        if self.env.is_empty() {
250            self.env = BTreeMap::new();
251        }
252
253        // Remove all environment variables that can be set by configurations
254        let env_fields = Configuration::get_env_field_names();
255        for field in &env_fields {
256            self.env.remove(*field);
257        }
258    }
259
260    /// Switch to a specific API configuration with specified storage mode
261    ///
262    /// Updates the settings.json file based on the storage mode:
263    /// - Env mode: Launch Claude with environment variables (cleans settings.json)
264    /// - Config mode: Write to env field in settings.json (settings file persistence)
265    ///
266    /// # Arguments
267    /// * `config` - Configuration containing token, URL, and optional model settings to apply
268    /// * `mode` - Storage mode to use (Env or Config)
269    /// * `custom_dir` - Optional custom directory for Claude settings
270    ///
271    /// # Errors
272    /// Returns error if settings cannot be saved
273    pub fn switch_to_config_with_mode(
274        &mut self,
275        config: &Configuration,
276        mode: StorageMode,
277        custom_dir: Option<&str>,
278    ) -> Result<()> {
279        match mode {
280            StorageMode::Env => {
281                // Env mode: Clean settings.json and use environment variables
282                // NOTE: This will affect existing Claude sessions
283
284                // Get environment variable names that should be cleared
285                // This excludes user preference fields like DISABLE_NONESSENTIAL_TRAFFIC
286                let clearable_env_fields = Configuration::get_clearable_env_field_names();
287
288                let mut removed_fields = Vec::new();
289
290                // Check and remove Anthropic variables from env field
291                for field in &clearable_env_fields {
292                    if self.env.remove(*field).is_some() {
293                        removed_fields.push(field.to_string());
294                    }
295                }
296
297                // If fields were removed, report what was cleaned and save
298                if !removed_fields.is_empty() {
299                    eprintln!("🧹 Cleaning settings.json for env mode:");
300                    eprintln!("   Removed configurable fields:");
301                    for field in &removed_fields {
302                        eprintln!("   - {}", field);
303                    }
304                    eprintln!();
305                    eprintln!(
306                        "   Settings.json cleaned. Environment variables will be used instead."
307                    );
308
309                    // Save the cleaned settings
310                    self.save(custom_dir)?;
311                }
312
313                // Env mode: Environment variables will be set directly when launching Claude
314            }
315            StorageMode::Config => {
316                // Config mode: Write Anthropic settings to env field with UPPERCASE names
317                // Check for conflicts with system environment variables (not settings.json)
318                // settings.json env field is managed by this tool, so it's expected to have values
319
320                // Get all environment variable names that can be set by configurations
321                let anthropic_env_fields = Configuration::get_env_field_names();
322
323                let mut conflicts = Vec::new();
324
325                // Check system environment variables for Anthropic variables
326                // This is the real conflict - user may have set these in their shell
327                for field in &anthropic_env_fields {
328                    if std::env::var(field).is_ok() {
329                        conflicts.push(format!("system env: {}", field));
330                    }
331                }
332
333                // If conflicts found, report error and exit
334                if !conflicts.is_empty() {
335                    eprintln!("❌ Conflict detected in config mode:");
336                    eprintln!("   Found existing Anthropic configuration in system environment:");
337                    for conflict in &conflicts {
338                        eprintln!("   - {}", conflict);
339                    }
340                    eprintln!();
341                    eprintln!(
342                        "   Config mode cannot work when Anthropic environment variables are set in system env."
343                    );
344                    eprintln!("   Please:");
345                    eprintln!("   1. Unset system environment variables, or");
346                    eprintln!("   2. Use 'env' mode instead");
347                    return Err(anyhow::anyhow!(
348                        "Config mode conflict: Anthropic environment variables exist in system env"
349                    ));
350                }
351
352                // Apply the new configuration to env field (overwrite existing values)
353                self.switch_to_config(config);
354
355                // Add the additional fields that switch_to_config doesn't handle
356                if let Some(max_thinking_tokens) = config.max_thinking_tokens {
357                    self.env.insert(
358                        "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
359                        max_thinking_tokens.to_string(),
360                    );
361                }
362
363                if let Some(timeout) = config.api_timeout_ms {
364                    self.env
365                        .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
366                }
367
368                if let Some(flag) = config.claude_code_disable_nonessential_traffic {
369                    self.env.insert(
370                        "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
371                        flag.to_string(),
372                    );
373                }
374
375                if let Some(model) = &config.anthropic_default_sonnet_model
376                    && !model.is_empty()
377                {
378                    self.env
379                        .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
380                }
381
382                if let Some(model) = &config.anthropic_default_opus_model
383                    && !model.is_empty()
384                {
385                    self.env
386                        .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
387                }
388
389                if let Some(model) = &config.anthropic_default_haiku_model
390                    && !model.is_empty()
391                {
392                    self.env
393                        .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
394                }
395
396                self.save(custom_dir)?;
397            }
398        }
399
400        Ok(())
401    }
402
403    /// Write the current alias for a specific session (per-PID file)
404    ///
405    /// Creates `~/.claude/cc_auto_switch_alias_<PID>` for per-session isolation.
406    /// This allows multiple Claude sessions to display different aliases.
407    ///
408    /// # Arguments
409    /// * `alias` - The alias name to write
410    ///
411    /// # Errors
412    /// Returns error if the file cannot be written
413    pub fn write_current_alias_for_pid(alias: &str) -> Result<()> {
414        let pid = std::process::id();
415        let path = Self::get_current_alias_for_pid(pid)?;
416
417        if let Some(parent) = path.parent() {
418            fs::create_dir_all(parent)
419                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
420        }
421
422        fs::write(&path, alias)
423            .with_context(|| format!("Failed to write current alias to {}", path.display()))?;
424
425        Ok(())
426    }
427
428    /// Clear the per-PID alias file
429    ///
430    /// Removes `~/.claude/cc_auto_switch_alias_<PID>` if it exists.
431    pub fn clear_current_alias_for_pid() -> Result<()> {
432        let pid = std::process::id();
433        let path = Self::get_current_alias_for_pid(pid)?;
434        if path.exists() {
435            fs::remove_file(&path)
436                .with_context(|| format!("Failed to remove {}", path.display()))?;
437        }
438        Ok(())
439    }
440
441    /// Get the path to the per-PID alias file
442    fn get_current_alias_for_pid(pid: u32) -> Result<std::path::PathBuf> {
443        let config_file = crate::config::get_config_storage_path()?;
444        let config_dir = config_file
445            .parent()
446            .context("Could not get config directory")?;
447        Ok(config_dir.join(format!("{PER_PID_ALIAS_PREFIX}{pid}")))
448    }
449
450    /// Clean up orphaned per-PID alias files
451    ///
452    /// Scans the config directory for alias files whose corresponding processes
453    /// have terminated, and removes them. Also removes the legacy global
454    /// `cc_auto_switch_current_alias` file (no longer used after v0.1.26).
455    pub fn cleanup_orphan_alias_files() -> Result<()> {
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
461        let legacy_global = config_dir.join("cc_auto_switch_current_alias");
462        if legacy_global.exists() {
463            let _ = fs::remove_file(&legacy_global);
464        }
465
466        for entry in fs::read_dir(config_dir)? {
467            let entry = entry?;
468            let file_name = entry.file_name();
469            let file_name_str = file_name.to_string_lossy();
470
471            if let Some(pid_str) = file_name_str.strip_prefix(PER_PID_ALIAS_PREFIX)
472                && let Ok(pid) = pid_str.parse::<u32>()
473                && !Self::is_process_running(pid)
474            {
475                let _ = fs::remove_file(entry.path());
476            }
477        }
478
479        Ok(())
480    }
481
482    #[cfg(unix)]
483    fn is_process_running(pid: u32) -> bool {
484        unsafe { libc::kill(pid as i32, 0) == 0 }
485    }
486
487    #[cfg(not(unix))]
488    fn is_process_running(_pid: u32) -> bool {
489        false
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn test_strip_trailing_commas_simple() {
499        let input = r#"{"a": 1,}"#;
500        let expected = r#"{"a": 1}"#;
501        assert_eq!(strip_trailing_commas(input), expected);
502    }
503
504    #[test]
505    fn test_strip_trailing_commas_nested_object() {
506        let input = r#"{"env": {"KEY": "value",},}"#;
507        let expected = r#"{"env": {"KEY": "value"}}"#;
508        assert_eq!(strip_trailing_commas(input), expected);
509    }
510
511    #[test]
512    fn test_strip_trailing_commas_array() {
513        let input = r#"{"items": [1, 2, 3,],}"#;
514        let expected = r#"{"items": [1, 2, 3]}"#;
515        assert_eq!(strip_trailing_commas(input), expected);
516    }
517
518    #[test]
519    fn test_strip_trailing_commas_multiline() {
520        let input = r#"{
521  "env": {
522    "KEY": "value",
523  },
524}"#;
525        let expected = r#"{
526  "env": {
527    "KEY": "value"
528  }
529}"#;
530        assert_eq!(strip_trailing_commas(input), expected);
531    }
532
533    #[test]
534    fn test_strip_trailing_commas_no_trailing() {
535        let input = r#"{"a": 1, "b": 2}"#;
536        assert_eq!(strip_trailing_commas(input), input);
537    }
538
539    #[test]
540    fn test_strip_trailing_commas_complex() {
541        let input = r#"{
542  "env": {
543    "ANTHROPIC_AUTH_TOKEN": "token",
544    "ANTHROPIC_BASE_URL": "https://api.example.com",
545  },
546  "model": "claude-3-opus",
547}"#;
548        let expected = r#"{
549  "env": {
550    "ANTHROPIC_AUTH_TOKEN": "token",
551    "ANTHROPIC_BASE_URL": "https://api.example.com"
552  },
553  "model": "claude-3-opus"
554}"#;
555        assert_eq!(strip_trailing_commas(input), expected);
556    }
557
558    #[test]
559    fn test_strip_trailing_commas_preserves_inner_commas() {
560        let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
561        let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
562        assert_eq!(strip_trailing_commas(input), expected);
563    }
564
565    #[test]
566    fn test_per_pid_alias_write_and_clear() {
567        use std::process;
568
569        let pid = process::id();
570        let test_alias = "test-alias-123";
571
572        // Write per-PID alias
573        ClaudeSettings::write_current_alias_for_pid(test_alias).unwrap();
574
575        // Verify file exists and contains correct alias
576        let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
577        assert!(path.exists());
578        let content = fs::read_to_string(&path).unwrap();
579        assert_eq!(content, test_alias);
580
581        // Clear per-PID alias
582        ClaudeSettings::clear_current_alias_for_pid().unwrap();
583
584        // Verify file is removed
585        assert!(!path.exists());
586    }
587
588    #[test]
589    fn test_per_pid_alias_path_format() {
590        let pid = 12345u32;
591        let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
592        let filename = path.file_name().unwrap().to_str().unwrap();
593        assert_eq!(filename, "cc_auto_switch_alias_12345");
594    }
595}