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