2 Commits

9 changed files with 247 additions and 43 deletions
+1 -28
View File
@@ -15,32 +15,14 @@ on:
env:
GITEA_SERVER_URL: https://git.pchuan.top
RUST_IMAGE: docker.m.daocloud.io/library/rust:1-bookworm
CARGO_REGISTRY: sparse+https://rsproxy.cn/index/
APT_MIRROR: https://mirrors.ustc.edu.cn/debian
CARGO_TERM_COLOR: always
HTTP_PROXY: http://172.17.0.1:1082
HTTPS_PROXY: http://172.17.0.1:1082
ALL_PROXY: http://172.17.0.1:1082
NO_PROXY: localhost,127.0.0.1,::1,172.17.0.1,git.pchuan.top,.pchuan.top
http_proxy: http://172.17.0.1:1082
https_proxy: http://172.17.0.1:1082
all_proxy: http://172.17.0.1:1082
no_proxy: localhost,127.0.0.1,::1,172.17.0.1,git.pchuan.top,.pchuan.top
jobs:
prepare-release:
name: Prepare release
runs-on: ubuntu-latest
steps:
- name: Configure network proxy
shell: bash
run: |
set -euo pipefail
git config --global http.proxy "${HTTPS_PROXY}"
git config --global https.proxy "${HTTPS_PROXY}"
git config --global http.noProxy "${NO_PROXY}"
- name: Checkout
uses: actions/checkout@v4
with:
@@ -119,15 +101,6 @@ jobs:
runs-on: ubuntu-latest
needs: prepare-release
steps:
- name: Configure network proxy
shell: bash
run: |
set -euo pipefail
git config --global http.proxy "${HTTPS_PROXY}"
git config --global https.proxy "${HTTPS_PROXY}"
git config --global http.noProxy "${NO_PROXY}"
- name: Checkout
uses: actions/checkout@v4
with:
@@ -148,7 +121,7 @@ jobs:
- name: Build release assets
shell: bash
run: bash scripts/publish.sh --version "${VERSION}" --rust-image "${RUST_IMAGE}" --cargo-registry "${CARGO_REGISTRY}" --apt-mirror "${APT_MIRROR}" --clean
run: bash scripts/publish.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.3"
version = "0.1.4"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cdxs"
version = "0.1.3"
version = "0.1.4"
edition = "2021"
description = "Codex account switcher CLI"
+2
View File
@@ -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 <key> --base-url <url> --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。
- `cdxs remove <账号>`:删除账号,等价于 `cdxs account remove <账号>`
- `cdxs pull`:从同步服务拉取账号状态,等价于 `cdxs sync pull`
+41 -11
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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<String> {
.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<String> {
}
}
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::<String>();
format!("{kind}_{short_suffix}")
}
fn best_auto_switch_account(store: &Store) -> Result<String> {
store
.accounts
+18
View File
@@ -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<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,
}
+6
View File
@@ -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
+11
View File
@@ -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,
+166 -2
View File
@@ -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<String>,
@@ -46,3 +50,163 @@ pub async fn run_with_account_or_home(
}
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
}
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<_>>>()?
};
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("<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
}
#[cfg(not(windows))]
{
let mut command = Command::new(&args[0]);
command.args(&args[1..]);
command
}
}