use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use std::io::Write;
mod claude_temp_launch;
mod codex_temp_launch;
pub mod commands;
pub mod editor;
pub mod i18n;
pub mod interactive;
pub mod terminal;
pub mod tui;
pub mod ui;
use crate::app_config::AppType;
#[derive(Parser)]
#[command(
name = "cc-switch-tui",
disable_version_flag = true,
about = "All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode, OpenClaw & Hermes",
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."
)]
pub struct Cli {
#[arg(short = 'V', long = "version", global = true)]
pub version: bool,
#[arg(short, long, global = true, value_enum)]
pub app: Option<AppType>,
#[arg(short, long, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
#[command(subcommand)]
Provider(commands::provider::ProviderCommand),
#[command(subcommand)]
Mcp(commands::mcp::McpCommand),
#[command(subcommand)]
Prompts(commands::prompts::PromptsCommand),
#[command(subcommand)]
Skills(commands::skills::SkillsCommand),
#[command(subcommand)]
Config(commands::config::ConfigCommand),
#[command(subcommand)]
Proxy(commands::proxy::ProxyCommand),
#[command(subcommand)]
Failover(commands::failover::FailoverCommand),
#[cfg(unix)]
#[command(subcommand)]
Start(commands::start::StartCommand),
#[command(subcommand)]
Env(commands::env::EnvCommand),
Update(commands::update::UpdateCommand),
#[command(alias = "ui")]
Interactive,
Completions(commands::completions::CompletionsCommand),
#[command(name = "internal", hide = true, subcommand)]
Internal(commands::internal::InternalCommand),
}
pub fn generate_completions(shell: Shell) {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
generate_completions_to(shell, &mut handle);
}
pub(crate) fn generate_completions_to<W: Write>(shell: Shell, writer: &mut W) {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, writer);
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn format_version_string(
name: &str,
version: &str,
git_hash: Option<&str>,
build_time: Option<&str>,
is_clean_commit: bool,
) -> String {
let mut metadata = Vec::new();
if let Some(git_hash) = non_empty(git_hash) {
if is_clean_commit {
metadata.push(git_hash.to_string());
} else {
metadata.push(format!("{git_hash}-dirty"));
}
}
if let Some(build_time) = non_empty(build_time) {
metadata.push(build_time.to_string());
}
if metadata.is_empty() {
format!("{name} {version}")
} else {
format!("{name} {version} ({})", metadata.join(" "))
}
}
pub fn version_string() -> String {
let version = non_empty(option_env!("CC_SWITCH_TUI_GIT_TAG_VERSION"))
.unwrap_or(env!("CARGO_PKG_VERSION"));
format_version_string(
env!("CARGO_PKG_NAME"),
version,
option_env!("CC_SWITCH_TUI_BUILD_GIT_HASH"),
option_env!("CC_SWITCH_TUI_BUILD_TIME"),
option_env!("CC_SWITCH_TUI_GIT_IS_CLEAN_COMMIT").is_some(),
)
}
#[cfg(test)]
mod tests {
use clap::{CommandFactory, Parser};
use std::ffi::OsString;
use super::{format_version_string, Cli, Commands};
use crate::cli::commands::completions::{
CompletionLifecycleCommand, CompletionsAction, ManagedShellSelection,
};
#[test]
fn long_help_mentions_prompts_and_proxy_routes() {
let mut cmd = Cli::command();
let help = cmd.render_long_help().to_string();
assert!(help.contains("prompts, local proxy routes, and environment checks"));
}
#[test]
fn parses_manual_version_flags_without_stealing_verbose() {
let long = Cli::parse_from(["cc-switch-tui", "--version"]);
let short = Cli::parse_from(["cc-switch-tui", "-V"]);
let subcommand = Cli::parse_from(["cc-switch-tui", "provider", "list", "--version"]);
let verbose = Cli::parse_from(["cc-switch-tui", "-v"]);
assert!(long.version);
assert!(short.version);
assert!(subcommand.version);
assert!(!verbose.version);
assert!(verbose.verbose);
}
#[test]
fn version_string_includes_dirty_suffix_for_unclean_builds() {
let version = format_version_string(
"cc-switch-tui",
"0.1.1",
Some("abc1234"),
Some("2026-05-11 12:34:56 +08:00"),
false,
);
assert_eq!(
version,
"cc-switch-tui 0.1.1 (abc1234-dirty 2026-05-11 12:34:56 +08:00)"
);
}
#[test]
fn version_string_omits_metadata_when_build_env_is_missing() {
let version = format_version_string("cc-switch-tui", "0.1.1", None, None, true);
assert_eq!(version, "cc-switch-tui 0.1.1");
}
#[test]
fn skills_help_uses_current_storage_description() {
let mut cmd = Cli::command();
let skills = cmd
.find_subcommand_mut("skills")
.expect("skills subcommand should exist");
let help = skills.render_long_help().to_string();
assert!(!help.contains("skills.json"));
assert!(help.contains("SSOT + database state"));
}
#[test]
fn parses_proxy_serve_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "proxy", "serve", "--listen-port", "0"]);
match cli.command {
Some(Commands::Proxy(super::commands::proxy::ProxyCommand::Serve {
listen_port,
..
})) => {
assert_eq!(listen_port, Some(0));
}
_ => panic!("expected proxy serve command"),
}
}
#[test]
fn parses_proxy_serve_takeover_flags() {
let cli = Cli::parse_from([
"cc-switch-tui",
"proxy",
"serve",
"--takeover",
"claude",
"--takeover",
"codex",
]);
match cli.command {
Some(Commands::Proxy(super::commands::proxy::ProxyCommand::Serve {
takeovers,
..
})) => {
assert_eq!(
takeovers,
vec![super::AppType::Claude, super::AppType::Codex]
);
}
_ => panic!("expected proxy serve command with takeover flags"),
}
}
#[test]
fn parses_proxy_enable_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "proxy", "enable"]);
match cli.command {
Some(Commands::Proxy(super::commands::proxy::ProxyCommand::Enable)) => {}
_ => panic!("expected proxy enable command"),
}
}
#[test]
fn parses_proxy_disable_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "proxy", "disable"]);
match cli.command {
Some(Commands::Proxy(super::commands::proxy::ProxyCommand::Disable)) => {}
_ => panic!("expected proxy disable command"),
}
}
#[test]
fn parses_failover_enable_subcommand() {
let cli = Cli::parse_from(["cc-switch", "failover", "enable"]);
match cli.command {
Some(Commands::Failover(super::commands::failover::FailoverCommand::Enable)) => {}
_ => panic!("expected failover enable command"),
}
}
#[test]
fn parses_failover_disable_subcommand() {
let cli = Cli::parse_from(["cc-switch", "failover", "disable"]);
match cli.command {
Some(Commands::Failover(super::commands::failover::FailoverCommand::Disable)) => {}
_ => panic!("expected failover disable command"),
}
}
#[test]
fn parses_failover_list_subcommand() {
let cli = Cli::parse_from(["cc-switch", "failover", "list"]);
match cli.command {
Some(Commands::Failover(super::commands::failover::FailoverCommand::List)) => {}
_ => panic!("expected failover list command"),
}
}
#[test]
fn parses_failover_add_subcommand() {
let cli = Cli::parse_from(["cc-switch", "failover", "add", "p1"]);
match cli.command {
Some(Commands::Failover(super::commands::failover::FailoverCommand::Add { id })) => {
assert_eq!(id, "p1");
}
_ => panic!("expected failover add command"),
}
}
#[test]
fn parses_failover_remove_subcommand() {
let cli = Cli::parse_from(["cc-switch", "failover", "remove", "p1"]);
match cli.command {
Some(Commands::Failover(super::commands::failover::FailoverCommand::Remove { id })) => {
assert_eq!(id, "p1");
}
_ => panic!("expected failover remove command"),
}
}
#[test]
fn parses_failover_move_subcommand() {
let cli = Cli::parse_from(["cc-switch", "failover", "move", "p1", "up"]);
match cli.command {
Some(Commands::Failover(super::commands::failover::FailoverCommand::Move {
id,
direction,
})) => {
assert_eq!(id, "p1");
assert_eq!(
direction,
super::commands::failover::FailoverMoveDirection::Up
);
}
_ => panic!("expected failover move command"),
}
}
#[test]
fn parses_failover_clear_subcommand() {
let cli = Cli::parse_from(["cc-switch", "failover", "clear", "--yes"]);
match cli.command {
Some(Commands::Failover(super::commands::failover::FailoverCommand::Clear { yes })) => {
assert!(yes);
}
_ => panic!("expected failover clear command"),
}
}
#[test]
fn parses_failover_show_with_app() {
let cli = Cli::parse_from(["cc-switch", "--app", "codex", "failover", "show"]);
assert_eq!(cli.app, Some(super::AppType::Codex));
match cli.command {
Some(Commands::Failover(super::commands::failover::FailoverCommand::Show)) => {}
_ => panic!("expected failover show command"),
}
}
#[cfg(unix)]
#[test]
fn parses_start_claude_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "start", "claude", "demo"]);
match cli.command {
Some(Commands::Start(super::commands::start::StartCommand::Claude {
selector,
native_args,
})) => {
assert_eq!(selector, "demo");
assert!(native_args.is_empty());
}
_ => panic!("expected start claude command"),
}
}
#[cfg(unix)]
#[test]
fn parses_start_claude_native_args_after_double_dash() {
let cli = Cli::parse_from([
"cc-switch-tui",
"start",
"claude",
"demo",
"--",
"--dangerously-skip-permissions",
]);
match cli.command {
Some(Commands::Start(super::commands::start::StartCommand::Claude {
selector,
native_args,
})) => {
assert_eq!(selector, "demo");
assert_eq!(
native_args,
vec![OsString::from("--dangerously-skip-permissions")]
);
}
_ => panic!("expected start claude command with native args"),
}
}
#[cfg(unix)]
#[test]
fn rejects_start_claude_native_args_without_double_dash() {
let result = Cli::try_parse_from([
"cc-switch-tui",
"start",
"claude",
"demo",
"--dangerously-skip-permissions",
]);
let rendered = match result {
Ok(_) => panic!("native args without `--` should be rejected"),
Err(err) => err.to_string(),
};
assert!(rendered.contains("-- --dangerously-skip-permissions"));
}
#[cfg(unix)]
#[test]
fn parses_start_codex_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "start", "codex", "demo"]);
match cli.command {
Some(Commands::Start(super::commands::start::StartCommand::Codex {
selector,
native_args,
})) => {
assert_eq!(selector, "demo");
assert!(native_args.is_empty());
}
_ => panic!("expected start codex command"),
}
}
#[cfg(unix)]
#[test]
fn parses_start_codex_multiple_native_args_after_double_dash() {
let cli = Cli::parse_from([
"cc-switch-tui",
"start",
"codex",
"demo",
"--",
"--model",
"gpt-5.4",
"--profile",
"local",
]);
match cli.command {
Some(Commands::Start(super::commands::start::StartCommand::Codex {
selector,
native_args,
})) => {
assert_eq!(selector, "demo");
assert_eq!(
native_args,
vec![
OsString::from("--model"),
OsString::from("gpt-5.4"),
OsString::from("--profile"),
OsString::from("local"),
]
);
}
_ => panic!("expected start codex command with native args"),
}
}
#[cfg(unix)]
#[test]
fn start_claude_help_mentions_double_dash_passthrough_examples() {
let mut cmd = Cli::command();
let start = cmd
.find_subcommand_mut("start")
.expect("start subcommand should exist");
let claude = start
.find_subcommand_mut("claude")
.expect("claude subcommand should exist");
let help = claude.render_long_help().to_string();
assert!(help.contains("Native Claude CLI arguments to pass through after `--`"));
assert!(help.contains("cc-switch start claude demo -- --dangerously-skip-permissions"));
}
#[cfg(unix)]
#[test]
fn start_codex_help_mentions_double_dash_passthrough_examples() {
let mut cmd = Cli::command();
let start = cmd
.find_subcommand_mut("start")
.expect("start subcommand should exist");
let codex = start
.find_subcommand_mut("codex")
.expect("codex subcommand should exist");
let help = codex.render_long_help().to_string();
assert!(help.contains("Native Codex CLI arguments to pass through after `--`"));
assert!(help.contains("cc-switch start codex demo -- --model gpt-5.4"));
}
#[test]
fn parses_provider_stream_check_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "provider", "stream-check", "demo"]);
match cli.command {
Some(Commands::Provider(super::commands::provider::ProviderCommand::StreamCheck {
id,
})) => {
assert_eq!(id, "demo");
}
_ => panic!("expected provider stream-check command"),
}
}
#[test]
fn parses_provider_fetch_models_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "provider", "fetch-models", "demo"]);
match cli.command {
Some(Commands::Provider(super::commands::provider::ProviderCommand::FetchModels {
id,
})) => {
assert_eq!(id, "demo");
}
_ => panic!("expected provider fetch-models command"),
}
}
#[test]
fn parses_provider_export_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "provider", "export", "demo"]);
match cli.command {
Some(Commands::Provider(super::commands::provider::ProviderCommand::Export {
id,
output,
})) => {
assert_eq!(id, "demo");
assert_eq!(output, None);
}
_ => panic!("expected provider export command"),
}
}
#[test]
fn parses_provider_export_with_output_subcommand() {
let cli = Cli::parse_from([
"cc-switch-tui",
"provider",
"export",
"demo",
"--output",
"/tmp/provider-settings.json",
]);
match cli.command {
Some(Commands::Provider(super::commands::provider::ProviderCommand::Export {
id,
output,
})) => {
assert_eq!(id, "demo");
assert_eq!(
output,
Some(std::path::PathBuf::from("/tmp/provider-settings.json"))
);
}
_ => panic!("expected provider export command with output"),
}
}
#[test]
fn parses_config_webdav_show_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "config", "webdav", "show"]);
match cli.command {
Some(Commands::Config(super::commands::config::ConfigCommand::WebDav(
super::commands::config_webdav::WebDavCommand::Show,
))) => {}
_ => panic!("expected config webdav show command"),
}
}
#[test]
fn parses_config_webdav_set_subcommand() {
let cli = Cli::parse_from([
"cc-switch-tui",
"config",
"webdav",
"set",
"--base-url",
"https://dav.example.com/root",
"--username",
"demo",
"--password",
"secret",
"--enable",
]);
match cli.command {
Some(Commands::Config(super::commands::config::ConfigCommand::WebDav(
super::commands::config_webdav::WebDavCommand::Set {
base_url,
username,
password,
enable,
..
},
))) => {
assert_eq!(base_url.as_deref(), Some("https://dav.example.com/root"));
assert_eq!(username.as_deref(), Some("demo"));
assert_eq!(password.as_deref(), Some("secret"));
assert!(enable);
}
_ => panic!("expected config webdav set command"),
}
}
#[test]
fn parses_config_webdav_check_connection_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "config", "webdav", "check-connection"]);
match cli.command {
Some(Commands::Config(super::commands::config::ConfigCommand::WebDav(
super::commands::config_webdav::WebDavCommand::CheckConnection,
))) => {}
_ => panic!("expected config webdav check-connection command"),
}
}
#[test]
fn config_common_set_help_describes_snippet_as_primary_contract() {
let mut cmd = Cli::command();
let config = cmd
.find_subcommand_mut("config")
.expect("config subcommand should exist");
let common = config
.find_subcommand_mut("common")
.expect("common subcommand should exist");
let set = common
.find_subcommand_mut("set")
.expect("set subcommand should exist");
let help = set.render_long_help().to_string();
assert!(help.contains("--snippet <SNIPPET>"));
assert!(help.contains("Inline snippet text"));
assert!(!help.contains("Compatibility flag for inline snippet text"));
assert!(help.contains("Compatibility:"));
assert!(help.contains("--json <SNIPPET>"));
assert!(help.contains("Legacy alias for --snippet <SNIPPET>"));
assert!(help.contains("Claude/Gemini"));
assert!(help.contains("OpenCode"));
assert!(help.contains("Codex"));
assert!(!help.contains("Apply to current provider immediately"));
assert!(help.contains("live config"));
assert!(help.contains("applicable"));
}
#[test]
fn parses_config_common_set_legacy_json_alias() {
let cli = Cli::parse_from(["cc-switch-tui", "config", "common", "set", "--json", "{}"]);
match cli.command {
Some(Commands::Config(super::commands::config::ConfigCommand::Common(_))) => {}
_ => panic!("expected config common set command"),
}
}
#[test]
fn config_common_clear_help_marks_apply_as_compatibility_flag() {
let mut cmd = Cli::command();
let config = cmd
.find_subcommand_mut("config")
.expect("config subcommand should exist");
let common = config
.find_subcommand_mut("common")
.expect("common subcommand should exist");
let clear = common
.find_subcommand_mut("clear")
.expect("clear subcommand should exist");
let help = clear.render_long_help().to_string();
assert!(!help.contains("Apply to current provider immediately"));
assert!(help.contains("Compatibility flag"));
assert!(help.contains("live config"));
assert!(help.contains("applicable"));
}
#[test]
fn parses_env_tools_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "env", "tools"]);
match cli.command {
Some(Commands::Env(super::commands::env::EnvCommand::Tools)) => {}
_ => panic!("expected env tools command"),
}
}
#[test]
fn parses_skills_repo_enable_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "skills", "repos", "enable", "foo/bar"]);
match cli.command {
Some(Commands::Skills(super::commands::skills::SkillsCommand::Repos(
super::commands::skills::SkillReposCommand::Enable { url },
))) => {
assert_eq!(url, "foo/bar");
}
_ => panic!("expected skills repos enable command"),
}
}
#[test]
fn parses_skills_repo_disable_subcommand() {
let cli = Cli::parse_from(["cc-switch-tui", "skills", "repos", "disable", "foo/bar"]);
match cli.command {
Some(Commands::Skills(super::commands::skills::SkillsCommand::Repos(
super::commands::skills::SkillReposCommand::Disable { url },
))) => {
assert_eq!(url, "foo/bar");
}
_ => panic!("expected skills repos disable command"),
}
}
#[test]
fn parses_completions_bash_generator_path() {
let cli = Cli::parse_from(["cc-switch-tui", "completions", "bash"]);
match cli.command {
Some(Commands::Completions(command)) => {
assert_eq!(command.shell, Some(clap_complete::Shell::Bash));
assert!(command.action.is_none());
}
_ => panic!("expected completions generator command"),
}
}
#[test]
fn parses_completions_zsh_generator_path() {
let cli = Cli::parse_from(["cc-switch-tui", "completions", "zsh"]);
match cli.command {
Some(Commands::Completions(command)) => {
assert_eq!(command.shell, Some(clap_complete::Shell::Zsh));
assert!(command.action.is_none());
}
_ => panic!("expected completions generator command"),
}
}
#[test]
fn parses_completions_install() {
let cli = Cli::parse_from(["cc-switch-tui", "completions", "install"]);
match cli.command {
Some(Commands::Completions(command)) => match command.action {
Some(CompletionsAction::Install(args)) => {
assert_eq!(args.shell, ManagedShellSelection::Auto);
assert!(!args.activate);
}
_ => panic!("expected completions install subcommand"),
},
_ => panic!("expected completions install command"),
}
}
#[test]
fn parses_completions_install_with_shell_and_activate() {
let cli = Cli::parse_from([
"cc-switch-tui",
"completions",
"install",
"--shell",
"zsh",
"--activate",
]);
match cli.command {
Some(Commands::Completions(command)) => match command.action {
Some(CompletionsAction::Install(args)) => {
assert_eq!(args.shell, ManagedShellSelection::Zsh);
assert!(args.activate);
}
_ => panic!("expected completions install subcommand"),
},
_ => panic!("expected completions install command"),
}
}
#[test]
fn parses_completions_status() {
let cli = Cli::parse_from(["cc-switch-tui", "completions", "status"]);
match cli.command {
Some(Commands::Completions(command)) => match command.action {
Some(CompletionsAction::Status(CompletionLifecycleCommand { shell })) => {
assert_eq!(shell, ManagedShellSelection::Auto);
}
_ => panic!("expected completions status subcommand"),
},
_ => panic!("expected completions status command"),
}
}
#[test]
fn parses_completions_uninstall_with_explicit_shell() {
let cli = Cli::parse_from([
"cc-switch-tui",
"completions",
"uninstall",
"--shell",
"bash",
]);
match cli.command {
Some(Commands::Completions(command)) => match command.action {
Some(CompletionsAction::Uninstall(CompletionLifecycleCommand { shell })) => {
assert_eq!(shell, ManagedShellSelection::Bash);
}
_ => panic!("expected completions uninstall subcommand"),
},
_ => panic!("expected completions uninstall command"),
}
}
#[test]
fn rejects_completions_generator_with_activate_flag() {
let err = match Cli::try_parse_from(["cc-switch-tui", "completions", "bash", "--activate"])
{
Ok(_) => panic!("generator path should reject lifecycle-only flags"),
Err(err) => err,
};
let rendered = err.to_string();
assert!(rendered.contains("--activate"));
assert!(rendered.contains("unexpected argument"));
}
}