Skip to main content

cc_switch/cli/
main.rs

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