From 8228b895ac5edd5fb0e314bc17124021713d1516 Mon Sep 17 00:00:00 2001 From: chuan Date: Mon, 8 Jun 2026 15:49:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 +- src/account/listing.rs | 6 - src/account/mod.rs | 2 +- src/cli.rs | 66 +++----- src/main.rs | 354 ++++++++++++++++++++--------------------- src/quota.rs | 110 ------------- src/run_cmd.rs | 295 ++++++++++++++-------------------- 7 files changed, 322 insertions(+), 524 deletions(-) diff --git a/README.md b/README.md index cfb9971..eee5886 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ Codex 的认证和会话状态都来自 `CODEX_HOME`。`cdxs` 做的是在这个目录外面加一层可操作的管理能力: - 账号管理:导入已有 `auth.json`,通过 OAuth 或 API Key 登录,切换当前账号,并在需要时刷新 OAuth token。 -- 配额查看:查询 OAuth 账号的 Codex 使用配额,并缓存到本地。 +- 配额缓存:列出账号时自动刷新过期的 OAuth 配额缓存。 - Home 管理:创建命名的 `CODEX_HOME`,并绑定到指定账号。 -- 命令运行:用指定账号或 home 启动子进程,只为该进程设置 `CODEX_HOME`。 +- 命令运行:用指定账号或 home 运行 `codex exec`,只为该进程设置 `CODEX_HOME`。 - 会话管理:查看会话、统计 token 和文件信息、移入可恢复垃圾箱、恢复会话、修复缺失的会话索引。 - 线程同步:在多个受管理 home 之间补齐缺失的会话线程。 - 状态同步:运行轻量 HTTP 服务,在多台机器之间推送或拉取账号状态。 @@ -39,16 +39,17 @@ Codex home 的解析顺序: ## 常用快捷命令 -- `cdxs list`:列出账号,并自动刷新过期配额缓存。 -- `cdxs show`:列出账号,但不请求配额接口。 +- `cdxs`:列出账号,并自动刷新过期配额缓存。 +- `cdxs -f`:强制刷新账号配额后再列出。 +- `cdxs show <账号>`:显示单个账号详情,等价于 `cdxs account show <账号>`。 - `cdxs alias set <别名> <账号>`:给任意 OAuth 或 API Key 账号设置别名。 - `cdxs alias list`:列出所有账号别名。 - `cdxs alias remove <别名或账号>`:删除指定账号的别名。 - `cdxs switch`:不带参数时自动选择可用配额最优的账号。 - `cdxs switch <账号或别名>`:切换到指定账号,OAuth 和 API Key 账号都可通过 alias 切换。 - `cdxs switch A --model --effort --name OpenAI`:切换时更新该账号默认模型、思考程度;`--name` 仅用于 API 模式的 provider name。 -- `cdxs ping`:以 5 并发 ping 所有 OAuth 账号,运行最低 reasoning 的最小 `codex exec`,触发 Codex 侧额度状态刷新。 -- `cdxs ping --account <账号>`:指定账号运行最小 `codex exec`;可重复传多个账号。 +- `cdxs exec <账号> -- --model gpt-5 "hello"`:用指定账号运行 `codex exec`。 +- `cdxs exec --home --temp -- --model gpt-5 "hello"`:基于指定受管 home 建立一次性临时 `CODEX_HOME` 后运行 `codex exec`。 - `cdxs login --api --base-url --alias A --model --name OpenAI --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。 - `cdxs login api --key --base-url --alias A --model --name OpenAI --switch`:同上,保留旧的子命令形式。 - `cdxs remove <账号>`:删除账号,等价于 `cdxs account remove <账号>`。 diff --git a/src/account/listing.rs b/src/account/listing.rs index d1c2bf5..87d8c15 100644 --- a/src/account/listing.rs +++ b/src/account/listing.rs @@ -40,12 +40,6 @@ pub async fn list_accounts(json: bool, force: bool) -> Result<()> { print_accounts(&store, json) } -pub fn show_accounts(json: bool) -> Result<()> { - let home = paths::codex_home(None)?; - let store = Store::load(&home)?; - print_accounts(&store, json) -} - pub fn list_aliases(json: bool) -> Result<()> { let home = paths::codex_home(None)?; let store = Store::load(&home)?; diff --git a/src/account/mod.rs b/src/account/mod.rs index 55cbe25..4147a45 100644 --- a/src/account/mod.rs +++ b/src/account/mod.rs @@ -19,7 +19,7 @@ use crate::config_store::{Account, AuthMode, Store}; pub use homes::{bind_home, create_home, home_path, list_homes, remove_home}; pub use listing::{ current_account, list_accounts, list_aliases, remove_account, remove_alias, set_alias, - show_account, show_accounts, + show_account, }; pub use storage::{add_api_key, import_auth, upsert_oauth_tokens}; pub use switching::{prepare_account_in_home, resolve_home_for_run, switch_account, switch_auto}; diff --git a/src/cli.rs b/src/cli.rs index efb8315..fad1d18 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,6 +5,9 @@ use clap::{Args, Parser, Subcommand}; #[derive(Parser)] #[command(name = "cdxs", version, about = "Codex Switch CLI")] pub struct Cli { + /// When no subcommand is given, force refresh quota before listing accounts. + #[arg(short, long)] + pub force: bool, #[command(subcommand)] pub command: Option, } @@ -15,15 +18,10 @@ pub enum Commands { Login(LoginArgs), /// Import an existing Codex auth file. Import(ImportArgs), - /// List saved accounts. - List { - #[arg(long)] - json: bool, - #[arg(short, long)] - force: bool, - }, - /// List saved accounts without refreshing quota. + /// Show one saved account. Show { + #[arg(value_name = "ACCOUNT_ID_OR_EMAIL")] + account: String, #[arg(long)] json: bool, }, @@ -70,17 +68,8 @@ pub enum Commands { #[arg(long)] name: Option, }, - /// 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")] - accounts: Vec, - #[arg(long)] - json: bool, - }, + /// Prepare auth, set CODEX_HOME, and run codex exec. + Exec(ExecArgs), /// Account management commands. Account { #[command(subcommand)] @@ -386,30 +375,17 @@ pub enum SessionVisibilityCommands { } #[derive(Args)] -pub struct RunArgs { - #[arg(long, conflicts_with = "home")] - pub account: Option, - #[arg(long, conflicts_with = "account")] - pub home: Option, - #[arg(long)] - pub codex_home: Option, - /// Command after `--`, for example: cdxs run --account me -- codex - #[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, +pub struct ExecArgs { + #[arg(value_name = "ACCOUNT_OR_HOME")] + pub name: String, + #[arg(long, help = "Treat as a managed home name")] + pub home: bool, + #[arg( + long, + help = "Run in a temporary CODEX_HOME cloned from the source config" + )] + pub temp: bool, + /// Arguments passed to `codex exec` after `--`. + #[arg(last = true, allow_hyphen_values = true)] + pub args: Vec, } diff --git a/src/main.rs b/src/main.rs index 89ce729..0c4aafc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,7 @@ mod session; mod sync_client; mod token; -use anyhow::Result; +use anyhow::{anyhow, Result}; use clap::Parser; use crate::cli::{ @@ -31,201 +31,191 @@ use crate::cli::{ #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); - match cli.command.unwrap_or(Commands::List { - json: false, - force: false, - }) { - Commands::Login(args) => match args.command { - Some(LoginCommands::Oauth { - manual, - port, - switch, - }) => oauth::login_oauth(manual, port, switch).await, - Some(LoginCommands::Api { - key, - base_url, - alias, - model, - name, - switch, - }) => account::add_api_key( - key, - base_url, - account::ApiKeyOptions { + if cli.force && cli.command.is_some() { + return Err(anyhow!( + "顶层 -f/--force 仅用于默认账号列表;对子命令请写 `cdxs -f`" + )); + } + match cli.command { + None => account::list_accounts(false, cli.force).await, + Some(command) => match command { + Commands::Login(args) => match args.command { + Some(LoginCommands::Oauth { + manual, + port, + switch, + }) => oauth::login_oauth(manual, port, switch).await, + Some(LoginCommands::Api { + key, + base_url, alias, model, - provider_name: name, - }, - switch, - ), - None => { - if let Some(key) = args.api { - account::add_api_key( - key, - args.base_url, - account::ApiKeyOptions { - alias: args.alias, - model: args.model, - provider_name: args.name, - }, - args.switch, - ) - } else { - oauth::login_oauth(args.manual, args.port, args.switch).await + name, + switch, + }) => account::add_api_key( + key, + base_url, + account::ApiKeyOptions { + alias, + model, + provider_name: name, + }, + switch, + ), + None => { + if let Some(key) = args.api { + account::add_api_key( + key, + args.base_url, + account::ApiKeyOptions { + alias: args.alias, + model: args.model, + provider_name: args.name, + }, + args.switch, + ) + } else { + oauth::login_oauth(args.manual, args.port, args.switch).await + } } - } - }, - Commands::Import(args) => match args.command { - Some(ImportCommands::Auth { - file, - codex_home, - switch, - }) => account::import_auth(file, codex_home, switch), - None => account::import_auth(args.file, args.codex_home, args.switch), - }, - Commands::List { json, force } => account::list_accounts(json, force).await, - Commands::Show { json } => account::show_accounts(json), - Commands::Pull { force } => sync_client::pull(force).await, - Commands::Push { force } => sync_client::push(force).await, - Commands::Remove { account } => account::remove_account(&account), - Commands::Alias { command } => match command { - AliasCommands::List { json } => account::list_aliases(json), - AliasCommands::Set { alias, account } => account::set_alias(&alias, &account), - AliasCommands::Remove { alias } => account::remove_alias(&alias), - }, - Commands::Switch { - account, - auto, - codex_home, - apply_fingerprint, - model, - effort, - name, - } => { - let options = account::SwitchOptions { - model, - reasoning_effort: effort, - provider_name: name, - }; - if auto || account.is_none() { - account::switch_auto(codex_home, apply_fingerprint, options).await - } else { - account::switch_account( - account.as_deref().ok_or_else(|| { - anyhow::anyhow!("switch 需要提供账号,或使用 -a/--auto 自动选择") - })?, + }, + Commands::Import(args) => match args.command { + Some(ImportCommands::Auth { + file, codex_home, - apply_fingerprint, - options, - ) - .await - } - } - Commands::Run(args) => { - run_cmd::run_with_account_or_home( - args.account, - args.home, - args.codex_home, - args.command, - ) - .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, - AccountCommands::Current { json } => account::current_account(json), - AccountCommands::Show { account, json } => account::show_account(&account, json), - AccountCommands::Remove { account } => account::remove_account(&account), - AccountCommands::AddApiKey { - key, - base_url, - alias, + switch, + }) => account::import_auth(file, codex_home, switch), + None => account::import_auth(args.file, args.codex_home, args.switch), + }, + Commands::Show { account, json } => account::show_account(&account, json), + Commands::Pull { force } => sync_client::pull(force).await, + Commands::Push { force } => sync_client::push(force).await, + Commands::Remove { account } => account::remove_account(&account), + Commands::Alias { command } => match command { + AliasCommands::List { json } => account::list_aliases(json), + AliasCommands::Set { alias, account } => account::set_alias(&alias, &account), + AliasCommands::Remove { alias } => account::remove_alias(&alias), + }, + Commands::Switch { + account, + auto, + codex_home, + apply_fingerprint, model, + effort, name, - switch, - } => account::add_api_key( - key, - base_url, - account::ApiKeyOptions { + } => { + let options = account::SwitchOptions { + model, + reasoning_effort: effort, + provider_name: name, + }; + if auto || account.is_none() { + account::switch_auto(codex_home, apply_fingerprint, options).await + } else { + account::switch_account( + account.as_deref().ok_or_else(|| { + anyhow::anyhow!("switch 需要提供账号,或使用 -a/--auto 自动选择") + })?, + codex_home, + apply_fingerprint, + options, + ) + .await + } + } + Commands::Exec(args) => { + run_cmd::exec_codex(args.name, args.home, args.temp, args.args).await + } + Commands::Account { command } => match command { + AccountCommands::List { json, force } => account::list_accounts(json, force).await, + AccountCommands::Current { json } => account::current_account(json), + AccountCommands::Show { account, json } => account::show_account(&account, json), + AccountCommands::Remove { account } => account::remove_account(&account), + AccountCommands::AddApiKey { + key, + base_url, alias, model, - provider_name: name, + name, + switch, + } => account::add_api_key( + key, + base_url, + account::ApiKeyOptions { + alias, + model, + provider_name: name, + }, + switch, + ), + }, + Commands::Home { command } => match command { + HomeCommands::List { json } => account::list_homes(json), + HomeCommands::Create { + name, + path, + account, + } => account::create_home(&name, path, account), + HomeCommands::Bind { name, account } => account::bind_home(&name, &account), + HomeCommands::Path { name } => account::home_path(&name), + HomeCommands::Remove { name } => account::remove_home(&name), + }, + Commands::Server { command } => match command { + ServerCommands::Run { bind, data_dir } => server::run_server(bind, data_dir).await, + ServerCommands::User { command } => match command { + ServerUserCommands::Add { + username, + password, + data_dir, + } => server::add_user(data_dir, &username, &password), }, - switch, - ), - }, - Commands::Home { command } => match command { - HomeCommands::List { json } => account::list_homes(json), - HomeCommands::Create { - name, - path, - account, - } => account::create_home(&name, path, account), - HomeCommands::Bind { name, account } => account::bind_home(&name, &account), - HomeCommands::Path { name } => account::home_path(&name), - HomeCommands::Remove { name } => account::remove_home(&name), - }, - Commands::Server { command } => match command { - ServerCommands::Run { bind, data_dir } => server::run_server(bind, data_dir).await, - ServerCommands::User { command } => match command { - ServerUserCommands::Add { - username, + }, + Commands::Sync { command } => match command { + SyncCommands::Login { + server, + user, password, - data_dir, - } => server::add_user(data_dir, &username, &password), + } => sync_client::login(&server, &user, &password).await, + SyncCommands::Pull { force } => sync_client::pull(force).await, + SyncCommands::Push { force } => sync_client::push(force).await, + SyncCommands::Remote { json } => sync_client::remote(json).await, + SyncCommands::Status => sync_client::status(), }, - }, - Commands::Sync { command } => match command { - SyncCommands::Login { - server, - user, - password, - } => sync_client::login(&server, &user, &password).await, - SyncCommands::Pull { force } => sync_client::pull(force).await, - SyncCommands::Push { force } => sync_client::push(force).await, - SyncCommands::Remote { json } => sync_client::remote(json).await, - SyncCommands::Status => sync_client::status(), - }, - Commands::Session { command } => match command { - SessionCommands::List { all_homes, json } => session::list_sessions(all_homes, json), - SessionCommands::Stats { - session_id, - all_homes, - json, - } => session::session_stats(&session_id, all_homes, json), - SessionCommands::Trash { - session_ids, - all_homes, - } => session::trash_sessions(session_ids, all_homes), - SessionCommands::TrashList { all_homes, json } => session::list_trash(all_homes, json), - SessionCommands::Restore { - session_ids, - all_homes, - } => session::restore_sessions(session_ids, all_homes), - SessionCommands::Visibility { command } => match command { - SessionVisibilityCommands::Check { all_homes, json } => { - session::visibility_check(all_homes, json) + Commands::Session { command } => match command { + SessionCommands::List { all_homes, json } => { + session::list_sessions(all_homes, json) } - SessionVisibilityCommands::Repair { all_homes, json } => { - session::visibility_repair(all_homes, json) + SessionCommands::Stats { + session_id, + all_homes, + json, + } => session::session_stats(&session_id, all_homes, json), + SessionCommands::Trash { + session_ids, + all_homes, + } => session::trash_sessions(session_ids, all_homes), + SessionCommands::TrashList { all_homes, json } => { + session::list_trash(all_homes, json) } + SessionCommands::Restore { + session_ids, + all_homes, + } => session::restore_sessions(session_ids, all_homes), + SessionCommands::Visibility { command } => match command { + SessionVisibilityCommands::Check { all_homes, json } => { + session::visibility_check(all_homes, json) + } + SessionVisibilityCommands::Repair { all_homes, json } => { + session::visibility_repair(all_homes, json) + } + }, + SessionCommands::SyncThreads { + all_homes, + dry_run, + json, + } => session::sync_threads(all_homes, dry_run, json), }, - SessionCommands::SyncThreads { - all_homes, - dry_run, - json, - } => session::sync_threads(all_homes, dry_run, json), }, } } diff --git a/src/quota.rs b/src/quota.rs index 30f450b..f8bc2e5 100644 --- a/src/quota.rs +++ b/src/quota.rs @@ -34,15 +34,6 @@ struct UsageResponse { rate_limit: Option, } -#[derive(Debug, Serialize)] -struct QuotaDisplay<'a> { - id: &'a str, - email: &'a str, - plan_type: Option<&'a str>, - quota: Option<&'a Quota>, - error: Option, -} - pub struct QuotaRefreshReport { pub errors: Vec<(String, String)>, pub changed: bool, @@ -55,95 +46,6 @@ struct QuotaFetchInput { account_id: Option, } -pub async fn quota_command(accounts: Vec, json: bool) -> Result<()> { - let home = crate::paths::codex_home(None)?; - let mut store = Store::load(&home)?; - let concurrency = store.settings.quota_concurrency; - // No account arguments means refresh every saved account. Otherwise refresh - // exactly the requested accounts, preserving the user's argument order. - let ids = if accounts.is_empty() { - store - .accounts - .iter() - .map(|account| account.id.clone()) - .collect() - } else { - accounts - .iter() - .map(|query| { - store - .find_account(query) - .ok_or_else(|| anyhow!("账号不存在: {query}")) - .map(|account| account.id.clone()) - }) - .collect::>>()? - }; - - if ids.is_empty() { - return Err(anyhow!("没有可查询的账号")); - } - - let report = refresh_quotas(&mut store, &ids, None, concurrency).await; - let errors = report.errors; - if report.changed { - store.save(&home)?; - } - - if json { - let rows = ids - .iter() - .filter_map(|id| store.find_account(id)) - .map(|account| QuotaDisplay { - id: &account.id, - email: &account.email, - plan_type: account.plan_type.as_deref(), - quota: account.quota.as_ref(), - error: errors - .iter() - .find(|(id, _)| id == &account.id) - .map(|(_, error)| error.clone()), - }) - .collect::>(); - println!("{}", serde_json::to_string_pretty(&rows)?); - return Ok(()); - } - - println!( - "{:<22} {:<34} {:<12} {:<10} {:<10} {}", - "ID", "Email", "Plan", "5h", "Weekly", "Status" - ); - for id in &ids { - let Some(account) = store.find_account(id) else { - continue; - }; - let error = errors.iter().find(|(err_id, _)| err_id == id); - let (primary, secondary) = account - .quota - .as_ref() - .map(|quota| { - ( - format!("{}%", quota.primary_remaining_percent), - format!("{}%", quota.secondary_remaining_percent), - ) - }) - .unwrap_or_else(|| ("-".to_string(), "-".to_string())); - println!( - "{:<22} {:<34} {:<12} {:<10} {:<10} {}", - shorten(&account.id, 22), - shorten(&account.email, 34), - account.plan_type.as_deref().unwrap_or("-"), - primary, - secondary, - error.map(|(_, error)| error.as_str()).unwrap_or("ok") - ); - } - - if !errors.is_empty() { - return Err(anyhow!("部分账号配额刷新失败: {} 个", errors.len())); - } - Ok(()) -} - pub async fn refresh_stale_quotas( store: &mut Store, ids: &[String], @@ -429,15 +331,3 @@ fn should_retry_with_refresh(message: &str) -> bool { || lower.contains("token_invalidated") || lower.contains("authentication token has been invalidated") } - -fn shorten(value: &str, width: usize) -> String { - if value.chars().count() <= width { - return value.to_string(); - } - let mut out = value - .chars() - .take(width.saturating_sub(1)) - .collect::(); - out.push('…'); - out -} diff --git a/src/run_cmd.rs b/src/run_cmd.rs index a12ac6b..65a8a59 100644 --- a/src/run_cmd.rs +++ b/src/run_cmd.rs @@ -1,212 +1,159 @@ -//! Execute a child command with a prepared Codex authentication context. +//! Run `codex exec` with a prepared authentication context. -use std::path::PathBuf; -use std::process::{Command, Stdio}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; use anyhow::{anyhow, Context, Result}; +use uuid::Uuid; -use crate::{ - account, - config_store::{AuthMode, Store}, - paths, -}; +use crate::{account, paths}; -pub async fn run_with_account_or_home( - account_query: Option, - home_name: Option, - codex_home: Option, - command: Vec, +pub async fn exec_codex( + name: String, + use_home: bool, + temp: bool, + exec_args: Vec, ) -> Result<()> { - if command.is_empty() { - return Err(anyhow!("缺少要执行的命令,请使用 `-- codex` 形式")); - } - - let target_home = if let Some(name) = home_name { - // Named homes may have a bound account. If so, refresh and materialize - // auth.json before launching the child command. + let base_home = if use_home { let (home_path, bound_account_id) = account::resolve_home_for_run(&name)?; - if let Some(account_id) = bound_account_id { - account::prepare_account_in_home(&account_id, home_path.clone()).await?; + ExecBase { + source_home: home_path, + account_query: bound_account_id, } - home_path } else { - let account = account_query.ok_or_else(|| anyhow!("run 需要 --account 或 --home"))?; - let home_path = paths::codex_home(codex_home)?; - account::prepare_account_in_home(&account, home_path.clone()).await?; - home_path + ExecBase { + source_home: paths::codex_home(None)?, + account_query: Some(name), + } }; - let mut child = Command::new(&command[0]); - child.args(&command[1..]); - // The child process sees only the selected home, so Codex reads the right - // auth.json and state files without changing the parent shell. + let temp_home = if temp { + let temp_home = create_temp_home()?; + clone_exec_template(&base_home.source_home, &temp_home)?; + Some(temp_home) + } else { + None + }; + let target_home = temp_home + .as_ref() + .cloned() + .unwrap_or_else(|| base_home.source_home.clone()); + + if let Some(account_query) = base_home.account_query.as_deref() { + account::prepare_account_in_home(account_query, target_home.clone()).await?; + } + + let mut child = codex_exec_command(&exec_args); child.env("CODEX_HOME", &target_home); + let display = command_display(&exec_args); let status = child .status() - .with_context(|| format!("启动命令失败: {}", command.join(" ")))?; + .with_context(|| format!("启动命令失败: {display}")); + + if let Some(path) = temp_home.as_deref() { + cleanup_temp_home(path); + } + + let status = status?; if !status.success() { return Err(anyhow!("命令退出失败: status={status}")); } 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 +struct ExecBase { + source_home: PathBuf, + account_query: Option, } -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::>() +fn create_temp_home() -> Result { + let path = std::env::temp_dir().join(format!("cdxs-exec-{}", Uuid::new_v4())); + fs::create_dir_all(&path) + .with_context(|| format!("创建临时 CODEX_HOME 失败: {}", path.display()))?; + Ok(path) +} + +fn clone_exec_template(source_home: &Path, target_home: &Path) -> Result<()> { + fs::create_dir_all(target_home) + .with_context(|| format!("创建 CODEX_HOME 目录失败: {}", target_home.display()))?; + copy_if_exists( + &paths::auth_path(source_home), + &paths::auth_path(target_home), + )?; + copy_if_exists( + &paths::codex_config_path(source_home), + &paths::codex_config_path(target_home), + )?; + if !source_home.exists() { + return Ok(()); + } + for entry in fs::read_dir(source_home) + .with_context(|| format!("读取 CODEX_HOME 目录失败: {}", source_home.display()))? + { + let entry = entry + .with_context(|| format!("读取 CODEX_HOME 子项失败: {}", source_home.display()))?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if file_name.ends_with(".config.toml") { + copy_if_exists(&path, &target_home.join(file_name))?; + } + } + Ok(()) +} + +fn copy_if_exists(source: &Path, target: &Path) -> Result<()> { + if !source.exists() { + return Ok(()); + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("创建目录失败: {}", parent.display()))?; + } + fs::copy(source, target).with_context(|| { + format!( + "复制文件失败: source={}, target={}", + source.display(), + target.display() + ) + })?; + Ok(()) +} + +fn cleanup_temp_home(path: &Path) { + if let Err(error) = fs::remove_dir_all(path) { + eprintln!( + "提示: 清理临时 CODEX_HOME 失败: {}: {error}", + path.display() + ); + } +} + +fn command_display(args: &[String]) -> String { + if args.is_empty() { + "codex exec".to_string() } 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 账号")); + format!("codex exec {}", args.join(" ")) } - - 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.arg("/C").arg("codex").arg("exec").args(args); command } #[cfg(not(windows))] { - let mut command = Command::new(&args[0]); - command.args(&args[1..]); + let mut command = Command::new("codex"); + command.arg("exec").args(args); command } }