diff --git a/Cargo.lock b/Cargo.lock index f4a382b..188fb00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,7 +232,7 @@ dependencies = [ [[package]] name = "cdxs" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index b8afd41..50b3b92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cdxs" -version = "0.1.3" +version = "0.1.4" edition = "2021" description = "Codex account switcher CLI" diff --git a/README.md b/README.md index 60ad336..0ae59f5 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Codex home 的解析顺序: - `cdxs show`:列出账号,但不请求配额接口。 - `cdxs switch`:不带参数时自动选择可用配额最优的账号。 - `cdxs switch <账号>`:切换到指定账号。 +- `cdxs ping`:以 5 并发 ping 所有 OAuth 账号,运行最低 reasoning 的最小 `codex exec`,触发 Codex 侧额度状态刷新。 +- `cdxs ping --account <账号>`:指定账号运行最小 `codex exec`;可重复传多个账号。 - `cdxs login api --key --base-url --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。 - `cdxs remove <账号>`:删除账号,等价于 `cdxs account remove <账号>`。 - `cdxs pull`:从同步服务拉取账号状态,等价于 `cdxs sync pull`。 diff --git a/src/account.rs b/src/account.rs index 8d161b0..f9cf0cd 100644 --- a/src/account.rs +++ b/src/account.rs @@ -105,15 +105,10 @@ fn print_accounts(store: &Store, json: bool) -> Result<()> { print_account_table_row("", "ID", "Email", "Mode", "Plan", "5h", "Week"); print_account_table_border(); let current_account_id = store.meta.current_account_id.as_deref(); - let mut accounts = store.accounts.iter().collect::>(); - accounts.sort_by_key(|account| { - if Some(account.id.as_str()) == current_account_id { - 0 - } else { - 1 - } - }); - for account in accounts { + let mut accounts = store.accounts.iter().enumerate().collect::>(); + accounts + .sort_by_key(|(index, account)| account_list_sort_key(account, *index, current_account_id)); + for (_, account) in accounts { let current = if store.meta.current_account_id.as_deref() == Some(account.id.as_str()) { "*" } else { @@ -122,7 +117,7 @@ fn print_accounts(store: &Store, json: bool) -> Result<()> { let (primary_quota, secondary_quota) = format_quota_cells(account); print_account_table_row( current, - &account.id, + &account_id_display(&account.id), account_email_display(account), auth_file::account_auth_mode_name(account), account_plan_display(account), @@ -134,6 +129,24 @@ fn print_accounts(store: &Store, json: bool) -> Result<()> { Ok(()) } +fn account_list_sort_key( + account: &Account, + index: usize, + current_account_id: Option<&str>, +) -> (u8, u8, usize) { + let current_rank = if Some(account.id.as_str()) == current_account_id { + 0 + } else { + 1 + }; + let mode_rank = if account.auth_mode == AuthMode::ApiKey { + 1 + } else { + 0 + }; + (current_rank, mode_rank, index) +} + pub fn current_account(json: bool) -> Result<()> { let home = paths::codex_home(None)?; let store = Store::load(&home)?; @@ -544,7 +557,8 @@ fn find_unique_account_id(store: &Store, query: &str) -> Result { .accounts .iter() .filter(|account| { - account.email.eq_ignore_ascii_case(query) + account_id_matches_query(&account.id, query) + || account.email.eq_ignore_ascii_case(query) || account .email .to_ascii_lowercase() @@ -575,6 +589,22 @@ fn find_unique_account_id(store: &Store, query: &str) -> Result { } } +fn account_id_matches_query(id: &str, query: &str) -> bool { + id.starts_with(query) + || id + .split_once('_') + .map(|(_, suffix)| suffix.starts_with(query)) + .unwrap_or(false) +} + +fn account_id_display(id: &str) -> String { + let Some((kind, suffix)) = id.split_once('_') else { + return shorten(id, 12); + }; + let short_suffix = suffix.chars().take(6).collect::(); + format!("{kind}_{short_suffix}") +} + fn best_auto_switch_account(store: &Store) -> Result { store .accounts diff --git a/src/cli.rs b/src/cli.rs index 1d7dfd9..06fbdff 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -61,6 +61,8 @@ pub enum Commands { }, /// Prepare auth, set CODEX_HOME, and execute a command. Run(RunArgs), + /// Run a minimal Codex exec to refresh Codex-side quota state. + Ping(PingArgs), /// Refresh and display Codex quota. Quota { #[arg(value_name = "ACCOUNT_ID_OR_EMAIL")] @@ -341,3 +343,19 @@ pub struct RunArgs { #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] pub command: Vec, } + +#[derive(Args)] +pub struct PingArgs { + #[arg(long, value_name = "ACCOUNT_ID_OR_EMAIL")] + pub account: Vec, + #[arg(long, default_value_t = 5)] + pub concurrency: usize, + #[arg(long)] + pub codex_home: Option, + #[arg(long, default_value = "gpt-5.4")] + pub model: String, + #[arg(long, default_value = "none")] + pub reasoning_effort: String, + #[arg(long, default_value = "hello")] + pub prompt: String, +} diff --git a/src/config_store.rs b/src/config_store.rs index 1b70de6..d2f30a9 100644 --- a/src/config_store.rs +++ b/src/config_store.rs @@ -228,6 +228,12 @@ impl Store { let query_lower = query.to_ascii_lowercase(); self.accounts.iter().find(|account| { account.id == query + || account.id.starts_with(query) + || account + .id + .split_once('_') + .map(|(_, suffix)| suffix.starts_with(query)) + .unwrap_or(false) || account.email.eq_ignore_ascii_case(query) || account .email diff --git a/src/main.rs b/src/main.rs index 89adc36..479bbdf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -89,6 +89,17 @@ async fn main() -> Result<()> { ) .await } + Commands::Ping(args) => { + run_cmd::ping_codex( + args.account, + args.concurrency, + args.codex_home, + args.model, + args.reasoning_effort, + args.prompt, + ) + .await + } Commands::Quota { accounts, json } => quota::quota_command(accounts, json).await, Commands::Account { command } => match command { AccountCommands::List { json, force } => account::list_accounts(json, force).await, diff --git a/src/run_cmd.rs b/src/run_cmd.rs index 066b604..a12ac6b 100644 --- a/src/run_cmd.rs +++ b/src/run_cmd.rs @@ -1,11 +1,15 @@ //! Execute a child command with a prepared Codex authentication context. use std::path::PathBuf; -use std::process::Command; +use std::process::{Command, Stdio}; use anyhow::{anyhow, Context, Result}; -use crate::{account, paths}; +use crate::{ + account, + config_store::{AuthMode, Store}, + paths, +}; pub async fn run_with_account_or_home( account_query: Option, @@ -46,3 +50,163 @@ pub async fn run_with_account_or_home( } Ok(()) } + +pub async fn ping_codex( + account_queries: Vec, + concurrency: usize, + codex_home: Option, + model: String, + reasoning_effort: String, + prompt: String, +) -> Result<()> { + ping_codex_many( + account_queries, + concurrency, + codex_home, + model, + reasoning_effort, + prompt, + ) + .await +} + +async fn ping_codex_many( + account_queries: Vec, + concurrency: usize, + codex_home: Option, + model: String, + reasoning_effort: String, + prompt: String, +) -> Result<()> { + let main_home = paths::codex_home(None)?; + let base_home = paths::codex_home(codex_home)?; + let store = Store::load(&main_home)?; + let account_ids = if account_queries.is_empty() { + store + .accounts + .iter() + .filter(|account| account.auth_mode == AuthMode::Oauth) + .map(|account| account.id.clone()) + .collect::>() + } else { + account_queries + .iter() + .map(|query| { + store + .find_account(query) + .ok_or_else(|| anyhow!("账号不存在: {query}")) + .map(|account| account.id.clone()) + }) + .collect::>>()? + }; + if account_ids.is_empty() { + return Err(anyhow!("没有可 ping 的 OAuth 账号")); + } + + let mut jobs = Vec::new(); + for account_id in &account_ids { + let ping_home = base_home + .join("cdxs-ping") + .join(safe_path_component(account_id)); + account::prepare_account_in_home(account_id, ping_home.clone()).await?; + jobs.push((account_id.clone(), ping_home)); + } + + let concurrency = concurrency.max(1); + let mut failures = Vec::new(); + for chunk in jobs.chunks(concurrency) { + let mut handles = Vec::new(); + for (account_id, ping_home) in chunk.iter().cloned() { + let model = model.clone(); + let reasoning_effort = reasoning_effort.clone(); + let prompt = prompt.clone(); + handles.push(tokio::spawn(async move { + let result = run_codex_ping(ping_home, model, reasoning_effort, prompt, true).await; + (account_id, result) + })); + } + for handle in handles { + match handle.await { + Ok((account_id, Ok(()))) => println!("ping ok: {account_id}"), + Ok((account_id, Err(error))) => { + eprintln!("ping failed: {account_id}: {error}"); + failures.push(account_id); + } + Err(error) => { + eprintln!("ping task failed: {error}"); + failures.push("".to_string()); + } + } + } + } + + if !failures.is_empty() { + return Err(anyhow!("部分账号 ping 失败: {} 个", failures.len())); + } + Ok(()) +} + +async fn run_codex_ping( + codex_home: PathBuf, + model: String, + reasoning_effort: String, + prompt: String, + quiet: bool, +) -> Result<()> { + let command_args = vec![ + "codex".to_string(), + "exec".to_string(), + "--ignore-user-config".to_string(), + "-c".to_string(), + format!("model_reasoning_effort=\"{reasoning_effort}\""), + "--model".to_string(), + model, + "--skip-git-repo-check".to_string(), + prompt, + ]; + let status = tokio::task::spawn_blocking(move || { + let mut child = codex_exec_command(&command_args); + child.env("CODEX_HOME", &codex_home); + if quiet { + child.stdout(Stdio::null()).stderr(Stdio::null()); + } + child + .status() + .with_context(|| format!("启动命令失败: {}", command_args.join(" "))) + }) + .await + .context("ping task join failed")??; + + if !status.success() { + return Err(anyhow!("命令退出失败: status={status}")); + } + Ok(()) +} + +fn safe_path_component(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '_' + } + }) + .collect() +} + +fn codex_exec_command(args: &[String]) -> Command { + #[cfg(windows)] + { + let mut command = Command::new("cmd.exe"); + command.arg("/C").args(args); + command + } + #[cfg(not(windows))] + { + let mut command = Command::new(&args[0]); + command.args(&args[1..]); + command + } +}