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