20 Commits

34 changed files with 3941 additions and 2662 deletions
+48 -134
View File
@@ -2,13 +2,20 @@ name: Release
on:
push:
branches:
- master
paths:
- ".github/workflows/release.yml"
- "scripts/build.ps1"
- "scripts/build.sh"
- "scripts/docker/Dockerfile.release"
tags:
- "*"
env:
GITEA_SERVER_URL: https://git.pchuan.top
RUST_IMAGE: docker.m.daocloud.io/library/rust:1-bookworm
CARGO_REGISTRY: sparse+https://rsproxy.cn/index/
APT_MIRROR: https://mirrors.ustc.edu.cn/debian
CARGO_TERM_COLOR: always
jobs:
@@ -28,14 +35,21 @@ jobs:
run: |
set -euo pipefail
current_tag="$(git describe --tags --exact-match)"
if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
current_tag="${GITHUB_REF_NAME}"
else
current_tag="$(git describe --tags --abbrev=0)"
fi
version="${current_tag#v}"
tag_target="$(git rev-list -n 1 "${current_tag}^{}")"
previous_tag="$(git describe --tags --abbrev=0 "${current_tag}^{}^" 2>/dev/null || true)"
api_base="${GITEA_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
{
echo "## Changes"
echo
echo "Built from commit: \`${GITHUB_SHA}\`"
echo
if [[ -n "${previous_tag}" ]]; then
echo "Changes since \`${previous_tag}\`:"
echo
@@ -56,7 +70,7 @@ jobs:
body="$(python3 -c 'import json, pathlib; print(json.dumps(pathlib.Path("RELEASE_NOTES.md").read_text()))')"
payload="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":%s,"draft":false,"prerelease":false}' \
"${current_tag}" "${GITHUB_SHA}" "${current_tag}" "${body}")"
"${current_tag}" "${tag_target}" "${current_tag}" "${body}")"
status="$(curl -sS -o release.json -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
@@ -82,155 +96,55 @@ jobs:
exit 1
fi
build-windows-x64:
name: Build windows-x64
runs-on: windows-latest
needs: prepare-release
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust target
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.cargo" | Out-Null
@"
[source.crates-io]
replace-with = "mirror"
[source.mirror]
registry = "$env:CARGO_REGISTRY"
"@ | Set-Content -Path "$env:USERPROFILE\.cargo\config.toml" -Encoding utf8
rustup target add x86_64-pc-windows-msvc
- name: Build release binary
shell: pwsh
run: |
$tag = git describe --tags --exact-match
$version = $tag -replace '^v', ''
./scripts/publish-binaries.ps1 -Version $version -Target win -Clean
- name: Upload release asset
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
$tag = git describe --tags --exact-match
$release = Invoke-RestMethod `
-Headers @{ Authorization = "token $env:GITEA_TOKEN" } `
-Uri "$env:GITEA_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY/releases/tags/$tag"
$file = Get-ChildItem -Path dist -File | Select-Object -First 1
curl.exe -fsS -X POST `
-H "Authorization: token $env:GITEA_TOKEN" `
-F "attachment=@$($file.FullName)" `
"$env:GITEA_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY/releases/$($release.id)/assets?name=$($file.Name)"
build-linux-x64:
name: Build linux-x64
build-release-assets:
name: Build release assets
runs-on: ubuntu-latest
needs: prepare-release
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build release binary
- name: Resolve release tag
shell: bash
run: |
set -euo pipefail
tag="$(git describe --tags --exact-match)"
version="${tag#v}"
target_name="linux-x64"
rust_target="x86_64-unknown-linux-gnu"
platform="linux/amd64"
image_tag="cdxs-build:${target_name}-${GITHUB_RUN_ID:-manual}"
container_name="cdxs-build-${target_name}-${GITHUB_RUN_ID:-manual}"
if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
release_tag="${GITHUB_REF_NAME}"
else
release_tag="$(git describe --tags --abbrev=0)"
fi
echo "RELEASE_TAG=${release_tag}" >> "${GITHUB_ENV}"
echo "VERSION=${release_tag#v}" >> "${GITHUB_ENV}"
mkdir -p dist
docker buildx build \
--platform "${platform}" \
--target builder \
--build-arg "RUST_TARGET=${rust_target}" \
--build-arg "RUST_IMAGE=${RUST_IMAGE}" \
--build-arg "CARGO_REGISTRY=${CARGO_REGISTRY}" \
--load \
-t "${image_tag}" \
-f scripts/docker/Dockerfile.release \
.
- name: Build release assets
shell: bash
run: bash scripts/build.sh --version "${VERSION}" --rust-image "${RUST_IMAGE}" --apt-mirror "${APT_MIRROR}" --clean
container_id="$(docker create --name "${container_name}" "${image_tag}")"
trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' EXIT
docker cp "${container_id}:/out/cdxs" "dist/cdxs-${version}-${target_name}"
- name: Upload release asset
- name: Upload release assets
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
tag="$(git describe --tags --exact-match)"
api_base="${GITEA_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/tags/${tag}" > release.json
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/tags/${RELEASE_TAG}" > release.json
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
file="$(find dist -maxdepth 1 -type f | head -n 1)"
name="$(basename "${file}")"
curl -fsS -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file}" \
"${api_base}/releases/${release_id}/assets?name=${name}"
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/${release_id}/assets" > assets.json
build-linux-arm64:
name: Build linux-arm64
runs-on: ubuntu-latest
needs: prepare-release
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build release binary
shell: bash
run: |
set -euo pipefail
tag="$(git describe --tags --exact-match)"
version="${tag#v}"
target_name="linux-arm64"
rust_target="aarch64-unknown-linux-gnu"
platform="linux/arm64"
image_tag="cdxs-build:${target_name}-${GITHUB_RUN_ID:-manual}"
container_name="cdxs-build-${target_name}-${GITHUB_RUN_ID:-manual}"
mkdir -p dist
docker buildx build \
--platform "${platform}" \
--target builder \
--build-arg "RUST_TARGET=${rust_target}" \
--build-arg "RUST_IMAGE=${RUST_IMAGE}" \
--build-arg "CARGO_REGISTRY=${CARGO_REGISTRY}" \
--load \
-t "${image_tag}" \
-f scripts/docker/Dockerfile.release \
.
container_id="$(docker create --name "${container_name}" "${image_tag}")"
trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' EXIT
docker cp "${container_id}:/out/cdxs" "dist/cdxs-${version}-${target_name}"
- name: Upload release asset
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
tag="$(git describe --tags --exact-match)"
api_base="${GITEA_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/tags/${tag}" > release.json
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
file="$(find dist -maxdepth 1 -type f | head -n 1)"
name="$(basename "${file}")"
curl -fsS -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file}" \
"${api_base}/releases/${release_id}/assets?name=${name}"
for file in dist/*; do
name="$(basename "${file}")"
existing_asset_id="$(python3 -c 'import json, sys; target = sys.argv[1]; print(next((str(asset["id"]) for asset in json.load(open("assets.json")) if asset.get("name") == target), ""))' "${name}")"
if [[ -n "${existing_asset_id}" ]]; then
curl -fsS -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${api_base}/releases/${release_id}/assets/${existing_asset_id}"
fi
curl -fsS -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file}" \
"${api_base}/releases/${release_id}/assets?name=${name}"
done
Generated
+12 -1
View File
@@ -232,7 +232,7 @@ dependencies = [
[[package]]
name = "cdxs"
version = "0.1.2"
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
View File
@@ -1,6 +1,6 @@
[package]
name = "cdxs"
version = "0.1.2"
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"] }
+17 -6
View File
@@ -8,10 +8,10 @@
Codex 的认证和会话状态都来自 `CODEX_HOME``cdxs` 做的是在这个目录外面加一层可操作的管理能力:
- 账号管理:导入已有 `auth.json`,通过 OAuth 登录,添加 API Key 账号,切换当前账号,并在需要时刷新 OAuth token。
- 配额查看:查询 OAuth 账号的 Codex 使用配额,并缓存到本地
- 账号管理:导入已有 `auth.json`,通过 OAuth API Key 登录,切换当前账号,并在需要时刷新 OAuth token。
- 配额缓存:列出账号时自动刷新过期的 OAuth 配额缓存
- Home 管理:创建命名的 `CODEX_HOME`,并绑定到指定账号。
- 命令运行:用指定账号或 home 启动子进程,只为该进程设置 `CODEX_HOME`
- 命令运行:用指定账号或 home 运行 `codex exec`,只为该进程设置 `CODEX_HOME`
- 会话管理:查看会话、统计 token 和文件信息、移入可恢复垃圾箱、恢复会话、修复缺失的会话索引。
- 线程同步:在多个受管理 home 之间补齐缺失的会话线程。
- 状态同步:运行轻量 HTTP 服务,在多台机器之间推送或拉取账号状态。
@@ -39,14 +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 <账号>`:切换到指定账号。
- `cdxs switch <账号或别名>`:切换到指定账号OAuth 和 API Key 账号都可通过 alias 切换
- `cdxs switch A --model <model> --effort <effort> --name OpenAI`:切换时更新该账号默认模型、思考程度;`--name` 仅用于 API 模式的 provider name。
- `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。
+1
View File
@@ -2,6 +2,7 @@
- 保存和管理多个 Codex OAuth 账号。
- 保存和管理多个 Codex API Key 账号。
- 通过 `login api` 保存 API Key 账号,可绑定自定义 API base URL。
- 从现有 Codex `auth.json` 导入账号。
- 通过 OpenAI OAuth 登录并保存 Codex token。
- 将指定账号切换写入 Codex `auth.json`
+133
View File
@@ -0,0 +1,133 @@
param(
[string]$Version = "",
[string]$OutputDir = "dist",
[ValidateSet("all", "win", "linux-x64", "linux-arm64")]
[string]$Target = "all",
[string]$RustImage = "docker.m.daocloud.io/library/rust:1-bookworm",
[string]$CargoRegistry = "sparse+https://rsproxy.cn/index/",
[string]$AptMirror = "https://mirrors.ustc.edu.cn/debian",
[switch]$DockerWindows,
[switch]$Clean
)
$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]
}
$dist = Join-Path $repoRoot $OutputDir
if ($Clean -and (Test-Path $dist)) {
Remove-Item -LiteralPath $dist -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $dist | Out-Null
function Copy-ReleaseFile {
param(
[string]$SourceFile,
[string]$OutputName
)
if (-not (Test-Path $SourceFile)) {
throw "Missing release binary: $SourceFile"
}
$outputPath = Join-Path $dist $OutputName
Copy-Item -LiteralPath $SourceFile -Destination $outputPath -Force
Write-Host "Wrote $outputPath"
}
function Build-DockerRelease {
param(
[string]$Name,
[string]$RustTarget,
[string]$Platform,
[string]$ContainerBinary = "cdxs",
[string]$OutputName = "cdxs-$Version-$Name"
)
$safeRunId = if ($env:GITHUB_RUN_ID) { $env:GITHUB_RUN_ID } else { [guid]::NewGuid().ToString("N") }
$imageTag = "cdxs-build:$Name-$safeRunId"
$containerName = "cdxs-build-$Name-$safeRunId"
Write-Host "Building $Name with Docker platform $Platform"
docker buildx build `
--platform $Platform `
--target builder `
--build-arg "RUST_TARGET=$RustTarget" `
--build-arg "RUST_IMAGE=$RustImage" `
--build-arg "CARGO_REGISTRY=$CargoRegistry" `
--build-arg "APT_MIRROR=$AptMirror" `
--load `
-t $imageTag `
-f "scripts/docker/Dockerfile.release" `
.
if ($LASTEXITCODE -ne 0) {
throw "Docker build failed for $Name"
}
$containerId = docker create --name $containerName $imageTag
if ($LASTEXITCODE -ne 0 -or -not $containerId) {
throw "Docker create failed for $imageTag"
}
try {
$tmpOutDir = Join-Path $dist ".tmp-$Name"
New-Item -ItemType Directory -Force -Path $tmpOutDir | Out-Null
docker cp "${containerName}:/out/$ContainerBinary" (Join-Path $tmpOutDir $ContainerBinary)
if ($LASTEXITCODE -ne 0) {
throw "Docker cp failed for $Name"
}
Copy-ReleaseFile `
-SourceFile (Join-Path $tmpOutDir $ContainerBinary) `
-OutputName $OutputName
} finally {
docker rm -f $containerName | Out-Null
if (Test-Path $tmpOutDir) {
Remove-Item -LiteralPath $tmpOutDir -Recurse -Force
}
}
}
if ($Target -eq "all" -or $Target -eq "win") {
if ($DockerWindows) {
Build-DockerRelease `
-Name "windows-x64" `
-RustTarget "x86_64-pc-windows-gnu" `
-Platform "linux/amd64" `
-ContainerBinary "cdxs.exe" `
-OutputName "cdxs-$Version-windows-x64.exe"
} else {
Write-Host "Building Windows x64"
cargo build --release --target x86_64-pc-windows-msvc
if ($LASTEXITCODE -ne 0) {
throw "Windows build failed"
}
Copy-ReleaseFile `
-SourceFile (Join-Path $repoRoot "target/x86_64-pc-windows-msvc/release/cdxs.exe") `
-OutputName "cdxs-$Version-windows-x64.exe"
}
}
if ($Target -eq "all" -or $Target -eq "linux-x64") {
Build-DockerRelease `
-Name "linux-x64" `
-RustTarget "x86_64-unknown-linux-gnu" `
-Platform "linux/amd64"
}
if ($Target -eq "all" -or $Target -eq "linux-arm64") {
Build-DockerRelease `
-Name "linux-arm64" `
-RustTarget "aarch64-unknown-linux-gnu" `
-Platform "linux/arm64"
}
Write-Host "Release artifacts are in $dist"
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
set -euo pipefail
version=""
output_dir="dist"
target="all"
rust_image="${RUST_IMAGE:-docker.m.daocloud.io/library/rust:1-bookworm}"
cargo_registry="${CARGO_REGISTRY:-sparse+https://rsproxy.cn/index/}"
apt_mirror="${APT_MIRROR:-https://mirrors.ustc.edu.cn/debian}"
clean=0
usage() {
cat <<'EOF'
Usage: scripts/build.sh [options]
Build release binaries with Docker buildx.
Options:
--version VERSION Version used in output filenames.
--output-dir DIR Output directory. Default: dist
--target TARGET all, win, linux-x64, linux-arm64. Default: all
--rust-image IMAGE Rust Docker image.
--cargo-registry URL Cargo registry mirror.
--apt-mirror URL Debian apt mirror.
--clean Remove output directory before building.
-h, --help Show this help.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
version="${2:?missing value for --version}"
shift 2
;;
--output-dir)
output_dir="${2:?missing value for --output-dir}"
shift 2
;;
--target)
target="${2:?missing value for --target}"
shift 2
;;
--rust-image)
rust_image="${2:?missing value for --rust-image}"
shift 2
;;
--cargo-registry)
cargo_registry="${2:?missing value for --cargo-registry}"
shift 2
;;
--apt-mirror)
apt_mirror="${2:?missing value for --apt-mirror}"
shift 2
;;
--clean)
clean=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
case "${target}" in
all|win|linux-x64|linux-arm64) ;;
*)
echo "Invalid --target: ${target}" >&2
exit 2
;;
esac
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
if [[ -z "${version}" ]]; then
version="$(sed -n 's/^version[[:space:]]*=[[:space:]]*"\([^"]*\)"/\1/p' Cargo.toml | head -n 1)"
if [[ -z "${version}" ]]; then
echo "Cannot read package version from Cargo.toml" >&2
exit 1
fi
fi
if [[ "${clean}" -eq 1 ]]; then
rm -rf "${output_dir}"
fi
mkdir -p "${output_dir}"
build_asset() {
local target_name="$1"
local rust_target="$2"
local platform="$3"
local container_binary="$4"
local output_name="$5"
local run_id="${GITHUB_RUN_ID:-$(date +%s)-$$}"
local image_tag="cdxs-build:${target_name}-${run_id}"
local container_name="cdxs-build-${target_name}-${run_id}"
echo "Building ${target_name} with Docker platform ${platform}"
local proxy_build_args=()
local proxy_var
for proxy_var in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy; do
if [[ -n "${!proxy_var:-}" ]]; then
proxy_build_args+=(--build-arg "${proxy_var}=${!proxy_var}")
fi
done
docker buildx build \
--platform "${platform}" \
--target builder \
--build-arg "RUST_TARGET=${rust_target}" \
--build-arg "RUST_IMAGE=${rust_image}" \
--build-arg "CARGO_REGISTRY=${cargo_registry}" \
--build-arg "APT_MIRROR=${apt_mirror}" \
"${proxy_build_args[@]}" \
--load \
-t "${image_tag}" \
-f scripts/docker/Dockerfile.release \
.
local container_id
container_id="$(docker create --name "${container_name}" "${image_tag}")"
trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' RETURN
docker cp "${container_id}:/out/${container_binary}" "${output_dir}/${output_name}"
docker rm -f "${container_name}" >/dev/null
trap - RETURN
echo "Wrote ${output_dir}/${output_name}"
}
if [[ "${target}" == "all" || "${target}" == "win" ]]; then
build_asset windows-x64 x86_64-pc-windows-gnu linux/amd64 cdxs.exe "cdxs-${version}-windows-x64.exe"
fi
if [[ "${target}" == "all" || "${target}" == "linux-x64" ]]; then
build_asset linux-x64 x86_64-unknown-linux-gnu linux/amd64 cdxs "cdxs-${version}-linux-x64"
fi
if [[ "${target}" == "all" || "${target}" == "linux-arm64" ]]; then
build_asset linux-arm64 aarch64-unknown-linux-gnu linux/arm64 cdxs "cdxs-${version}-linux-arm64"
fi
echo "Release artifacts are in ${output_dir}"
+42 -6
View File
@@ -3,26 +3,62 @@ FROM --platform=$BUILDPLATFORM ${RUST_IMAGE} AS builder
ARG RUST_TARGET=x86_64-unknown-linux-gnu
ARG CARGO_REGISTRY=sparse+https://rsproxy.cn/index/
ARG APT_MIRROR=https://mirrors.ustc.edu.cn/debian
WORKDIR /app
RUN mkdir -p /usr/local/cargo \
&& printf '[source.crates-io]\nreplace-with = "mirror"\n\n[source.mirror]\nregistry = "%s"\n' "${CARGO_REGISTRY}" > /usr/local/cargo/config.toml
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
gcc-aarch64-linux-gnu \
libc6-dev-arm64-cross \
RUN find /etc/apt/sources.list.d -type f \( -name "*.list" -o -name "*.sources" \) -print0 2>/dev/null \
| xargs -0 -r sed -i \
-e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
-e "s|https://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
-e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
-e "s|https://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
-e "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
-e "s|https://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
-e "s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
-e "s|https://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
-e "s|https://deb.debian.org/debian|${APT_MIRROR}|g" \
&& if [ -f /etc/apt/sources.list ]; then \
sed -i \
-e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
-e "s|https://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
-e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
-e "s|https://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
-e "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
-e "s|https://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
-e "s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
-e "s|https://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
-e "s|https://deb.debian.org/debian|${APT_MIRROR}|g" \
/etc/apt/sources.list; \
fi \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& case "${RUST_TARGET}" in \
aarch64-unknown-linux-gnu) \
apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libc6-dev-arm64-cross ;; \
x86_64-pc-windows-gnu) \
apt-get install -y --no-install-recommends binutils-mingw-w64-x86-64 gcc-mingw-w64-x86-64-posix mingw-w64-x86-64-dev ;; \
esac \
&& rm -rf /var/lib/apt/lists/*
RUN rustup target add ${RUST_TARGET}
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_AR=x86_64-w64-mingw32-ar
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --target ${RUST_TARGET} \
&& mkdir -p /out \
&& cp target/${RUST_TARGET}/release/cdxs /out/cdxs
&& if [ "${RUST_TARGET}" = "x86_64-pc-windows-gnu" ]; then \
cp target/${RUST_TARGET}/release/cdxs.exe /out/cdxs.exe; \
else \
cp target/${RUST_TARGET}/release/cdxs /out/cdxs; \
fi
-107
View File
@@ -1,107 +0,0 @@
param(
[string]$Version = "",
[string]$OutputDir = "dist",
[ValidateSet("all", "win", "linux-x64", "linux-arm64")]
[string]$Target = "all",
[string]$RustImage = "rust:1-bookworm",
[switch]$SkipDockerLinux,
[switch]$Clean
)
$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]
}
$dist = Join-Path $repoRoot $OutputDir
if ($Clean -and (Test-Path $dist)) {
Remove-Item -LiteralPath $dist -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $dist | Out-Null
if ($SkipDockerLinux) {
$Target = "win"
}
function Copy-ReleaseFile {
param(
[string]$SourceFile,
[string]$OutputName
)
if (-not (Test-Path $SourceFile)) {
throw "Missing release binary: $SourceFile"
}
$outputPath = Join-Path $dist $OutputName
Copy-Item -LiteralPath $SourceFile -Destination $outputPath -Force
Write-Host "Wrote $outputPath"
}
if ($Target -eq "all" -or $Target -eq "win") {
Write-Host "Building Windows x64"
cargo build --release --target x86_64-pc-windows-msvc
Copy-ReleaseFile `
-SourceFile (Join-Path $repoRoot "target/x86_64-pc-windows-msvc/release/cdxs.exe") `
-OutputName "cdxs-$Version-windows-x64.exe"
}
$linuxTargets = @(
@{ Name = "linux-x64"; RustTarget = "x86_64-unknown-linux-gnu"; Platform = "linux/amd64" },
@{ Name = "linux-arm64"; RustTarget = "aarch64-unknown-linux-gnu"; Platform = "linux/arm64" }
) | Where-Object { $Target -eq "all" -or $Target -eq $_.Name }
foreach ($linuxTarget in $linuxTargets) {
$containerName = "cdxs-build-$($linuxTarget.Name)-$([guid]::NewGuid().ToString('N'))"
$imageTag = "cdxs-build:$($linuxTarget.Name)"
$tmpOutDir = Join-Path $dist ".tmp-$($linuxTarget.Name)"
New-Item -ItemType Directory -Force -Path $tmpOutDir | Out-Null
Write-Host "Building $($linuxTarget.Name) with Docker platform $($linuxTarget.Platform)"
$buildArgs = @(
"buildx", "build",
"--platform", $linuxTarget.Platform,
"--target", "builder",
"--build-arg", "RUST_TARGET=$($linuxTarget.RustTarget)",
"--build-arg", "RUST_IMAGE=$RustImage",
"--load",
"-t", $imageTag,
"-f", "scripts/docker/Dockerfile.release",
"."
)
docker @buildArgs
if ($LASTEXITCODE -ne 0) {
throw "Docker build failed for $($linuxTarget.Name)"
}
docker image inspect $imageTag | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Docker image was not created: $imageTag"
}
$containerId = docker create --name $containerName $imageTag
if ($LASTEXITCODE -ne 0 -or -not $containerId) {
throw "Docker create failed for $imageTag"
}
try {
docker cp "${containerName}:/out/cdxs" (Join-Path $tmpOutDir "cdxs")
if ($LASTEXITCODE -ne 0) {
throw "Docker cp failed for $($linuxTarget.Name)"
}
} finally {
docker rm -f $containerName | Out-Null
}
Copy-ReleaseFile `
-SourceFile (Join-Path $tmpOutDir "cdxs") `
-OutputName "cdxs-$Version-$($linuxTarget.Name)"
Remove-Item -LiteralPath $tmpOutDir -Recurse -Force
}
Write-Host "Binary release artifacts are in $dist"
-48
View File
@@ -1,48 +0,0 @@
param(
[string]$Registry = "docker.pchuan.top",
[string]$ImageName = "cdxs",
[string]$Tag = "",
[switch]$NoLatest,
[string]$Platform = ""
)
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
Set-Location $repoRoot
if (-not $Tag) {
$cargoToml = Get-Content -Raw -Path "Cargo.toml"
if ($cargoToml -notmatch '(?m)^version\s*=\s*"([^"]+)"') {
throw "Cannot read package version from Cargo.toml"
}
$Tag = $Matches[1]
}
$image = "$Registry/$ImageName"
$versionTag = "${image}:$Tag"
$latestTag = "${image}:latest"
Write-Host "Building $versionTag"
$buildArgs = @("build", "-t", $versionTag)
if (-not $NoLatest) {
$buildArgs += @("-t", $latestTag)
}
if ($Platform) {
$buildArgs += @("--platform", $Platform)
}
$buildArgs += "."
docker @buildArgs
Write-Host "Pushing $versionTag"
docker push $versionTag
if (-not $NoLatest) {
Write-Host "Pushing $latestTag"
docker push $latestTag
}
Write-Host "Published $versionTag"
if (-not $NoLatest) {
Write-Host "Published $latestTag"
}
+50
View File
@@ -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"
-759
View File
@@ -1,759 +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::{jwt, paths, token};
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)?;
}
store.save(&config_home)?;
println!("已导入账号: {email} ({id})");
Ok(())
}
pub fn add_api_key(key: String, base_url: Option<String>, switch: bool) -> Result<()> {
let home = paths::codex_home(None)?;
let mut store = Store::load(&home)?;
let account = api_key_account(key, base_url)?;
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)?;
}
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().collect::<Vec<_>>();
accounts.sort_by_key(|account| {
if Some(account.id.as_str()) == current_account_id {
0
} else {
1
}
});
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,
&account.email,
auth_file::account_auth_mode_name(account),
account.plan_type.as_deref().unwrap_or("-"),
&primary_quota,
&secondary_quota,
);
}
print_account_table_border();
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(())
}
pub async fn switch_account(
query: &str,
codex_home: Option<PathBuf>,
apply_fingerprint: bool,
) -> 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)?;
switch_account_id(&mut store, &config_home, &target_home, &account_id).await
}
pub async fn switch_auto(codex_home: Option<PathBuf>, apply_fingerprint: bool) -> 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)?;
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)?;
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)?;
}
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)?;
(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));
}
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,
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>) -> Result<Account> {
let key = key.trim();
if key.is_empty() {
return Err(anyhow!("API Key 不能为空"));
}
let id = stable_id("apikey", key, base_url.as_deref(), None);
let email = format!("api-key-{}", &id[id.len().saturating_sub(8)..]);
let now = Utc::now().timestamp();
Ok(Account {
id,
email,
auth_mode: AuthMode::ApiKey,
plan_type: Some("API_KEY".to_string()),
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) {
return Ok(account.id.clone());
}
let query_lower = query.to_ascii_lowercase();
let matches = store
.accounts
.iter()
.filter(|account| {
account.email.eq_ignore_ascii_case(query)
|| 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 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 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(8),
"-".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} | {:<6} | {:<6} | {:<12} | {:<12} |",
shorten(marker, 1),
shorten(id, 22),
shorten(email, 28),
shorten(mode, 6),
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!("email: {}", account.email);
println!("auth_mode: {}", auth_file::account_auth_mode_name(account));
println!("plan_type: {}", account.plan_type.as_deref().unwrap_or("-"));
println!(
"account_id: {}",
account.account_id.as_deref().unwrap_or("-")
);
println!(
"organization_id: {}",
account.organization_id.as_deref().unwrap_or("-")
);
println!("requires_reauth: {}", account.requires_reauth);
}
+113
View File
@@ -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(())
}
+365
View File
@@ -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")
}
+254
View File
@@ -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();
}
+194
View File
@@ -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,
})
}
+212
View File
@@ -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
}
}
+23
View File
@@ -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() {
+83 -26
View File
@@ -5,6 +5,9 @@ use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[command(name = "cdxs", version, about = "Codex Switch CLI")]
pub struct Cli {
/// When no subcommand is given, force refresh quota before listing accounts.
#[arg(short, long)]
pub force: bool,
#[command(subcommand)]
pub command: Option<Commands>,
}
@@ -15,15 +18,10 @@ pub enum Commands {
Login(LoginArgs),
/// Import an existing Codex auth file.
Import(ImportArgs),
/// List saved accounts.
List {
#[arg(long)]
json: bool,
#[arg(short, long)]
force: bool,
},
/// List saved accounts without refreshing quota.
/// Show one saved account.
Show {
#[arg(value_name = "ACCOUNT_ID_OR_EMAIL")]
account: String,
#[arg(long)]
json: bool,
},
@@ -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(
@@ -58,16 +61,15 @@ pub enum Commands {
codex_home: Option<PathBuf>,
#[arg(long)]
apply_fingerprint: bool,
},
/// Prepare auth, set CODEX_HOME, and execute a command.
Run(RunArgs),
/// Refresh and display Codex quota.
Quota {
#[arg(value_name = "ACCOUNT_ID_OR_EMAIL")]
accounts: Vec<String>,
#[arg(long)]
json: bool,
model: Option<String>,
#[arg(long, alias = "reasoning-effort")]
effort: Option<String>,
#[arg(long)]
name: Option<String>,
},
/// Prepare auth, set CODEX_HOME, and run codex exec.
Exec(ExecArgs),
/// Account management commands.
Account {
#[command(subcommand)]
@@ -100,6 +102,17 @@ pub struct LoginArgs {
/// Start OpenAI OAuth login for Codex.
#[command(subcommand)]
pub command: Option<LoginCommands>,
/// Add an API key account directly, for example: cdxs login --api sk-...
#[arg(long, value_name = "KEY")]
pub api: Option<String>,
#[arg(long)]
pub base_url: Option<String>,
#[arg(long)]
pub alias: Option<String>,
#[arg(long)]
pub model: Option<String>,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub manual: bool,
#[arg(long, default_value_t = 1455)]
@@ -132,6 +145,21 @@ pub enum LoginCommands {
#[arg(long)]
switch: bool,
},
/// Add an OpenAI API key account.
Api {
#[arg(long)]
key: String,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
alias: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
switch: bool,
},
}
#[derive(Subcommand)]
@@ -173,10 +201,36 @@ pub enum AccountCommands {
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
alias: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
switch: bool,
},
}
#[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 {
@@ -321,14 +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>,
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>,
}
+332
View File
@@ -0,0 +1,332 @@
//! Codex `config.toml` helpers for account-specific provider settings.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use toml::map::Map;
use toml::Value;
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 {
AuthMode::Oauth => apply_oauth_config(codex_home, account),
AuthMode::ApiKey => apply_api_key_provider(codex_home, account),
}
}
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() {
return Ok(());
}
let mut config = read_config(&path)?;
let mut changed = apply_common_model_settings(&mut config, account);
changed |= remove_previous_managed_provider(&mut config);
if changed {
write_config(&path, codex_home, &config)?;
}
Ok(())
}
fn apply_api_key_provider(codex_home: &Path, account: &Account) -> Result<()> {
let path = paths::codex_config_path(codex_home);
let mut config = read_config(&path)?;
remove_previous_managed_provider(&mut config);
apply_common_model_settings(&mut config, account);
let base_url = account
.api_base_url
.as_deref()
.and_then(normalize_base_url)
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
let provider_id = provider_id(account, &base_url);
config.insert(
"model_provider".to_string(),
Value::String(provider_id.clone()),
);
let providers = table_entry(&mut config, "model_providers");
let mut provider = Map::new();
provider.insert(
"name".to_string(),
Value::String(
account
.api_provider_name
.as_deref()
.and_then(normalize_field)
.unwrap_or_else(|| "OpenAI".to_string()),
),
);
provider.insert("base_url".to_string(), Value::String(base_url));
provider.insert(
"wire_api".to_string(),
Value::String("responses".to_string()),
);
provider.insert("requires_openai_auth".to_string(), Value::Boolean(true));
providers.insert(provider_id, Value::Table(provider));
write_config(&path, codex_home, &config)
}
fn read_config(path: &Path) -> Result<Map<String, Value>> {
if !path.exists() {
return Ok(Map::new());
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("读取 Codex config.toml 失败: {}", path.display()))?;
if content.trim().is_empty() {
return Ok(Map::new());
}
let value: Value = toml::from_str(&content)
.with_context(|| format!("解析 Codex config.toml 失败: {}", path.display()))?;
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 失败")?;
atomic::write_atomic(path, &content)
}
fn remove_previous_managed_provider(config: &mut Map<String, Value>) -> bool {
let mut changed = false;
let managed_current = config
.get("model_provider")
.and_then(Value::as_str)
.filter(|provider| provider.starts_with(MANAGED_PROVIDER_PREFIX))
.map(ToOwned::to_owned);
if managed_current.is_some() {
config.remove("model_provider");
changed = true;
}
if let Some(Value::Table(providers)) = config.get_mut("model_providers") {
let before = providers.len();
providers.retain(|key, _| !key.starts_with(MANAGED_PROVIDER_PREFIX));
changed |= providers.len() != before;
}
changed
}
fn table_entry<'a>(config: &'a mut Map<String, Value>, key: &str) -> &'a mut Map<String, Value> {
let needs_table = !matches!(config.get(key), Some(Value::Table(_)));
if needs_table {
config.insert(key.to_string(), Value::Table(Map::new()));
}
config
.get_mut(key)
.and_then(Value::as_table_mut)
.expect("table entry was just inserted")
}
fn provider_id(account: &Account, base_url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(account.id.as_bytes());
hasher.update([0]);
hasher.update(base_url.as_bytes());
let hex = hex::encode(hasher.finalize());
format!("{MANAGED_PROVIDER_PREFIX}{}", &hex[..12])
}
fn normalize_base_url(value: &str) -> Option<String> {
let value = value.trim().trim_end_matches('/');
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn apply_common_model_settings(config: &mut Map<String, Value>, account: &Account) -> bool {
let mut changed = false;
if let Some(model) = account.model.as_deref().and_then(normalize_field) {
config.insert("model".to_string(), Value::String(model));
changed = true;
}
if let Some(reasoning_effort) = account
.reasoning_effort
.as_deref()
.and_then(normalize_field)
{
config.insert(
"model_reasoning_effort".to_string(),
Value::String(reasoning_effort),
);
changed = true;
}
changed
}
fn normalize_field(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
None
} else {
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);
}
}
+19
View File
@@ -56,6 +56,14 @@ pub struct Account {
pub email: String,
pub auth_mode: AuthMode,
#[serde(default)]
pub alias: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub api_provider_name: Option<String>,
#[serde(default)]
pub plan_type: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
@@ -228,6 +236,17 @@ impl Store {
let query_lower = query.to_ascii_lowercase();
self.accounts.iter().find(|account| {
account.id == query
|| account.id.starts_with(query)
|| account
.id
.split_once('_')
.map(|(_, suffix)| suffix.starts_with(query))
.unwrap_or(false)
|| account
.alias
.as_deref()
.map(|alias| alias.eq_ignore_ascii_case(query))
.unwrap_or(false)
|| account.email.eq_ignore_ascii_case(query)
|| account
.email
+182 -122
View File
@@ -7,6 +7,7 @@ mod account;
mod atomic;
mod auth_file;
mod cli;
mod codex_config;
mod config_store;
mod http_client;
mod jwt;
@@ -19,143 +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,
None => 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,
} => {
if auto || account.is_none() {
account::switch_auto(codex_home, apply_fingerprint).await
} else {
account::switch_account(
account.as_deref().ok_or_else(|| {
anyhow::anyhow!("switch 需要提供账号,或使用 -a/--auto 自动选择")
})?,
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,
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,
apply_fingerprint,
)
.await
}
}
Commands::Run(args) => {
run_cmd::run_with_account_or_home(
args.account,
args.home,
args.codex_home,
args.command,
)
.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,
switch,
} => account::add_api_key(key, base_url, switch),
},
Commands::Home { command } => match command {
HomeCommands::List { json } => account::list_homes(json),
HomeCommands::Create {
name,
path,
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,
} => 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,
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 自动选择")
})?,
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,
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),
},
},
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),
},
}
}
+4
View File
@@ -27,6 +27,10 @@ pub fn auth_path(codex_home: &std::path::Path) -> PathBuf {
codex_home.join("auth.json")
}
pub fn codex_config_path(codex_home: &std::path::Path) -> PathBuf {
codex_home.join("config.toml")
}
pub fn expand_home(path: PathBuf) -> PathBuf {
// PathBuf does not expand ~ on Windows or Unix, so handle the common cases.
let raw = path.to_string_lossy();
+23 -113
View File
@@ -29,19 +29,11 @@ struct RateLimitInfo {
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UsageResponse {
plan_type: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_plan_type")]
plan_type: Option<Option<String>>,
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,
@@ -54,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],
@@ -318,7 +221,7 @@ fn apply_quota_result(store: &mut Store, account_id: &str, quota: FetchQuotaResu
.find_account_mut(account_id)
.expect("quota result references an existing account");
if let Some(plan) = quota.plan_type {
account.plan_type = Some(plan);
account.plan_type = plan;
}
account.quota = Some(quota.quota);
account.updated_at = Utc::now().timestamp();
@@ -326,7 +229,7 @@ fn apply_quota_result(store: &mut Store, account_id: &str, quota: FetchQuotaResu
struct FetchQuotaResult {
quota: Quota,
plan_type: Option<String>,
plan_type: Option<Option<String>>,
}
async fn fetch_quota(access_token: &str, account_id: Option<&str>) -> Result<FetchQuotaResult> {
@@ -388,6 +291,25 @@ fn parse_quota(usage: &UsageResponse) -> Quota {
}
}
fn deserialize_optional_plan_type<'de, D>(
deserializer: D,
) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(match value {
None => None,
Some(serde_json::Value::Null) => Some(None),
Some(serde_json::Value::String(plan)) => Some(Some(plan)),
Some(value) => {
return Err(serde::de::Error::custom(format!(
"expected plan_type to be string or null, got {value}"
)))
}
})
}
fn remaining_percent(window: &WindowInfo) -> i32 {
100 - window.used_percent.unwrap_or(0).clamp(0, 100)
}
@@ -409,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
}
+137 -26
View File
@@ -1,48 +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::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{anyhow, Context, Result};
use uuid::Uuid;
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(())
}
struct ExecBase {
source_home: PathBuf,
account_query: Option<String>,
}
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 {
format!("codex exec {}", args.join(" "))
}
}
fn codex_exec_command(args: &[String]) -> Command {
#[cfg(windows)]
{
let mut command = Command::new("cmd.exe");
command.arg("/C").arg("codex").arg("exec").args(args);
command
}
#[cfg(not(windows))]
{
let mut command = Command::new("codex");
command.arg("exec").args(args);
command
}
}
-1296
View File
File diff suppressed because it is too large Load Diff
+159
View File
@@ -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>,
}
+180
View File
@@ -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()
}
+417
View File
@@ -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)
}
}
+124
View File
@@ -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
}
}
+279
View File
@@ -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)
}
+342
View File
@@ -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);
}
}
+36 -5
View File
@@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize};
use crate::config_store::{Account, AccountSyncState, Store};
const DEFAULT_API_BASE_URL: &str = "https://api.openai.com/v1";
#[derive(Debug, Serialize)]
struct LoginRequest<'a> {
username: &'a str,
@@ -146,8 +148,8 @@ pub async fn remote(json: bool) -> Result<()> {
return Ok(());
}
println!(
"{:<22} {:<34} {:<10} {:<12} {}",
"ID", "Email", "Mode", "Plan", "Quota"
"{:<22} {:<16} {:<34} {:<10} {:<18} {:<12} {}",
"ID", "Alias", "Email", "Mode", "Model", "Provider", "Quota"
);
for account in &remote_state.accounts {
let quota = account
@@ -161,11 +163,13 @@ pub async fn remote(json: bool) -> Result<()> {
})
.unwrap_or_else(|| "-".to_string());
println!(
"{:<22} {:<34} {:<10} {:<12} {}",
"{:<22} {:<16} {:<34} {:<10} {:<18} {:<12} {}",
shorten(&account.id, 22),
shorten(&account.email, 34),
shorten(account.alias.as_deref().unwrap_or("-"), 16),
shorten(account_email_display(account), 34),
account_auth_mode_name(account),
account.plan_type.as_deref().unwrap_or("-"),
shorten(account.model.as_deref().unwrap_or("-"), 18),
shorten(account_provider_display(account), 12),
quota
);
}
@@ -287,6 +291,33 @@ fn account_auth_mode_name(account: &Account) -> &'static str {
}
}
fn account_email_display(account: &Account) -> &str {
if account.auth_mode == crate::config_store::AuthMode::ApiKey {
account
.api_base_url
.as_deref()
.unwrap_or(DEFAULT_API_BASE_URL)
} else {
&account.email
}
}
fn account_plan_display(account: &Account) -> &str {
if account.auth_mode == crate::config_store::AuthMode::ApiKey {
"-"
} else {
account.plan_type.as_deref().unwrap_or("-")
}
}
fn account_provider_display(account: &Account) -> &str {
if account.auth_mode == crate::config_store::AuthMode::ApiKey {
account.api_provider_name.as_deref().unwrap_or("-")
} else {
account_plan_display(account)
}
}
fn shorten(value: &str, width: usize) -> String {
if value.chars().count() <= width {
return value.to_string();
+8 -12
View File
@@ -69,24 +69,20 @@ pub async fn refresh_account(store: &mut Store, account_id: &str) -> Result<()>
account.tokens = Some(tokens);
account.requires_reauth = false;
account.updated_at = Utc::now().timestamp();
if let Some(plan) = auth
.as_ref()
.and_then(|auth| auth.chatgpt_plan_type.clone())
{
account.plan_type = Some(plan);
}
if account.account_id.is_none() {
account.account_id = auth.as_ref().and_then(|auth| auth.account_id.clone());
}
if account.organization_id.is_none() {
account.organization_id =
auth.as_ref().and_then(|auth| auth.organization_id.clone());
if let Some(auth) = auth {
account.plan_type = auth.chatgpt_plan_type;
account.account_id = auth.account_id;
account.organization_id = auth.organization_id;
}
Ok(())
}
Err(error) => {
if let Some(account) = store.find_account_mut(account_id) {
account.requires_reauth = true;
account.plan_type = None;
account.account_id = None;
account.organization_id = None;
account.quota = None;
account.updated_at = Utc::now().timestamp();
}
Err(error)