cc_switch/cli/
main.rs

1use crate::cli::completion::{generate_aliases, generate_completion, list_aliases_for_completion};
2use crate::cli::{Cli, Commands};
3use crate::config::types::AddCommandParams;
4use crate::config::{ConfigStorage, Configuration, EnvironmentConfig, validate_alias_name};
5use crate::interactive::{handle_interactive_selection, read_input, read_sensitive_input};
6use anyhow::{Result, anyhow};
7use clap::Parser;
8use colored::*;
9use std::fs;
10use std::path::Path;
11use std::process::Command;
12use std::thread;
13use std::time::Duration;
14
15/// Parse a configuration from a JSON file
16///
17/// # Arguments
18/// * `file_path` - Path to the JSON configuration file
19///
20/// # Returns
21/// Result containing a tuple of (alias_name, token, url, model, small_fast_model, max_thinking_tokens, api_timeout_ms, claude_code_disable_nonessential_traffic, anthropic_default_sonnet_model, anthropic_default_opus_model, anthropic_default_haiku_model)
22///
23/// # Errors
24/// Returns error if file cannot be read or parsed
25#[allow(clippy::type_complexity)]
26fn parse_config_from_file(
27    file_path: &str,
28) -> Result<(
29    String,
30    String,
31    String,
32    Option<String>,
33    Option<String>,
34    Option<u32>,
35    Option<u32>,
36    Option<u32>,
37    Option<String>,
38    Option<String>,
39    Option<String>,
40)> {
41    // Read the file
42    let file_content = fs::read_to_string(file_path)
43        .map_err(|e| anyhow!("Failed to read file '{}': {}", file_path, e))?;
44
45    // Parse JSON
46    let json: serde_json::Value = serde_json::from_str(&file_content)
47        .map_err(|e| anyhow!("Failed to parse JSON from file '{}': {}", file_path, e))?;
48
49    // Extract env section
50    let env = json.get("env").and_then(|v| v.as_object()).ok_or_else(|| {
51        anyhow!(
52            "File '{}' does not contain a valid 'env' section",
53            file_path
54        )
55    })?;
56
57    // Extract alias name from filename (without extension)
58    let path = Path::new(file_path);
59    let alias_name = path
60        .file_stem()
61        .and_then(|s| s.to_str())
62        .ok_or_else(|| anyhow!("Invalid file path: {}", file_path))?
63        .to_string();
64
65    // Extract and map environment variables
66    let token = env
67        .get("ANTHROPIC_AUTH_TOKEN")
68        .and_then(|v| v.as_str())
69        .ok_or_else(|| anyhow!("Missing ANTHROPIC_AUTH_TOKEN in file '{}'", file_path))?
70        .to_string();
71
72    let url = env
73        .get("ANTHROPIC_BASE_URL")
74        .and_then(|v| v.as_str())
75        .ok_or_else(|| anyhow!("Missing ANTHROPIC_BASE_URL in file '{}'", file_path))?
76        .to_string();
77
78    let model = env
79        .get("ANTHROPIC_MODEL")
80        .and_then(|v| v.as_str())
81        .map(|s| s.to_string());
82
83    let small_fast_model = env
84        .get("ANTHROPIC_SMALL_FAST_MODEL")
85        .and_then(|v| v.as_str())
86        .map(|s| s.to_string());
87
88    let max_thinking_tokens = env
89        .get("ANTHROPIC_MAX_THINKING_TOKENS")
90        .and_then(|v| v.as_u64())
91        .map(|u| u as u32);
92
93    let api_timeout_ms = env
94        .get("API_TIMEOUT_MS")
95        .and_then(|v| v.as_u64())
96        .map(|u| u as u32);
97
98    let claude_code_disable_nonessential_traffic = env
99        .get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC")
100        .and_then(|v| v.as_u64())
101        .map(|u| u as u32);
102
103    let anthropic_default_sonnet_model = env
104        .get("ANTHROPIC_DEFAULT_SONNET_MODEL")
105        .and_then(|v| v.as_str())
106        .map(|s| s.to_string());
107
108    let anthropic_default_opus_model = env
109        .get("ANTHROPIC_DEFAULT_OPUS_MODEL")
110        .and_then(|v| v.as_str())
111        .map(|s| s.to_string());
112
113    let anthropic_default_haiku_model = env
114        .get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
115        .and_then(|v| v.as_str())
116        .map(|s| s.to_string());
117
118    Ok((
119        alias_name,
120        token,
121        url,
122        model,
123        small_fast_model,
124        max_thinking_tokens,
125        api_timeout_ms,
126        claude_code_disable_nonessential_traffic,
127        anthropic_default_sonnet_model,
128        anthropic_default_opus_model,
129        anthropic_default_haiku_model,
130    ))
131}
132
133/// Handle adding a configuration with all the new features
134///
135/// # Arguments
136/// * `params` - Parameters for the add command
137/// * `storage` - Mutable reference to config storage
138///
139/// # Errors
140/// Returns error if validation fails or user cancels interactive input
141fn handle_add_command(mut params: AddCommandParams, storage: &mut ConfigStorage) -> Result<()> {
142    // If from-file is provided, parse the file and use those values
143    if let Some(file_path) = &params.from_file {
144        println!("Importing configuration from file: {}", file_path);
145
146        let (
147            file_alias_name,
148            file_token,
149            file_url,
150            file_model,
151            file_small_fast_model,
152            file_max_thinking_tokens,
153            file_api_timeout_ms,
154            file_claude_disable_nonessential_traffic,
155            file_sonnet_model,
156            file_opus_model,
157            file_haiku_model,
158        ) = parse_config_from_file(file_path)?;
159
160        // Use the file's alias name (ignoring the one provided via command line)
161        params.alias_name = file_alias_name;
162
163        // Override params with file values
164        params.token = Some(file_token);
165        params.url = Some(file_url);
166        params.model = file_model;
167        params.small_fast_model = file_small_fast_model;
168        params.max_thinking_tokens = file_max_thinking_tokens;
169        params.api_timeout_ms = file_api_timeout_ms;
170        params.claude_code_disable_nonessential_traffic = file_claude_disable_nonessential_traffic;
171        params.anthropic_default_sonnet_model = file_sonnet_model;
172        params.anthropic_default_opus_model = file_opus_model;
173        params.anthropic_default_haiku_model = file_haiku_model;
174
175        println!(
176            "Configuration '{}' will be imported from file",
177            params.alias_name
178        );
179    }
180
181    // Validate alias name
182    validate_alias_name(&params.alias_name)?;
183
184    // Check if alias already exists
185    if storage.get_configuration(&params.alias_name).is_some() && !params.force {
186        eprintln!("Configuration '{}' already exists.", params.alias_name);
187        eprintln!("Use --force to overwrite or choose a different alias name.");
188        return Ok(());
189    }
190
191    // Cannot use interactive mode with --from-file
192    if params.interactive && params.from_file.is_some() {
193        anyhow::bail!("Cannot use --interactive mode with --from-file");
194    }
195
196    // Determine token value
197    let final_token = if params.interactive {
198        if params.token.is_some() || params.token_arg.is_some() {
199            eprintln!(
200                "Warning: Token provided via flags/arguments will be ignored in interactive mode"
201            );
202        }
203        read_sensitive_input("Enter API token (sk-ant-xxx): ")?
204    } else {
205        match (&params.token, &params.token_arg) {
206            (Some(t), _) => t.clone(),
207            (None, Some(t)) => t.clone(),
208            (None, None) => {
209                anyhow::bail!(
210                    "Token is required. Use -t flag, provide as argument, or use interactive mode with -i"
211                );
212            }
213        }
214    };
215
216    // Determine URL value
217    let final_url = if params.interactive {
218        if params.url.is_some() || params.url_arg.is_some() {
219            eprintln!(
220                "Warning: URL provided via flags/arguments will be ignored in interactive mode"
221            );
222        }
223        read_input("Enter API URL (default: https://api.anthropic.com): ")?
224    } else {
225        match (&params.url, &params.url_arg) {
226            (Some(u), _) => u.clone(),
227            (None, Some(u)) => u.clone(),
228            (None, None) => "https://api.anthropic.com".to_string(),
229        }
230    };
231
232    // Use default URL if empty
233    let final_url = if final_url.is_empty() {
234        "https://api.anthropic.com".to_string()
235    } else {
236        final_url
237    };
238
239    // Determine model value
240    let final_model = if params.interactive {
241        if params.model.is_some() {
242            eprintln!("Warning: Model provided via flags will be ignored in interactive mode");
243        }
244        let model_input = read_input("Enter model name (optional, press enter to skip): ")?;
245        if model_input.is_empty() {
246            None
247        } else {
248            Some(model_input)
249        }
250    } else {
251        params.model
252    };
253
254    // Determine small fast model value
255    let final_small_fast_model = if params.interactive {
256        if params.small_fast_model.is_some() {
257            eprintln!(
258                "Warning: Small fast model provided via flags will be ignored in interactive mode"
259            );
260        }
261        let small_model_input =
262            read_input("Enter small fast model name (optional, press enter to skip): ")?;
263        if small_model_input.is_empty() {
264            None
265        } else {
266            Some(small_model_input)
267        }
268    } else {
269        params.small_fast_model
270    };
271
272    // Determine max thinking tokens value
273    let final_max_thinking_tokens = if params.interactive {
274        if params.max_thinking_tokens.is_some() {
275            eprintln!(
276                "Warning: Max thinking tokens provided via flags will be ignored in interactive mode"
277            );
278        }
279        let tokens_input = read_input(
280            "Enter maximum thinking tokens (optional, press enter to skip, enter 0 to clear): ",
281        )?;
282        if tokens_input.is_empty() {
283            None
284        } else if let Ok(tokens) = tokens_input.parse::<u32>() {
285            if tokens == 0 { None } else { Some(tokens) }
286        } else {
287            eprintln!("Warning: Invalid max thinking tokens value, skipping");
288            None
289        }
290    } else {
291        params.max_thinking_tokens
292    };
293
294    // Determine API timeout value
295    let final_api_timeout_ms = if params.interactive {
296        if params.api_timeout_ms.is_some() {
297            eprintln!(
298                "Warning: API timeout provided via flags will be ignored in interactive mode"
299            );
300        }
301        let timeout_input = read_input(
302            "Enter API timeout in milliseconds (optional, press enter to skip, enter 0 to clear): ",
303        )?;
304        if timeout_input.is_empty() {
305            None
306        } else if let Ok(timeout) = timeout_input.parse::<u32>() {
307            if timeout == 0 { None } else { Some(timeout) }
308        } else {
309            eprintln!("Warning: Invalid API timeout value, skipping");
310            None
311        }
312    } else {
313        params.api_timeout_ms
314    };
315
316    // Determine disable nonessential traffic flag value
317    let final_claude_code_disable_nonessential_traffic = if params.interactive {
318        if params.claude_code_disable_nonessential_traffic.is_some() {
319            eprintln!(
320                "Warning: Disable nonessential traffic flag provided via flags will be ignored in interactive mode"
321            );
322        }
323        let flag_input = read_input(
324            "Enter disable nonessential traffic flag (optional, press enter to skip, enter 0 to clear): ",
325        )?;
326        if flag_input.is_empty() {
327            None
328        } else if let Ok(flag) = flag_input.parse::<u32>() {
329            if flag == 0 { None } else { Some(flag) }
330        } else {
331            eprintln!("Warning: Invalid disable nonessential traffic flag value, skipping");
332            None
333        }
334    } else {
335        params.claude_code_disable_nonessential_traffic
336    };
337
338    // Determine default Sonnet model value
339    let final_anthropic_default_sonnet_model = if params.interactive {
340        if params.anthropic_default_sonnet_model.is_some() {
341            eprintln!(
342                "Warning: Default Sonnet model provided via flags will be ignored in interactive mode"
343            );
344        }
345        let model_input =
346            read_input("Enter default Sonnet model name (optional, press enter to skip): ")?;
347        if model_input.is_empty() {
348            None
349        } else {
350            Some(model_input)
351        }
352    } else {
353        params.anthropic_default_sonnet_model
354    };
355
356    // Determine default Opus model value
357    let final_anthropic_default_opus_model = if params.interactive {
358        if params.anthropic_default_opus_model.is_some() {
359            eprintln!(
360                "Warning: Default Opus model provided via flags will be ignored in interactive mode"
361            );
362        }
363        let model_input =
364            read_input("Enter default Opus model name (optional, press enter to skip): ")?;
365        if model_input.is_empty() {
366            None
367        } else {
368            Some(model_input)
369        }
370    } else {
371        params.anthropic_default_opus_model
372    };
373
374    // Determine default Haiku model value
375    let final_anthropic_default_haiku_model = if params.interactive {
376        if params.anthropic_default_haiku_model.is_some() {
377            eprintln!(
378                "Warning: Default Haiku model provided via flags will be ignored in interactive mode"
379            );
380        }
381        let model_input =
382            read_input("Enter default Haiku model name (optional, press enter to skip): ")?;
383        if model_input.is_empty() {
384            None
385        } else {
386            Some(model_input)
387        }
388    } else {
389        params.anthropic_default_haiku_model
390    };
391
392    // Validate token format with flexible API provider support
393    let is_anthropic_official = final_url.contains("api.anthropic.com");
394    if is_anthropic_official {
395        if !final_token.starts_with("sk-ant-api03-") {
396            eprintln!(
397                "Warning: For official Anthropic API (api.anthropic.com), token should start with 'sk-ant-api03-'"
398            );
399        }
400    } else {
401        // For non-official APIs, provide general guidance
402        if final_token.starts_with("sk-ant-api03-") {
403            eprintln!("Warning: Using official Claude token format with non-official API endpoint");
404        }
405        // Don't validate format for third-party APIs as they may use different formats
406    }
407
408    // Create and add configuration
409    let config = Configuration {
410        alias_name: params.alias_name.clone(),
411        token: final_token,
412        url: final_url,
413        model: final_model,
414        small_fast_model: final_small_fast_model,
415        max_thinking_tokens: final_max_thinking_tokens,
416        api_timeout_ms: final_api_timeout_ms,
417        claude_code_disable_nonessential_traffic: final_claude_code_disable_nonessential_traffic,
418        anthropic_default_sonnet_model: final_anthropic_default_sonnet_model,
419        anthropic_default_opus_model: final_anthropic_default_opus_model,
420        anthropic_default_haiku_model: final_anthropic_default_haiku_model,
421    };
422
423    storage.add_configuration(config);
424    storage.save()?;
425
426    println!("Configuration '{}' added successfully", params.alias_name);
427    if params.force {
428        println!("(Overwrote existing configuration)");
429    }
430
431    Ok(())
432}
433
434/// Handle configuration switching command
435///
436/// Processes the switch subcommand to switch Claude API configuration:
437/// - None: Enter interactive selection mode  
438/// - "cc": Launch Claude without custom environment variables (default behavior)
439/// - Other alias: Launch Claude with the specified configuration's environment variables
440///
441/// After switching, displays the current URL and automatically launches Claude CLI
442///
443/// # Arguments
444/// * `alias_name` - Optional name of configuration to switch to, or "cc" for default
445///
446/// # Errors
447/// Returns error if configuration is not found or Claude CLI fails to launch
448pub fn handle_switch_command(alias_name: Option<&str>) -> Result<()> {
449    let storage = ConfigStorage::load()?;
450
451    // If no alias provided, enter interactive mode
452    if alias_name.is_none() {
453        return handle_interactive_selection(&storage);
454    }
455
456    let alias_name = alias_name.unwrap();
457
458    let env_config = if alias_name == "cc" {
459        // Default operation: launch Claude without custom environment variables
460        println!("Using default Claude configuration (no custom API settings)");
461        println!("Current URL: Default (api.anthropic.com)");
462        EnvironmentConfig::empty()
463    } else if let Some(config) = storage.get_configuration(alias_name) {
464        let mut config_info = format!("token: {}, url: {}", config.token, config.url);
465        if let Some(model) = &config.model {
466            config_info.push_str(&format!(", model: {model}"));
467        }
468        if let Some(small_fast_model) = &config.small_fast_model {
469            config_info.push_str(&format!(", small_fast_model: {small_fast_model}"));
470        }
471        if let Some(max_thinking_tokens) = config.max_thinking_tokens {
472            config_info.push_str(&format!(", max_thinking_tokens: {max_thinking_tokens}"));
473        }
474        if let Some(api_timeout_ms) = config.api_timeout_ms {
475            config_info.push_str(&format!(", api_timeout_ms: {api_timeout_ms}"));
476        }
477        if let Some(claude_code_disable_nonessential_traffic) =
478            config.claude_code_disable_nonessential_traffic
479        {
480            config_info.push_str(&format!(
481                ", disable_nonessential_traffic: {claude_code_disable_nonessential_traffic}"
482            ));
483        }
484        if let Some(sonnet_model) = &config.anthropic_default_sonnet_model {
485            config_info.push_str(&format!(", default_sonnet_model: {sonnet_model}"));
486        }
487        if let Some(opus_model) = &config.anthropic_default_opus_model {
488            config_info.push_str(&format!(", default_opus_model: {opus_model}"));
489        }
490        if let Some(haiku_model) = &config.anthropic_default_haiku_model {
491            config_info.push_str(&format!(", default_haiku_model: {haiku_model}"));
492        }
493        println!("Switched to configuration '{alias_name}' ({config_info})");
494        println!("Current URL: {}", config.url);
495        EnvironmentConfig::from_config(config)
496    } else {
497        anyhow::bail!(
498            "Configuration '{}' not found. Use 'list' command to see available configurations.",
499            alias_name
500        );
501    };
502
503    // Wait 0.2 second
504    println!("Waiting 0.2 second before launching Claude...");
505    println!(
506        "Executing: claude {}",
507        "--dangerously-skip-permissions".red()
508    );
509    thread::sleep(Duration::from_millis(200));
510
511    // Set environment variables for current process
512    for (key, value) in env_config.as_env_tuples() {
513        unsafe {
514            std::env::set_var(&key, &value);
515        }
516    }
517
518    // Launch Claude CLI with exec to replace current process
519    println!("Launching Claude CLI...");
520
521    // On Unix systems, use exec to replace current process
522    #[cfg(unix)]
523    {
524        use std::os::unix::process::CommandExt;
525        let error = Command::new("claude")
526            .arg("--dangerously-skip-permissions")
527            .exec();
528        // exec never returns on success, so if we get here, it failed
529        anyhow::bail!("Failed to exec claude: {}", error);
530    }
531
532    // On non-Unix systems, fallback to spawn and wait
533    #[cfg(not(unix))]
534    {
535        use std::process::Stdio;
536        let mut child = Command::new("claude")
537            .arg("--dangerously-skip-permissions")
538            .stdin(Stdio::inherit())
539            .stdout(Stdio::inherit())
540            .stderr(Stdio::inherit())
541            .spawn()
542            .context(
543                "Failed to launch Claude CLI. Make sure 'claude' command is available in PATH",
544            )?;
545
546        // Wait for the Claude process to finish and pass control to it
547        let status = child
548            .wait()
549            .context("Failed to wait for Claude CLI process")?;
550
551        if !status.success() {
552            anyhow::bail!("Claude CLI exited with error status: {}", status);
553        }
554    }
555}
556
557/// Main entry point for the CLI application
558///
559/// Parses command-line arguments and executes the appropriate action:
560/// - Switch configuration with `-c` flag
561/// - Execute subcommands (add, remove, list)
562/// - Show help if no arguments provided
563///
564/// # Errors
565/// Returns error if any operation fails (file I/O, parsing, etc.)
566pub fn run() -> Result<()> {
567    let cli = Cli::parse();
568
569    // Handle --migrate flag: migrate old path to new path and exit
570    if cli.migrate {
571        ConfigStorage::migrate_from_old_path()?;
572        return Ok(());
573    }
574
575    // Handle --list-aliases flag for completion
576    if cli.list_aliases {
577        list_aliases_for_completion()?;
578        return Ok(());
579    }
580
581    // Handle subcommands
582    if let Some(command) = cli.command {
583        let mut storage = ConfigStorage::load()?;
584
585        match command {
586            Commands::Add {
587                alias_name,
588                token,
589                url,
590                model,
591                small_fast_model,
592                max_thinking_tokens,
593                api_timeout_ms,
594                claude_code_disable_nonessential_traffic,
595                anthropic_default_sonnet_model,
596                anthropic_default_opus_model,
597                anthropic_default_haiku_model,
598                force,
599                interactive,
600                token_arg,
601                url_arg,
602                from_file,
603            } => {
604                // When from_file is provided, alias_name will be extracted from the file
605                // For other cases, use the provided alias_name or provide a default
606                let final_alias_name = if from_file.is_some() {
607                    // Will be set from file parsing, use a placeholder for now
608                    "placeholder".to_string()
609                } else {
610                    alias_name.unwrap_or_else(|| {
611                        eprintln!("Error: alias_name is required when not using --from-file");
612                        std::process::exit(1);
613                    })
614                };
615
616                let params = AddCommandParams {
617                    alias_name: final_alias_name,
618                    token,
619                    url,
620                    model,
621                    small_fast_model,
622                    max_thinking_tokens,
623                    api_timeout_ms,
624                    claude_code_disable_nonessential_traffic,
625                    anthropic_default_sonnet_model,
626                    anthropic_default_opus_model,
627                    anthropic_default_haiku_model,
628                    force,
629                    interactive,
630                    token_arg,
631                    url_arg,
632                    from_file,
633                };
634                handle_add_command(params, &mut storage)?;
635            }
636            Commands::Remove { alias_names } => {
637                let mut removed_count = 0;
638                let mut not_found_aliases = Vec::new();
639
640                for alias_name in &alias_names {
641                    if storage.remove_configuration(alias_name) {
642                        removed_count += 1;
643                        println!("Configuration '{alias_name}' removed successfully");
644                    } else {
645                        not_found_aliases.push(alias_name.clone());
646                        println!("Configuration '{alias_name}' not found");
647                    }
648                }
649
650                if removed_count > 0 {
651                    storage.save()?;
652                }
653
654                if !not_found_aliases.is_empty() {
655                    eprintln!(
656                        "Warning: The following configurations were not found: {}",
657                        not_found_aliases.join(", ")
658                    );
659                }
660
661                if removed_count > 0 {
662                    println!("Successfully removed {removed_count} configuration(s)");
663                }
664            }
665            Commands::List { plain } => {
666                if plain {
667                    // Text output when -p flag is used
668                    if storage.configurations.is_empty() {
669                        println!("No configurations stored");
670                    } else {
671                        println!("Stored configurations:");
672                        for (alias_name, config) in &storage.configurations {
673                            let mut info = format!("token={}, url={}", config.token, config.url);
674                            if let Some(model) = &config.model {
675                                info.push_str(&format!(", model={model}"));
676                            }
677                            if let Some(small_fast_model) = &config.small_fast_model {
678                                info.push_str(&format!(", small_fast_model={small_fast_model}"));
679                            }
680                            if let Some(max_thinking_tokens) = config.max_thinking_tokens {
681                                info.push_str(&format!(
682                                    ", max_thinking_tokens={max_thinking_tokens}"
683                                ));
684                            }
685                            println!("  {alias_name}: {info}");
686                        }
687                    }
688                } else {
689                    // JSON output (default)
690                    println!(
691                        "{}",
692                        serde_json::to_string_pretty(&storage.configurations)
693                            .map_err(|e| anyhow!("Failed to serialize configurations: {}", e))?
694                    );
695                }
696            }
697            Commands::Completion { shell } => {
698                generate_completion(&shell)?;
699            }
700            Commands::Alias { shell } => {
701                generate_aliases(&shell)?;
702            }
703            Commands::Use { alias_name } => {
704                handle_switch_command(Some(&alias_name))?;
705            }
706            Commands::Version => {
707                println!("{}", env!("CARGO_PKG_VERSION"));
708            }
709        }
710    } else {
711        // No command provided, show interactive configuration selection
712        let storage = ConfigStorage::load()?;
713        handle_interactive_selection(&storage)?;
714    }
715
716    Ok(())
717}