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        if let Some(flag) = config.disable_prompt_caching {
243            self.env
244                .insert("DISABLE_PROMPT_CACHING".to_string(), flag.to_string());
245        }
246
247        if let Some(flag) = config.claude_code_disable_experimental_betas {
248            self.env.insert(
249                "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS".to_string(),
250                flag.to_string(),
251            );
252        }
253
254        if let Some(flag) = config.disable_autoupdater {
255            self.env
256                .insert("DISABLE_AUTOUPDATER".to_string(), flag.to_string());
257        }
258    }
259
260    /// Remove Anthropic environment variables
261    ///
262    /// Clears all Anthropic-related environment variables from settings
263    /// Used to reset to default Claude behavior
264    pub fn remove_anthropic_env(&mut self) {
265        // Ensure env field exists
266        if self.env.is_empty() {
267            self.env = BTreeMap::new();
268        }
269
270        // Remove all environment variables that can be set by configurations
271        let env_fields = Configuration::get_env_field_names();
272        for field in &env_fields {
273            self.env.remove(*field);
274        }
275    }
276
277    /// Switch to a specific API configuration with specified storage mode
278    ///
279    /// Updates the settings.json file based on the storage mode:
280    /// - Env mode: Launch Claude with environment variables (cleans settings.json)
281    /// - Config mode: Write to env field in settings.json (settings file persistence)
282    ///
283    /// # Arguments
284    /// * `config` - Configuration containing token, URL, and optional model settings to apply
285    /// * `mode` - Storage mode to use (Env or Config)
286    /// * `custom_dir` - Optional custom directory for Claude settings
287    ///
288    /// # Errors
289    /// Returns error if settings cannot be saved
290    pub fn switch_to_config_with_mode(
291        &mut self,
292        config: &Configuration,
293        mode: StorageMode,
294        custom_dir: Option<&str>,
295    ) -> Result<()> {
296        match mode {
297            StorageMode::Env => {
298                // Env mode: Clean settings.json and use environment variables
299                // NOTE: This will affect existing Claude sessions
300
301                // Get environment variable names that should be cleared
302                // This excludes user preference fields like DISABLE_NONESSENTIAL_TRAFFIC
303                let clearable_env_fields = Configuration::get_clearable_env_field_names();
304
305                let mut removed_fields = Vec::new();
306
307                // Check and remove Anthropic variables from env field
308                for field in &clearable_env_fields {
309                    if self.env.remove(*field).is_some() {
310                        removed_fields.push(field.to_string());
311                    }
312                }
313
314                // If fields were removed, report what was cleaned and save
315                if !removed_fields.is_empty() {
316                    eprintln!("๐Ÿงน Cleaning settings.json for env mode:");
317                    eprintln!("   Removed configurable fields:");
318                    for field in &removed_fields {
319                        eprintln!("   - {}", field);
320                    }
321                    eprintln!();
322                    eprintln!(
323                        "   Settings.json cleaned. Environment variables will be used instead."
324                    );
325
326                    // Save the cleaned settings
327                    self.save(custom_dir)?;
328                }
329
330                // Env mode: Environment variables will be set directly when launching Claude
331            }
332            StorageMode::Config => {
333                // Config mode: Write Anthropic settings to env field with UPPERCASE names
334                // Check for conflicts with system environment variables (not settings.json)
335                // settings.json env field is managed by this tool, so it's expected to have values
336
337                // Get all environment variable names that can be set by configurations
338                let anthropic_env_fields = Configuration::get_env_field_names();
339
340                let mut conflicts = Vec::new();
341
342                // Check system environment variables for Anthropic variables
343                // This is the real conflict - user may have set these in their shell
344                for field in &anthropic_env_fields {
345                    if std::env::var(field).is_ok() {
346                        conflicts.push(format!("system env: {}", field));
347                    }
348                }
349
350                // If conflicts found, report error and exit
351                if !conflicts.is_empty() {
352                    eprintln!("โŒ Conflict detected in config mode:");
353                    eprintln!("   Found existing Anthropic configuration in system environment:");
354                    for conflict in &conflicts {
355                        eprintln!("   - {}", conflict);
356                    }
357                    eprintln!();
358                    eprintln!(
359                        "   Config mode cannot work when Anthropic environment variables are set in system env."
360                    );
361                    eprintln!("   Please:");
362                    eprintln!("   1. Unset system environment variables, or");
363                    eprintln!("   2. Use 'env' mode instead");
364                    return Err(anyhow::anyhow!(
365                        "Config mode conflict: Anthropic environment variables exist in system env"
366                    ));
367                }
368
369                // Apply the new configuration to env field (overwrite existing values)
370                self.switch_to_config(config);
371
372                // Add the additional fields that switch_to_config doesn't handle
373                if let Some(max_thinking_tokens) = config.max_thinking_tokens {
374                    self.env.insert(
375                        "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
376                        max_thinking_tokens.to_string(),
377                    );
378                }
379
380                if let Some(timeout) = config.api_timeout_ms {
381                    self.env
382                        .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
383                }
384
385                if let Some(flag) = config.claude_code_disable_nonessential_traffic {
386                    self.env.insert(
387                        "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
388                        flag.to_string(),
389                    );
390                }
391
392                if let Some(model) = &config.anthropic_default_sonnet_model
393                    && !model.is_empty()
394                {
395                    self.env
396                        .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
397                }
398
399                if let Some(model) = &config.anthropic_default_opus_model
400                    && !model.is_empty()
401                {
402                    self.env
403                        .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
404                }
405
406                if let Some(model) = &config.anthropic_default_haiku_model
407                    && !model.is_empty()
408                {
409                    self.env
410                        .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
411                }
412
413                self.save(custom_dir)?;
414            }
415        }
416
417        Ok(())
418    }
419
420    /// Write the current alias for a specific session (per-PID file)
421    ///
422    /// Creates `~/.claude/cc_auto_switch_alias_<PID>` for per-session isolation.
423    /// This allows multiple Claude sessions to display different aliases.
424    ///
425    /// # Arguments
426    /// * `alias` - The alias name to write
427    ///
428    /// # Errors
429    /// Returns error if the file cannot be written
430    pub fn write_current_alias_for_pid(alias: &str) -> Result<()> {
431        let pid = std::process::id();
432        let path = Self::get_current_alias_for_pid(pid)?;
433
434        if let Some(parent) = path.parent() {
435            fs::create_dir_all(parent)
436                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
437        }
438
439        fs::write(&path, alias)
440            .with_context(|| format!("Failed to write current alias to {}", path.display()))?;
441
442        Ok(())
443    }
444
445    /// Clear the per-PID alias file
446    ///
447    /// Removes `~/.claude/cc_auto_switch_alias_<PID>` if it exists.
448    pub fn clear_current_alias_for_pid() -> Result<()> {
449        let pid = std::process::id();
450        let path = Self::get_current_alias_for_pid(pid)?;
451        if path.exists() {
452            fs::remove_file(&path)
453                .with_context(|| format!("Failed to remove {}", path.display()))?;
454        }
455        Ok(())
456    }
457
458    /// Get the path to the per-PID alias file
459    fn get_current_alias_for_pid(pid: u32) -> Result<std::path::PathBuf> {
460        let config_file = crate::config::get_config_storage_path()?;
461        let config_dir = config_file
462            .parent()
463            .context("Could not get config directory")?;
464        let alias_dir = config_dir.join("cc_auto_tmp_pid");
465        // Best-effort ensure the directory exists; callers tolerate pre-existing files
466        let _ = fs::create_dir_all(&alias_dir);
467        Ok(alias_dir.join(format!("{PER_PID_ALIAS_PREFIX}{pid}")))
468    }
469
470    /// Clean up orphaned per-PID alias files
471    ///
472    /// Scans the config directory for alias files whose corresponding processes
473    /// have terminated, and removes them. Also removes the legacy global
474    /// `cc_auto_switch_current_alias` file (no longer used after v0.1.26).
475    pub fn cleanup_orphan_alias_files() -> Result<()> {
476        let config_file = crate::config::get_config_storage_path()?;
477        let config_dir = config_file
478            .parent()
479            .context("Could not get config directory")?;
480
481        // Remove legacy global file (no longer used after v0.1.26)
482        let legacy_global = config_dir.join("cc_auto_switch_current_alias");
483        if legacy_global.exists() {
484            let _ = fs::remove_file(&legacy_global);
485        }
486
487        // Remove legacy per-PID files in the old location (config_dir root)
488        if let Ok(entries) = fs::read_dir(config_dir) {
489            for entry in entries.flatten() {
490                let file_name_str = entry.file_name();
491                let file_name = file_name_str.to_string_lossy();
492                if let Some(pid_str) = file_name.strip_prefix(PER_PID_ALIAS_PREFIX)
493                    && let Ok(pid) = pid_str.parse::<u32>()
494                    && !Self::is_process_running(pid)
495                {
496                    let _ = fs::remove_file(entry.path());
497                }
498            }
499        }
500
501        // Scan the new cc_auto_tmp_pid directory for orphaned files
502        let alias_dir = config_dir.join("cc_auto_tmp_pid");
503        if alias_dir.exists() {
504            for entry in fs::read_dir(&alias_dir)? {
505                let entry = entry?;
506                let file_name = entry.file_name();
507                let file_name_str = file_name.to_string_lossy();
508
509                if let Some(pid_str) = file_name_str.strip_prefix(PER_PID_ALIAS_PREFIX)
510                    && let Ok(pid) = pid_str.parse::<u32>()
511                    && !Self::is_process_running(pid)
512                {
513                    let _ = fs::remove_file(entry.path());
514                }
515            }
516        }
517
518        Ok(())
519    }
520
521    #[cfg(unix)]
522    fn is_process_running(pid: u32) -> bool {
523        unsafe { libc::kill(pid as i32, 0) == 0 }
524    }
525
526    #[cfg(not(unix))]
527    fn is_process_running(_pid: u32) -> bool {
528        false
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    #[test]
537    fn test_strip_trailing_commas_simple() {
538        let input = r#"{"a": 1,}"#;
539        let expected = r#"{"a": 1}"#;
540        assert_eq!(strip_trailing_commas(input), expected);
541    }
542
543    #[test]
544    fn test_strip_trailing_commas_nested_object() {
545        let input = r#"{"env": {"KEY": "value",},}"#;
546        let expected = r#"{"env": {"KEY": "value"}}"#;
547        assert_eq!(strip_trailing_commas(input), expected);
548    }
549
550    #[test]
551    fn test_strip_trailing_commas_array() {
552        let input = r#"{"items": [1, 2, 3,],}"#;
553        let expected = r#"{"items": [1, 2, 3]}"#;
554        assert_eq!(strip_trailing_commas(input), expected);
555    }
556
557    #[test]
558    fn test_strip_trailing_commas_multiline() {
559        let input = r#"{
560  "env": {
561    "KEY": "value",
562  },
563}"#;
564        let expected = r#"{
565  "env": {
566    "KEY": "value"
567  }
568}"#;
569        assert_eq!(strip_trailing_commas(input), expected);
570    }
571
572    #[test]
573    fn test_strip_trailing_commas_no_trailing() {
574        let input = r#"{"a": 1, "b": 2}"#;
575        assert_eq!(strip_trailing_commas(input), input);
576    }
577
578    #[test]
579    fn test_strip_trailing_commas_complex() {
580        let input = r#"{
581  "env": {
582    "ANTHROPIC_AUTH_TOKEN": "token",
583    "ANTHROPIC_BASE_URL": "https://api.example.com",
584  },
585  "model": "claude-3-opus",
586}"#;
587        let expected = r#"{
588  "env": {
589    "ANTHROPIC_AUTH_TOKEN": "token",
590    "ANTHROPIC_BASE_URL": "https://api.example.com"
591  },
592  "model": "claude-3-opus"
593}"#;
594        assert_eq!(strip_trailing_commas(input), expected);
595    }
596
597    #[test]
598    fn test_strip_trailing_commas_preserves_inner_commas() {
599        let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
600        let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
601        assert_eq!(strip_trailing_commas(input), expected);
602    }
603
604    #[test]
605    fn test_per_pid_alias_write_and_clear() {
606        use std::process;
607
608        let pid = process::id();
609        let test_alias = "test-alias-123";
610
611        // Write per-PID alias
612        ClaudeSettings::write_current_alias_for_pid(test_alias).unwrap();
613
614        // Verify file exists and contains correct alias
615        let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
616        assert!(path.exists());
617        let content = fs::read_to_string(&path).unwrap();
618        assert_eq!(content, test_alias);
619
620        // Clear per-PID alias
621        ClaudeSettings::clear_current_alias_for_pid().unwrap();
622
623        // Verify file is removed
624        assert!(!path.exists());
625    }
626
627    #[test]
628    fn test_per_pid_alias_path_format() {
629        let pid = 12345u32;
630        let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
631        let filename = path.file_name().unwrap().to_str().unwrap();
632        assert_eq!(filename, "cc_auto_switch_alias_12345");
633    }
634}