Release 0.1.7
Release / Prepare release (push) Successful in 7s
Release / Build release assets (push) Successful in 6m54s

This commit is contained in:
2026-06-05 11:22:07 +08:00
Unverified
parent c13ebdc4dc
commit 5f835a61b1
6 changed files with 166 additions and 5 deletions
Generated
+1 -1
View File
@@ -232,7 +232,7 @@ dependencies = [
[[package]]
name = "cdxs"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cdxs"
version = "0.1.6"
version = "0.1.7"
edition = "2021"
description = "Codex account switcher CLI"
+6 -1
View File
@@ -41,8 +41,11 @@ Codex home 的解析顺序:
- `cdxs list`:列出账号,并自动刷新过期配额缓存。
- `cdxs show`:列出账号,但不请求配额接口。
- `cdxs alias set <别名> <账号>`:给任意 OAuth 或 API Key 账号设置别名。
- `cdxs alias list`:列出所有账号别名。
- `cdxs alias remove <别名或账号>`:删除指定账号的别名。
- `cdxs switch`:不带参数时自动选择可用配额最优的账号。
- `cdxs switch <账号或别名>`:切换到指定账号,可用 API 账号的 `--alias` 别名
- `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`;可重复传多个账号。
@@ -52,6 +55,8 @@ Codex home 的解析顺序:
- `cdxs pull`:从同步服务拉取账号状态,等价于 `cdxs sync pull`
- `cdxs push`:推送账号状态到同步服务,等价于 `cdxs sync push`
账号 alias 保存在账号记录里,因此会随 `cdxs push` 推送到同步服务,并随 `cdxs pull` 从同步服务拉取回来。
## 同步服务
内置同步服务只保存每个用户的账号状态,并提供登录、拉取、推送接口。它同步的是可迁移的 `cdxs` 账号状态,不同步整个 Codex home。
+126
View File
@@ -8,6 +8,7 @@ use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::auth_file;
@@ -30,6 +31,14 @@ pub struct SwitchOptions {
pub provider_name: Option<String>,
}
#[derive(Debug, Serialize)]
struct AliasRow {
alias: String,
account_id: String,
email: String,
auth_mode: String,
}
pub fn import_auth(file: Option<PathBuf>, codex_home: Option<PathBuf>, switch: bool) -> Result<()> {
// Store imported credentials in the main cdxs config, even when the source
// auth file came from another CODEX_HOME.
@@ -64,6 +73,9 @@ pub fn add_api_key(
let account = api_key_account(key, base_url, options)?;
let id = account.id.clone();
let email = account.email.clone();
if let Some(alias) = account.alias.as_deref() {
ensure_alias_available(&store, alias, &id)?;
}
store.upsert_account(account);
if switch {
store.meta.current_account_id = Some(id.clone());
@@ -111,6 +123,84 @@ pub fn show_accounts(json: bool) -> Result<()> {
print_accounts(&store, json)
}
pub fn list_aliases(json: bool) -> Result<()> {
let home = paths::codex_home(None)?;
let store = Store::load(&home)?;
let mut rows = store
.accounts
.iter()
.filter_map(|account| {
account.alias.as_ref().map(|alias| AliasRow {
alias: alias.clone(),
account_id: account.id.clone(),
email: alias_target_display(account).to_string(),
auth_mode: auth_file::account_auth_mode_name(account).to_string(),
})
})
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
left.alias
.to_ascii_lowercase()
.cmp(&right.alias.to_ascii_lowercase())
.then_with(|| left.account_id.cmp(&right.account_id))
});
if json {
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
if rows.is_empty() {
println!("没有设置 alias。");
return Ok(());
}
println!("{:<18} {:<22} {:<10} Account", "Alias", "ID", "Mode");
for row in rows {
println!(
"{:<18} {:<22} {:<10} {}",
shorten(&row.alias, 18),
shorten(&row.account_id, 22),
shorten(&row.auth_mode, 10),
row.email
);
}
Ok(())
}
pub fn set_alias(alias: &str, query: &str) -> Result<()> {
let home = paths::codex_home(None)?;
let mut store = Store::load(&home)?;
let alias = normalize_alias(alias)?;
let account_id = find_unique_account_id(&store, query)?;
ensure_alias_available(&store, &alias, &account_id)?;
let account = store
.find_account_mut(&account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
let changed = account.alias.as_deref() != Some(alias.as_str());
account.alias = Some(alias.clone());
if changed {
account.updated_at = Utc::now().timestamp();
}
store.save(&home)?;
println!("已设置 alias: {alias} -> {account_id}");
Ok(())
}
pub fn remove_alias(query: &str) -> Result<()> {
let home = paths::codex_home(None)?;
let mut store = Store::load(&home)?;
let account_id = find_unique_account_id(&store, query)?;
let account = store
.find_account_mut(&account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
let Some(alias) = account.alias.take() else {
return Err(anyhow!("账号未设置 alias: {account_id}"));
};
account.updated_at = Utc::now().timestamp();
store.save(&home)?;
println!("已删除 alias: {alias} ({account_id})");
Ok(())
}
fn print_accounts(store: &Store, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(&store.accounts)?);
@@ -775,6 +865,14 @@ fn account_email_display(account: &Account) -> &str {
}
}
fn alias_target_display(account: &Account) -> &str {
if account.auth_mode == AuthMode::ApiKey {
api_base_url_display(account.api_base_url.as_deref())
} else {
&account.email
}
}
fn api_base_url_display(base_url: Option<&str>) -> &str {
base_url.unwrap_or(DEFAULT_API_BASE_URL)
}
@@ -936,6 +1034,14 @@ fn normalize_api_base_url(base_url: Option<String>) -> Option<String> {
.map(|value| value.trim_end_matches('/').to_string())
}
fn normalize_alias(alias: &str) -> Result<String> {
let alias = alias.trim();
if alias.is_empty() {
return Err(anyhow!("alias 不能为空"));
}
Ok(alias.to_string())
}
fn normalize_optional_field(value: Option<String>) -> Option<String> {
value
.as_deref()
@@ -944,6 +1050,26 @@ fn normalize_optional_field(value: Option<String>) -> Option<String> {
.map(ToOwned::to_owned)
}
fn ensure_alias_available(store: &Store, alias: &str, account_id: &str) -> Result<()> {
for account in &store.accounts {
if account.id == account_id {
continue;
}
if account
.alias
.as_deref()
.map(|existing| existing.eq_ignore_ascii_case(alias))
.unwrap_or(false)
{
return Err(anyhow!("alias 已被使用: {alias} -> {}", account.id));
}
if account.id.eq_ignore_ascii_case(alias) || account.email.eq_ignore_ascii_case(alias) {
return Err(anyhow!("alias 与已有账号冲突: {alias} -> {}", account.id));
}
}
Ok(())
}
fn mask_api_key(key: &str) -> String {
let chars = key.chars().collect::<Vec<_>>();
if chars.len() <= 8 {
+25
View File
@@ -45,6 +45,11 @@ pub enum Commands {
)]
account: String,
},
/// Manage account aliases.
Alias {
#[command(subcommand)]
command: AliasCommands,
},
/// Switch Codex auth.json to a saved account.
Switch {
#[arg(
@@ -217,6 +222,26 @@ pub enum AccountCommands {
},
}
#[derive(Subcommand)]
pub enum AliasCommands {
/// List account aliases.
List {
#[arg(long)]
json: bool,
},
/// Set or replace an alias for any saved account.
Set {
alias: String,
#[arg(value_name = "ACCOUNT_ID_OR_EMAIL")]
account: String,
},
/// Remove an alias by alias name or account selector.
Remove {
#[arg(value_name = "ALIAS_OR_ACCOUNT")]
alias: String,
},
}
#[derive(Subcommand)]
pub enum HomeCommands {
List {
+7 -2
View File
@@ -24,8 +24,8 @@ use anyhow::Result;
use clap::Parser;
use crate::cli::{
AccountCommands, Cli, Commands, HomeCommands, ImportCommands, LoginCommands, ServerCommands,
ServerUserCommands, SessionCommands, SessionVisibilityCommands, SyncCommands,
AccountCommands, AliasCommands, Cli, Commands, HomeCommands, ImportCommands, LoginCommands,
ServerCommands, ServerUserCommands, SessionCommands, SessionVisibilityCommands, SyncCommands,
};
#[tokio::main]
@@ -88,6 +88,11 @@ async fn main() -> Result<()> {
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,