Compare commits
5 Commits
@@ -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
+12
-1
@@ -232,7 +232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cdxs"
|
||||
version = "0.1.6"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -240,6 +240,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"filetime",
|
||||
"hex",
|
||||
"rand 0.10.1",
|
||||
"reqwest",
|
||||
@@ -467,6 +468,16 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cdxs"
|
||||
version = "0.1.6"
|
||||
version = "0.1.9"
|
||||
edition = "2021"
|
||||
description = "Codex account switcher CLI"
|
||||
|
||||
@@ -11,6 +11,7 @@ base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
dirs = "6"
|
||||
filetime = "0.2"
|
||||
hex = "0.4"
|
||||
rand = "0.10.1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "system-proxy", "gzip", "brotli", "deflate", "zstd"] }
|
||||
|
||||
@@ -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,19 +39,25 @@ 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 <账号或别名>`:切换到指定账号,可用 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`;可重复传多个账号。
|
||||
- `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 <账号>`。
|
||||
- `cdxs pull`:从同步服务拉取账号状态,等价于 `cdxs sync pull`。
|
||||
- `cdxs push`:推送账号状态到同步服务,等价于 `cdxs sync push`。
|
||||
|
||||
账号 alias 保存在账号记录里,因此会随 `cdxs push` 推送到同步服务,并随 `cdxs pull` 从同步服务拉取回来。
|
||||
|
||||
## 同步服务
|
||||
|
||||
内置同步服务只保存每个用户的账号状态,并提供登录、拉取、推送接口。它同步的是可迁移的 `cdxs` 账号状态,不同步整个 Codex home。
|
||||
|
||||
@@ -11,7 +11,7 @@ clean=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/publish.sh [options]
|
||||
Usage: scripts/build.sh [options]
|
||||
|
||||
Build release binaries with Docker buildx.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[string]$Image = "docker.pchuan.top/cdxs",
|
||||
[string]$Dockerfile = "Dockerfile",
|
||||
[string]$Platform = "linux/amd64",
|
||||
[switch]$NoCache
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
Set-Location $repoRoot
|
||||
|
||||
if (-not $Version) {
|
||||
$cargoToml = Get-Content -Raw -Path "Cargo.toml"
|
||||
if ($cargoToml -notmatch '(?m)^version\s*=\s*"([^"]+)"') {
|
||||
throw "Cannot read package version from Cargo.toml"
|
||||
}
|
||||
$Version = $Matches[1]
|
||||
}
|
||||
|
||||
$versionTag = "${Image}:${Version}"
|
||||
$latestTag = "${Image}:latest"
|
||||
|
||||
Write-Host "Building and pushing Docker image for $Platform"
|
||||
Write-Host "Tags:"
|
||||
Write-Host " $versionTag"
|
||||
Write-Host " $latestTag"
|
||||
|
||||
$buildArgs = @(
|
||||
"buildx", "build",
|
||||
"--platform", $Platform,
|
||||
"-f", $Dockerfile,
|
||||
"-t", $versionTag,
|
||||
"-t", $latestTag,
|
||||
"--push"
|
||||
)
|
||||
|
||||
if ($NoCache) {
|
||||
$buildArgs += "--no-cache"
|
||||
}
|
||||
|
||||
$buildArgs += "."
|
||||
|
||||
docker @buildArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker buildx push failed"
|
||||
}
|
||||
|
||||
Write-Host "Pushed $versionTag and $latestTag"
|
||||
-962
@@ -1,962 +0,0 @@
|
||||
//! Account and managed `CODEX_HOME` operations.
|
||||
//!
|
||||
//! This module owns the high-level workflows that mutate `cdxs.toml` and
|
||||
//! Codex `auth.json`: importing credentials, switching accounts, creating
|
||||
//! named homes, and preparing an isolated home before running Codex.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::Utc;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::auth_file;
|
||||
use crate::config_store::{self, Account, AuthMode, Home, Store, Tokens};
|
||||
use crate::{codex_config, jwt, paths, token};
|
||||
|
||||
const DEFAULT_API_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ApiKeyOptions {
|
||||
pub alias: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub provider_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SwitchOptions {
|
||||
pub model: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub provider_name: Option<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.
|
||||
let config_home = paths::codex_home(None)?;
|
||||
let source_home = paths::codex_home(codex_home)?;
|
||||
let auth_path = file.unwrap_or_else(|| paths::auth_path(&source_home));
|
||||
let auth = auth_file::read_auth_file(&auth_path)?;
|
||||
let mut store = Store::load(&config_home)?;
|
||||
let account = account_from_auth(auth)?;
|
||||
let id = account.id.clone();
|
||||
let email = account.email.clone();
|
||||
store.upsert_account(account);
|
||||
if switch {
|
||||
store.meta.current_account_id = Some(id.clone());
|
||||
let account = store.find_account(&id).expect("just inserted account");
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&source_home), &source_home, account)?;
|
||||
codex_config::apply_account_provider(&source_home, account)?;
|
||||
}
|
||||
store.save(&config_home)?;
|
||||
println!("已导入账号: {email} ({id})");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_api_key(
|
||||
key: String,
|
||||
base_url: Option<String>,
|
||||
options: ApiKeyOptions,
|
||||
switch: bool,
|
||||
) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
let account = api_key_account(key, base_url, options)?;
|
||||
let id = account.id.clone();
|
||||
let email = account.email.clone();
|
||||
store.upsert_account(account);
|
||||
if switch {
|
||||
store.meta.current_account_id = Some(id.clone());
|
||||
let account = store.find_account(&id).expect("just inserted account");
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?;
|
||||
codex_config::apply_account_provider(&home, account)?;
|
||||
}
|
||||
store.save(&home)?;
|
||||
println!("已保存 API Key 账号: {email} ({id})");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_accounts(json: bool, force: bool) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
let quota_concurrency = store.settings.quota_concurrency;
|
||||
let quota_max_age_seconds = if force { 0 } else { 60 };
|
||||
let ids = store
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|account| account.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let quota_report = crate::quota::refresh_stale_quotas(
|
||||
&mut store,
|
||||
&ids,
|
||||
quota_max_age_seconds,
|
||||
quota_concurrency,
|
||||
)
|
||||
.await;
|
||||
if !quota_report.errors.is_empty() {
|
||||
eprintln!(
|
||||
"提示: {} 个账号配额刷新失败,已使用本地缓存。",
|
||||
quota_report.errors.len()
|
||||
);
|
||||
}
|
||||
if quota_report.changed {
|
||||
store.save(&home)?;
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
fn print_accounts(store: &Store, json: bool) -> Result<()> {
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&store.accounts)?);
|
||||
return Ok(());
|
||||
}
|
||||
if store.accounts.is_empty() {
|
||||
println!("没有保存的账号。可使用 `cdxs import auth` 导入。");
|
||||
return Ok(());
|
||||
}
|
||||
print_account_table_border();
|
||||
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().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 {
|
||||
""
|
||||
};
|
||||
let (primary_quota, secondary_quota) = format_quota_cells(account);
|
||||
print_account_table_row(
|
||||
current,
|
||||
&account_id_display(&account.id),
|
||||
account_email_display(account),
|
||||
auth_file::account_auth_mode_name(account),
|
||||
account_plan_display(account),
|
||||
&primary_quota,
|
||||
&secondary_quota,
|
||||
);
|
||||
}
|
||||
print_account_table_border();
|
||||
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)?;
|
||||
let Some(id) = store.meta.current_account_id.as_deref() else {
|
||||
println!("当前未设置账号。");
|
||||
return Ok(());
|
||||
};
|
||||
let account = store
|
||||
.find_account(id)
|
||||
.ok_or_else(|| anyhow!("current_account_id 指向不存在的账号: {id}"))?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(account)?);
|
||||
} else {
|
||||
print_account(account);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show_account(query: &str, json: bool) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let store = Store::load(&home)?;
|
||||
let account = store
|
||||
.find_account(query)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {query}"))?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(account)?);
|
||||
} else {
|
||||
print_account(account);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_account(query: &str) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
let id = store
|
||||
.find_account(query)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
|
||||
.id
|
||||
.clone();
|
||||
store.accounts.retain(|account| account.id != id);
|
||||
if store.meta.current_account_id.as_deref() == Some(id.as_str()) {
|
||||
store.meta.current_account_id = None;
|
||||
}
|
||||
for home in &mut store.homes {
|
||||
if home.bound_account_id.as_deref() == Some(id.as_str()) {
|
||||
home.bound_account_id = None;
|
||||
}
|
||||
}
|
||||
store.save(&home)?;
|
||||
println!("已删除账号: {id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn switch_account(
|
||||
query: &str,
|
||||
codex_home: Option<PathBuf>,
|
||||
apply_fingerprint: bool,
|
||||
options: SwitchOptions,
|
||||
) -> Result<()> {
|
||||
if apply_fingerprint {
|
||||
eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。");
|
||||
}
|
||||
// The saved account list is always loaded from the main cdxs config, while
|
||||
// the target auth.json can be redirected with --codex-home.
|
||||
let config_home = paths::codex_home(None)?;
|
||||
let target_home = paths::codex_home(codex_home)?;
|
||||
let mut store = Store::load(&config_home)?;
|
||||
let account_id = find_unique_account_id(&store, query)?;
|
||||
update_account_switch_options(&mut store, &account_id, &options)?;
|
||||
switch_account_id(&mut store, &config_home, &target_home, &account_id).await
|
||||
}
|
||||
|
||||
pub async fn switch_auto(
|
||||
codex_home: Option<PathBuf>,
|
||||
apply_fingerprint: bool,
|
||||
options: SwitchOptions,
|
||||
) -> Result<()> {
|
||||
if apply_fingerprint {
|
||||
eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。");
|
||||
}
|
||||
let config_home = paths::codex_home(None)?;
|
||||
let target_home = paths::codex_home(codex_home)?;
|
||||
let mut store = Store::load(&config_home)?;
|
||||
let ids = store
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|account| account.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let quota_concurrency = store.settings.quota_concurrency;
|
||||
let quota_report =
|
||||
crate::quota::refresh_stale_quotas(&mut store, &ids, 60, quota_concurrency).await;
|
||||
if !quota_report.errors.is_empty() {
|
||||
eprintln!(
|
||||
"提示: {} 个账号配额刷新失败,自动选择将使用本地缓存。",
|
||||
quota_report.errors.len()
|
||||
);
|
||||
}
|
||||
let account_id = best_auto_switch_account(&store)?;
|
||||
update_account_switch_options(&mut store, &account_id, &options)?;
|
||||
switch_account_id(&mut store, &config_home, &target_home, &account_id).await?;
|
||||
if let Some(account) = store.find_account(&account_id) {
|
||||
println!(
|
||||
"自动选择账号: {} <{}> plan={} quota={}",
|
||||
account.id,
|
||||
account.email,
|
||||
account.plan_type.as_deref().unwrap_or("-"),
|
||||
format_quota(account)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn switch_account_id(
|
||||
store: &mut Store,
|
||||
config_home: &std::path::Path,
|
||||
target_home: &std::path::Path,
|
||||
account_id: &str,
|
||||
) -> Result<()> {
|
||||
token::refresh_account_if_needed(store, account_id).await?;
|
||||
let account = store
|
||||
.find_account(account_id)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
|
||||
auth_file::write_account_to_auth(&paths::auth_path(target_home), target_home, account)?;
|
||||
codex_config::apply_account_provider(target_home, account)?;
|
||||
if let Some(account) = store.find_account_mut(account_id) {
|
||||
account.last_used_at = Utc::now().timestamp();
|
||||
}
|
||||
store.meta.current_account_id = Some(account_id.to_string());
|
||||
store.save(config_home)?;
|
||||
println!("已切换 Codex 账号: {account_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_homes(json: bool) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let store = Store::load(&home)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&store.homes)?);
|
||||
return Ok(());
|
||||
}
|
||||
for item in &store.homes {
|
||||
let current = if store.meta.current_home.as_deref() == Some(item.name.as_str()) {
|
||||
"*"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
println!(
|
||||
"{} {:<18} {:<48} {}",
|
||||
current,
|
||||
item.name,
|
||||
item.path,
|
||||
item.bound_account_id.as_deref().unwrap_or("-")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_home(name: &str, path: PathBuf, account: Option<String>) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
if store.homes.iter().any(|item| item.name == name) {
|
||||
return Err(anyhow!("home 已存在: {name}"));
|
||||
}
|
||||
let bound_account_id = if let Some(query) = account {
|
||||
Some(
|
||||
store
|
||||
.find_account(&query)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
|
||||
.id
|
||||
.clone(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let path = paths::expand_home(path);
|
||||
std::fs::create_dir_all(&path)
|
||||
.with_context(|| format!("创建 CODEX_HOME 目录失败: {}", path.display()))?;
|
||||
// When an account is bound at creation time, make the new home immediately
|
||||
// runnable by writing its auth.json now.
|
||||
if let Some(account_id) = bound_account_id.as_deref() {
|
||||
let account = store.find_account(account_id).expect("checked account");
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&path), &path, account)?;
|
||||
codex_config::apply_account_provider(&path, account)?;
|
||||
}
|
||||
store.homes.push(Home {
|
||||
name: name.to_string(),
|
||||
path: config_store::path_to_string(&path),
|
||||
bound_account_id,
|
||||
});
|
||||
store.save(&home)?;
|
||||
println!("已创建 home: {name} -> {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn bind_home(name: &str, account: &str) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
let account_id = store
|
||||
.find_account(account)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {account}"))?
|
||||
.id
|
||||
.clone();
|
||||
let target_home = store
|
||||
.homes
|
||||
.iter_mut()
|
||||
.find(|item| item.name == name)
|
||||
.ok_or_else(|| anyhow!("home 不存在: {name}"))?;
|
||||
target_home.bound_account_id = Some(account_id.clone());
|
||||
store.save(&home)?;
|
||||
println!("已绑定 home {name} -> {account_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn home_path(name: &str) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let store = Store::load(&home)?;
|
||||
let item = store
|
||||
.find_home(name)
|
||||
.ok_or_else(|| anyhow!("home 不存在: {name}"))?;
|
||||
println!("{}", item.path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_home(name: &str) -> Result<()> {
|
||||
if name == "default" {
|
||||
return Err(anyhow!("不能删除 default home"));
|
||||
}
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
let before = store.homes.len();
|
||||
store.homes.retain(|item| item.name != name);
|
||||
if store.homes.len() == before {
|
||||
return Err(anyhow!("home 不存在: {name}"));
|
||||
}
|
||||
store.save(&home)?;
|
||||
println!("已删除 home: {name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn prepare_account_in_home(query: &str, codex_home: PathBuf) -> Result<String> {
|
||||
// Used by `cdxs run`: refresh the account if needed, write auth.json into
|
||||
// the selected home, then let the caller execute a child process there.
|
||||
let main_home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&main_home)?;
|
||||
let account_id = store
|
||||
.find_account(query)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
|
||||
.id
|
||||
.clone();
|
||||
token::refresh_account_if_needed(&mut store, &account_id).await?;
|
||||
let (account_id, email) = {
|
||||
let account = store
|
||||
.find_account(&account_id)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&codex_home), &codex_home, account)?;
|
||||
codex_config::apply_account_provider(&codex_home, account)?;
|
||||
(account.id.clone(), account.email.clone())
|
||||
};
|
||||
if let Some(account) = store.find_account_mut(&account_id) {
|
||||
account.last_used_at = Utc::now().timestamp();
|
||||
}
|
||||
store.save(&main_home)?;
|
||||
Ok(email)
|
||||
}
|
||||
|
||||
pub fn resolve_home_for_run(name: &str) -> Result<(PathBuf, Option<String>)> {
|
||||
let main_home = paths::codex_home(None)?;
|
||||
let store = Store::load(&main_home)?;
|
||||
let home = store
|
||||
.find_home(name)
|
||||
.ok_or_else(|| anyhow!("home 不存在: {name}"))?;
|
||||
Ok((
|
||||
paths::expand_home(PathBuf::from(&home.path)),
|
||||
home.bound_account_id.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
fn account_from_auth(auth: auth_file::CodexAuthFile) -> Result<Account> {
|
||||
// Codex auth.json can represent either API-key mode or OAuth-token mode.
|
||||
// Normalize both shapes into one stored Account record.
|
||||
if auth_file::is_api_key_mode(&auth) {
|
||||
let key = auth_file::extract_api_key(&auth)
|
||||
.ok_or_else(|| anyhow!("auth.json 缺少 OPENAI_API_KEY"))?;
|
||||
return api_key_account(
|
||||
key,
|
||||
auth_file::api_base_url(&auth),
|
||||
ApiKeyOptions::default(),
|
||||
);
|
||||
}
|
||||
|
||||
let tokens = auth
|
||||
.tokens
|
||||
.ok_or_else(|| anyhow!("auth.json 缺少 tokens 或 OPENAI_API_KEY"))?;
|
||||
let account_id_hint = tokens.account_id.clone();
|
||||
let store_tokens = auth_file::auth_tokens_to_store(tokens);
|
||||
oauth_account(store_tokens, account_id_hint)
|
||||
}
|
||||
|
||||
pub fn upsert_oauth_tokens(
|
||||
tokens: Tokens,
|
||||
account_id_hint: Option<String>,
|
||||
switch: bool,
|
||||
) -> Result<Account> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
let account = oauth_account(tokens, account_id_hint)?;
|
||||
let saved = account.clone();
|
||||
store.upsert_account(account);
|
||||
if switch {
|
||||
store.meta.current_account_id = Some(saved.id.clone());
|
||||
let account = store
|
||||
.find_account(&saved.id)
|
||||
.expect("just inserted account");
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?;
|
||||
}
|
||||
store.save(&home)?;
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
fn oauth_account(tokens: Tokens, account_id_hint: Option<String>) -> Result<Account> {
|
||||
// The id_token carries email, plan and organization hints. Account ids are
|
||||
// deterministic so re-importing the same auth updates the existing record.
|
||||
let payload = jwt::decode_payload(&tokens.id_token)?;
|
||||
let auth = payload.auth.clone();
|
||||
let email = payload
|
||||
.email
|
||||
.or_else(|| payload.sub.map(|sub| format!("{sub}@unknown.local")))
|
||||
.unwrap_or_else(|| "unknown@unknown.local".to_string());
|
||||
let account_id =
|
||||
account_id_hint.or_else(|| auth.as_ref().and_then(|item| item.account_id.clone()));
|
||||
let organization_id = auth.as_ref().and_then(|item| item.organization_id.clone());
|
||||
let plan_type = auth
|
||||
.as_ref()
|
||||
.and_then(|item| item.chatgpt_plan_type.clone());
|
||||
let id = stable_id(
|
||||
"oauth",
|
||||
&email,
|
||||
account_id.as_deref(),
|
||||
organization_id.as_deref().or_else(|| {
|
||||
if account_id.is_none() {
|
||||
plan_type.as_deref()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
);
|
||||
let now = Utc::now().timestamp();
|
||||
Ok(Account {
|
||||
id,
|
||||
email,
|
||||
auth_mode: AuthMode::Oauth,
|
||||
alias: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
api_provider_name: None,
|
||||
plan_type,
|
||||
account_id,
|
||||
organization_id,
|
||||
tokens: Some(tokens),
|
||||
openai_api_key: None,
|
||||
api_base_url: None,
|
||||
quota: None,
|
||||
fingerprint_id: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_used_at: now,
|
||||
requires_reauth: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn api_key_account(
|
||||
key: String,
|
||||
base_url: Option<String>,
|
||||
options: ApiKeyOptions,
|
||||
) -> Result<Account> {
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return Err(anyhow!("API Key 不能为空"));
|
||||
}
|
||||
let base_url = normalize_api_base_url(base_url);
|
||||
let alias = normalize_optional_field(options.alias);
|
||||
let model = normalize_optional_field(options.model);
|
||||
let provider_name = normalize_optional_field(options.provider_name);
|
||||
let id = stable_id("apikey", key, base_url.as_deref(), None);
|
||||
let email = api_base_url_display(base_url.as_deref()).to_string();
|
||||
let now = Utc::now().timestamp();
|
||||
Ok(Account {
|
||||
id,
|
||||
email,
|
||||
auth_mode: AuthMode::ApiKey,
|
||||
alias,
|
||||
model,
|
||||
reasoning_effort: None,
|
||||
api_provider_name: provider_name,
|
||||
plan_type: None,
|
||||
account_id: None,
|
||||
organization_id: None,
|
||||
tokens: None,
|
||||
openai_api_key: Some(key.to_string()),
|
||||
api_base_url: base_url,
|
||||
quota: None,
|
||||
fingerprint_id: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_used_at: now,
|
||||
requires_reauth: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn stable_id(kind: &str, a: &str, b: Option<&str>, c: Option<&str>) -> String {
|
||||
// Avoid storing secrets in ids while still making repeated imports stable.
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(kind.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(a.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(b.unwrap_or_default().as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(c.unwrap_or_default().as_bytes());
|
||||
let hex = hex::encode(hasher.finalize());
|
||||
format!("{kind}_{}", &hex[..16])
|
||||
}
|
||||
|
||||
fn find_unique_account_id(store: &Store, query: &str) -> Result<String> {
|
||||
// Exact ids are always unambiguous. Email and prefix queries can match both
|
||||
// personal and team accounts for the same login email, so reject ambiguity.
|
||||
if let Some(account) = store
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|account| account.id == query || account.alias.as_deref() == Some(query))
|
||||
{
|
||||
return Ok(account.id.clone());
|
||||
}
|
||||
|
||||
let query_lower = query.to_ascii_lowercase();
|
||||
let matches = store
|
||||
.accounts
|
||||
.iter()
|
||||
.filter(|account| {
|
||||
account_id_matches_query(&account.id, query)
|
||||
|| account.email.eq_ignore_ascii_case(query)
|
||||
|| account
|
||||
.alias
|
||||
.as_deref()
|
||||
.map(|alias| alias.eq_ignore_ascii_case(query))
|
||||
.unwrap_or(false)
|
||||
|| account
|
||||
.email
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(query_lower.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match matches.as_slice() {
|
||||
[] => Err(anyhow!("账号不存在: {query}")),
|
||||
[account] => Ok(account.id.clone()),
|
||||
_ => {
|
||||
let choices = matches
|
||||
.iter()
|
||||
.map(|account| {
|
||||
format!(
|
||||
"{} ({}, account_id={})",
|
||||
account.id,
|
||||
account.plan_type.as_deref().unwrap_or("-"),
|
||||
account.account_id.as_deref().unwrap_or("-")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
Err(anyhow!(
|
||||
"账号匹配不唯一: {query}。请使用完整账号 ID。匹配项: {choices}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_account_switch_options(
|
||||
store: &mut Store,
|
||||
account_id: &str,
|
||||
options: &SwitchOptions,
|
||||
) -> Result<()> {
|
||||
let Some(account) = store.find_account_mut(account_id) else {
|
||||
return Err(anyhow!("账号不存在: {account_id}"));
|
||||
};
|
||||
let mut changed = false;
|
||||
if let Some(model) = normalize_optional_field(options.model.clone()) {
|
||||
changed |= account.model.as_deref() != Some(model.as_str());
|
||||
account.model = Some(model);
|
||||
}
|
||||
if let Some(reasoning_effort) = normalize_optional_field(options.reasoning_effort.clone()) {
|
||||
changed |= account.reasoning_effort.as_deref() != Some(reasoning_effort.as_str());
|
||||
account.reasoning_effort = Some(reasoning_effort);
|
||||
}
|
||||
if let Some(provider_name) = normalize_optional_field(options.provider_name.clone()) {
|
||||
if account.auth_mode != AuthMode::ApiKey {
|
||||
return Err(anyhow!("--name 仅支持 API Key 账号"));
|
||||
}
|
||||
changed |= account.api_provider_name.as_deref() != Some(provider_name.as_str());
|
||||
account.api_provider_name = Some(provider_name);
|
||||
}
|
||||
if changed {
|
||||
account.updated_at = Utc::now().timestamp();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|(left_index, left), (right_index, right)| {
|
||||
auto_switch_key(left, *left_index).cmp(&auto_switch_key(right, *right_index))
|
||||
})
|
||||
.map(|(_, account)| account.id.clone())
|
||||
.ok_or_else(|| anyhow!("没有可切换的账号"))
|
||||
}
|
||||
|
||||
fn auto_switch_key(account: &Account, index: usize) -> (u8, i32, i32, usize) {
|
||||
let (weekly, primary) = account
|
||||
.quota
|
||||
.as_ref()
|
||||
.map(|quota| {
|
||||
(
|
||||
quota.secondary_remaining_percent,
|
||||
quota.primary_remaining_percent,
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, 0));
|
||||
(
|
||||
account_auto_rank(account),
|
||||
-weekly,
|
||||
-primary,
|
||||
if account.auth_mode == AuthMode::ApiKey {
|
||||
index
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn account_auto_rank(account: &Account) -> u8 {
|
||||
if account.auth_mode == AuthMode::ApiKey {
|
||||
return 2;
|
||||
}
|
||||
let plan = account
|
||||
.plan_type
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if plan.contains("team") {
|
||||
0
|
||||
} else if plan.contains("free") {
|
||||
1
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn format_quota(account: &Account) -> String {
|
||||
account
|
||||
.quota
|
||||
.as_ref()
|
||||
.map(|quota| {
|
||||
format!(
|
||||
"5h={}%, weekly={}%",
|
||||
quota.primary_remaining_percent, quota.secondary_remaining_percent
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn account_plan_display(account: &Account) -> &str {
|
||||
if account.auth_mode == AuthMode::ApiKey {
|
||||
"-"
|
||||
} else if account.requires_reauth {
|
||||
"reauth"
|
||||
} else {
|
||||
account.plan_type.as_deref().unwrap_or("-")
|
||||
}
|
||||
}
|
||||
|
||||
fn account_email_display(account: &Account) -> &str {
|
||||
if let Some(alias) = account.alias.as_deref() {
|
||||
alias
|
||||
} else 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)
|
||||
}
|
||||
|
||||
fn format_quota_cells(account: &Account) -> (String, String) {
|
||||
let Some(quota) = account.quota.as_ref() else {
|
||||
return ("-".to_string(), "-".to_string());
|
||||
};
|
||||
(
|
||||
format_quota_cell(
|
||||
quota.primary_remaining_percent,
|
||||
quota.primary_reset_time,
|
||||
format_reset_time,
|
||||
),
|
||||
format_quota_cell(
|
||||
quota.secondary_remaining_percent,
|
||||
quota.secondary_reset_time,
|
||||
format_week_reset_time,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_quota_cell(
|
||||
remaining_percent: i32,
|
||||
reset_time: Option<i64>,
|
||||
format_reset: fn(i64) -> String,
|
||||
) -> String {
|
||||
format!(
|
||||
"{:>3}% / {}",
|
||||
remaining_percent,
|
||||
reset_time
|
||||
.map(format_reset)
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
fn format_reset_time(reset_time: i64) -> String {
|
||||
let now = Utc::now().timestamp();
|
||||
let seconds = reset_time.saturating_sub(now);
|
||||
if seconds <= 0 {
|
||||
return "now".to_string();
|
||||
}
|
||||
|
||||
let days = seconds / 86_400;
|
||||
let hours = (seconds % 86_400) / 3_600;
|
||||
let minutes = (seconds % 3_600) / 60;
|
||||
if days > 0 {
|
||||
format!("{days}d{hours}h")
|
||||
} else if hours > 0 {
|
||||
format!("{hours}h{minutes:02}m")
|
||||
} else {
|
||||
format!("{minutes}m")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_week_reset_time(reset_time: i64) -> String {
|
||||
let now = Utc::now().timestamp();
|
||||
let seconds = reset_time.saturating_sub(now);
|
||||
if seconds <= 0 {
|
||||
return "now".to_string();
|
||||
}
|
||||
|
||||
let days = seconds / 86_400;
|
||||
let hours = (seconds % 86_400) / 3_600;
|
||||
format!("{days}d{hours:02}h")
|
||||
}
|
||||
|
||||
fn print_account_table_border() {
|
||||
println!(
|
||||
"+{}+{}+{}+{}+{}+{}+{}+",
|
||||
"-".repeat(3),
|
||||
"-".repeat(24),
|
||||
"-".repeat(30),
|
||||
"-".repeat(10),
|
||||
"-".repeat(8),
|
||||
"-".repeat(14),
|
||||
"-".repeat(14)
|
||||
);
|
||||
}
|
||||
|
||||
fn print_account_table_row(
|
||||
marker: &str,
|
||||
id: &str,
|
||||
email: &str,
|
||||
mode: &str,
|
||||
plan: &str,
|
||||
primary_quota: &str,
|
||||
secondary_quota: &str,
|
||||
) {
|
||||
println!(
|
||||
"| {:<1} | {:<22} | {:<28} | {:<8} | {:<6} | {:<12} | {:<12} |",
|
||||
shorten(marker, 1),
|
||||
shorten(id, 22),
|
||||
shorten(email, 28),
|
||||
shorten(mode, 8),
|
||||
shorten(plan, 6),
|
||||
shorten(primary_quota, 12),
|
||||
shorten(secondary_quota, 12)
|
||||
);
|
||||
}
|
||||
|
||||
fn shorten(value: &str, width: usize) -> String {
|
||||
if value.chars().count() <= width {
|
||||
return value.to_string();
|
||||
}
|
||||
if width <= 1 {
|
||||
return "…".to_string();
|
||||
}
|
||||
let mut out = value.chars().take(width - 1).collect::<String>();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
fn print_account(account: &Account) {
|
||||
println!("id: {}", account.id);
|
||||
println!("alias: {}", account.alias.as_deref().unwrap_or("-"));
|
||||
println!("email: {}", account_email_display(account));
|
||||
println!("auth_mode: {}", auth_file::account_auth_mode_name(account));
|
||||
println!("model: {}", account.model.as_deref().unwrap_or("-"));
|
||||
println!(
|
||||
"reasoning_effort: {}",
|
||||
account.reasoning_effort.as_deref().unwrap_or("-")
|
||||
);
|
||||
println!("plan_type: {}", account_plan_display(account));
|
||||
println!(
|
||||
"account_id: {}",
|
||||
account.account_id.as_deref().unwrap_or("-")
|
||||
);
|
||||
println!(
|
||||
"organization_id: {}",
|
||||
account.organization_id.as_deref().unwrap_or("-")
|
||||
);
|
||||
if account.auth_mode == AuthMode::ApiKey {
|
||||
println!(
|
||||
"api_base_url: {}",
|
||||
api_base_url_display(account.api_base_url.as_deref())
|
||||
);
|
||||
println!(
|
||||
"api_provider_name: {}",
|
||||
account.api_provider_name.as_deref().unwrap_or("-")
|
||||
);
|
||||
println!(
|
||||
"openai_api_key: {}",
|
||||
account
|
||||
.openai_api_key
|
||||
.as_deref()
|
||||
.map(mask_api_key)
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
);
|
||||
}
|
||||
println!("requires_reauth: {}", account.requires_reauth);
|
||||
}
|
||||
|
||||
fn normalize_api_base_url(base_url: Option<String>) -> Option<String> {
|
||||
base_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_field(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn mask_api_key(key: &str) -> String {
|
||||
let chars = key.chars().collect::<Vec<_>>();
|
||||
if chars.len() <= 8 {
|
||||
return "<stored>".to_string();
|
||||
}
|
||||
let prefix = chars.iter().take(3).collect::<String>();
|
||||
let suffix = chars
|
||||
.iter()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
format!("{prefix}...{suffix}")
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use crate::auth_file;
|
||||
use crate::config_store::{self, Home, Store};
|
||||
use crate::{codex_config, paths};
|
||||
|
||||
pub fn list_homes(json: bool) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let store = Store::load(&home)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&store.homes)?);
|
||||
return Ok(());
|
||||
}
|
||||
for item in &store.homes {
|
||||
let current = if store.meta.current_home.as_deref() == Some(item.name.as_str()) {
|
||||
"*"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
println!(
|
||||
"{} {:<18} {:<48} {}",
|
||||
current,
|
||||
item.name,
|
||||
item.path,
|
||||
item.bound_account_id.as_deref().unwrap_or("-")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_home(name: &str, path: PathBuf, account: Option<String>) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
if store.homes.iter().any(|item| item.name == name) {
|
||||
return Err(anyhow!("home 已存在: {name}"));
|
||||
}
|
||||
let bound_account_id = if let Some(query) = account {
|
||||
Some(
|
||||
store
|
||||
.find_account(&query)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
|
||||
.id
|
||||
.clone(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let path = paths::expand_home(path);
|
||||
std::fs::create_dir_all(&path)
|
||||
.with_context(|| format!("创建 CODEX_HOME 目录失败: {}", path.display()))?;
|
||||
// When an account is bound at creation time, make the new home immediately
|
||||
// runnable by writing its auth.json now.
|
||||
if let Some(account_id) = bound_account_id.as_deref() {
|
||||
let account = store.find_account(account_id).expect("checked account");
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&path), &path, account)?;
|
||||
codex_config::apply_account_provider(&path, account)?;
|
||||
}
|
||||
store.homes.push(Home {
|
||||
name: name.to_string(),
|
||||
path: config_store::path_to_string(&path),
|
||||
bound_account_id,
|
||||
});
|
||||
store.save(&home)?;
|
||||
println!("已创建 home: {name} -> {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn bind_home(name: &str, account: &str) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
let account_id = store
|
||||
.find_account(account)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {account}"))?
|
||||
.id
|
||||
.clone();
|
||||
let target_home = store
|
||||
.homes
|
||||
.iter_mut()
|
||||
.find(|item| item.name == name)
|
||||
.ok_or_else(|| anyhow!("home 不存在: {name}"))?;
|
||||
target_home.bound_account_id = Some(account_id.clone());
|
||||
store.save(&home)?;
|
||||
println!("已绑定 home {name} -> {account_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn home_path(name: &str) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let store = Store::load(&home)?;
|
||||
let item = store
|
||||
.find_home(name)
|
||||
.ok_or_else(|| anyhow!("home 不存在: {name}"))?;
|
||||
println!("{}", item.path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_home(name: &str) -> Result<()> {
|
||||
if name == "default" {
|
||||
return Err(anyhow!("不能删除 default home"));
|
||||
}
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
let before = store.homes.len();
|
||||
store.homes.retain(|item| item.name != name);
|
||||
if store.homes.len() == before {
|
||||
return Err(anyhow!("home 不存在: {name}"));
|
||||
}
|
||||
store.save(&home)?;
|
||||
println!("已删除 home: {name}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::auth_file;
|
||||
use crate::config_store::{Account, AuthMode, Store};
|
||||
use crate::paths;
|
||||
|
||||
use super::{
|
||||
account_email_display, account_id_display, account_plan_display, alias_target_display,
|
||||
ensure_alias_available, find_unique_account_id, mask_api_key, normalize_alias, shorten,
|
||||
touch_updated_at, AliasRow,
|
||||
};
|
||||
|
||||
pub async fn list_accounts(json: bool, force: bool) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
let quota_concurrency = store.settings.quota_concurrency;
|
||||
let quota_max_age_seconds = if force { 0 } else { 60 };
|
||||
let ids = store
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|account| account.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let quota_report = crate::quota::refresh_stale_quotas(
|
||||
&mut store,
|
||||
&ids,
|
||||
quota_max_age_seconds,
|
||||
quota_concurrency,
|
||||
)
|
||||
.await;
|
||||
if !quota_report.errors.is_empty() {
|
||||
eprintln!(
|
||||
"提示: {} 个账号配额刷新失败,已使用本地缓存。",
|
||||
quota_report.errors.len()
|
||||
);
|
||||
}
|
||||
if quota_report.changed {
|
||||
store.save(&home)?;
|
||||
}
|
||||
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 {
|
||||
touch_updated_at(account);
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn current_account(json: bool) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let store = Store::load(&home)?;
|
||||
let Some(id) = store.meta.current_account_id.as_deref() else {
|
||||
println!("当前未设置账号。");
|
||||
return Ok(());
|
||||
};
|
||||
let account = store
|
||||
.find_account(id)
|
||||
.ok_or_else(|| anyhow!("current_account_id 指向不存在的账号: {id}"))?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(account)?);
|
||||
} else {
|
||||
print_account(account);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show_account(query: &str, json: bool) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let store = Store::load(&home)?;
|
||||
let account = store
|
||||
.find_account(query)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {query}"))?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(account)?);
|
||||
} else {
|
||||
print_account(account);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_account(query: &str) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
let id = store
|
||||
.find_account(query)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
|
||||
.id
|
||||
.clone();
|
||||
store.accounts.retain(|account| account.id != id);
|
||||
if store.meta.current_account_id.as_deref() == Some(id.as_str()) {
|
||||
store.meta.current_account_id = None;
|
||||
}
|
||||
for home in &mut store.homes {
|
||||
if home.bound_account_id.as_deref() == Some(id.as_str()) {
|
||||
home.bound_account_id = None;
|
||||
}
|
||||
}
|
||||
store.save(&home)?;
|
||||
println!("已删除账号: {id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_accounts(store: &Store, json: bool) -> Result<()> {
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&store.accounts)?);
|
||||
return Ok(());
|
||||
}
|
||||
if store.accounts.is_empty() {
|
||||
println!("没有保存的账号。可使用 `cdxs import auth` 导入。");
|
||||
return Ok(());
|
||||
}
|
||||
print_account_table_border();
|
||||
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().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 {
|
||||
""
|
||||
};
|
||||
let (primary_quota, secondary_quota) = format_quota_cells(account);
|
||||
print_account_table_row(
|
||||
current,
|
||||
&account_id_display(&account.id),
|
||||
account_email_display(account),
|
||||
auth_file::account_auth_mode_name(account),
|
||||
account_plan_display(account),
|
||||
&primary_quota,
|
||||
&secondary_quota,
|
||||
);
|
||||
}
|
||||
print_account_table_border();
|
||||
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)
|
||||
}
|
||||
|
||||
fn print_account_table_border() {
|
||||
println!(
|
||||
"+{}+{}+{}+{}+{}+{}+{}+",
|
||||
"-".repeat(3),
|
||||
"-".repeat(24),
|
||||
"-".repeat(30),
|
||||
"-".repeat(10),
|
||||
"-".repeat(8),
|
||||
"-".repeat(14),
|
||||
"-".repeat(14)
|
||||
);
|
||||
}
|
||||
|
||||
fn print_account_table_row(
|
||||
marker: &str,
|
||||
id: &str,
|
||||
email: &str,
|
||||
mode: &str,
|
||||
plan: &str,
|
||||
primary_quota: &str,
|
||||
secondary_quota: &str,
|
||||
) {
|
||||
println!(
|
||||
"| {:<1} | {:<22} | {:<28} | {:<8} | {:<6} | {:<12} | {:<12} |",
|
||||
shorten(marker, 1),
|
||||
shorten(id, 22),
|
||||
shorten(email, 28),
|
||||
shorten(mode, 8),
|
||||
shorten(plan, 6),
|
||||
shorten(primary_quota, 12),
|
||||
shorten(secondary_quota, 12)
|
||||
);
|
||||
}
|
||||
|
||||
fn print_account(account: &Account) {
|
||||
println!("id: {}", account.id);
|
||||
println!("alias: {}", account.alias.as_deref().unwrap_or("-"));
|
||||
println!("email: {}", account_email_display(account));
|
||||
println!("auth_mode: {}", auth_file::account_auth_mode_name(account));
|
||||
println!("model: {}", account.model.as_deref().unwrap_or("-"));
|
||||
println!(
|
||||
"reasoning_effort: {}",
|
||||
account.reasoning_effort.as_deref().unwrap_or("-")
|
||||
);
|
||||
println!("plan_type: {}", account_plan_display(account));
|
||||
println!(
|
||||
"account_id: {}",
|
||||
account.account_id.as_deref().unwrap_or("-")
|
||||
);
|
||||
println!(
|
||||
"organization_id: {}",
|
||||
account.organization_id.as_deref().unwrap_or("-")
|
||||
);
|
||||
if account.auth_mode == AuthMode::ApiKey {
|
||||
println!(
|
||||
"api_base_url: {}",
|
||||
super::api_base_url_display(account.api_base_url.as_deref())
|
||||
);
|
||||
println!(
|
||||
"api_provider_name: {}",
|
||||
account.api_provider_name.as_deref().unwrap_or("-")
|
||||
);
|
||||
println!(
|
||||
"openai_api_key: {}",
|
||||
account
|
||||
.openai_api_key
|
||||
.as_deref()
|
||||
.map(mask_api_key)
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
);
|
||||
}
|
||||
println!("requires_reauth: {}", account.requires_reauth);
|
||||
}
|
||||
|
||||
pub(super) fn format_quota_cells(account: &Account) -> (String, String) {
|
||||
let Some(quota) = account.quota.as_ref() else {
|
||||
return ("-".to_string(), "-".to_string());
|
||||
};
|
||||
(
|
||||
format_quota_cell(
|
||||
quota.primary_remaining_percent,
|
||||
quota.primary_reset_time,
|
||||
format_reset_time,
|
||||
),
|
||||
format_quota_cell(
|
||||
quota.secondary_remaining_percent,
|
||||
quota.secondary_reset_time,
|
||||
format_week_reset_time,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_quota_cell(
|
||||
remaining_percent: i32,
|
||||
reset_time: Option<i64>,
|
||||
format_reset: fn(i64) -> String,
|
||||
) -> String {
|
||||
format!(
|
||||
"{:>3}% / {}",
|
||||
remaining_percent,
|
||||
reset_time
|
||||
.map(format_reset)
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
fn format_reset_time(reset_time: i64) -> String {
|
||||
let now = Utc::now().timestamp();
|
||||
let seconds = reset_time.saturating_sub(now);
|
||||
if seconds <= 0 {
|
||||
return "now".to_string();
|
||||
}
|
||||
|
||||
let days = seconds / 86_400;
|
||||
let hours = (seconds % 86_400) / 3_600;
|
||||
let minutes = (seconds % 3_600) / 60;
|
||||
if days > 0 {
|
||||
format!("{days}d{hours}h")
|
||||
} else if hours > 0 {
|
||||
format!("{hours}h{minutes:02}m")
|
||||
} else {
|
||||
format!("{minutes}m")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_week_reset_time(reset_time: i64) -> String {
|
||||
let now = Utc::now().timestamp();
|
||||
let seconds = reset_time.saturating_sub(now);
|
||||
if seconds <= 0 {
|
||||
return "now".to_string();
|
||||
}
|
||||
|
||||
let days = seconds / 86_400;
|
||||
let hours = (seconds % 86_400) / 3_600;
|
||||
format!("{days}d{hours:02}h")
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Account and managed `CODEX_HOME` operations.
|
||||
//!
|
||||
//! This module owns the high-level workflows that mutate `cdxs.toml` and
|
||||
//! Codex `auth.json`: importing credentials, switching accounts, creating
|
||||
//! named homes, and preparing an isolated home before running Codex.
|
||||
|
||||
mod homes;
|
||||
mod listing;
|
||||
mod storage;
|
||||
mod switching;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
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,
|
||||
};
|
||||
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};
|
||||
|
||||
const DEFAULT_API_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ApiKeyOptions {
|
||||
pub alias: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub provider_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SwitchOptions {
|
||||
pub model: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub provider_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct AliasRow {
|
||||
alias: String,
|
||||
account_id: String,
|
||||
email: String,
|
||||
auth_mode: String,
|
||||
}
|
||||
|
||||
pub(super) fn stable_id(kind: &str, a: &str, b: Option<&str>, c: Option<&str>) -> String {
|
||||
// Avoid storing secrets in ids while still making repeated imports stable.
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(kind.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(a.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(b.unwrap_or_default().as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(c.unwrap_or_default().as_bytes());
|
||||
let hex = hex::encode(hasher.finalize());
|
||||
format!("{kind}_{}", &hex[..16])
|
||||
}
|
||||
|
||||
pub(super) fn find_unique_account_id(store: &Store, query: &str) -> Result<String> {
|
||||
// Exact ids are always unambiguous. Email and prefix queries can match both
|
||||
// personal and team accounts for the same login email, so reject ambiguity.
|
||||
if let Some(account) = store
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|account| account.id == query || account.alias.as_deref() == Some(query))
|
||||
{
|
||||
return Ok(account.id.clone());
|
||||
}
|
||||
|
||||
let query_lower = query.to_ascii_lowercase();
|
||||
let matches = store
|
||||
.accounts
|
||||
.iter()
|
||||
.filter(|account| {
|
||||
account_id_matches_query(&account.id, query)
|
||||
|| account.email.eq_ignore_ascii_case(query)
|
||||
|| account
|
||||
.alias
|
||||
.as_deref()
|
||||
.map(|alias| alias.eq_ignore_ascii_case(query))
|
||||
.unwrap_or(false)
|
||||
|| account
|
||||
.email
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(query_lower.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match matches.as_slice() {
|
||||
[] => Err(anyhow!("账号不存在: {query}")),
|
||||
[account] => Ok(account.id.clone()),
|
||||
_ => {
|
||||
let choices = matches
|
||||
.iter()
|
||||
.map(|account| {
|
||||
format!(
|
||||
"{} ({}, account_id={})",
|
||||
account.id,
|
||||
account.plan_type.as_deref().unwrap_or("-"),
|
||||
account.account_id.as_deref().unwrap_or("-")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
Err(anyhow!(
|
||||
"账号匹配不唯一: {query}。请使用完整账号 ID。匹配项: {choices}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
|
||||
pub(super) 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}")
|
||||
}
|
||||
|
||||
pub(super) fn account_plan_display(account: &Account) -> &str {
|
||||
if account.auth_mode == AuthMode::ApiKey {
|
||||
"-"
|
||||
} else if account.requires_reauth {
|
||||
"reauth"
|
||||
} else {
|
||||
account.plan_type.as_deref().unwrap_or("-")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn account_email_display(account: &Account) -> &str {
|
||||
if let Some(alias) = account.alias.as_deref() {
|
||||
alias
|
||||
} else if account.auth_mode == AuthMode::ApiKey {
|
||||
api_base_url_display(account.api_base_url.as_deref())
|
||||
} else {
|
||||
&account.email
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn api_base_url_display(base_url: Option<&str>) -> &str {
|
||||
base_url.unwrap_or(DEFAULT_API_BASE_URL)
|
||||
}
|
||||
|
||||
pub(super) fn format_quota(account: &Account) -> String {
|
||||
account
|
||||
.quota
|
||||
.as_ref()
|
||||
.map(|quota| {
|
||||
format!(
|
||||
"5h={}%, weekly={}%",
|
||||
quota.primary_remaining_percent, quota.secondary_remaining_percent
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn normalize_api_base_url(base_url: Option<String>) -> Option<String> {
|
||||
base_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
pub(super) fn normalize_alias(alias: &str) -> Result<String> {
|
||||
let alias = alias.trim();
|
||||
if alias.is_empty() {
|
||||
return Err(anyhow!("alias 不能为空"));
|
||||
}
|
||||
Ok(alias.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn normalize_optional_field(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(super) 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(())
|
||||
}
|
||||
|
||||
pub(super) fn mask_api_key(key: &str) -> String {
|
||||
let chars = key.chars().collect::<Vec<_>>();
|
||||
if chars.len() <= 8 {
|
||||
return "<stored>".to_string();
|
||||
}
|
||||
let prefix = chars.iter().take(3).collect::<String>();
|
||||
let suffix = chars
|
||||
.iter()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
format!("{prefix}...{suffix}")
|
||||
}
|
||||
|
||||
pub(super) fn shorten(value: &str, width: usize) -> String {
|
||||
if value.chars().count() <= width {
|
||||
return value.to_string();
|
||||
}
|
||||
if width <= 1 {
|
||||
return "…".to_string();
|
||||
}
|
||||
let mut out = value.chars().take(width - 1).collect::<String>();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn touch_updated_at(account: &mut Account) {
|
||||
account.updated_at = Utc::now().timestamp();
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::auth_file;
|
||||
use crate::config_store::{Account, AuthMode, Store, Tokens};
|
||||
use crate::{codex_config, jwt, paths};
|
||||
|
||||
use super::{
|
||||
ensure_alias_available, normalize_api_base_url, normalize_optional_field, stable_id,
|
||||
ApiKeyOptions,
|
||||
};
|
||||
|
||||
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.
|
||||
let config_home = paths::codex_home(None)?;
|
||||
let source_home = paths::codex_home(codex_home)?;
|
||||
let auth_path = file.unwrap_or_else(|| paths::auth_path(&source_home));
|
||||
let auth = auth_file::read_auth_file(&auth_path)?;
|
||||
let mut store = Store::load(&config_home)?;
|
||||
let account = account_from_auth(auth)?;
|
||||
let id = account.id.clone();
|
||||
let email = account.email.clone();
|
||||
store.upsert_account(account);
|
||||
if switch {
|
||||
store.meta.current_account_id = Some(id.clone());
|
||||
let account = store.find_account(&id).expect("just inserted account");
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&source_home), &source_home, account)?;
|
||||
codex_config::apply_account_provider(&source_home, account)?;
|
||||
}
|
||||
store.save(&config_home)?;
|
||||
println!("已导入账号: {email} ({id})");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_api_key(
|
||||
key: String,
|
||||
base_url: Option<String>,
|
||||
options: ApiKeyOptions,
|
||||
switch: bool,
|
||||
) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
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());
|
||||
let account = store.find_account(&id).expect("just inserted account");
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?;
|
||||
codex_config::apply_account_provider(&home, account)?;
|
||||
}
|
||||
store.save(&home)?;
|
||||
println!("已保存 API Key 账号: {email} ({id})");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_oauth_tokens(
|
||||
tokens: Tokens,
|
||||
account_id_hint: Option<String>,
|
||||
switch: bool,
|
||||
) -> Result<Account> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&home)?;
|
||||
let account = oauth_account(tokens, account_id_hint)?;
|
||||
let saved = account.clone();
|
||||
store.upsert_account(account);
|
||||
if switch {
|
||||
store.meta.current_account_id = Some(saved.id.clone());
|
||||
let account = store
|
||||
.find_account(&saved.id)
|
||||
.expect("just inserted account");
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?;
|
||||
}
|
||||
store.save(&home)?;
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
fn account_from_auth(auth: auth_file::CodexAuthFile) -> Result<Account> {
|
||||
// Codex auth.json can represent either API-key mode or OAuth-token mode.
|
||||
// Normalize both shapes into one stored Account record.
|
||||
if auth_file::is_api_key_mode(&auth) {
|
||||
let key = auth_file::extract_api_key(&auth)
|
||||
.ok_or_else(|| anyhow!("auth.json 缺少 OPENAI_API_KEY"))?;
|
||||
return api_key_account(
|
||||
key,
|
||||
auth_file::api_base_url(&auth),
|
||||
ApiKeyOptions::default(),
|
||||
);
|
||||
}
|
||||
|
||||
let tokens = auth
|
||||
.tokens
|
||||
.ok_or_else(|| anyhow!("auth.json 缺少 tokens 或 OPENAI_API_KEY"))?;
|
||||
let account_id_hint = tokens.account_id.clone();
|
||||
let store_tokens = auth_file::auth_tokens_to_store(tokens);
|
||||
oauth_account(store_tokens, account_id_hint)
|
||||
}
|
||||
|
||||
fn oauth_account(tokens: Tokens, account_id_hint: Option<String>) -> Result<Account> {
|
||||
// The id_token carries email, plan and organization hints. Account ids are
|
||||
// deterministic so re-importing the same auth updates the existing record.
|
||||
let payload = jwt::decode_payload(&tokens.id_token)?;
|
||||
let auth = payload.auth.clone();
|
||||
let email = payload
|
||||
.email
|
||||
.or_else(|| payload.sub.map(|sub| format!("{sub}@unknown.local")))
|
||||
.unwrap_or_else(|| "unknown@unknown.local".to_string());
|
||||
let account_id =
|
||||
account_id_hint.or_else(|| auth.as_ref().and_then(|item| item.account_id.clone()));
|
||||
let organization_id = auth.as_ref().and_then(|item| item.organization_id.clone());
|
||||
let plan_type = auth
|
||||
.as_ref()
|
||||
.and_then(|item| item.chatgpt_plan_type.clone());
|
||||
let id = stable_id(
|
||||
"oauth",
|
||||
&email,
|
||||
account_id.as_deref(),
|
||||
organization_id.as_deref().or_else(|| {
|
||||
if account_id.is_none() {
|
||||
plan_type.as_deref()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
);
|
||||
let now = Utc::now().timestamp();
|
||||
Ok(Account {
|
||||
id,
|
||||
email,
|
||||
auth_mode: AuthMode::Oauth,
|
||||
alias: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
api_provider_name: None,
|
||||
plan_type,
|
||||
account_id,
|
||||
organization_id,
|
||||
tokens: Some(tokens),
|
||||
openai_api_key: None,
|
||||
api_base_url: None,
|
||||
quota: None,
|
||||
fingerprint_id: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_used_at: now,
|
||||
requires_reauth: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn api_key_account(
|
||||
key: String,
|
||||
base_url: Option<String>,
|
||||
options: ApiKeyOptions,
|
||||
) -> Result<Account> {
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return Err(anyhow!("API Key 不能为空"));
|
||||
}
|
||||
let base_url = normalize_api_base_url(base_url);
|
||||
let alias = normalize_optional_field(options.alias);
|
||||
let model = normalize_optional_field(options.model);
|
||||
let provider_name = normalize_optional_field(options.provider_name);
|
||||
let id = stable_id("apikey", key, base_url.as_deref(), None);
|
||||
let email = super::api_base_url_display(base_url.as_deref()).to_string();
|
||||
let now = Utc::now().timestamp();
|
||||
Ok(Account {
|
||||
id,
|
||||
email,
|
||||
auth_mode: AuthMode::ApiKey,
|
||||
alias,
|
||||
model,
|
||||
reasoning_effort: None,
|
||||
api_provider_name: provider_name,
|
||||
plan_type: None,
|
||||
account_id: None,
|
||||
organization_id: None,
|
||||
tokens: None,
|
||||
openai_api_key: Some(key.to_string()),
|
||||
api_base_url: base_url,
|
||||
quota: None,
|
||||
fingerprint_id: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_used_at: now,
|
||||
requires_reauth: false,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::config_store::{Account, AuthMode, Store};
|
||||
use crate::{auth_file, codex_config, paths, session, token};
|
||||
|
||||
use super::{
|
||||
find_unique_account_id, format_quota, normalize_optional_field, touch_updated_at, SwitchOptions,
|
||||
};
|
||||
|
||||
pub async fn switch_account(
|
||||
query: &str,
|
||||
codex_home: Option<PathBuf>,
|
||||
apply_fingerprint: bool,
|
||||
options: SwitchOptions,
|
||||
) -> Result<()> {
|
||||
if apply_fingerprint {
|
||||
eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。");
|
||||
}
|
||||
// The saved account list is always loaded from the main cdxs config, while
|
||||
// the target auth.json can be redirected with --codex-home.
|
||||
let config_home = paths::codex_home(None)?;
|
||||
let target_home = paths::codex_home(codex_home)?;
|
||||
let mut store = Store::load(&config_home)?;
|
||||
let account_id = find_unique_account_id(&store, query)?;
|
||||
update_account_switch_options(&mut store, &account_id, &options)?;
|
||||
switch_account_id(&mut store, &config_home, &target_home, &account_id).await
|
||||
}
|
||||
|
||||
pub async fn switch_auto(
|
||||
codex_home: Option<PathBuf>,
|
||||
apply_fingerprint: bool,
|
||||
options: SwitchOptions,
|
||||
) -> Result<()> {
|
||||
if apply_fingerprint {
|
||||
eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。");
|
||||
}
|
||||
let config_home = paths::codex_home(None)?;
|
||||
let target_home = paths::codex_home(codex_home)?;
|
||||
let mut store = Store::load(&config_home)?;
|
||||
let ids = store
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|account| account.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let quota_concurrency = store.settings.quota_concurrency;
|
||||
let quota_report =
|
||||
crate::quota::refresh_stale_quotas(&mut store, &ids, 60, quota_concurrency).await;
|
||||
if !quota_report.errors.is_empty() {
|
||||
eprintln!(
|
||||
"提示: {} 个账号配额刷新失败,自动选择将使用本地缓存。",
|
||||
quota_report.errors.len()
|
||||
);
|
||||
}
|
||||
let account_id = best_auto_switch_account(&store)?;
|
||||
update_account_switch_options(&mut store, &account_id, &options)?;
|
||||
switch_account_id(&mut store, &config_home, &target_home, &account_id).await?;
|
||||
if let Some(account) = store.find_account(&account_id) {
|
||||
println!(
|
||||
"自动选择账号: {} <{}> plan={} quota={}",
|
||||
account.id,
|
||||
account.email,
|
||||
account.plan_type.as_deref().unwrap_or("-"),
|
||||
format_quota(account)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn prepare_account_in_home(query: &str, codex_home: PathBuf) -> Result<String> {
|
||||
// Used by `cdxs run`: refresh the account if needed, write auth.json into
|
||||
// the selected home, then let the caller execute a child process there.
|
||||
let main_home = paths::codex_home(None)?;
|
||||
let mut store = Store::load(&main_home)?;
|
||||
let account_id = store
|
||||
.find_account(query)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
|
||||
.id
|
||||
.clone();
|
||||
token::refresh_account_if_needed(&mut store, &account_id).await?;
|
||||
let (account_id, email) = {
|
||||
let account = store
|
||||
.find_account(&account_id)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&codex_home), &codex_home, account)?;
|
||||
codex_config::apply_account_provider(&codex_home, account)?;
|
||||
(account.id.clone(), account.email.clone())
|
||||
};
|
||||
session::align_provider_buckets(&codex_home)?;
|
||||
if let Some(account) = store.find_account_mut(&account_id) {
|
||||
account.last_used_at = Utc::now().timestamp();
|
||||
}
|
||||
store.save(&main_home)?;
|
||||
Ok(email)
|
||||
}
|
||||
|
||||
pub fn resolve_home_for_run(name: &str) -> Result<(PathBuf, Option<String>)> {
|
||||
let main_home = paths::codex_home(None)?;
|
||||
let store = Store::load(&main_home)?;
|
||||
let home = store
|
||||
.find_home(name)
|
||||
.ok_or_else(|| anyhow!("home 不存在: {name}"))?;
|
||||
Ok((
|
||||
paths::expand_home(PathBuf::from(&home.path)),
|
||||
home.bound_account_id.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn switch_account_id(
|
||||
store: &mut Store,
|
||||
config_home: &Path,
|
||||
target_home: &Path,
|
||||
account_id: &str,
|
||||
) -> Result<()> {
|
||||
token::refresh_account_if_needed(store, account_id).await?;
|
||||
let account = store
|
||||
.find_account(account_id)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
|
||||
auth_file::write_account_to_auth(&paths::auth_path(target_home), target_home, account)?;
|
||||
codex_config::apply_account_provider(target_home, account)?;
|
||||
session::align_provider_buckets(target_home)?;
|
||||
if let Some(account) = store.find_account_mut(account_id) {
|
||||
account.last_used_at = Utc::now().timestamp();
|
||||
}
|
||||
store.meta.current_account_id = Some(account_id.to_string());
|
||||
store.save(config_home)?;
|
||||
println!("已切换 Codex 账号: {account_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_account_switch_options(
|
||||
store: &mut Store,
|
||||
account_id: &str,
|
||||
options: &SwitchOptions,
|
||||
) -> Result<()> {
|
||||
let Some(account) = store.find_account_mut(account_id) else {
|
||||
return Err(anyhow!("账号不存在: {account_id}"));
|
||||
};
|
||||
let mut changed = false;
|
||||
if let Some(model) = normalize_optional_field(options.model.clone()) {
|
||||
changed |= account.model.as_deref() != Some(model.as_str());
|
||||
account.model = Some(model);
|
||||
}
|
||||
if let Some(reasoning_effort) = normalize_optional_field(options.reasoning_effort.clone()) {
|
||||
changed |= account.reasoning_effort.as_deref() != Some(reasoning_effort.as_str());
|
||||
account.reasoning_effort = Some(reasoning_effort);
|
||||
}
|
||||
if let Some(provider_name) = normalize_optional_field(options.provider_name.clone()) {
|
||||
if account.auth_mode != AuthMode::ApiKey {
|
||||
return Err(anyhow!("--name 仅支持 API Key 账号"));
|
||||
}
|
||||
changed |= account.api_provider_name.as_deref() != Some(provider_name.as_str());
|
||||
account.api_provider_name = Some(provider_name);
|
||||
}
|
||||
if changed {
|
||||
touch_updated_at(account);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn best_auto_switch_account(store: &Store) -> Result<String> {
|
||||
store
|
||||
.accounts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|(left_index, left), (right_index, right)| {
|
||||
auto_switch_key(left, *left_index).cmp(&auto_switch_key(right, *right_index))
|
||||
})
|
||||
.map(|(_, account)| account.id.clone())
|
||||
.ok_or_else(|| anyhow!("没有可切换的账号"))
|
||||
}
|
||||
|
||||
fn auto_switch_key(account: &Account, index: usize) -> (u8, i32, i32, usize) {
|
||||
let (weekly, primary) = account
|
||||
.quota
|
||||
.as_ref()
|
||||
.map(|quota| {
|
||||
(
|
||||
quota.secondary_remaining_percent,
|
||||
quota.primary_remaining_percent,
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, 0));
|
||||
(
|
||||
account_auto_rank(account),
|
||||
-weekly,
|
||||
-primary,
|
||||
if account.auth_mode == AuthMode::ApiKey {
|
||||
index
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn account_auto_rank(account: &Account) -> u8 {
|
||||
if account.auth_mode == AuthMode::ApiKey {
|
||||
return 2;
|
||||
}
|
||||
let plan = account
|
||||
.plan_type
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if plan.contains("team") {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use filetime::{set_file_times, FileTime};
|
||||
|
||||
/// Write a file through a sibling temporary file and then rename it into place.
|
||||
/// This keeps auth/config files from being left half-written after failures.
|
||||
@@ -24,6 +25,28 @@ pub fn write_atomic(path: &Path, content: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically replace a file while preserving its original access/modify times.
|
||||
pub fn write_atomic_preserve_times(path: &Path, content: &str) -> Result<()> {
|
||||
let original_times = if path.exists() {
|
||||
let metadata = fs::metadata(path)
|
||||
.with_context(|| format!("读取文件元数据失败: {}", path.display()))?;
|
||||
Some((
|
||||
FileTime::from_last_access_time(&metadata),
|
||||
FileTime::from_last_modification_time(&metadata),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
write_atomic(path, content)?;
|
||||
|
||||
if let Some((atime, mtime)) = original_times {
|
||||
set_file_times(path, atime, mtime)
|
||||
.with_context(|| format!("恢复文件时间失败: {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Backup an existing file into `<codex_home>/cdxs-backups/`.
|
||||
pub fn backup_if_exists(path: &Path, codex_home: &Path, label: &str) -> Result<Option<PathBuf>> {
|
||||
if !path.exists() {
|
||||
|
||||
+46
-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,
|
||||
},
|
||||
@@ -45,6 +43,11 @@ pub enum Commands {
|
||||
)]
|
||||
account: String,
|
||||
},
|
||||
/// Manage account aliases.
|
||||
Alias {
|
||||
#[command(subcommand)]
|
||||
command: AliasCommands,
|
||||
},
|
||||
/// Switch Codex auth.json to a saved account.
|
||||
Switch {
|
||||
#[arg(
|
||||
@@ -65,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)]
|
||||
@@ -217,6 +211,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 {
|
||||
@@ -361,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>,
|
||||
}
|
||||
|
||||
+159
-1
@@ -1,6 +1,6 @@
|
||||
//! Codex `config.toml` helpers for account-specific provider settings.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -11,6 +11,9 @@ use crate::config_store::{Account, AuthMode};
|
||||
use crate::{atomic, paths};
|
||||
|
||||
const MANAGED_PROVIDER_PREFIX: &str = "cdxs_api_";
|
||||
const STATE_DB_FILENAME: &str = "state_5.sqlite";
|
||||
const DEFAULT_PROVIDER: &str = "openai";
|
||||
const CODEX_SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME";
|
||||
|
||||
pub fn apply_account_provider(codex_home: &Path, account: &Account) -> Result<()> {
|
||||
match account.auth_mode {
|
||||
@@ -19,6 +22,57 @@ pub fn apply_account_provider(codex_home: &Path, account: &Account) -> Result<()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_model_provider(codex_home: &Path) -> Result<String> {
|
||||
let config = read_effective_config(codex_home)?;
|
||||
Ok(config
|
||||
.get("model_provider")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_PROVIDER)
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub fn state_db_path(codex_home: &Path) -> Result<PathBuf> {
|
||||
let config = read_effective_config(codex_home)?;
|
||||
let config_sqlite_home = config
|
||||
.get("sqlite_home")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from);
|
||||
if let Some(path) = config_sqlite_home {
|
||||
let root = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
codex_home.join(path)
|
||||
};
|
||||
return Ok(root.join(STATE_DB_FILENAME));
|
||||
}
|
||||
|
||||
let env_sqlite_home = std::env::var(CODEX_SQLITE_HOME_ENV)
|
||||
.ok()
|
||||
.map(|value| {
|
||||
value
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from);
|
||||
|
||||
let root = match env_sqlite_home {
|
||||
Some(path) if path.is_absolute() => path,
|
||||
Some(path) => std::env::current_dir()
|
||||
.context("解析 CODEX_SQLITE_HOME 相对路径失败")?
|
||||
.join(path),
|
||||
None => codex_home.to_path_buf(),
|
||||
};
|
||||
Ok(root.join(STATE_DB_FILENAME))
|
||||
}
|
||||
|
||||
fn apply_oauth_config(codex_home: &Path, account: &Account) -> Result<()> {
|
||||
let path = paths::codex_config_path(codex_home);
|
||||
if !path.exists() && account.model.is_none() && account.reasoning_effort.is_none() {
|
||||
@@ -88,6 +142,57 @@ fn read_config(path: &Path) -> Result<Map<String, Value>> {
|
||||
Ok(value.as_table().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn read_effective_config(codex_home: &Path) -> Result<Map<String, Value>> {
|
||||
let mut config = read_config(&paths::codex_config_path(codex_home))?;
|
||||
let profile = config
|
||||
.get("profile")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
if let Some(profile) = profile {
|
||||
apply_profile_config(codex_home, &mut config, &profile)?;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn apply_profile_config(
|
||||
codex_home: &Path,
|
||||
config: &mut Map<String, Value>,
|
||||
profile: &str,
|
||||
) -> Result<()> {
|
||||
if !is_valid_profile_name(profile) {
|
||||
return Ok(());
|
||||
}
|
||||
let profile_path = codex_home.join(format!("{profile}.config.toml"));
|
||||
if profile_path.exists() {
|
||||
let profile_config = read_config(&profile_path)?;
|
||||
overlay_config(config, &profile_config);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(Value::Table(profiles)) = config.get("profiles") {
|
||||
if let Some(Value::Table(profile_config)) = profiles.get(profile) {
|
||||
let profile_config = profile_config.clone();
|
||||
overlay_config(config, &profile_config);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn overlay_config(config: &mut Map<String, Value>, overlay: &Map<String, Value>) {
|
||||
for key in ["model_provider", "sqlite_home"] {
|
||||
if let Some(value) = overlay.get(key) {
|
||||
config.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_profile_name(profile: &str) -> bool {
|
||||
profile
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
}
|
||||
|
||||
fn write_config(path: &Path, codex_home: &Path, config: &Map<String, Value>) -> Result<()> {
|
||||
atomic::backup_if_exists(path, codex_home, "config.toml")?;
|
||||
let content = toml::to_string_pretty(config).context("序列化 Codex config.toml 失败")?;
|
||||
@@ -172,3 +277,56 @@ fn normalize_field(value: &str) -> Option<String> {
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn temp_home() -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("cdxs-test-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&path).expect("create temp dir");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_model_provider_defaults_to_openai() {
|
||||
let home = temp_home();
|
||||
assert_eq!(active_model_provider(&home).unwrap(), "openai");
|
||||
let _ = fs::remove_dir_all(home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_db_path_follows_sqlite_home_config() {
|
||||
let home = temp_home();
|
||||
fs::write(home.join("config.toml"), "sqlite_home = \"sqlite-data\"\n").unwrap();
|
||||
assert_eq!(
|
||||
state_db_path(&home).unwrap(),
|
||||
home.join("sqlite-data").join("state_5.sqlite")
|
||||
);
|
||||
let _ = fs::remove_dir_all(home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_model_provider_reads_selected_profile_config() {
|
||||
let home = temp_home();
|
||||
fs::write(
|
||||
home.join("config.toml"),
|
||||
"model_provider = \"openai\"\nprofile = \"work\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
home.join("work.config.toml"),
|
||||
"model_provider = \"bedrock\"\nsqlite_home = \"work-state\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(active_model_provider(&home).unwrap(), "bedrock");
|
||||
assert_eq!(
|
||||
state_db_path(&home).unwrap(),
|
||||
home.join("work-state").join("state_5.sqlite")
|
||||
);
|
||||
let _ = fs::remove_dir_all(home);
|
||||
}
|
||||
}
|
||||
|
||||
+174
-179
@@ -20,207 +20,202 @@ mod session;
|
||||
mod sync_client;
|
||||
mod token;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{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]
|
||||
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::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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
-1296
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::store::{
|
||||
find_thread, homes, read_sessions_for_home, resolve_rollout_path, summary_from_thread,
|
||||
};
|
||||
use super::SessionStats;
|
||||
|
||||
pub fn list_sessions(all_homes: bool, json: bool) -> Result<()> {
|
||||
let mut sessions = Vec::new();
|
||||
for home in homes(all_homes)? {
|
||||
sessions.extend(read_sessions_for_home(&home)?);
|
||||
}
|
||||
sessions.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms));
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&sessions)?);
|
||||
return Ok(());
|
||||
}
|
||||
if sessions.is_empty() {
|
||||
println!("没有找到 Codex 会话。");
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"{:<22} {:<12} {:<10} {:<20} {}",
|
||||
"ID", "Home", "Tokens", "Updated", "Title"
|
||||
);
|
||||
for item in sessions {
|
||||
println!(
|
||||
"{:<22} {:<12} {:<10} {:<20} {}",
|
||||
super::shorten(&item.id, 22),
|
||||
super::shorten(&item.home_name, 12),
|
||||
item.tokens_used,
|
||||
format_time(item.updated_at_ms),
|
||||
super::shorten(item.title.as_deref().unwrap_or("-"), 80)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session_stats(session_id: &str, all_homes: bool, json: bool) -> Result<()> {
|
||||
let (home, row) =
|
||||
find_thread(session_id, all_homes)?.ok_or_else(|| anyhow!("会话不存在: {session_id}"))?;
|
||||
let summary = summary_from_thread(&home, &row);
|
||||
let rollout = resolve_rollout_path(&home.path, &row.rollout_path);
|
||||
let (rollout_bytes, rollout_lines, token_usage) = rollout_stats(&rollout)?;
|
||||
let stats = SessionStats {
|
||||
sqlite_tokens_used: row.tokens_used.unwrap_or_default(),
|
||||
session: summary,
|
||||
rollout_bytes,
|
||||
rollout_lines,
|
||||
rollout_total_tokens: token_usage.as_ref().and_then(|v| v.total_tokens),
|
||||
rollout_input_tokens: token_usage.as_ref().and_then(|v| v.input_tokens),
|
||||
rollout_output_tokens: token_usage.as_ref().and_then(|v| v.output_tokens),
|
||||
};
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&stats)?);
|
||||
return Ok(());
|
||||
}
|
||||
println!("id: {}", stats.session.id);
|
||||
println!(
|
||||
"home: {} ({})",
|
||||
stats.session.home_name, stats.session.home_path
|
||||
);
|
||||
println!("title: {}", stats.session.title.as_deref().unwrap_or("-"));
|
||||
println!("cwd: {}", stats.session.cwd.as_deref().unwrap_or("-"));
|
||||
println!("updated: {}", format_time(stats.session.updated_at_ms));
|
||||
println!("sqlite_tokens_used: {}", stats.sqlite_tokens_used);
|
||||
println!(
|
||||
"rollout_tokens: total={}, input={}, output={}",
|
||||
opt_i64(stats.rollout_total_tokens),
|
||||
opt_i64(stats.rollout_input_tokens),
|
||||
opt_i64(stats.rollout_output_tokens)
|
||||
);
|
||||
println!(
|
||||
"rollout_file: {} (bytes={}, lines={})",
|
||||
stats.session.rollout_path,
|
||||
opt_u64(stats.rollout_bytes),
|
||||
stats
|
||||
.rollout_lines
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollout_stats(path: &Path) -> Result<(Option<u64>, Option<usize>, Option<TokenUsage>)> {
|
||||
if !path.exists() {
|
||||
return Ok((None, None, None));
|
||||
}
|
||||
let metadata = fs::metadata(path)
|
||||
.with_context(|| format!("读取 rollout 元数据失败: {}", path.display()))?;
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("读取 rollout 失败: {}", path.display()))?;
|
||||
let mut usage = None;
|
||||
for line in content.lines() {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(line) {
|
||||
if let Some(next) = find_token_usage(&value) {
|
||||
usage = Some(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((Some(metadata.len()), Some(content.lines().count()), usage))
|
||||
}
|
||||
|
||||
fn find_token_usage(value: &Value) -> Option<TokenUsage> {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if let Some(total) = map.get("total_token_usage") {
|
||||
return Some(TokenUsage {
|
||||
total_tokens: total.get("total_tokens").and_then(Value::as_i64),
|
||||
input_tokens: total.get("input_tokens").and_then(Value::as_i64),
|
||||
output_tokens: total.get("output_tokens").and_then(Value::as_i64),
|
||||
});
|
||||
}
|
||||
for child in map.values() {
|
||||
if let Some(usage) = find_token_usage(child) {
|
||||
return Some(usage);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Value::Array(items) => items.iter().find_map(find_token_usage),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_time(ms: Option<i64>) -> String {
|
||||
let Some(ms) = ms else {
|
||||
return "-".to_string();
|
||||
};
|
||||
DateTime::<Utc>::from_timestamp_millis(ms)
|
||||
.map(|value| value.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| ms.to_string())
|
||||
}
|
||||
|
||||
fn opt_i64(value: Option<i64>) -> String {
|
||||
value
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn opt_u64(value: Option<u64>) -> String {
|
||||
value
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct TokenUsage {
|
||||
total_tokens: Option<i64>,
|
||||
input_tokens: Option<i64>,
|
||||
output_tokens: Option<i64>,
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Inspect and repair Codex session state.
|
||||
//!
|
||||
//! Codex stores session visibility across SQLite (`state_5.sqlite`),
|
||||
//! `session_index.jsonl`, and rollout JSONL files. This module keeps those
|
||||
//! pieces consistent, implements a reversible cdxs trash, and can copy missing
|
||||
//! threads across managed homes.
|
||||
|
||||
mod inspect;
|
||||
mod store;
|
||||
mod sync_threads;
|
||||
mod trash;
|
||||
mod visibility;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use inspect::{list_sessions, session_stats};
|
||||
pub use sync_threads::sync_threads;
|
||||
pub use trash::{list_trash, restore_sessions, trash_sessions};
|
||||
pub use visibility::{align_provider_buckets, visibility_check, visibility_repair};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionSummary {
|
||||
pub home_name: String,
|
||||
pub home_path: String,
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub cwd: Option<String>,
|
||||
pub updated_at_ms: Option<i64>,
|
||||
pub tokens_used: i64,
|
||||
pub rollout_path: String,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionStats {
|
||||
pub session: SessionSummary,
|
||||
pub rollout_bytes: Option<u64>,
|
||||
pub rollout_lines: Option<usize>,
|
||||
pub sqlite_tokens_used: i64,
|
||||
pub rollout_total_tokens: Option<i64>,
|
||||
pub rollout_input_tokens: Option<i64>,
|
||||
pub rollout_output_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct VisibilityIssue {
|
||||
pub home_name: String,
|
||||
pub home_path: String,
|
||||
pub session_id: String,
|
||||
pub issue: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RepairAction {
|
||||
pub home_name: String,
|
||||
pub session_id: String,
|
||||
pub action: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProviderAlignSummary {
|
||||
pub provider: String,
|
||||
pub updated_threads: u64,
|
||||
pub checked_rollouts: u64,
|
||||
pub updated_rollouts: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SyncThreadAction {
|
||||
pub session_id: String,
|
||||
pub source_home: String,
|
||||
pub target_home: String,
|
||||
pub action: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(super) struct TrashManifest {
|
||||
version: u32,
|
||||
deleted_at: String,
|
||||
home_name: String,
|
||||
home_path: String,
|
||||
session_id: String,
|
||||
original_rollout_path: String,
|
||||
rollout_backup_file: Option<String>,
|
||||
session_index_entries: Vec<String>,
|
||||
thread: ThreadRowData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(super) struct TrashEntry {
|
||||
home_name: String,
|
||||
home_path: String,
|
||||
session_id: String,
|
||||
title: Option<String>,
|
||||
cwd: Option<String>,
|
||||
deleted_at: String,
|
||||
trash_dir: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(super) struct ThreadRowData {
|
||||
id: String,
|
||||
rollout_path: String,
|
||||
created_at: Option<i64>,
|
||||
updated_at: Option<i64>,
|
||||
source: Option<String>,
|
||||
model_provider: Option<String>,
|
||||
cwd: Option<String>,
|
||||
title: Option<String>,
|
||||
sandbox_policy: Option<String>,
|
||||
approval_mode: Option<String>,
|
||||
tokens_used: Option<i64>,
|
||||
has_user_event: Option<i64>,
|
||||
archived: Option<i64>,
|
||||
archived_at: Option<i64>,
|
||||
git_sha: Option<String>,
|
||||
git_branch: Option<String>,
|
||||
git_origin_url: Option<String>,
|
||||
cli_version: Option<String>,
|
||||
first_user_message: Option<String>,
|
||||
agent_nickname: Option<String>,
|
||||
agent_role: Option<String>,
|
||||
memory_mode: Option<String>,
|
||||
model: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
agent_path: Option<String>,
|
||||
created_at_ms: Option<i64>,
|
||||
updated_at_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct HomeTarget {
|
||||
name: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct RolloutMeta {
|
||||
session_id: String,
|
||||
relative_path: String,
|
||||
cwd: Option<String>,
|
||||
model_provider: Option<String>,
|
||||
source: Option<String>,
|
||||
cli_version: Option<String>,
|
||||
created_at_ms: Option<i64>,
|
||||
updated_at_ms: Option<i64>,
|
||||
}
|
||||
|
||||
pub(super) fn shorten(value: &str, width: usize) -> String {
|
||||
if value.chars().count() <= width {
|
||||
return value.to_string();
|
||||
}
|
||||
if width <= 1 {
|
||||
return "...".to_string();
|
||||
}
|
||||
let mut out = value
|
||||
.chars()
|
||||
.take(width.saturating_sub(3))
|
||||
.collect::<String>();
|
||||
out.push_str("...");
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn safe_name(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::{params, Connection, Row};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config_store::Store;
|
||||
use crate::{atomic, codex_config, paths};
|
||||
|
||||
use super::{HomeTarget, RolloutMeta, SessionSummary, ThreadRowData};
|
||||
|
||||
pub(super) fn read_sessions_for_home(home: &HomeTarget) -> Result<Vec<SessionSummary>> {
|
||||
let db = state_db_path(&home.path);
|
||||
if !db.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = Connection::open(&db)
|
||||
.with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?;
|
||||
let rows = read_thread_rows(&conn)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| summary_from_thread(home, row))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) fn read_thread_rows_for_home(home: &HomeTarget) -> Result<Vec<ThreadRowData>> {
|
||||
let db = state_db_path(&home.path);
|
||||
if !db.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = open_state_db(&home.path)?;
|
||||
read_thread_rows(&conn)
|
||||
}
|
||||
|
||||
pub(super) fn read_thread_rows(conn: &Connection) -> Result<Vec<ThreadRowData>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
||||
sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at,
|
||||
git_sha, git_branch, git_origin_url, cli_version, first_user_message,
|
||||
agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path,
|
||||
created_at_ms, updated_at_ms
|
||||
FROM threads",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], thread_from_row)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub(super) fn read_rollout_targets(conn: &Connection) -> Result<Vec<(String, String)>> {
|
||||
let mut stmt = conn.prepare("SELECT id, rollout_path FROM threads WHERE rollout_path <> ''")?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub(super) fn find_thread(
|
||||
session_id: &str,
|
||||
all_homes: bool,
|
||||
) -> Result<Option<(HomeTarget, ThreadRowData)>> {
|
||||
for home in homes(all_homes)? {
|
||||
let db = state_db_path(&home.path);
|
||||
if !db.exists() {
|
||||
continue;
|
||||
}
|
||||
let conn = Connection::open(&db)
|
||||
.with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
||||
sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at,
|
||||
git_sha, git_branch, git_origin_url, cli_version, first_user_message,
|
||||
agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path,
|
||||
created_at_ms, updated_at_ms
|
||||
FROM threads
|
||||
WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![session_id])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
return Ok(Some((home, thread_from_row(row)?)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(super) fn read_session_index_ids(home: &Path) -> Result<HashSet<String>> {
|
||||
let path = home.join("session_index.jsonl");
|
||||
if !path.exists() {
|
||||
return Ok(HashSet::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("读取 session_index 失败: {}", path.display()))?;
|
||||
Ok(content.lines().filter_map(line_session_id).collect())
|
||||
}
|
||||
|
||||
pub(super) fn append_session_index_entry(home: &Path, entry: &str) -> Result<()> {
|
||||
let path = home.join("session_index.jsonl");
|
||||
let mut content = if path.exists() {
|
||||
fs::read_to_string(&path)
|
||||
.with_context(|| format!("读取 session_index 失败: {}", path.display()))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if !content.is_empty() && !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(entry);
|
||||
content.push('\n');
|
||||
atomic::write_atomic(&path, &content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn index_entry_from_thread(row: &ThreadRowData) -> Result<String> {
|
||||
let title = row
|
||||
.title
|
||||
.as_deref()
|
||||
.or(row.first_user_message.as_deref())
|
||||
.unwrap_or(&row.id);
|
||||
let updated_at = row
|
||||
.updated_at_ms
|
||||
.or(row.updated_at.map(|value| value * 1000))
|
||||
.and_then(DateTime::<Utc>::from_timestamp_millis)
|
||||
.unwrap_or_else(Utc::now);
|
||||
let value = serde_json::json!({
|
||||
"id": row.id,
|
||||
"thread_name": title,
|
||||
"updated_at": updated_at.to_rfc3339(),
|
||||
});
|
||||
Ok(serde_json::to_string(&value)?)
|
||||
}
|
||||
|
||||
pub(super) fn scan_rollouts(home: &Path) -> Result<HashMap<String, RolloutMeta>> {
|
||||
let mut out = HashMap::new();
|
||||
for dirname in ["sessions", "archived_sessions"] {
|
||||
let root = home.join(dirname);
|
||||
if root.exists() {
|
||||
scan_rollout_dir(home, &root, &mut out)?;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn scan_rollout_dir(home: &Path, dir: &Path, out: &mut HashMap<String, RolloutMeta>) -> Result<()> {
|
||||
for entry in fs::read_dir(dir).with_context(|| format!("读取目录失败: {}", dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_rollout_dir(home, &path, out)?;
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
if let Some(meta) = read_rollout_meta(home, &path)? {
|
||||
out.entry(meta.session_id.clone()).or_insert(meta);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_rollout_meta(home: &Path, path: &Path) -> Result<Option<RolloutMeta>> {
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("读取 rollout 失败: {}", path.display()))?;
|
||||
for line in content.lines().take(25) {
|
||||
let Ok(value) = serde_json::from_str::<Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
if value.get("type").and_then(Value::as_str) != Some("session_meta") {
|
||||
continue;
|
||||
}
|
||||
let payload = value.get("payload").unwrap_or(&Value::Null);
|
||||
let Some(session_id) = payload.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let metadata = fs::metadata(path)
|
||||
.with_context(|| format!("读取 rollout 元数据失败: {}", path.display()))?;
|
||||
let updated_at_ms = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|value| value.as_millis() as i64);
|
||||
return Ok(Some(RolloutMeta {
|
||||
session_id: session_id.to_string(),
|
||||
relative_path: relative_path(home, path),
|
||||
cwd: payload
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
model_provider: payload
|
||||
.get("model_provider")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
source: payload
|
||||
.get("source")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
cli_version: payload
|
||||
.get("cli_version")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
created_at_ms: payload
|
||||
.get("timestamp")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(parse_rfc3339_ms),
|
||||
updated_at_ms,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(super) fn thread_from_rollout(meta: &RolloutMeta) -> ThreadRowData {
|
||||
let now_ms = Utc::now().timestamp_millis();
|
||||
let created_at_ms = meta.created_at_ms.or(meta.updated_at_ms).unwrap_or(now_ms);
|
||||
let updated_at_ms = meta.updated_at_ms.unwrap_or(created_at_ms);
|
||||
ThreadRowData {
|
||||
id: meta.session_id.clone(),
|
||||
rollout_path: meta.relative_path.clone(),
|
||||
created_at: Some(created_at_ms / 1000),
|
||||
updated_at: Some(updated_at_ms / 1000),
|
||||
source: meta.source.clone(),
|
||||
model_provider: meta
|
||||
.model_provider
|
||||
.clone()
|
||||
.or_else(|| Some("openai".to_string())),
|
||||
cwd: meta.cwd.clone(),
|
||||
title: Some(meta.session_id.clone()),
|
||||
sandbox_policy: None,
|
||||
approval_mode: None,
|
||||
tokens_used: Some(0),
|
||||
has_user_event: Some(1),
|
||||
archived: Some(0),
|
||||
archived_at: None,
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
cli_version: meta.cli_version.clone().or_else(|| Some(String::new())),
|
||||
first_user_message: Some(String::new()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
memory_mode: Some("enabled".to_string()),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agent_path: None,
|
||||
created_at_ms: Some(created_at_ms),
|
||||
updated_at_ms: Some(updated_at_ms),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn backup_state_files(home: &Path) -> Result<()> {
|
||||
let db = state_db_path(home);
|
||||
atomic::backup_if_exists(&db, home, "state_5.sqlite")?;
|
||||
atomic::backup_if_exists(
|
||||
&home.join("session_index.jsonl"),
|
||||
home,
|
||||
"session_index.jsonl",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn open_state_db(home: &Path) -> Result<Connection> {
|
||||
let db = state_db_path(home);
|
||||
Connection::open(&db).with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))
|
||||
}
|
||||
|
||||
pub(super) fn relative_path(base: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(base)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
fn parse_rfc3339_ms(value: &str) -> Option<i64> {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.map(|value| value.timestamp_millis())
|
||||
}
|
||||
|
||||
pub(super) fn homes(all_homes: bool) -> Result<Vec<HomeTarget>> {
|
||||
let default_home = paths::codex_home(None)?;
|
||||
if !all_homes {
|
||||
return Ok(vec![HomeTarget {
|
||||
name: "default".to_string(),
|
||||
path: default_home,
|
||||
}]);
|
||||
}
|
||||
|
||||
let store = Store::load(&default_home)?;
|
||||
let mut seen = HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
for home in &store.homes {
|
||||
let path = paths::expand_home(PathBuf::from(&home.path));
|
||||
let key = path.to_string_lossy().to_string();
|
||||
if seen.insert(key) {
|
||||
result.push(HomeTarget {
|
||||
name: home.name.clone(),
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn thread_from_row(row: &Row<'_>) -> rusqlite::Result<ThreadRowData> {
|
||||
Ok(ThreadRowData {
|
||||
id: row.get(0)?,
|
||||
rollout_path: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
source: row.get(4)?,
|
||||
model_provider: row.get(5)?,
|
||||
cwd: row.get(6)?,
|
||||
title: row.get(7)?,
|
||||
sandbox_policy: row.get(8)?,
|
||||
approval_mode: row.get(9)?,
|
||||
tokens_used: row.get(10)?,
|
||||
has_user_event: row.get(11)?,
|
||||
archived: row.get(12)?,
|
||||
archived_at: row.get(13)?,
|
||||
git_sha: row.get(14)?,
|
||||
git_branch: row.get(15)?,
|
||||
git_origin_url: row.get(16)?,
|
||||
cli_version: row.get(17)?,
|
||||
first_user_message: row.get(18)?,
|
||||
agent_nickname: row.get(19)?,
|
||||
agent_role: row.get(20)?,
|
||||
memory_mode: row.get(21)?,
|
||||
model: row.get(22)?,
|
||||
reasoning_effort: row.get(23)?,
|
||||
agent_path: row.get(24)?,
|
||||
created_at_ms: row.get(25)?,
|
||||
updated_at_ms: row.get(26)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn insert_thread(conn: &Connection, row: &ThreadRowData) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO threads (
|
||||
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
||||
sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at,
|
||||
git_sha, git_branch, git_origin_url, cli_version, first_user_message,
|
||||
agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27
|
||||
)",
|
||||
params![
|
||||
row.id,
|
||||
row.rollout_path,
|
||||
row.created_at,
|
||||
row.updated_at,
|
||||
row.source,
|
||||
row.model_provider,
|
||||
row.cwd,
|
||||
row.title,
|
||||
row.sandbox_policy,
|
||||
row.approval_mode,
|
||||
row.tokens_used,
|
||||
row.has_user_event,
|
||||
row.archived,
|
||||
row.archived_at,
|
||||
row.git_sha,
|
||||
row.git_branch,
|
||||
row.git_origin_url,
|
||||
row.cli_version,
|
||||
row.first_user_message,
|
||||
row.agent_nickname,
|
||||
row.agent_role,
|
||||
row.memory_mode,
|
||||
row.model,
|
||||
row.reasoning_effort,
|
||||
row.agent_path,
|
||||
row.created_at_ms,
|
||||
row.updated_at_ms,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn summary_from_thread(home: &HomeTarget, row: &ThreadRowData) -> SessionSummary {
|
||||
SessionSummary {
|
||||
home_name: home.name.clone(),
|
||||
home_path: home.path.to_string_lossy().to_string(),
|
||||
id: row.id.clone(),
|
||||
title: row.title.clone(),
|
||||
cwd: row.cwd.clone(),
|
||||
updated_at_ms: row
|
||||
.updated_at_ms
|
||||
.or(row.updated_at.map(|value| value * 1000)),
|
||||
tokens_used: row.tokens_used.unwrap_or_default(),
|
||||
rollout_path: row.rollout_path.clone(),
|
||||
archived: row.archived.unwrap_or_default() != 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn line_session_id(line: &str) -> Option<String> {
|
||||
serde_json::from_str::<Value>(line)
|
||||
.ok()
|
||||
.and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_string))
|
||||
}
|
||||
|
||||
pub(super) fn state_db_path(home: &Path) -> PathBuf {
|
||||
codex_config::state_db_path(home).unwrap_or_else(|_| home.join("state_5.sqlite"))
|
||||
}
|
||||
|
||||
pub(super) fn resolve_rollout_path(home: &Path, rollout_path: &str) -> PathBuf {
|
||||
let path = PathBuf::from(rollout_path);
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
home.join(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use super::store::{
|
||||
append_session_index_entry, backup_state_files, homes, index_entry_from_thread, insert_thread,
|
||||
read_session_index_ids, read_thread_rows_for_home, relative_path, resolve_rollout_path,
|
||||
};
|
||||
use super::{HomeTarget, SyncThreadAction, ThreadRowData};
|
||||
|
||||
pub fn sync_threads(all_homes: bool, dry_run: bool, json: bool) -> Result<()> {
|
||||
let selected_homes = homes(all_homes)?;
|
||||
if selected_homes.len() < 2 {
|
||||
return Err(anyhow!(
|
||||
"sync-threads 至少需要两个 home,请先用 `cdxs home create` 添加实例"
|
||||
));
|
||||
}
|
||||
|
||||
let mut rows_by_id: HashMap<String, (HomeTarget, ThreadRowData)> = HashMap::new();
|
||||
let mut indexes: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
for home in &selected_homes {
|
||||
let rows = read_thread_rows_for_home(home)?;
|
||||
indexes.insert(home.name.clone(), read_session_index_ids(&home.path)?);
|
||||
for row in rows {
|
||||
rows_by_id
|
||||
.entry(row.id.clone())
|
||||
.or_insert((home.clone(), row));
|
||||
}
|
||||
}
|
||||
|
||||
let mut actions = Vec::new();
|
||||
for target in &selected_homes {
|
||||
let target_rows = read_thread_rows_for_home(target)?;
|
||||
let target_ids: HashSet<String> = target_rows.into_iter().map(|row| row.id).collect();
|
||||
for (session_id, (source, source_row)) in &rows_by_id {
|
||||
if target_ids.contains(session_id) {
|
||||
continue;
|
||||
}
|
||||
let source_rollout = resolve_rollout_path(&source.path, &source_row.rollout_path);
|
||||
if !source_rollout.exists() {
|
||||
actions.push(SyncThreadAction {
|
||||
session_id: session_id.clone(),
|
||||
source_home: source.name.clone(),
|
||||
target_home: target.name.clone(),
|
||||
action: "skip".to_string(),
|
||||
detail: "source rollout missing".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut target_row = source_row.clone();
|
||||
let target_relative = portable_rollout_path(source_row, source, target);
|
||||
let target_rollout = resolve_rollout_path(&target.path, &target_relative);
|
||||
target_row.rollout_path = target_relative.clone();
|
||||
actions.push(SyncThreadAction {
|
||||
session_id: session_id.clone(),
|
||||
source_home: source.name.clone(),
|
||||
target_home: target.name.clone(),
|
||||
action: if dry_run { "would_sync" } else { "synced" }.to_string(),
|
||||
detail: target_relative.clone(),
|
||||
});
|
||||
if dry_run {
|
||||
continue;
|
||||
}
|
||||
|
||||
backup_state_files(&target.path)?;
|
||||
if let Some(parent) = target_rollout.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("创建 rollout 目录失败: {}", parent.display()))?;
|
||||
}
|
||||
std::fs::copy(&source_rollout, &target_rollout).with_context(|| {
|
||||
format!(
|
||||
"复制 rollout 失败: source={}, target={}",
|
||||
source_rollout.display(),
|
||||
target_rollout.display()
|
||||
)
|
||||
})?;
|
||||
let conn = super::store::open_state_db(&target.path)?;
|
||||
insert_thread(&conn, &target_row)?;
|
||||
if !indexes
|
||||
.get(&target.name)
|
||||
.map(|ids| ids.contains(session_id))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
append_session_index_entry(&target.path, &index_entry_from_thread(&target_row)?)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&actions)?);
|
||||
return Ok(());
|
||||
}
|
||||
if actions.is_empty() {
|
||||
println!("所有 home 的会话线程已经一致。");
|
||||
return Ok(());
|
||||
}
|
||||
for action in actions {
|
||||
println!(
|
||||
"{} -> {} {}: {}",
|
||||
action.source_home, action.target_home, action.session_id, action.action
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn portable_rollout_path(row: &ThreadRowData, source: &HomeTarget, target: &HomeTarget) -> String {
|
||||
let source_rollout = resolve_rollout_path(&source.path, &row.rollout_path);
|
||||
let relative = relative_path(&source.path, &source_rollout);
|
||||
let target_rollout = resolve_rollout_path(&target.path, &relative);
|
||||
if target_rollout.exists() {
|
||||
let file_name = source_rollout
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("rollout.jsonl");
|
||||
format!(
|
||||
"sessions/cdxs-sync/{}/{}",
|
||||
super::safe_name(&source.name),
|
||||
file_name
|
||||
)
|
||||
} else {
|
||||
relative
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::atomic;
|
||||
|
||||
use super::store::{
|
||||
find_thread, homes, insert_thread, line_session_id, open_state_db, resolve_rollout_path,
|
||||
state_db_path,
|
||||
};
|
||||
use super::{ThreadRowData, TrashEntry, TrashManifest};
|
||||
|
||||
pub fn trash_sessions(session_ids: Vec<String>, all_homes: bool) -> Result<()> {
|
||||
if session_ids.is_empty() {
|
||||
return Err(anyhow!("至少需要提供一个 session id"));
|
||||
}
|
||||
for session_id in session_ids {
|
||||
let (home, row) = find_thread(&session_id, all_homes)?
|
||||
.ok_or_else(|| anyhow!("会话不存在: {session_id}"))?;
|
||||
trash_one(&home, row)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_trash(all_homes: bool, json: bool) -> Result<()> {
|
||||
let mut entries = Vec::new();
|
||||
for home in homes(all_homes)? {
|
||||
entries.extend(read_trash_entries(&home)?);
|
||||
}
|
||||
entries.sort_by(|a, b| b.deleted_at.cmp(&a.deleted_at));
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&entries)?);
|
||||
return Ok(());
|
||||
}
|
||||
if entries.is_empty() {
|
||||
println!("垃圾箱为空。");
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"{:<22} {:<12} {:<24} {}",
|
||||
"ID", "Home", "Deleted At", "Title"
|
||||
);
|
||||
for item in entries {
|
||||
println!(
|
||||
"{:<22} {:<12} {:<24} {}",
|
||||
super::shorten(&item.session_id, 22),
|
||||
super::shorten(&item.home_name, 12),
|
||||
item.deleted_at,
|
||||
super::shorten(item.title.as_deref().unwrap_or("-"), 80)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restore_sessions(session_ids: Vec<String>, all_homes: bool) -> Result<()> {
|
||||
if session_ids.is_empty() {
|
||||
return Err(anyhow!("至少需要提供一个 session id"));
|
||||
}
|
||||
let manifests = trash_manifests(all_homes)?;
|
||||
for session_id in session_ids {
|
||||
let (manifest_path, manifest) = manifests
|
||||
.iter()
|
||||
.find(|(_, item)| item.session_id == session_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("垃圾箱中没有找到会话: {session_id}"))?;
|
||||
restore_one(&manifest_path, &manifest)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trash_one(home: &super::HomeTarget, row: ThreadRowData) -> Result<()> {
|
||||
let stamp = Utc::now().format("%Y%m%d-%H%M%S%.3f").to_string();
|
||||
let trash_dir = home.path.join("cdxs-trash").join(format!(
|
||||
"{}-{}-{}",
|
||||
stamp,
|
||||
super::safe_name(&row.id),
|
||||
super::safe_name(&home.name)
|
||||
));
|
||||
fs::create_dir_all(&trash_dir)
|
||||
.with_context(|| format!("创建垃圾箱目录失败: {}", trash_dir.display()))?;
|
||||
|
||||
let rollout = resolve_rollout_path(&home.path, &row.rollout_path);
|
||||
let rollout_backup_file = if rollout.exists() {
|
||||
let file_name = rollout
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("rollout.jsonl")
|
||||
.to_string();
|
||||
fs::copy(&rollout, trash_dir.join(&file_name)).with_context(|| {
|
||||
format!(
|
||||
"备份 rollout 失败: source={}, trash={}",
|
||||
rollout.display(),
|
||||
trash_dir.display()
|
||||
)
|
||||
})?;
|
||||
Some(file_name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let index_entries = remove_session_index_entries(&home.path, &row.id)?;
|
||||
let manifest = TrashManifest {
|
||||
version: 1,
|
||||
deleted_at: Utc::now().to_rfc3339(),
|
||||
home_name: home.name.clone(),
|
||||
home_path: home.path.to_string_lossy().to_string(),
|
||||
session_id: row.id.clone(),
|
||||
original_rollout_path: row.rollout_path.clone(),
|
||||
rollout_backup_file,
|
||||
session_index_entries: index_entries,
|
||||
thread: row.clone(),
|
||||
};
|
||||
let manifest_content = serde_json::to_string_pretty(&manifest)?;
|
||||
atomic::write_atomic(&trash_dir.join("manifest.json"), &manifest_content)?;
|
||||
|
||||
let db = state_db_path(&home.path);
|
||||
let conn = rusqlite::Connection::open(&db)
|
||||
.with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM threads WHERE id = ?1",
|
||||
rusqlite::params![row.id],
|
||||
)?;
|
||||
if rollout.exists() {
|
||||
fs::remove_file(&rollout)
|
||||
.with_context(|| format!("删除 rollout 文件失败: {}", rollout.display()))?;
|
||||
}
|
||||
println!(
|
||||
"已移入垃圾箱: {} ({})",
|
||||
manifest.session_id,
|
||||
trash_dir.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_one(manifest_path: &Path, manifest: &TrashManifest) -> Result<()> {
|
||||
let home_path = PathBuf::from(&manifest.home_path);
|
||||
let conn = open_state_db(&home_path)?;
|
||||
insert_thread(&conn, &manifest.thread)?;
|
||||
|
||||
if let Some(file_name) = manifest.rollout_backup_file.as_deref() {
|
||||
let backup = manifest_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("manifest 路径无父目录: {}", manifest_path.display()))?
|
||||
.join(file_name);
|
||||
let target = resolve_rollout_path(&home_path, &manifest.original_rollout_path);
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("创建 rollout 目录失败: {}", parent.display()))?;
|
||||
}
|
||||
fs::copy(&backup, &target).with_context(|| {
|
||||
format!(
|
||||
"恢复 rollout 失败: source={}, target={}",
|
||||
backup.display(),
|
||||
target.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
restore_session_index_entries(&home_path, &manifest.session_index_entries)?;
|
||||
|
||||
let trash_dir = manifest_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("manifest 路径无父目录: {}", manifest_path.display()))?;
|
||||
fs::remove_dir_all(trash_dir)
|
||||
.with_context(|| format!("清理垃圾箱条目失败: {}", trash_dir.display()))?;
|
||||
println!("已恢复会话: {}", manifest.session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_session_index_entries(home: &Path, session_id: &str) -> Result<Vec<String>> {
|
||||
let path = home.join("session_index.jsonl");
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
atomic::backup_if_exists(&path, home, "session_index.jsonl")?;
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("读取 session_index 失败: {}", path.display()))?;
|
||||
let mut kept = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
for line in content.lines() {
|
||||
if line_session_id(line).as_deref() == Some(session_id) {
|
||||
removed.push(line.to_string());
|
||||
} else {
|
||||
kept.push(line.to_string());
|
||||
}
|
||||
}
|
||||
let mut output = kept.join("\n");
|
||||
if !output.is_empty() {
|
||||
output.push('\n');
|
||||
}
|
||||
atomic::write_atomic(&path, &output)?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
fn restore_session_index_entries(home: &Path, entries: &[String]) -> Result<()> {
|
||||
if entries.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = home.join("session_index.jsonl");
|
||||
let mut existing = if path.exists() {
|
||||
fs::read_to_string(&path)
|
||||
.with_context(|| format!("读取 session_index 失败: {}", path.display()))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let existing_ids: std::collections::HashSet<String> =
|
||||
existing.lines().filter_map(line_session_id).collect();
|
||||
if !existing.is_empty() && !existing.ends_with('\n') {
|
||||
existing.push('\n');
|
||||
}
|
||||
for entry in entries {
|
||||
let should_append = line_session_id(entry)
|
||||
.map(|id| !existing_ids.contains(&id))
|
||||
.unwrap_or(true);
|
||||
if should_append {
|
||||
existing.push_str(entry);
|
||||
existing.push('\n');
|
||||
}
|
||||
}
|
||||
atomic::backup_if_exists(&path, home, "session_index.jsonl")?;
|
||||
atomic::write_atomic(&path, &existing)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trash_manifests(all_homes: bool) -> Result<Vec<(PathBuf, TrashManifest)>> {
|
||||
let mut manifests = Vec::new();
|
||||
for home in homes(all_homes)? {
|
||||
let root = home.path.join("cdxs-trash");
|
||||
if !root.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in
|
||||
fs::read_dir(&root).with_context(|| format!("读取垃圾箱失败: {}", root.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let manifest_path = entry.path().join("manifest.json");
|
||||
if !manifest_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let content = fs::read_to_string(&manifest_path)
|
||||
.with_context(|| format!("读取 manifest 失败: {}", manifest_path.display()))?;
|
||||
let manifest: TrashManifest =
|
||||
serde_json::from_str(&content).context("解析 manifest 失败")?;
|
||||
manifests.push((manifest_path, manifest));
|
||||
}
|
||||
}
|
||||
Ok(manifests)
|
||||
}
|
||||
|
||||
fn read_trash_entries(home: &super::HomeTarget) -> Result<Vec<TrashEntry>> {
|
||||
let mut out = Vec::new();
|
||||
let root = home.path.join("cdxs-trash");
|
||||
if !root.exists() {
|
||||
return Ok(out);
|
||||
}
|
||||
for entry in
|
||||
fs::read_dir(&root).with_context(|| format!("读取垃圾箱失败: {}", root.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let manifest_path = entry.path().join("manifest.json");
|
||||
if !manifest_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let content = fs::read_to_string(&manifest_path)
|
||||
.with_context(|| format!("读取 manifest 失败: {}", manifest_path.display()))?;
|
||||
let manifest: TrashManifest =
|
||||
serde_json::from_str(&content).context("解析 manifest 失败")?;
|
||||
out.push(TrashEntry {
|
||||
home_name: manifest.home_name,
|
||||
home_path: manifest.home_path,
|
||||
session_id: manifest.session_id,
|
||||
title: manifest.thread.title,
|
||||
cwd: manifest.thread.cwd,
|
||||
deleted_at: manifest.deleted_at,
|
||||
trash_dir: entry.path().to_string_lossy().to_string(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{params, Connection};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::atomic;
|
||||
use crate::codex_config;
|
||||
|
||||
use super::store::{
|
||||
append_session_index_entry, backup_state_files, homes, index_entry_from_thread, insert_thread,
|
||||
open_state_db, read_rollout_targets, read_session_index_ids, read_thread_rows,
|
||||
read_thread_rows_for_home, resolve_rollout_path, scan_rollouts, thread_from_rollout,
|
||||
};
|
||||
use super::{ProviderAlignSummary, RepairAction, VisibilityIssue};
|
||||
|
||||
pub fn visibility_check(all_homes: bool, json: bool) -> Result<()> {
|
||||
let issues = collect_visibility_issues(all_homes)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&issues)?);
|
||||
return Ok(());
|
||||
}
|
||||
if issues.is_empty() {
|
||||
println!("没有发现会话可见性问题。");
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"{:<12} {:<22} {:<18} {}",
|
||||
"Home", "Session", "Issue", "Detail"
|
||||
);
|
||||
for issue in issues {
|
||||
println!(
|
||||
"{:<12} {:<22} {:<18} {}",
|
||||
super::shorten(&issue.home_name, 12),
|
||||
super::shorten(&issue.session_id, 22),
|
||||
issue.issue,
|
||||
issue.detail
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn visibility_repair(all_homes: bool, json: bool) -> Result<()> {
|
||||
let mut actions = Vec::new();
|
||||
for home in homes(all_homes)? {
|
||||
actions.extend(repair_home_visibility(&home)?);
|
||||
}
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&actions)?);
|
||||
return Ok(());
|
||||
}
|
||||
if actions.is_empty() {
|
||||
println!("没有需要修复的会话可见性问题。");
|
||||
return Ok(());
|
||||
}
|
||||
for action in actions {
|
||||
println!(
|
||||
"{} {}: {}",
|
||||
action.home_name, action.session_id, action.detail
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn align_provider_buckets(codex_home: &Path) -> Result<ProviderAlignSummary> {
|
||||
let provider = codex_config::active_model_provider(codex_home)?;
|
||||
let db = codex_config::state_db_path(codex_home)?;
|
||||
if !db.exists() {
|
||||
return Ok(ProviderAlignSummary {
|
||||
provider,
|
||||
updated_threads: 0,
|
||||
checked_rollouts: 0,
|
||||
updated_rollouts: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let mut conn = Connection::open(&db)
|
||||
.with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?;
|
||||
let rollout_targets = read_rollout_targets(&conn)?;
|
||||
|
||||
atomic::backup_if_exists(&db, codex_home, "state_5.sqlite")?;
|
||||
let tx = conn.transaction()?;
|
||||
let updated_threads = tx.execute(
|
||||
"UPDATE threads SET model_provider = ?1 WHERE model_provider <> ?1",
|
||||
params![provider.as_str()],
|
||||
)? as u64;
|
||||
tx.commit()?;
|
||||
|
||||
let mut checked_rollouts = 0;
|
||||
let mut updated_rollouts = 0;
|
||||
for (session_id, rollout_path) in rollout_targets {
|
||||
let rollout = resolve_rollout_path(codex_home, &rollout_path);
|
||||
if !rollout.exists() {
|
||||
continue;
|
||||
}
|
||||
checked_rollouts += 1;
|
||||
if align_rollout_provider(&rollout, &session_id, provider.as_str(), codex_home)? {
|
||||
updated_rollouts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ProviderAlignSummary {
|
||||
provider,
|
||||
updated_threads,
|
||||
checked_rollouts,
|
||||
updated_rollouts,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_visibility_issues(all_homes: bool) -> Result<Vec<VisibilityIssue>> {
|
||||
let mut issues = Vec::new();
|
||||
for home in homes(all_homes)? {
|
||||
let threads = read_thread_rows_for_home(&home)?;
|
||||
let thread_ids: HashSet<String> = threads.iter().map(|row| row.id.clone()).collect();
|
||||
let index_ids = read_session_index_ids(&home.path)?;
|
||||
let rollouts = scan_rollouts(&home.path)?;
|
||||
let rollout_ids: HashSet<String> = rollouts.keys().cloned().collect();
|
||||
|
||||
for row in &threads {
|
||||
if !resolve_rollout_path(&home.path, &row.rollout_path).exists() {
|
||||
issues.push(issue(
|
||||
&home,
|
||||
&row.id,
|
||||
"missing_rollout",
|
||||
format!("rollout not found: {}", row.rollout_path),
|
||||
));
|
||||
}
|
||||
if !index_ids.contains(&row.id) {
|
||||
issues.push(issue(
|
||||
&home,
|
||||
&row.id,
|
||||
"missing_index",
|
||||
"session_index.jsonl missing entry".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
for id in index_ids.difference(&thread_ids) {
|
||||
issues.push(issue(
|
||||
&home,
|
||||
id,
|
||||
"orphan_index",
|
||||
"session_index.jsonl entry has no SQLite thread".to_string(),
|
||||
));
|
||||
}
|
||||
for id in rollout_ids.difference(&thread_ids) {
|
||||
issues.push(issue(
|
||||
&home,
|
||||
id,
|
||||
"orphan_rollout",
|
||||
"rollout file has no SQLite thread".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(issues)
|
||||
}
|
||||
|
||||
fn repair_home_visibility(home: &super::HomeTarget) -> Result<Vec<RepairAction>> {
|
||||
let mut actions = Vec::new();
|
||||
let db = super::store::state_db_path(&home.path);
|
||||
if !db.exists() {
|
||||
return Ok(actions);
|
||||
}
|
||||
let conn = open_state_db(&home.path)?;
|
||||
let threads = read_thread_rows(&conn)?;
|
||||
let mut index_ids = read_session_index_ids(&home.path)?;
|
||||
let rollouts = scan_rollouts(&home.path)?;
|
||||
let thread_ids: HashSet<String> = threads.iter().map(|row| row.id.clone()).collect();
|
||||
|
||||
for row in &threads {
|
||||
let current_rollout = resolve_rollout_path(&home.path, &row.rollout_path);
|
||||
if !current_rollout.exists() {
|
||||
if let Some(found) = rollouts.get(&row.id) {
|
||||
backup_state_files(&home.path)?;
|
||||
conn.execute(
|
||||
"UPDATE threads SET rollout_path = ?1 WHERE id = ?2",
|
||||
params![found.relative_path, row.id],
|
||||
)?;
|
||||
actions.push(repair_action(
|
||||
home,
|
||||
&row.id,
|
||||
"repair_rollout_path",
|
||||
format!("updated rollout_path to {}", found.relative_path),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !index_ids.contains(&row.id) {
|
||||
backup_state_files(&home.path)?;
|
||||
append_session_index_entry(&home.path, &index_entry_from_thread(row)?)?;
|
||||
index_ids.insert(row.id.clone());
|
||||
actions.push(repair_action(
|
||||
home,
|
||||
&row.id,
|
||||
"append_index",
|
||||
"added session_index.jsonl entry".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (session_id, rollout) in &rollouts {
|
||||
if thread_ids.contains(session_id) {
|
||||
continue;
|
||||
}
|
||||
backup_state_files(&home.path)?;
|
||||
let row = thread_from_rollout(rollout);
|
||||
insert_thread(&conn, &row)?;
|
||||
if !index_ids.contains(session_id) {
|
||||
append_session_index_entry(&home.path, &index_entry_from_thread(&row)?)?;
|
||||
index_ids.insert(session_id.clone());
|
||||
}
|
||||
actions.push(repair_action(
|
||||
home,
|
||||
session_id,
|
||||
"insert_thread",
|
||||
"created minimal SQLite thread from rollout metadata".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(actions)
|
||||
}
|
||||
|
||||
fn issue(
|
||||
home: &super::HomeTarget,
|
||||
session_id: &str,
|
||||
issue: &str,
|
||||
detail: String,
|
||||
) -> VisibilityIssue {
|
||||
VisibilityIssue {
|
||||
home_name: home.name.clone(),
|
||||
home_path: home.path.to_string_lossy().to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
issue: issue.to_string(),
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn repair_action(
|
||||
home: &super::HomeTarget,
|
||||
session_id: &str,
|
||||
action: &str,
|
||||
detail: String,
|
||||
) -> RepairAction {
|
||||
RepairAction {
|
||||
home_name: home.name.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
action: action.to_string(),
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn align_rollout_provider(
|
||||
path: &Path,
|
||||
session_id: &str,
|
||||
provider: &str,
|
||||
codex_home: &Path,
|
||||
) -> Result<bool> {
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("读取 rollout 失败: {}", path.display()))?;
|
||||
let Some(newline_index) = content.find('\n') else {
|
||||
return Ok(false);
|
||||
};
|
||||
let first_line = &content[..newline_index];
|
||||
let mut value: Value = match serde_json::from_str(first_line) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
if value.get("type").and_then(Value::as_str) != Some("session_meta") {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(payload) = value.get_mut("payload").and_then(Value::as_object_mut) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if payload.get("id").and_then(Value::as_str) != Some(session_id) {
|
||||
return Ok(false);
|
||||
}
|
||||
if payload
|
||||
.get("model_provider")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|current| current == provider)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
payload.insert(
|
||||
"model_provider".to_string(),
|
||||
Value::String(provider.to_string()),
|
||||
);
|
||||
let replacement = serde_json::to_string(&value)?;
|
||||
let mut rewritten = String::with_capacity(content.len() + replacement.len());
|
||||
rewritten.push_str(&replacement);
|
||||
rewritten.push_str(&content[newline_index..]);
|
||||
atomic::backup_if_exists(path, codex_home, "rollout.jsonl")?;
|
||||
atomic::write_atomic_preserve_times(path, &rewritten)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use filetime::{set_file_times, FileTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::align_rollout_provider;
|
||||
|
||||
fn temp_home() -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("cdxs-session-test-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&path).expect("create temp dir");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_rollout_provider_rewrites_session_meta_first_line() {
|
||||
let home = temp_home();
|
||||
let rollout = home.join("sessions").join("thread.jsonl");
|
||||
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
&rollout,
|
||||
concat!(
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread-1\",\"model_provider\":\"old\"}}\n",
|
||||
"{\"type\":\"message\",\"payload\":{\"text\":\"hello\"}}\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let original_time = FileTime::from_unix_time(1_700_000_000, 0);
|
||||
set_file_times(&rollout, original_time, original_time).unwrap();
|
||||
|
||||
let changed = align_rollout_provider(&rollout, "thread-1", "new", &home).unwrap();
|
||||
let content = fs::read_to_string(&rollout).unwrap();
|
||||
let metadata = fs::metadata(&rollout).unwrap();
|
||||
|
||||
assert!(changed);
|
||||
assert!(content.contains("\"model_provider\":\"new\""));
|
||||
assert!(content.contains("\"text\":\"hello\""));
|
||||
assert_eq!(
|
||||
FileTime::from_last_modification_time(&metadata),
|
||||
original_time
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(home);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user