3 Commits

10 changed files with 327 additions and 529 deletions
+3 -3
View File
@@ -6,8 +6,8 @@ on:
- master
paths:
- ".github/workflows/release.yml"
- "scripts/publish.ps1"
- "scripts/publish.sh"
- "scripts/build.ps1"
- "scripts/build.sh"
- "scripts/docker/Dockerfile.release"
tags:
- "*"
@@ -121,7 +121,7 @@ jobs:
- name: Build release assets
shell: bash
run: bash scripts/publish.sh --version "${VERSION}" --rust-image "${RUST_IMAGE}" --apt-mirror "${APT_MIRROR}" --clean
run: bash scripts/build.sh --version "${VERSION}" --rust-image "${RUST_IMAGE}" --apt-mirror "${APT_MIRROR}" --clean
- name: Upload release assets
shell: bash
Generated
+1 -1
View File
@@ -232,7 +232,7 @@ dependencies = [
[[package]]
name = "cdxs"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cdxs"
version = "0.1.8"
version = "0.1.9"
edition = "2021"
description = "Codex account switcher CLI"
+7 -6
View File
@@ -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 <model> --effort <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> --home --temp -- --model gpt-5 "hello"`:基于指定受管 home 建立一次性临时 `CODEX_HOME` 后运行 `codex exec`
- `cdxs login --api <key> --base-url <url> --alias A --model <model> --name OpenAI --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。
- `cdxs login api --key <key> --base-url <url> --alias A --model <model> --name OpenAI --switch`:同上,保留旧的子命令形式。
- `cdxs remove <账号>`:删除账号,等价于 `cdxs account remove <账号>`
-6
View File
@@ -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)?;
+1 -1
View File
@@ -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};
+21 -45
View File
@@ -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<Commands>,
}
@@ -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<String>,
},
/// 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<String>,
#[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<String>,
#[arg(long, conflicts_with = "account")]
pub home: Option<String>,
#[arg(long)]
pub codex_home: Option<PathBuf>,
/// Command after `--`, for example: cdxs run --account me -- codex
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
pub command: Vec<String>,
}
#[derive(Args)]
pub struct PingArgs {
#[arg(long, value_name = "ACCOUNT_ID_OR_EMAIL")]
pub account: Vec<String>,
#[arg(long, default_value_t = 5)]
pub concurrency: usize,
#[arg(long)]
pub codex_home: Option<PathBuf>,
#[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 <ACCOUNT_OR_HOME> 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<String>,
}
+172 -182
View File
@@ -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 <subcommand> -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),
},
}
}
-110
View File
@@ -34,15 +34,6 @@ struct UsageResponse {
rate_limit: Option<RateLimitInfo>,
}
#[derive(Debug, Serialize)]
struct QuotaDisplay<'a> {
id: &'a str,
email: &'a str,
plan_type: Option<&'a str>,
quota: Option<&'a Quota>,
error: Option<String>,
}
pub struct QuotaRefreshReport {
pub errors: Vec<(String, String)>,
pub changed: bool,
@@ -55,95 +46,6 @@ struct QuotaFetchInput {
account_id: Option<String>,
}
pub async fn quota_command(accounts: Vec<String>, 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::<Result<Vec<_>>>()?
};
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::<Vec<_>>();
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::<String>();
out.push('…');
out
}
+121 -174
View File
@@ -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<String>,
home_name: Option<String>,
codex_home: Option<PathBuf>,
command: Vec<String>,
pub async fn exec_codex(
name: String,
use_home: bool,
temp: bool,
exec_args: Vec<String>,
) -> 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<String>,
concurrency: usize,
codex_home: Option<PathBuf>,
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<String>,
}
async fn ping_codex_many(
account_queries: Vec<String>,
concurrency: usize,
codex_home: Option<PathBuf>,
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::<Vec<_>>()
fn create_temp_home() -> Result<PathBuf> {
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::<Result<Vec<_>>>()?
};
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("<unknown>".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
}
}