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 active alias name to a file
404    ///
405    /// Stores the alias name in `~/.cc-switch/current_alias` so that
406    /// external tools (like statusLine scripts) can read the current configuration.
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(alias: &str) -> Result<()> {
414        let path = Self::get_current_alias_path()?;
415
416        // Create directory if it doesn't exist
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    /// Read the current active alias name from file
429    ///
430    /// Returns `None` if the file doesn't exist or is empty.
431    pub fn read_current_alias() -> Option<String> {
432        let path = Self::get_current_alias_path().ok()?;
433        let content = fs::read_to_string(&path).ok()?;
434        let trimmed = content.trim();
435        if trimmed.is_empty() {
436            None
437        } else {
438            Some(trimmed.to_string())
439        }
440    }
441
442    /// Clear the current active alias file
443    ///
444    /// Removes the file if it exists. Does not error if file doesn't exist.
445    pub fn clear_current_alias() -> Result<()> {
446        let path = Self::get_current_alias_path()?;
447        if path.exists() {
448            fs::remove_file(&path)
449                .with_context(|| format!("Failed to remove {}", path.display()))?;
450        }
451        Ok(())
452    }
453
454    /// Get the path to the current alias file
455    ///
456    /// Returns `~/.claude/cc_auto_switch_current_alias`
457    fn get_current_alias_path() -> Result<std::path::PathBuf> {
458        let config_file = crate::config::get_config_storage_path()?;
459        let config_dir = config_file
460            .parent()
461            .context("Could not get config directory")?;
462        Ok(config_dir.join("cc_auto_switch_current_alias"))
463    }
464
465    /// Write the current alias for a specific session (per-PID file)
466    ///
467    /// Creates `~/.claude/cc_auto_switch_alias_<PID>` for per-session isolation.
468    /// This allows multiple Claude sessions to display different aliases.
469    ///
470    /// # Arguments
471    /// * `alias` - The alias name to write
472    ///
473    /// # Errors
474    /// Returns error if the file cannot be written
475    pub fn write_current_alias_for_pid(alias: &str) -> Result<()> {
476        let pid = std::process::id();
477        let path = Self::get_current_alias_for_pid(pid)?;
478
479        if let Some(parent) = path.parent() {
480            fs::create_dir_all(parent)
481                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
482        }
483
484        fs::write(&path, alias)
485            .with_context(|| format!("Failed to write current alias to {}", path.display()))?;
486
487        Ok(())
488    }
489
490    /// Clear the per-PID alias file
491    ///
492    /// Removes `~/.claude/cc_auto_switch_alias_<PID>` if it exists.
493    pub fn clear_current_alias_for_pid() -> Result<()> {
494        let pid = std::process::id();
495        let path = Self::get_current_alias_for_pid(pid)?;
496        if path.exists() {
497            fs::remove_file(&path)
498                .with_context(|| format!("Failed to remove {}", path.display()))?;
499        }
500        Ok(())
501    }
502
503    /// Get the path to the per-PID alias file
504    fn get_current_alias_for_pid(pid: u32) -> Result<std::path::PathBuf> {
505        let config_file = crate::config::get_config_storage_path()?;
506        let config_dir = config_file
507            .parent()
508            .context("Could not get config directory")?;
509        Ok(config_dir.join(format!("{PER_PID_ALIAS_PREFIX}{pid}")))
510    }
511
512    /// Clean up orphaned per-PID alias files
513    ///
514    /// Scans the config directory for alias files whose corresponding processes
515    /// have terminated, and removes them. This prevents file accumulation on
516    /// Unix systems where exec() replaces the process and cannot clean up.
517    pub fn cleanup_orphan_alias_files() -> Result<()> {
518        let config_file = crate::config::get_config_storage_path()?;
519        let config_dir = config_file
520            .parent()
521            .context("Could not get config directory")?;
522
523        for entry in fs::read_dir(config_dir)? {
524            let entry = entry?;
525            let file_name = entry.file_name();
526            let file_name_str = file_name.to_string_lossy();
527
528            if let Some(pid_str) = file_name_str.strip_prefix(PER_PID_ALIAS_PREFIX)
529                && let Ok(pid) = pid_str.parse::<u32>()
530                && !Self::is_process_running(pid)
531            {
532                let _ = fs::remove_file(entry.path());
533            }
534        }
535
536        Ok(())
537    }
538
539    #[cfg(unix)]
540    fn is_process_running(pid: u32) -> bool {
541        unsafe { libc::kill(pid as i32, 0) == 0 }
542    }
543
544    #[cfg(not(unix))]
545    fn is_process_running(_pid: u32) -> bool {
546        false
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn test_strip_trailing_commas_simple() {
556        let input = r#"{"a": 1,}"#;
557        let expected = r#"{"a": 1}"#;
558        assert_eq!(strip_trailing_commas(input), expected);
559    }
560
561    #[test]
562    fn test_strip_trailing_commas_nested_object() {
563        let input = r#"{"env": {"KEY": "value",},}"#;
564        let expected = r#"{"env": {"KEY": "value"}}"#;
565        assert_eq!(strip_trailing_commas(input), expected);
566    }
567
568    #[test]
569    fn test_strip_trailing_commas_array() {
570        let input = r#"{"items": [1, 2, 3,],}"#;
571        let expected = r#"{"items": [1, 2, 3]}"#;
572        assert_eq!(strip_trailing_commas(input), expected);
573    }
574
575    #[test]
576    fn test_strip_trailing_commas_multiline() {
577        let input = r#"{
578  "env": {
579    "KEY": "value",
580  },
581}"#;
582        let expected = r#"{
583  "env": {
584    "KEY": "value"
585  }
586}"#;
587        assert_eq!(strip_trailing_commas(input), expected);
588    }
589
590    #[test]
591    fn test_strip_trailing_commas_no_trailing() {
592        let input = r#"{"a": 1, "b": 2}"#;
593        assert_eq!(strip_trailing_commas(input), input);
594    }
595
596    #[test]
597    fn test_strip_trailing_commas_complex() {
598        let input = r#"{
599  "env": {
600    "ANTHROPIC_AUTH_TOKEN": "token",
601    "ANTHROPIC_BASE_URL": "https://api.example.com",
602  },
603  "model": "claude-3-opus",
604}"#;
605        let expected = r#"{
606  "env": {
607    "ANTHROPIC_AUTH_TOKEN": "token",
608    "ANTHROPIC_BASE_URL": "https://api.example.com"
609  },
610  "model": "claude-3-opus"
611}"#;
612        assert_eq!(strip_trailing_commas(input), expected);
613    }
614
615    #[test]
616    fn test_strip_trailing_commas_preserves_inner_commas() {
617        let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
618        let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
619        assert_eq!(strip_trailing_commas(input), expected);
620    }
621
622    #[test]
623    fn test_per_pid_alias_write_and_clear() {
624        use std::process;
625
626        let pid = process::id();
627        let test_alias = "test-alias-123";
628
629        // Write per-PID alias
630        ClaudeSettings::write_current_alias_for_pid(test_alias).unwrap();
631
632        // Verify file exists and contains correct alias
633        let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
634        assert!(path.exists());
635        let content = fs::read_to_string(&path).unwrap();
636        assert_eq!(content, test_alias);
637
638        // Clear per-PID alias
639        ClaudeSettings::clear_current_alias_for_pid().unwrap();
640
641        // Verify file is removed
642        assert!(!path.exists());
643    }
644
645    #[test]
646    fn test_per_pid_alias_path_format() {
647        let pid = 12345u32;
648        let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
649        let filename = path.file_name().unwrap().to_str().unwrap();
650        assert_eq!(filename, "cc_auto_switch_alias_12345");
651    }
652}