Skip to main content

cc_switch_lib/cli/
mod.rs

1use clap::{CommandFactory, Parser, Subcommand};
2use clap_complete::Shell;
3use std::io::Write;
4
5mod claude_temp_launch;
6mod codex_temp_launch;
7pub mod commands;
8pub mod editor;
9pub mod i18n;
10pub mod interactive;
11pub mod terminal;
12pub mod tui;
13pub mod ui;
14
15use crate::app_config::AppType;
16
17#[derive(Parser)]
18#[command(
19    name = "cc-switch-tui",
20    disable_version_flag = true,
21    about = "All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode, OpenClaw & Hermes",
22    long_about = "Unified management for Claude Code, Codex, Gemini, OpenCode, OpenClaw, and Hermes provider configurations, MCP servers, skills, prompts, local proxy routes, and environment checks.\n\nRun without arguments to enter interactive mode."
23)]
24pub struct Cli {
25    /// Show version information
26    #[arg(short = 'V', long = "version", global = true)]
27    pub version: bool,
28
29    /// Specify the application type
30    #[arg(short, long, global = true, value_enum)]
31    pub app: Option<AppType>,
32
33    /// Enable verbose output
34    #[arg(short, long, global = true)]
35    pub verbose: bool,
36
37    #[command(subcommand)]
38    pub command: Option<Commands>,
39}
40
41#[derive(Subcommand)]
42pub enum Commands {
43    /// Manage providers (list, switch, export, speedtest, stream-check, fetch-models)
44    #[command(subcommand)]
45    Provider(commands::provider::ProviderCommand),
46
47    /// Manage MCP servers (list, add, edit, delete, sync)
48    #[command(subcommand)]
49    Mcp(commands::mcp::McpCommand),
50
51    /// Manage prompts (list, activate, create, rename, edit)
52    #[command(subcommand)]
53    Prompts(commands::prompts::PromptsCommand),
54
55    /// Manage skills and skill repositories
56    #[command(subcommand)]
57    Skills(commands::skills::SkillsCommand),
58
59    /// Manage configuration, backups, common snippets, and WebDAV sync
60    #[command(subcommand)]
61    Config(commands::config::ConfigCommand),
62
63    /// Manage local multi-app proxy
64    #[command(subcommand)]
65    Proxy(commands::proxy::ProxyCommand),
66
67    /// Start an app with a provider selector without switching the global current provider
68    #[cfg(unix)]
69    #[command(subcommand)]
70    Start(commands::start::StartCommand),
71
72    /// Manage environment variables and local CLI tool checks
73    #[command(subcommand)]
74    Env(commands::env::EnvCommand),
75
76    /// Update cc-switch binary to latest release
77    Update(commands::update::UpdateCommand),
78
79    /// Enter interactive mode
80    #[command(alias = "ui")]
81    Interactive,
82
83    /// Generate, install, inspect, or uninstall shell completions
84    Completions(commands::completions::CompletionsCommand),
85
86    #[command(name = "internal", hide = true, subcommand)]
87    Internal(commands::internal::InternalCommand),
88}
89
90/// Generate shell completions
91pub fn generate_completions(shell: Shell) {
92    let stdout = std::io::stdout();
93    let mut handle = stdout.lock();
94    generate_completions_to(shell, &mut handle);
95}
96
97pub(crate) fn generate_completions_to<W: Write>(shell: Shell, writer: &mut W) {
98    let mut cmd = Cli::command();
99    let name = cmd.get_name().to_string();
100    clap_complete::generate(shell, &mut cmd, name, writer);
101}
102
103fn non_empty(value: Option<&str>) -> Option<&str> {
104    value.map(str::trim).filter(|value| !value.is_empty())
105}
106
107fn format_version_string(
108    name: &str,
109    version: &str,
110    git_hash: Option<&str>,
111    build_time: Option<&str>,
112    is_clean_commit: bool,
113) -> String {
114    let mut metadata = Vec::new();
115
116    if let Some(git_hash) = non_empty(git_hash) {
117        if is_clean_commit {
118            metadata.push(git_hash.to_string());
119        } else {
120            metadata.push(format!("{git_hash}-dirty"));
121        }
122    }
123
124    if let Some(build_time) = non_empty(build_time) {
125        metadata.push(build_time.to_string());
126    }
127
128    if metadata.is_empty() {
129        format!("{name} {version}")
130    } else {
131        format!("{name} {version} ({})", metadata.join(" "))
132    }
133}
134
135pub fn version_string() -> String {
136    let version = non_empty(option_env!("CC_SWITCH_TUI_GIT_TAG_VERSION"))
137        .unwrap_or(env!("CARGO_PKG_VERSION"));
138
139    format_version_string(
140        env!("CARGO_PKG_NAME"),
141        version,
142        option_env!("CC_SWITCH_TUI_BUILD_GIT_HASH"),
143        option_env!("CC_SWITCH_TUI_BUILD_TIME"),
144        option_env!("CC_SWITCH_TUI_GIT_IS_CLEAN_COMMIT").is_some(),
145    )
146}
147
148#[cfg(test)]
149mod tests {
150    use clap::{CommandFactory, Parser};
151    use std::ffi::OsString;
152
153    use super::{format_version_string, Cli, Commands};
154    use crate::cli::commands::completions::{
155        CompletionLifecycleCommand, CompletionsAction, ManagedShellSelection,
156    };
157
158    #[test]
159    fn long_help_mentions_prompts_and_proxy_routes() {
160        let mut cmd = Cli::command();
161        let help = cmd.render_long_help().to_string();
162
163        assert!(help.contains("prompts, local proxy routes, and environment checks"));
164    }
165
166    #[test]
167    fn parses_manual_version_flags_without_stealing_verbose() {
168        let long = Cli::parse_from(["cc-switch-tui", "--version"]);
169        let short = Cli::parse_from(["cc-switch-tui", "-V"]);
170        let subcommand = Cli::parse_from(["cc-switch-tui", "provider", "list", "--version"]);
171        let verbose = Cli::parse_from(["cc-switch-tui", "-v"]);
172
173        assert!(long.version);
174        assert!(short.version);
175        assert!(subcommand.version);
176        assert!(!verbose.version);
177        assert!(verbose.verbose);
178    }
179
180    #[test]
181    fn version_string_includes_dirty_suffix_for_unclean_builds() {
182        let version = format_version_string(
183            "cc-switch-tui",
184            "0.1.1",
185            Some("abc1234"),
186            Some("2026-05-11 12:34:56 +08:00"),
187            false,
188        );
189
190        assert_eq!(
191            version,
192            "cc-switch-tui 0.1.1 (abc1234-dirty 2026-05-11 12:34:56 +08:00)"
193        );
194    }
195
196    #[test]
197    fn version_string_omits_metadata_when_build_env_is_missing() {
198        let version = format_version_string("cc-switch-tui", "0.1.1", None, None, true);
199
200        assert_eq!(version, "cc-switch-tui 0.1.1");
201    }
202
203    #[test]
204    fn skills_help_uses_current_storage_description() {
205        let mut cmd = Cli::command();
206        let skills = cmd
207            .find_subcommand_mut("skills")
208            .expect("skills subcommand should exist");
209        let help = skills.render_long_help().to_string();
210
211        assert!(!help.contains("skills.json"));
212        assert!(help.contains("SSOT + database state"));
213    }
214
215    #[test]
216    fn parses_proxy_serve_subcommand() {
217        let cli = Cli::parse_from(["cc-switch-tui", "proxy", "serve", "--listen-port", "0"]);
218
219        match cli.command {
220            Some(Commands::Proxy(super::commands::proxy::ProxyCommand::Serve {
221                listen_port,
222                ..
223            })) => {
224                assert_eq!(listen_port, Some(0));
225            }
226            _ => panic!("expected proxy serve command"),
227        }
228    }
229
230    #[test]
231    fn parses_proxy_serve_takeover_flags() {
232        let cli = Cli::parse_from([
233            "cc-switch-tui",
234            "proxy",
235            "serve",
236            "--takeover",
237            "claude",
238            "--takeover",
239            "codex",
240        ]);
241
242        match cli.command {
243            Some(Commands::Proxy(super::commands::proxy::ProxyCommand::Serve {
244                takeovers,
245                ..
246            })) => {
247                assert_eq!(
248                    takeovers,
249                    vec![super::AppType::Claude, super::AppType::Codex]
250                );
251            }
252            _ => panic!("expected proxy serve command with takeover flags"),
253        }
254    }
255
256    #[test]
257    fn parses_proxy_enable_subcommand() {
258        let cli = Cli::parse_from(["cc-switch-tui", "proxy", "enable"]);
259
260        match cli.command {
261            Some(Commands::Proxy(super::commands::proxy::ProxyCommand::Enable)) => {}
262            _ => panic!("expected proxy enable command"),
263        }
264    }
265
266    #[test]
267    fn parses_proxy_disable_subcommand() {
268        let cli = Cli::parse_from(["cc-switch-tui", "proxy", "disable"]);
269
270        match cli.command {
271            Some(Commands::Proxy(super::commands::proxy::ProxyCommand::Disable)) => {}
272            _ => panic!("expected proxy disable command"),
273        }
274    }
275
276    #[cfg(unix)]
277    #[test]
278    fn parses_start_claude_subcommand() {
279        let cli = Cli::parse_from(["cc-switch-tui", "start", "claude", "demo"]);
280
281        match cli.command {
282            Some(Commands::Start(super::commands::start::StartCommand::Claude {
283                selector,
284                native_args,
285            })) => {
286                assert_eq!(selector, "demo");
287                assert!(native_args.is_empty());
288            }
289            _ => panic!("expected start claude command"),
290        }
291    }
292
293    #[cfg(unix)]
294    #[test]
295    fn parses_start_claude_native_args_after_double_dash() {
296        let cli = Cli::parse_from([
297            "cc-switch-tui",
298            "start",
299            "claude",
300            "demo",
301            "--",
302            "--dangerously-skip-permissions",
303        ]);
304
305        match cli.command {
306            Some(Commands::Start(super::commands::start::StartCommand::Claude {
307                selector,
308                native_args,
309            })) => {
310                assert_eq!(selector, "demo");
311                assert_eq!(
312                    native_args,
313                    vec![OsString::from("--dangerously-skip-permissions")]
314                );
315            }
316            _ => panic!("expected start claude command with native args"),
317        }
318    }
319
320    #[cfg(unix)]
321    #[test]
322    fn rejects_start_claude_native_args_without_double_dash() {
323        let result = Cli::try_parse_from([
324            "cc-switch-tui",
325            "start",
326            "claude",
327            "demo",
328            "--dangerously-skip-permissions",
329        ]);
330        let rendered = match result {
331            Ok(_) => panic!("native args without `--` should be rejected"),
332            Err(err) => err.to_string(),
333        };
334
335        assert!(rendered.contains("-- --dangerously-skip-permissions"));
336    }
337
338    #[cfg(unix)]
339    #[test]
340    fn parses_start_codex_subcommand() {
341        let cli = Cli::parse_from(["cc-switch-tui", "start", "codex", "demo"]);
342
343        match cli.command {
344            Some(Commands::Start(super::commands::start::StartCommand::Codex {
345                selector,
346                native_args,
347            })) => {
348                assert_eq!(selector, "demo");
349                assert!(native_args.is_empty());
350            }
351            _ => panic!("expected start codex command"),
352        }
353    }
354
355    #[cfg(unix)]
356    #[test]
357    fn parses_start_codex_multiple_native_args_after_double_dash() {
358        let cli = Cli::parse_from([
359            "cc-switch-tui",
360            "start",
361            "codex",
362            "demo",
363            "--",
364            "--model",
365            "gpt-5.4",
366            "--profile",
367            "local",
368        ]);
369
370        match cli.command {
371            Some(Commands::Start(super::commands::start::StartCommand::Codex {
372                selector,
373                native_args,
374            })) => {
375                assert_eq!(selector, "demo");
376                assert_eq!(
377                    native_args,
378                    vec![
379                        OsString::from("--model"),
380                        OsString::from("gpt-5.4"),
381                        OsString::from("--profile"),
382                        OsString::from("local"),
383                    ]
384                );
385            }
386            _ => panic!("expected start codex command with native args"),
387        }
388    }
389
390    #[cfg(unix)]
391    #[test]
392    fn start_claude_help_mentions_double_dash_passthrough_examples() {
393        let mut cmd = Cli::command();
394        let start = cmd
395            .find_subcommand_mut("start")
396            .expect("start subcommand should exist");
397        let claude = start
398            .find_subcommand_mut("claude")
399            .expect("claude subcommand should exist");
400        let help = claude.render_long_help().to_string();
401
402        assert!(help.contains("Native Claude CLI arguments to pass through after `--`"));
403        assert!(help.contains("cc-switch start claude demo -- --dangerously-skip-permissions"));
404    }
405
406    #[cfg(unix)]
407    #[test]
408    fn start_codex_help_mentions_double_dash_passthrough_examples() {
409        let mut cmd = Cli::command();
410        let start = cmd
411            .find_subcommand_mut("start")
412            .expect("start subcommand should exist");
413        let codex = start
414            .find_subcommand_mut("codex")
415            .expect("codex subcommand should exist");
416        let help = codex.render_long_help().to_string();
417
418        assert!(help.contains("Native Codex CLI arguments to pass through after `--`"));
419        assert!(help.contains("cc-switch start codex demo -- --model gpt-5.4"));
420    }
421
422    #[test]
423    fn parses_provider_stream_check_subcommand() {
424        let cli = Cli::parse_from(["cc-switch-tui", "provider", "stream-check", "demo"]);
425
426        match cli.command {
427            Some(Commands::Provider(super::commands::provider::ProviderCommand::StreamCheck {
428                id,
429            })) => {
430                assert_eq!(id, "demo");
431            }
432            _ => panic!("expected provider stream-check command"),
433        }
434    }
435
436    #[test]
437    fn parses_provider_fetch_models_subcommand() {
438        let cli = Cli::parse_from(["cc-switch-tui", "provider", "fetch-models", "demo"]);
439
440        match cli.command {
441            Some(Commands::Provider(super::commands::provider::ProviderCommand::FetchModels {
442                id,
443            })) => {
444                assert_eq!(id, "demo");
445            }
446            _ => panic!("expected provider fetch-models command"),
447        }
448    }
449
450    #[test]
451    fn parses_provider_export_subcommand() {
452        let cli = Cli::parse_from(["cc-switch-tui", "provider", "export", "demo"]);
453
454        match cli.command {
455            Some(Commands::Provider(super::commands::provider::ProviderCommand::Export {
456                id,
457                output,
458            })) => {
459                assert_eq!(id, "demo");
460                assert_eq!(output, None);
461            }
462            _ => panic!("expected provider export command"),
463        }
464    }
465
466    #[test]
467    fn parses_provider_export_with_output_subcommand() {
468        let cli = Cli::parse_from([
469            "cc-switch-tui",
470            "provider",
471            "export",
472            "demo",
473            "--output",
474            "/tmp/provider-settings.json",
475        ]);
476
477        match cli.command {
478            Some(Commands::Provider(super::commands::provider::ProviderCommand::Export {
479                id,
480                output,
481            })) => {
482                assert_eq!(id, "demo");
483                assert_eq!(
484                    output,
485                    Some(std::path::PathBuf::from("/tmp/provider-settings.json"))
486                );
487            }
488            _ => panic!("expected provider export command with output"),
489        }
490    }
491
492    #[test]
493    fn parses_config_webdav_show_subcommand() {
494        let cli = Cli::parse_from(["cc-switch-tui", "config", "webdav", "show"]);
495
496        match cli.command {
497            Some(Commands::Config(super::commands::config::ConfigCommand::WebDav(
498                super::commands::config_webdav::WebDavCommand::Show,
499            ))) => {}
500            _ => panic!("expected config webdav show command"),
501        }
502    }
503
504    #[test]
505    fn parses_config_webdav_set_subcommand() {
506        let cli = Cli::parse_from([
507            "cc-switch-tui",
508            "config",
509            "webdav",
510            "set",
511            "--base-url",
512            "https://dav.example.com/root",
513            "--username",
514            "demo",
515            "--password",
516            "secret",
517            "--enable",
518        ]);
519
520        match cli.command {
521            Some(Commands::Config(super::commands::config::ConfigCommand::WebDav(
522                super::commands::config_webdav::WebDavCommand::Set {
523                    base_url,
524                    username,
525                    password,
526                    enable,
527                    ..
528                },
529            ))) => {
530                assert_eq!(base_url.as_deref(), Some("https://dav.example.com/root"));
531                assert_eq!(username.as_deref(), Some("demo"));
532                assert_eq!(password.as_deref(), Some("secret"));
533                assert!(enable);
534            }
535            _ => panic!("expected config webdav set command"),
536        }
537    }
538
539    #[test]
540    fn parses_config_webdav_check_connection_subcommand() {
541        let cli = Cli::parse_from(["cc-switch-tui", "config", "webdav", "check-connection"]);
542
543        match cli.command {
544            Some(Commands::Config(super::commands::config::ConfigCommand::WebDav(
545                super::commands::config_webdav::WebDavCommand::CheckConnection,
546            ))) => {}
547            _ => panic!("expected config webdav check-connection command"),
548        }
549    }
550
551    #[test]
552    fn config_common_set_help_describes_snippet_as_primary_contract() {
553        let mut cmd = Cli::command();
554        let config = cmd
555            .find_subcommand_mut("config")
556            .expect("config subcommand should exist");
557        let common = config
558            .find_subcommand_mut("common")
559            .expect("common subcommand should exist");
560        let set = common
561            .find_subcommand_mut("set")
562            .expect("set subcommand should exist");
563        let help = set.render_long_help().to_string();
564
565        assert!(help.contains("--snippet <SNIPPET>"));
566        assert!(help.contains("Inline snippet text"));
567        assert!(!help.contains("Compatibility flag for inline snippet text"));
568        assert!(help.contains("Compatibility:"));
569        assert!(help.contains("--json <SNIPPET>"));
570        assert!(help.contains("Legacy alias for --snippet <SNIPPET>"));
571        assert!(help.contains("Claude/Gemini"));
572        assert!(help.contains("OpenCode"));
573        assert!(help.contains("Codex"));
574        assert!(!help.contains("Apply to current provider immediately"));
575        assert!(help.contains("live config"));
576        assert!(help.contains("applicable"));
577    }
578
579    #[test]
580    fn parses_config_common_set_legacy_json_alias() {
581        let cli = Cli::parse_from(["cc-switch-tui", "config", "common", "set", "--json", "{}"]);
582
583        match cli.command {
584            Some(Commands::Config(super::commands::config::ConfigCommand::Common(_))) => {}
585            _ => panic!("expected config common set command"),
586        }
587    }
588
589    #[test]
590    fn config_common_clear_help_marks_apply_as_compatibility_flag() {
591        let mut cmd = Cli::command();
592        let config = cmd
593            .find_subcommand_mut("config")
594            .expect("config subcommand should exist");
595        let common = config
596            .find_subcommand_mut("common")
597            .expect("common subcommand should exist");
598        let clear = common
599            .find_subcommand_mut("clear")
600            .expect("clear subcommand should exist");
601        let help = clear.render_long_help().to_string();
602
603        assert!(!help.contains("Apply to current provider immediately"));
604        assert!(help.contains("Compatibility flag"));
605        assert!(help.contains("live config"));
606        assert!(help.contains("applicable"));
607    }
608
609    #[test]
610    fn parses_env_tools_subcommand() {
611        let cli = Cli::parse_from(["cc-switch-tui", "env", "tools"]);
612
613        match cli.command {
614            Some(Commands::Env(super::commands::env::EnvCommand::Tools)) => {}
615            _ => panic!("expected env tools command"),
616        }
617    }
618
619    #[test]
620    fn parses_skills_repo_enable_subcommand() {
621        let cli = Cli::parse_from(["cc-switch-tui", "skills", "repos", "enable", "foo/bar"]);
622
623        match cli.command {
624            Some(Commands::Skills(super::commands::skills::SkillsCommand::Repos(
625                super::commands::skills::SkillReposCommand::Enable { url },
626            ))) => {
627                assert_eq!(url, "foo/bar");
628            }
629            _ => panic!("expected skills repos enable command"),
630        }
631    }
632
633    #[test]
634    fn parses_skills_repo_disable_subcommand() {
635        let cli = Cli::parse_from(["cc-switch-tui", "skills", "repos", "disable", "foo/bar"]);
636
637        match cli.command {
638            Some(Commands::Skills(super::commands::skills::SkillsCommand::Repos(
639                super::commands::skills::SkillReposCommand::Disable { url },
640            ))) => {
641                assert_eq!(url, "foo/bar");
642            }
643            _ => panic!("expected skills repos disable command"),
644        }
645    }
646
647    #[test]
648    fn parses_completions_bash_generator_path() {
649        let cli = Cli::parse_from(["cc-switch-tui", "completions", "bash"]);
650
651        match cli.command {
652            Some(Commands::Completions(command)) => {
653                assert_eq!(command.shell, Some(clap_complete::Shell::Bash));
654                assert!(command.action.is_none());
655            }
656            _ => panic!("expected completions generator command"),
657        }
658    }
659
660    #[test]
661    fn parses_completions_zsh_generator_path() {
662        let cli = Cli::parse_from(["cc-switch-tui", "completions", "zsh"]);
663
664        match cli.command {
665            Some(Commands::Completions(command)) => {
666                assert_eq!(command.shell, Some(clap_complete::Shell::Zsh));
667                assert!(command.action.is_none());
668            }
669            _ => panic!("expected completions generator command"),
670        }
671    }
672
673    #[test]
674    fn parses_completions_install() {
675        let cli = Cli::parse_from(["cc-switch-tui", "completions", "install"]);
676
677        match cli.command {
678            Some(Commands::Completions(command)) => match command.action {
679                Some(CompletionsAction::Install(args)) => {
680                    assert_eq!(args.shell, ManagedShellSelection::Auto);
681                    assert!(!args.activate);
682                }
683                _ => panic!("expected completions install subcommand"),
684            },
685            _ => panic!("expected completions install command"),
686        }
687    }
688
689    #[test]
690    fn parses_completions_install_with_shell_and_activate() {
691        let cli = Cli::parse_from([
692            "cc-switch-tui",
693            "completions",
694            "install",
695            "--shell",
696            "zsh",
697            "--activate",
698        ]);
699
700        match cli.command {
701            Some(Commands::Completions(command)) => match command.action {
702                Some(CompletionsAction::Install(args)) => {
703                    assert_eq!(args.shell, ManagedShellSelection::Zsh);
704                    assert!(args.activate);
705                }
706                _ => panic!("expected completions install subcommand"),
707            },
708            _ => panic!("expected completions install command"),
709        }
710    }
711
712    #[test]
713    fn parses_completions_status() {
714        let cli = Cli::parse_from(["cc-switch-tui", "completions", "status"]);
715
716        match cli.command {
717            Some(Commands::Completions(command)) => match command.action {
718                Some(CompletionsAction::Status(CompletionLifecycleCommand { shell })) => {
719                    assert_eq!(shell, ManagedShellSelection::Auto);
720                }
721                _ => panic!("expected completions status subcommand"),
722            },
723            _ => panic!("expected completions status command"),
724        }
725    }
726
727    #[test]
728    fn parses_completions_uninstall_with_explicit_shell() {
729        let cli = Cli::parse_from([
730            "cc-switch-tui",
731            "completions",
732            "uninstall",
733            "--shell",
734            "bash",
735        ]);
736
737        match cli.command {
738            Some(Commands::Completions(command)) => match command.action {
739                Some(CompletionsAction::Uninstall(CompletionLifecycleCommand { shell })) => {
740                    assert_eq!(shell, ManagedShellSelection::Bash);
741                }
742                _ => panic!("expected completions uninstall subcommand"),
743            },
744            _ => panic!("expected completions uninstall command"),
745        }
746    }
747
748    #[test]
749    fn rejects_completions_generator_with_activate_flag() {
750        let err = match Cli::try_parse_from(["cc-switch-tui", "completions", "bash", "--activate"])
751        {
752            Ok(_) => panic!("generator path should reject lifecycle-only flags"),
753            Err(err) => err,
754        };
755        let rendered = err.to_string();
756
757        assert!(rendered.contains("--activate"));
758        assert!(rendered.contains("unexpected argument"));
759    }
760}