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