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 #[arg(short = 'V', long = "version", global = true)]
27 pub version: bool,
28
29 #[arg(short, long, global = true, value_enum)]
31 pub app: Option<AppType>,
32
33 #[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 #[command(subcommand)]
45 Provider(commands::provider::ProviderCommand),
46
47 #[command(subcommand)]
49 Mcp(commands::mcp::McpCommand),
50
51 #[command(subcommand)]
53 Prompts(commands::prompts::PromptsCommand),
54
55 #[command(subcommand)]
57 Skills(commands::skills::SkillsCommand),
58
59 #[command(subcommand)]
61 Config(commands::config::ConfigCommand),
62
63 #[command(subcommand)]
65 Proxy(commands::proxy::ProxyCommand),
66
67 #[cfg(unix)]
69 #[command(subcommand)]
70 Start(commands::start::StartCommand),
71
72 #[command(subcommand)]
74 Env(commands::env::EnvCommand),
75
76 Update(commands::update::UpdateCommand),
78
79 #[command(alias = "ui")]
81 Interactive,
82
83 Completions(commands::completions::CompletionsCommand),
85
86 #[command(name = "internal", hide = true, subcommand)]
87 Internal(commands::internal::InternalCommand),
88}
89
90pub 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}