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