From a63eab607e0495635f6c5dccad89005c4cc754c3 Mon Sep 17 00:00:00 2001 From: quangnv13 Date: Mon, 8 Jun 2026 22:37:13 +0700 Subject: [PATCH 1/2] feat: add interactive CLI tool for account switching and management - Implement interactive CLI switcher in codex-switch.rs - Add CLI dependency libraries (clap, dialoguer, console, crossterm) - Add cli script to package.json - Refactor console prints in usage.rs to only log when CODEX_DEBUG is set --- package.json | 1 + src-tauri/Cargo.lock | 238 ++++++++++- src-tauri/Cargo.toml | 6 + src-tauri/src/api/usage.rs | 53 +-- src-tauri/src/bin/codex-switch.rs | 665 ++++++++++++++++++++++++++++++ 5 files changed, 937 insertions(+), 26 deletions(-) create mode 100644 src-tauri/src/bin/codex-switch.rs diff --git a/package.json b/package.json index be3bf074..983550e6 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview", "tauri": "sh ./scripts/tauri.sh", "lan": "pnpm build && cargo run --manifest-path src-tauri/Cargo.toml --bin codex-web", + "cli": "cargo run --manifest-path src-tauri/Cargo.toml --bin codex-switch --", "version:bump": "node scripts/bump-version.mjs", "version:patch": "node scripts/bump-version.mjs patch", "version:minor": "node scripts/bump-version.mjs minor", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5340fa16..93e6154a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -51,6 +51,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -524,6 +574,46 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "codex-switcher" version = "0.2.2" @@ -532,6 +622,10 @@ dependencies = [ "base64 0.22.1", "chacha20poly1305", "chrono", + "clap", + "console", + "crossterm", + "dialoguer", "dirs", "flate2", "futures", @@ -556,6 +650,12 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -575,12 +675,34 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "convert_case" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -674,6 +796,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -796,7 +945,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -818,12 +967,26 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -902,6 +1065,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.25.1" @@ -973,6 +1145,12 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2045,6 +2223,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -2270,6 +2454,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2377,6 +2567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2640,6 +2831,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -3978,12 +4175,39 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5117,6 +5341,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -5182,6 +5412,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.22.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3e505a34..10471f47 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,3 +39,9 @@ url = "2" flate2 = "1" chacha20poly1305 = "0.10" pbkdf2 = "0.12" + +# CLI dependencies +clap = { version = "4.4", features = ["derive"] } +dialoguer = "0.11" +console = "0.15" +crossterm = "0.29.0" diff --git a/src-tauri/src/api/usage.rs b/src-tauri/src/api/usage.rs index 6c4c1331..3277049d 100644 --- a/src-tauri/src/api/usage.rs +++ b/src-tauri/src/api/usage.rs @@ -24,6 +24,14 @@ const CHATGPT_CODEX_RESPONSES_API: &str = "https://chatgpt.com/backend-api/codex const OPENAI_API: &str = "https://api.openai.com/v1"; const CODEX_USER_AGENT: &str = "codex-cli/1.0.0"; +macro_rules! log_debug { + ($($arg:tt)*) => { + if std::env::var("CODEX_DEBUG").is_ok() { + println!($($arg)*); + } + }; +} + #[derive(Debug, Clone)] pub struct ChatGptAccountMetadata { pub plan_type: Option, @@ -58,11 +66,11 @@ struct AccountsCheckEntitlement { /// Get usage information for an account pub async fn get_account_usage(account: &StoredAccount) -> Result { - println!("[Usage] Fetching usage for account: {}", account.name); + log_debug!("[Usage] Fetching usage for account: {}", account.name); match &account.auth_data { AuthData::ApiKey { .. } => { - println!("[Usage] API key accounts don't support usage info"); + log_debug!("[Usage] API key accounts don't support usage info"); Ok(UsageInfo { account_id: account.id.clone(), plan_type: Some("api_key".to_string()), @@ -84,8 +92,7 @@ pub async fn get_account_usage(account: &StoredAccount) -> Result { /// Send a minimal authenticated request to warm up account traffic paths. pub async fn warmup_account(account: &StoredAccount) -> Result<()> { - println!( - "[Warmup] Sending warm-up request for account: {}", + log_debug!("[Warmup] Sending warm-up request for account: {}", account.name ); @@ -138,8 +145,7 @@ async fn get_usage_with_chatgpt_auth(account: &StoredAccount) -> Result Result { let status = response.status(); - println!("[Usage] Response status: {status}"); + log_debug!("[Usage] Response status: {status}"); if !status.is_success() { let body = response.text().await.unwrap_or_default(); - println!("[Usage] Error response: {body}"); + log_debug!("[Usage] Error response: {body}"); return Ok(UsageInfo::error( account_id.to_string(), format!("API error: {status}"), @@ -177,19 +183,17 @@ async fn parse_usage_response( .text() .await .context("Failed to read response body")?; - println!( - "[Usage] Response body: {}", + log_debug!("[Usage] Response body: {}", &body_text[..body_text.len().min(200)] ); let payload: RateLimitStatusPayload = serde_json::from_str(&body_text).context("Failed to parse usage response")?; - println!("[Usage] Parsed plan_type: {}", payload.plan_type); + log_debug!("[Usage] Parsed plan_type: {}", payload.plan_type); let usage = convert_payload_to_usage_info(account_id, payload); - println!( - "[Usage] {} - primary: {:?}%, plan: {:?}", + log_debug!("[Usage] {} - primary: {:?}%, plan: {:?}", account_name, usage.primary_used_percent, usage.plan_type ); @@ -202,8 +206,7 @@ async fn warmup_with_chatgpt_auth(account: &StoredAccount) -> Result<()> { let mut response = send_chatgpt_warmup_request(access_token, chatgpt_account_id, true).await?; if response.status() == StatusCode::UNAUTHORIZED { - println!( - "[Warmup] Unauthorized for account {}, refreshing token and retrying once", + log_debug!("[Warmup] Unauthorized for account {}, refreshing token and retrying once", fresh_account.name ); let refreshed_account = refresh_chatgpt_tokens(&fresh_account).await?; @@ -214,7 +217,7 @@ async fn warmup_with_chatgpt_auth(account: &StoredAccount) -> Result<()> { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - println!("[Warmup] ChatGPT warm-up error response: {body}"); + log_debug!("[Warmup] ChatGPT warm-up error response: {body}"); anyhow::bail!("ChatGPT warm-up failed with status {status}"); } @@ -239,7 +242,7 @@ async fn warmup_with_api_key(api_key: &str) -> Result<()> { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - println!("[Warmup] API key warm-up error response: {body}"); + log_debug!("[Warmup] API key warm-up error response: {body}"); anyhow::bail!("API key warm-up failed with status {status}"); } @@ -296,7 +299,7 @@ fn build_chatgpt_headers( ); if let Some(acc_id) = chatgpt_account_id { - println!("[Usage] Using ChatGPT Account ID: {acc_id}"); + log_debug!("[Usage] Using ChatGPT Account ID: {acc_id}"); if let Ok(header_name) = HeaderName::from_bytes(b"chatgpt-account-id") { if let Ok(header_value) = HeaderValue::from_str(acc_id) { headers.insert(header_name, header_value); @@ -337,7 +340,7 @@ async fn send_chatgpt_get_request( ) -> Result { let client = reqwest::Client::new(); let headers = build_chatgpt_headers(access_token, chatgpt_account_id)?; - println!("[Usage] Requesting: {url}"); + log_debug!("[Usage] Requesting: {url}"); client .get(url) @@ -367,12 +370,12 @@ async fn send_chatgpt_warmup_request( fn log_warmup_response(source: &str, body: &str, is_sse: bool) { if body.trim().is_empty() { - println!("[Warmup] {source} warm-up response was empty"); + log_debug!("[Warmup] {source} warm-up response was empty"); return; } let preview = truncate_text(body, 300); - println!("[Warmup] {source} warm-up response preview: {preview}"); + log_debug!("[Warmup] {source} warm-up response preview: {preview}"); let extracted = if is_sse { extract_text_from_sse(body) @@ -382,7 +385,7 @@ fn log_warmup_response(source: &str, body: &str, is_sse: bool) { if let Some(message) = extracted { let message_preview = truncate_text(&message, 200); - println!("[Warmup] {source} warm-up message: {message_preview}"); + log_debug!("[Warmup] {source} warm-up message: {message_preview}"); } } @@ -491,7 +494,7 @@ fn extract_credits(credits: Option) -> Option Vec { - println!("[Usage] Refreshing usage for {} accounts", accounts.len()); + log_debug!("[Usage] Refreshing usage for {} accounts", accounts.len()); let concurrency = accounts.len().min(10).max(1); let results: Vec = stream::iter(accounts.iter().cloned()) @@ -499,7 +502,7 @@ pub async fn refresh_all_usage(accounts: &[StoredAccount]) -> Vec { match get_account_usage(&account).await { Ok(info) => info, Err(e) => { - println!("[Usage] Error for {}: {}", account.name, e); + log_debug!("[Usage] Error for {}: {}", account.name, e); UsageInfo::error(account.id.clone(), e.to_string()) } } @@ -508,6 +511,6 @@ pub async fn refresh_all_usage(accounts: &[StoredAccount]) -> Vec { .collect() .await; - println!("[Usage] Refresh complete"); + log_debug!("[Usage] Refresh complete"); results } diff --git a/src-tauri/src/bin/codex-switch.rs b/src-tauri/src/bin/codex-switch.rs new file mode 100644 index 00000000..1fa1309a --- /dev/null +++ b/src-tauri/src/bin/codex-switch.rs @@ -0,0 +1,665 @@ +use clap::Parser; +use console::style; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use codex_switcher_lib::commands::{ + cancel_login, complete_login, delete_account, list_accounts, + refresh_all_accounts_usage, rename_account, start_login, switch_account, +}; +use codex_switcher_lib::types::{AccountInfo, AuthMode, UsageInfo}; +use codex_switcher_lib::auth::storage::get_config_dir; +use std::time::{Instant, Duration}; +use std::io::{self, Write}; +use crossterm::{ + event::{self, Event, KeyCode, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, + cursor, +}; +use tokio::sync::mpsc; +use chrono::Local; + +#[derive(Parser)] +#[command(name = "codex-switch")] +#[command(author = "lampese")] +#[command(version = "0.2.2")] +#[command(about = "Codex Account Switcher - Interactive CLI", long_about = None)] +struct Cli {} + +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)] +struct CliConfig { + refresh_interval_secs: u64, +} + +fn load_cli_config() -> CliConfig { + let path = get_config_dir().map(|p| p.join("cli-config.json")); + if let Ok(p) = path { + if p.exists() { + if let Ok(content) = std::fs::read_to_string(&p) { + if let Ok(config) = serde_json::from_str::(&content) { + return config; + } + } + } else { + // Write default config + let default_config = CliConfig { refresh_interval_secs: 60 }; + if let Ok(content) = serde_json::to_string_pretty(&default_config) { + let _ = std::fs::write(&p, content); + } + } + } + CliConfig { refresh_interval_secs: 60 } +} + +#[derive(Clone, PartialEq)] +enum MenuState { + Main, + SwitchSelect, +} + +enum MainMenuChoice { + SwitchAccount, + AddAccount, + RenameAccount, + DeleteAccount, + Exit, +} + +enum SubMenuChoice { + Select(AccountInfo), + Back, +} + +#[tokio::main] +async fn main() { + let _cli = Cli::parse(); + let config = load_cli_config(); + + if let Err(e) = run_interactive_loop(config).await { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Show); + eprintln!("{}", style(format!("Fatal Error: {}", e)).red().bold()); + std::process::exit(1); + } +} + +async fn run_interactive_loop(config: CliConfig) -> Result<(), String> { + enable_raw_mode().map_err(|e| e.to_string())?; + let mut stdout = io::stdout(); + execute!(stdout, cursor::Hide).map_err(|e| e.to_string())?; + + // State + let mut accounts = list_accounts().await?; + let mut usages: Vec = Vec::new(); + let mut menu_state = MenuState::Main; + let mut selected_index = 0; + let mut last_refresh = Instant::now() - Duration::from_secs(config.refresh_interval_secs + 1); // trigger initial refresh + let mut last_refresh_str = "Never".to_string(); + let mut is_refreshing = false; + let mut should_render = true; + + // Channel for background refresh + let (tx, mut rx) = mpsc::channel::, String>>(1); + + loop { + // Check if background refresh finished + if let Ok(res) = rx.try_recv() { + is_refreshing = false; + should_render = true; + match res { + Ok(new_usages) => { + usages = new_usages; + last_refresh = Instant::now(); + last_refresh_str = Local::now().format("%H:%M:%S").to_string(); + } + Err(_e) => {} + } + } + + // Trigger auto refresh if due and not currently refreshing + if !is_refreshing && last_refresh.elapsed() >= Duration::from_secs(config.refresh_interval_secs) { + is_refreshing = true; + should_render = true; + let tx_clone = tx.clone(); + tokio::spawn(async move { + let res = refresh_all_accounts_usage().await; + let _ = tx_clone.send(res).await; + }); + } + + // Build current choice list based on menu state + let mut main_choices = Vec::new(); + let mut sub_choices = Vec::new(); + let choices_len = match menu_state { + MenuState::Main => { + main_choices.push(MainMenuChoice::SwitchAccount); + main_choices.push(MainMenuChoice::AddAccount); + main_choices.push(MainMenuChoice::RenameAccount); + main_choices.push(MainMenuChoice::DeleteAccount); + main_choices.push(MainMenuChoice::Exit); + main_choices.len() + } + MenuState::SwitchSelect => { + for acc in &accounts { + sub_choices.push(SubMenuChoice::Select(acc.clone())); + } + sub_choices.push(SubMenuChoice::Back); + sub_choices.len() + } + }; + + // Keep selected index within bounds + if selected_index >= choices_len { + selected_index = choices_len.saturating_sub(1); + } + + // Render screen only if state changed + if should_render { + render_screen( + &accounts, + &usages, + &menu_state, + &main_choices, + &sub_choices, + selected_index, + is_refreshing, + &last_refresh_str, + &config, + )?; + should_render = false; + } + + // Poll for key events (non-blocking wait) + if event::poll(Duration::from_millis(50)).map_err(|e| e.to_string())? { + if let Event::Key(key_event) = event::read().map_err(|e| e.to_string())? { + if key_event.kind == event::KeyEventKind::Press { + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => { + if selected_index > 0 { + selected_index -= 1; + } else { + selected_index = choices_len.saturating_sub(1); // wrap around + } + should_render = true; + } + KeyCode::Down | KeyCode::Char('j') => { + if selected_index < choices_len.saturating_sub(1) { + selected_index += 1; + } else { + selected_index = 0; // wrap around + } + should_render = true; + } + KeyCode::Enter => { + match menu_state { + MenuState::Main => { + let choice = &main_choices[selected_index]; + match choice { + MainMenuChoice::Exit => { + break; + } + MainMenuChoice::SwitchAccount => { + menu_state = MenuState::SwitchSelect; + selected_index = 0; + should_render = true; + } + MainMenuChoice::AddAccount => { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Show); + if let Err(e) = add_account_prompt().await { + println!("{}", style(format!("Failed to add account: {}", e)).red()); + wait_for_enter(); + } + let _ = enable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Hide); + accounts = list_accounts().await?; + should_render = true; + // Trigger instant refresh + last_refresh = Instant::now() - Duration::from_secs(config.refresh_interval_secs + 1); + } + MainMenuChoice::RenameAccount => { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Show); + if let Err(e) = rename_account_prompt(&accounts).await { + println!("{}", style(format!("Rename failed: {}", e)).red()); + wait_for_enter(); + } + let _ = enable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Hide); + accounts = list_accounts().await?; + should_render = true; + } + MainMenuChoice::DeleteAccount => { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Show); + if let Err(e) = delete_account_prompt(&accounts).await { + println!("{}", style(format!("Delete failed: {}", e)).red()); + wait_for_enter(); + } + let _ = enable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Hide); + accounts = list_accounts().await?; + should_render = true; + // Trigger instant refresh + last_refresh = Instant::now() - Duration::from_secs(config.refresh_interval_secs + 1); + } + } + } + MenuState::SwitchSelect => { + let choice = &sub_choices[selected_index]; + match choice { + SubMenuChoice::Back => { + menu_state = MenuState::Main; + selected_index = 0; + should_render = true; + } + SubMenuChoice::Select(acc) => { + if !acc.is_active { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Show); + println!("\nSwitching active account to '{}'...", acc.name); + if let Err(e) = switch_account(acc.id.clone()).await { + println!("{}", style(format!("Failed to switch: {}", e)).red()); + wait_for_enter(); + } else { + println!("{}", style(format!("Successfully switched to '{}'!", acc.name)).green().bold()); + // Trigger instant refresh + last_refresh = Instant::now() - Duration::from_secs(config.refresh_interval_secs + 1); + } + let _ = enable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Hide); + accounts = list_accounts().await?; + } + menu_state = MenuState::Main; + selected_index = 0; + should_render = true; + } + } + } + } + } + KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { + break; // Ctrl+C to exit + } + KeyCode::Char('r') => { + // Manual refresh trigger + if !is_refreshing { + is_refreshing = true; + should_render = true; + let tx_clone = tx.clone(); + tokio::spawn(async move { + let res = refresh_all_accounts_usage().await; + let _ = tx_clone.send(res).await; + }); + } + } + _ => {} + } + } + } + } + } + + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Show); + println!("\nGoodbye!"); + Ok(()) +} + +fn render_screen( + accounts: &[AccountInfo], + usages: &[UsageInfo], + menu_state: &MenuState, + main_choices: &[MainMenuChoice], + sub_choices: &[SubMenuChoice], + selected_index: usize, + is_refreshing: bool, + last_refresh_str: &str, + config: &CliConfig, +) -> Result<(), String> { + let mut stdout = io::stdout(); + // Clear the screen and move cursor to (0,0) + execute!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0)).map_err(|e| e.to_string())?; + + println!("{}", style("=== Codex Account Switcher ===").bold().cyan()); + println!(); + + // 1. Render Usage Limits Table + let mut max_name = 4; + for acc in accounts { + max_name = max_name.max(acc.name.len()); + } + max_name = max_name.min(40); + + let h_name = "Name"; + let h_mode = "Mode"; + let h_primary = "Primary (5h)"; + let h_secondary = "Secondary (Weekly)"; + let h_credits = "Credits / Balance / Details"; + + let header_str = format!( + " {: "APIKey", + AuthMode::ChatGPT => "OAuth ", + }; + + let name_val = if acc.name.len() > max_name { format!("{}...", &acc.name[..max_name - 3]) } else { acc.name.clone() }; + + let (primary_val, secondary_val, credit_val) = match usage { + Some(u) => { + if let Some(err) = &u.error { + ("-".to_string(), "-".to_string(), style(err.clone()).dim().to_string()) + } else { + let prim = u.primary_used_percent.map(|p| render_bar(get_remaining_percent(p))).unwrap_or_else(|| "-".to_string()); + let sec = u.secondary_used_percent.map(|p| render_bar(get_remaining_percent(p))).unwrap_or_else(|| "-".to_string()); + let cred = if let Some(true) = u.unlimited_credits { + style("Unlimited Credits").green().to_string() + } else if let Some(balance) = &u.credits_balance { + format!("Credits: {}", balance) + } else if let Some(true) = u.has_credits { + style("Has Credits").green().to_string() + } else { + "-".to_string() + }; + (prim, sec, cred) + } + } + None => ("-".to_string(), "-".to_string(), style("No usage data").dim().to_string()), + }; + + let primary_padded = pad_styled_string(&primary_val, 28); + let secondary_padded = pad_styled_string(&secondary_val, 28); + + let line = format!( + " {: { + println!("{}", style("Select an action:").bold().yellow()); + for (idx, choice) in main_choices.iter().enumerate() { + let is_selected = idx == selected_index; + let prefix = if is_selected { + style("> ").green().bold().to_string() + } else { + " ".to_string() + }; + + let text = match choice { + MainMenuChoice::SwitchAccount => style("[Switch Account]").green().to_string(), + MainMenuChoice::AddAccount => style("[Add Account]").cyan().to_string(), + MainMenuChoice::RenameAccount => style("[Rename Account]").magenta().to_string(), + MainMenuChoice::DeleteAccount => style("[Delete Account]").red().to_string(), + MainMenuChoice::Exit => style("[Exit]").red().to_string(), + }; + + if is_selected { + println!("{}{}", prefix, style(text).underlined().bold()); + } else { + println!("{}{}", prefix, text); + } + } + } + MenuState::SwitchSelect => { + println!("{}", style("Select an account to switch:").bold().yellow()); + for (idx, choice) in sub_choices.iter().enumerate() { + let is_selected = idx == selected_index; + let prefix = if is_selected { + style("> ").green().bold().to_string() + } else { + " ".to_string() + }; + + let text = match choice { + SubMenuChoice::Select(acc) => { + let active_marker = if acc.is_active { + style("* [Active]").green().bold().to_string() + } else { + "".to_string() + }; + let details = acc.email.as_deref().unwrap_or(match acc.auth_mode { + AuthMode::ApiKey => "API Key", + AuthMode::ChatGPT => "ChatGPT", + }); + format!("{} ({}) {}", acc.name, details, active_marker) + } + SubMenuChoice::Back => style("[Back]").yellow().to_string(), + }; + + if is_selected { + println!("{}{}", prefix, style(text).underlined().bold()); + } else { + println!("{}{}", prefix, text); + } + } + } + } + + let _ = io::stdout().flush(); + Ok(()) +} + +fn get_remaining_percent(used_percent: f64) -> f64 { + (100.0 - used_percent).clamp(0.0, 100.0) +} + +fn render_bar(remaining: f64) -> String { + let clamped = remaining.clamp(0.0, 100.0); + let filled_blocks = (clamped / 5.0).round() as usize; + let empty_blocks = 20 - filled_blocks; + let bar = format!("{}{}", "█".repeat(filled_blocks), "░".repeat(empty_blocks)); + + if clamped > 30.0 { + format!("[{}] {:.1}%", style(bar).green().bold(), remaining) + } else if clamped > 10.0 { + format!("[{}] {:.1}%", style(bar).yellow().bold(), remaining) + } else { + format!("[{}] {:.1}%", style(bar).red().bold(), remaining) + } +} + +fn pad_styled_string(s: &str, width: usize) -> String { + let vis_len = visual_len(s); + if vis_len >= width { + s.to_string() + } else { + format!("{}{}", s, " ".repeat(width - vis_len)) + } +} + +fn visual_len(s: &str) -> usize { + let mut len = 0; + let mut in_escape = false; + for c in s.chars() { + if c == '\x1b' { + in_escape = true; + } else if in_escape { + if c == 'm' { + in_escape = false; + } + } else { + len += 1; + } + } + len +} + +fn wait_for_enter() { + println!("\nPress Enter to return to the menu..."); + let mut buf = String::new(); + let _ = std::io::stdin().read_line(&mut buf); +} + +async fn add_account_prompt() -> Result<(), String> { + println!(); + println!("{}", style("=== Add Account ===").bold().cyan()); + + let name: String = Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter name for this account") + .interact_text() + .map_err(|e| e.to_string())?; + + if name.trim().is_empty() { + return Err("Name cannot be empty".to_string()); + } + + println!("Generating login link and starting local server..."); + let info = start_login(name.trim().to_string()).await?; + + println!("Opening browser to complete authorization..."); + if let Err(e) = webbrowser::open(&info.auth_url) { + println!("{}", style(format!("Could not open browser automatically: {}", e)).yellow()); + println!("Please open this URL manually in your browser:\n{}", info.auth_url); + } + + println!("\nWaiting for authentication in browser..."); + println!("{}", style("Press ESC to cancel.").dim()); + + // Switch back to raw mode to handle Esc key + enable_raw_mode().map_err(|e| e.to_string())?; + execute!(io::stdout(), cursor::Hide).map_err(|e| e.to_string())?; + + let login_fut = complete_login(); + tokio::pin!(login_fut); + + let result = loop { + tokio::select! { + res = &mut login_fut => { + break res; + } + _ = tokio::time::sleep(Duration::from_millis(100)) => { + if event::poll(Duration::from_millis(0)).unwrap_or(false) { + if let Event::Key(key_event) = event::read().unwrap() { + if key_event.kind == event::KeyEventKind::Press && key_event.code == KeyCode::Esc { + // User wants to cancel + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Show); + println!("\nCancelling OAuth login..."); + let _ = cancel_login().await; + return Err("Login cancelled by user".to_string()); + } + } + } + } + } + }; + + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), cursor::Show); + + match result { + Ok(acc_info) => { + println!("{}", style(format!("\nAccount '{}' successfully added and set as active!", acc_info.name)).green().bold()); + wait_for_enter(); + Ok(()) + } + Err(e) => Err(e), + } +} + +async fn rename_account_prompt(accounts: &[AccountInfo]) -> Result<(), String> { + println!(); + println!("{}", style("=== Rename Account ===").bold().cyan()); + + let mut options = Vec::new(); + for acc in accounts { + options.push(acc.name.clone()); + } + options.push("Cancel".to_string()); + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select an account to rename") + .items(&options) + .default(0) + .interact() + .map_err(|e| e.to_string())?; + + if selection == options.len() - 1 { + return Ok(()); // Canceled + } + + let target = &accounts[selection]; + let new_name: String = Input::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("Enter new name for '{}'", target.name)) + .interact_text() + .map_err(|e| e.to_string())?; + + if new_name.trim().is_empty() { + return Err("Name cannot be empty".to_string()); + } + + rename_account(target.id.clone(), new_name.trim().to_string()).await?; + println!("{}", style("Account renamed successfully!").green().bold()); + wait_for_enter(); + Ok(()) +} + +async fn delete_account_prompt(accounts: &[AccountInfo]) -> Result<(), String> { + println!(); + println!("{}", style("=== Delete Account ===").bold().cyan()); + + let mut options = Vec::new(); + for acc in accounts { + let active_mark = if acc.is_active { " (Active)" } else { "" }; + options.push(format!("{}{}", acc.name, active_mark)); + } + options.push("Cancel".to_string()); + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select an account to delete") + .items(&options) + .default(0) + .interact() + .map_err(|e| e.to_string())?; + + if selection == options.len() - 1 { + return Ok(()); // Canceled + } + + let target = &accounts[selection]; + + let confirm = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("Are you sure you want to delete account '{}'?", target.name)) + .default(false) + .interact() + .map_err(|e| e.to_string())?; + + if confirm { + delete_account(target.id.clone()).await?; + println!("{}", style("Account deleted successfully!").green().bold()); + } + wait_for_enter(); + Ok(()) +} From 2fb405c8a7630f091da1e5102f57c58f9521ee83 Mon Sep 17 00:00:00 2001 From: quangnv13 Date: Mon, 8 Jun 2026 22:37:13 +0700 Subject: [PATCH 2/2] docs: add interactive CLI usage instructions to README --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 9b84f490..c421d9c4 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,26 @@ Optional environment variables: The browser dashboard serves the same UI and backend actions through `/api/invoke/*`, which makes it usable over LAN, Tailscale, or a remote host tunnel when you expose the chosen port safely. +### Run the Interactive CLI + +An interactive command-line interface is available for fast account switching and status checks directly from your terminal. + +```bash +# Run the interactive CLI +pnpm cli +``` + +Alternatively, you can run it directly with Cargo: +```bash +cargo run --manifest-path src-tauri/Cargo.toml --bin codex-switch -- +``` + +**CLI Features:** +- **Interactive Menu**: Navigate with arrow keys (or `j`/`k`) and `Enter` to switch, add, rename, or delete accounts. +- **Real-Time Quota Display**: Displays progress bars for 5-hour and weekly usage limits, and shows remaining balance/credits. +- **OAuth Login flow**: Add new accounts by triggering a login URL which opens in your default browser, then completes securely back in the CLI. +- **Background Auto-Refresh**: Periodically updates account quotas in the background (frequency configured in the config directory's `cli-config.json`, defaults to 60 seconds). You can also press `r` to trigger a manual refresh. + ## Disclaimer This tool is designed **exclusively for individuals who personally own multiple OpenAI/ChatGPT accounts**. It is intended to help users manage their own accounts more conveniently.