feat: 优化命令
This commit is contained in:
@@ -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 <账号>`。
|
||||
|
||||
@@ -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
@@ -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
@@ -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>,
|
||||
}
|
||||
|
||||
+19
-29
@@ -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,10 +31,14 @@ use crate::cli::{
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command.unwrap_or(Commands::List {
|
||||
json: false,
|
||||
force: false,
|
||||
}) {
|
||||
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,
|
||||
@@ -83,8 +87,7 @@ async fn main() -> Result<()> {
|
||||
}) => 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::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),
|
||||
@@ -121,27 +124,9 @@ async fn main() -> Result<()> {
|
||||
.await
|
||||
}
|
||||
}
|
||||
Commands::Run(args) => {
|
||||
run_cmd::run_with_account_or_home(
|
||||
args.account,
|
||||
args.home,
|
||||
args.codex_home,
|
||||
args.command,
|
||||
)
|
||||
.await
|
||||
Commands::Exec(args) => {
|
||||
run_cmd::exec_codex(args.name, args.home, args.temp, args.args).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),
|
||||
@@ -198,7 +183,9 @@ async fn main() -> Result<()> {
|
||||
SyncCommands::Status => sync_client::status(),
|
||||
},
|
||||
Commands::Session { command } => match command {
|
||||
SessionCommands::List { all_homes, json } => session::list_sessions(all_homes, json),
|
||||
SessionCommands::List { all_homes, json } => {
|
||||
session::list_sessions(all_homes, json)
|
||||
}
|
||||
SessionCommands::Stats {
|
||||
session_id,
|
||||
all_homes,
|
||||
@@ -208,7 +195,9 @@ async fn main() -> Result<()> {
|
||||
session_ids,
|
||||
all_homes,
|
||||
} => session::trash_sessions(session_ids, all_homes),
|
||||
SessionCommands::TrashList { all_homes, json } => session::list_trash(all_homes, json),
|
||||
SessionCommands::TrashList { all_homes, json } => {
|
||||
session::list_trash(all_homes, json)
|
||||
}
|
||||
SessionCommands::Restore {
|
||||
session_ids,
|
||||
all_homes,
|
||||
@@ -227,5 +216,6 @@ async fn main() -> Result<()> {
|
||||
json,
|
||||
} => session::sync_threads(all_homes, dry_run, json),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
-110
@@ -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
|
||||
}
|
||||
|
||||
+110
-163
@@ -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<_>>()
|
||||
} else {
|
||||
account_queries
|
||||
.iter()
|
||||
.map(|query| {
|
||||
store
|
||||
.find_account(query)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {query}"))
|
||||
.map(|account| account.id.clone())
|
||||
})
|
||||
.collect::<Result<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 account_ids.is_empty() {
|
||||
return Err(anyhow!("没有可 ping 的 OAuth 账号"));
|
||||
if file_name.ends_with(".config.toml") {
|
||||
copy_if_exists(&path, &target_home.join(file_name))?;
|
||||
}
|
||||
|
||||
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());
|
||||
fn copy_if_exists(source: &Path, target: &Path) -> Result<()> {
|
||||
if !source.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
child
|
||||
.status()
|
||||
.with_context(|| format!("启动命令失败: {}", command_args.join(" ")))
|
||||
})
|
||||
.await
|
||||
.context("ping task join failed")??;
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow!("命令退出失败: status={status}"));
|
||||
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 safe_path_component(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
|
||||
ch
|
||||
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 {
|
||||
'_'
|
||||
format!("codex exec {}", args.join(" "))
|
||||
}
|
||||
})
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user