codex-switch-helper 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -0
- package/codex-switch +545 -0
- package/csw +28 -0
- package/examples/anyrouter/auth.json.example +4 -0
- package/examples/anyrouter/provider.env +5 -0
- package/examples/official/provider.env +4 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Codex-Switch-Helper (`csw`)
|
|
2
|
+
|
|
3
|
+
在 **官方 ChatGPT 登录** 和 **各种第三方 API-key 中转**(anyrouter、muyuan、本地代理 …)之间一键切换
|
|
4
|
+
[Codex](https://github.com/openai/codex) 的后端,**不丢失任何现有配置**。纯 bash、零依赖、Mac / Linux 本地。
|
|
5
|
+
|
|
6
|
+
- **包名**:`codex-switch-helper`(npm)
|
|
7
|
+
- **命令**:`csw`
|
|
8
|
+
|
|
9
|
+
> 它只动两样东西,其余 `~/.codex/config.toml`(MCP servers、`[features]`、`[projects]` 信任列表、
|
|
10
|
+
> desktop 设置等)**一个字不碰**:
|
|
11
|
+
> 1. `config.toml` 里一小段「受管块」(`model` / `model_provider` / 鉴权方式)
|
|
12
|
+
> 2. `~/.codex/auth.json`(ChatGPT OAuth 令牌 ↔ `sk-` API key)
|
|
13
|
+
|
|
14
|
+
> 🔐 **密钥从不进仓库。** 真实凭证只存在本机 `~/.codex/profiles/<名字>/auth.json`(权限 600)。
|
|
15
|
+
> 本仓库只有脚本、文档和占位示例;`.gitignore` 还会兜底拦截 `auth.json` / 备份 / `.active`。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 安装
|
|
20
|
+
|
|
21
|
+
### npm(推荐)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g codex-switch-helper
|
|
25
|
+
csw list
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 从源码
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://github.com/VicZhang6/codex-switch.git
|
|
32
|
+
cd codex-switch
|
|
33
|
+
./install.sh # 把 csw / codex-switch / switch 软链到 PATH(无需 sudo)
|
|
34
|
+
csw list # 验证
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`install.sh` 只创建软链,不碰 `~/.codex/` 里任何现有内容。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 命令速查
|
|
42
|
+
|
|
43
|
+
| 命令 | 作用 |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `csw` | 交互式选单(↑↓ / j k 移动 · Enter 确认 · 1-9 直选 · q 取消) |
|
|
46
|
+
| `csw <名字>` | 直接切到某 profile,如 `csw official` / `csw anyrouter` |
|
|
47
|
+
| `csw add [名字]` | **向导**:新建一个自定义 API-key provider(见下) |
|
|
48
|
+
| `csw list` | 列出所有 profile,`*` 标当前 |
|
|
49
|
+
| `csw status` | 显示当前 profile + 受管块 + auth 模式 |
|
|
50
|
+
|
|
51
|
+
> 兼容别名:源码安装后也可使用 `switch` / `codex-switch`(与 `csw` 相同能力)。
|
|
52
|
+
|
|
53
|
+
> ⚠️ **切换后要重开一个 `codex` 会话 / 重启 Codex App 才生效** —— Codex 在会话启动时读配置,
|
|
54
|
+
> 已开着的会话不会热加载。
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 工作机制
|
|
59
|
+
|
|
60
|
+
一次 `csw <名字>`:
|
|
61
|
+
|
|
62
|
+
1. 把当前 live `auth.json` 存回**上一个** profile(这样 ChatGPT 的 token 刷新不会丢);
|
|
63
|
+
2. 备份 `config.toml` 和 `auth.json`(带时间戳,各留最近 10 份);
|
|
64
|
+
3. 用 `awk` 只重写 `#__CODEX_SWITCH_BEGIN/END__` 标记**之间**的内容;
|
|
65
|
+
4. 把目标 profile 的 `auth.json` 装到 `~/.codex/auth.json`(`install -m 600`);
|
|
66
|
+
5. 更新 `~/.codex/profiles/.active`。
|
|
67
|
+
|
|
68
|
+
**可逆**:`csw official` 任何时候都能切回原始 ChatGPT 登录(auth.json 来回切已验证字节级一致)。
|
|
69
|
+
|
|
70
|
+
### 文件结构
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
~/.codex/ # ← 运行时数据(不在本仓库)
|
|
74
|
+
├── csw codex-switch switch # 软链到本仓库(install.sh 创建;npm 只装 csw)
|
|
75
|
+
├── config.toml # 你的原配置 + 受管块 + [model_providers.*] 表
|
|
76
|
+
├── auth.json # 当前生效凭证(切换时被替换)
|
|
77
|
+
├── *.bak.* # 自动备份(各留最近 10 份)
|
|
78
|
+
└── profiles/
|
|
79
|
+
├── .active # 当前 profile 名
|
|
80
|
+
├── official/ { provider.env, auth.json } # auth.json = 原始 OAuth 快照
|
|
81
|
+
└── <其它>/ { provider.env, auth.json }
|
|
82
|
+
|
|
83
|
+
本仓库:
|
|
84
|
+
├── codex-switch # 主脚本
|
|
85
|
+
├── csw # 短命令入口(npm bin / 默认无参 = 选单)
|
|
86
|
+
├── switch # 兼容别名
|
|
87
|
+
├── install.sh # 软链到 PATH
|
|
88
|
+
├── package.json # npm 包:codex-switch-helper
|
|
89
|
+
├── examples/ # provider.env / auth.json.example 模板
|
|
90
|
+
└── README.md
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`provider.env`:
|
|
94
|
+
```bash
|
|
95
|
+
MODE=apikey # 或 chatgpt(official)
|
|
96
|
+
MODEL=gpt-5.5
|
|
97
|
+
PROVIDER=anyrouter # apikey 时必填,且 == config.toml 里 [model_providers.<这里>]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
受管块(脚本只改这对标记之间):
|
|
101
|
+
```toml
|
|
102
|
+
#__CODEX_SWITCH_BEGIN__
|
|
103
|
+
model = "gpt-5.5"
|
|
104
|
+
model_provider = "anyrouter" # 切到 official 时这两行消失 → 走默认 ChatGPT
|
|
105
|
+
preferred_auth_method = "apikey"
|
|
106
|
+
#__CODEX_SWITCH_END__
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
provider 端点定义一次,平时**惰性**(只有被 `model_provider` 指向才生效):
|
|
110
|
+
```toml
|
|
111
|
+
[model_providers.anyrouter]
|
|
112
|
+
name = "Any Router"
|
|
113
|
+
base_url = "https://anyrouter.top/v1"
|
|
114
|
+
wire_api = "responses"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 新增 provider —— `csw add`
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
$ csw add CPA
|
|
123
|
+
显示名 (provider name=, 默认 CPA):
|
|
124
|
+
base_url (如 https://anyrouter.top/v1): http://localhost:8317/v1
|
|
125
|
+
wire_api [responses/chat] (默认 responses): chat
|
|
126
|
+
模型 model (如 gpt-5.5): gpt-5.5
|
|
127
|
+
API key (sk-...): sk-....
|
|
128
|
+
用 GET .../models 验证 key? [Y/n]
|
|
129
|
+
现在就切到 CPA? [y/N]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
向导会:写好 `profiles/<名>/{provider.env,auth.json}`(600)→ 往 `config.toml` 追加
|
|
133
|
+
`[model_providers.<名>]`(已存在则跳过)→ 追加前先备份 config → 可选 `GET /models` 验证 →
|
|
134
|
+
可选当场切过去。名字只允许 `字母/数字/-/_`,挡保留字,已存在会问是否覆盖。
|
|
135
|
+
|
|
136
|
+
### `base_url` / `wire_api` 怎么填(重要)
|
|
137
|
+
|
|
138
|
+
> 新版 Codex([codex#7782](https://github.com/openai/codex/discussions/7782))**只支持 `wire_api = "responses"`**,
|
|
139
|
+
> `chat` 已废弃 —— 配了 `chat` 整个 `config.toml` 会**拒绝加载**(codex 起不来)。
|
|
140
|
+
> 因此 provider 必须支持 **Responses API**(`<base_url>/responses`);只提供 `/v1/chat/completions` 的中转用不了。
|
|
141
|
+
|
|
142
|
+
| 配置 | 填法 |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `base_url` | 带版本前缀,如 `https://host/v1`(codex 会 POST 到 `<base_url>/responses`) |
|
|
145
|
+
| `wire_api` | 固定 `responses` |
|
|
146
|
+
|
|
147
|
+
- 漏了 `/v1` → codex POST 到错路径报 **404**。
|
|
148
|
+
- 验证某中转是否支持 Responses:
|
|
149
|
+
`curl -X POST http://host/v1/responses -H "Authorization: Bearer <key>" -H 'content-type: application/json' -d '{"model":"gpt-5.5","input":"hi"}'` → 200 即可用。
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 排错
|
|
154
|
+
|
|
155
|
+
| 现象 | 处理 |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `⚠ MCP client for 'paper' failed to start` | **无害**,与切 provider 无关;Codex 桌面端 paper MCP 没在跑而已,codex 照常继续。要消掉就在 `config.toml` 把 `[mcp_servers.paper]` 设 `enabled = false`。 |
|
|
158
|
+
| `wire_api = "chat" is no longer supported`(codex 起不来) | 新版 Codex 删了 chat(codex#7782);把该 provider 改 `wire_api = "responses"`(provider 须支持 Responses API)。 |
|
|
159
|
+
| `unexpected status 404 ... /responses` | `base_url` 漏 `/v1`;codex 会 POST 到 `<base_url>/responses`,确认中转支持该端点,再**重开 codex 会话**。 |
|
|
160
|
+
| 改了配置但没变 | 旧会话不热加载,**重开 `codex` / 重启 App**。 |
|
|
161
|
+
| `csw add` 里 `GET /models` 非 200 | 不一定 key 无效 —— 有些中转不实现 `/models` 或严格校验格式。anyrouter 这类 `/responses` 通道只能用真实 codex 客户端验证(手搓 curl 会被判 `invalid codex request` 假阴性)。最终以 `codex` 真跑一把为准。 |
|
|
162
|
+
| 验证某中转能用 | `csw <名>` 后:`codex exec --skip-git-repo-check -c 'mcp_servers={}' "print: OK"` |
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 和 `Loongphy/codex-auth` 的区别
|
|
167
|
+
|
|
168
|
+
- `codex-auth`:多 **ChatGPT/OpenAI 账号**切换(换身份、看额度),**不改 `config.toml`**,做不到指向第三方中转。
|
|
169
|
+
- **Codex-Switch-Helper(`csw` / 本项目)**:多 **provider** 切换(官方登录 ↔ 第三方中转 key,连 `base_url`/模型一起换)。
|
|
170
|
+
解决的是不同问题,命令也不撞名,可共存。
|
package/codex-switch
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# codex-switch — toggle Codex between the official ChatGPT login and custom API-key
|
|
3
|
+
# providers WITHOUT losing any configuration.
|
|
4
|
+
#
|
|
5
|
+
# It only swaps two things, leaving the rest of config.toml (MCP servers, features,
|
|
6
|
+
# [projects] trust list, desktop settings, ...) completely untouched:
|
|
7
|
+
# 1. a small managed block in ~/.codex/config.toml (model / model_provider / auth method)
|
|
8
|
+
# 2. ~/.codex/auth.json (ChatGPT OAuth tokens vs sk- API key)
|
|
9
|
+
#
|
|
10
|
+
# Profiles live in ~/.codex/profiles/<name>/ as { provider.env, auth.json }.
|
|
11
|
+
# Provider endpoints are defined once as [model_providers.<name>] in config.toml.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# codex-switch # show current status
|
|
15
|
+
# codex-switch list # list profiles
|
|
16
|
+
# codex-switch doctor # check/repair the managed block (e.g. after Codex rewrote config.toml)
|
|
17
|
+
# codex-switch pick # interactive arrow-key picker (also: `switch`)
|
|
18
|
+
# codex-switch add [name] # wizard: create a new custom API-key profile
|
|
19
|
+
# codex-switch official # back to ChatGPT login (your original setup)
|
|
20
|
+
# codex-switch anyrouter # custom key via anyrouter.top
|
|
21
|
+
# codex-switch muyuan # custom key via muyuan.do (free/temp)
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
|
|
25
|
+
CONFIG="$CODEX_HOME/config.toml"
|
|
26
|
+
AUTH="$CODEX_HOME/auth.json"
|
|
27
|
+
PROFILES="$CODEX_HOME/profiles"
|
|
28
|
+
ACTIVE_FILE="$PROFILES/.active"
|
|
29
|
+
BEGIN='#__CODEX_SWITCH_BEGIN__'
|
|
30
|
+
END='#__CODEX_SWITCH_END__'
|
|
31
|
+
|
|
32
|
+
die(){ echo "codex-switch: error: $*" >&2; exit 1; }
|
|
33
|
+
current(){ [ -f "$ACTIVE_FILE" ] && cat "$ACTIVE_FILE" || echo official; }
|
|
34
|
+
|
|
35
|
+
list(){
|
|
36
|
+
echo "profiles (current: $(current)):"
|
|
37
|
+
for d in "$PROFILES"/*/; do
|
|
38
|
+
[ -d "$d" ] || continue
|
|
39
|
+
n=$(basename "$d")
|
|
40
|
+
mark=" "; [ "$n" = "$(current)" ] && mark="* "
|
|
41
|
+
info=$( unset MODE MODEL PROVIDER; . "$d/provider.env" 2>/dev/null
|
|
42
|
+
if [ "${MODE:-}" = chatgpt ]; then echo "ChatGPT login model=$MODEL"
|
|
43
|
+
else echo "apikey provider=$PROVIDER model=$MODEL"; fi )
|
|
44
|
+
printf "%s%-12s %s\n" "$mark" "$n" "$info"
|
|
45
|
+
done
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
build_block(){ # $1 = profile dir -> prints the managed config block
|
|
49
|
+
( unset MODE MODEL PROVIDER; . "$1/provider.env"
|
|
50
|
+
echo "model = \"$MODEL\""
|
|
51
|
+
if [ "${MODE:-}" = apikey ]; then
|
|
52
|
+
echo "model_provider = \"$PROVIDER\""
|
|
53
|
+
echo "preferred_auth_method = \"apikey\""
|
|
54
|
+
fi )
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
replace_block(){ # $1 = file holding the new block; rewrites region between markers
|
|
58
|
+
grep -q "$BEGIN" "$CONFIG" || die "managed markers missing in $CONFIG (run: codex-switch doctor)"
|
|
59
|
+
# Read the replacement from a file (not -v): BSD awk rejects a multi-line -v value.
|
|
60
|
+
awk -v b="$BEGIN" -v e="$END" -v rf="$1" '
|
|
61
|
+
$0==b{print; while((getline line < rf) > 0) print line; close(rf); skip=1; next}
|
|
62
|
+
$0==e{skip=0; print; next}
|
|
63
|
+
skip{next}
|
|
64
|
+
{print}' "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# doctor — check the managed markers. If they're present, do NOTHING (the block
|
|
68
|
+
# content is whatever you last selected — via codex-switch OR the Codex app — and
|
|
69
|
+
# is none of doctor's business). Only if the markers were dropped (e.g. Codex
|
|
70
|
+
# rewrote config.toml and stripped all comments) do we rebuild them, preferring
|
|
71
|
+
# the real values left behind in the file over the active-profile fallback.
|
|
72
|
+
doctor(){
|
|
73
|
+
[ -f "$CONFIG" ] || die "no config at $CONFIG"
|
|
74
|
+
|
|
75
|
+
if grep -q "$BEGIN" "$CONFIG" && grep -q "$END" "$CONFIG"; then
|
|
76
|
+
echo "✓ markers present in $CONFIG — nothing to fix (current: $(current))"
|
|
77
|
+
awk -v b="$BEGIN" -v e="$END" '$0==b{p=1} p{print} $0==e{p=0}' "$CONFIG" | sed 's/^/ /'
|
|
78
|
+
return 0
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
echo "managed markers missing — likely Codex rewrote $CONFIG and dropped the comment markers."
|
|
82
|
+
|
|
83
|
+
# Backup config (keep newest 10), same policy as use()/add().
|
|
84
|
+
cp -p "$CONFIG" "$CONFIG.bak.$(date +%Y%m%d-%H%M%S)"
|
|
85
|
+
ls -t "$CONFIG".bak.* 2>/dev/null | tail -n +11 | xargs -r rm -f
|
|
86
|
+
|
|
87
|
+
# Prefer re-wrapping the model keys the rewrite LEFT BEHIND (your real current
|
|
88
|
+
# values), so doctor never overrides a choice you made in the Codex app. Only
|
|
89
|
+
# fall back to the active profile if even a top-level `model` key is gone.
|
|
90
|
+
if awk '/^[[:space:]]*\[/{exit} /^[[:space:]]*model[[:space:]]*=/{f=1} END{exit !f}' "$CONFIG"; then
|
|
91
|
+
echo "re-wrapping the existing top-level model keys (values preserved)."
|
|
92
|
+
awk -v b="$BEGIN" -v e="$END" '
|
|
93
|
+
BEGIN { intop=1; done=0; m=""; mp=""; pa="" }
|
|
94
|
+
intop && /^[[:space:]]*model[[:space:]]*=/ { m=$0; next }
|
|
95
|
+
intop && /^[[:space:]]*model_provider[[:space:]]*=/ { mp=$0; next }
|
|
96
|
+
intop && /^[[:space:]]*preferred_auth_method[[:space:]]*=/ { pa=$0; next }
|
|
97
|
+
/^[[:space:]]*\[/ && intop {
|
|
98
|
+
if (!done) { print b; if(m!="")print m; if(mp!="")print mp; if(pa!="")print pa; print e; done=1 }
|
|
99
|
+
intop=0
|
|
100
|
+
}
|
|
101
|
+
{ print }
|
|
102
|
+
END { if(!done){ print b; if(m!="")print m; if(mp!="")print mp; if(pa!="")print pa; print e } }
|
|
103
|
+
' "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG"
|
|
104
|
+
else
|
|
105
|
+
local active d; active="$(current)"; d="$PROFILES/$active"
|
|
106
|
+
{ [ -d "$d" ] && [ -f "$d/provider.env" ]; } || die "no top-level model key in $CONFIG AND active profile '$active' is unusable (run: codex-switch use <name>)"
|
|
107
|
+
echo "no top-level model key found — rebuilding from active profile: $active"
|
|
108
|
+
local blk; blk=$(mktemp); build_block "$d" > "$blk"
|
|
109
|
+
awk -v rf="$blk" -v b="$BEGIN" -v e="$END" '
|
|
110
|
+
BEGIN { intop=1; done=0 }
|
|
111
|
+
/^[[:space:]]*\[/ && intop {
|
|
112
|
+
if (!done) { print b; while ((getline ln < rf) > 0) print ln; close(rf); print e; done=1 }
|
|
113
|
+
intop=0
|
|
114
|
+
}
|
|
115
|
+
intop && /^[[:space:]]*(model|model_provider|preferred_auth_method)[[:space:]]*=/ { next }
|
|
116
|
+
{ print }
|
|
117
|
+
END { if (!done) { print b; while ((getline ln < rf) > 0) print ln; close(rf); print e } }
|
|
118
|
+
' "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG"
|
|
119
|
+
rm -f "$blk"
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
echo "✓ repaired. managed block now:"
|
|
123
|
+
awk -v b="$BEGIN" -v e="$END" '$0==b{p=1} p{print} $0==e{p=0}' "$CONFIG" | sed 's/^/ /'
|
|
124
|
+
echo "Restart the Codex app / start a new \`codex\` session to pick it up."
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
use(){
|
|
128
|
+
local name="$1" d="$PROFILES/$1"
|
|
129
|
+
[ -d "$d" ] || die "no such profile: $name (try: codex-switch list)"
|
|
130
|
+
[ -f "$d/provider.env" ] || die "$name is missing provider.env"
|
|
131
|
+
[ -f "$d/auth.json" ] || die "$name is missing auth.json"
|
|
132
|
+
if grep -q REPLACE_WITH "$d/auth.json"; then
|
|
133
|
+
die "$name still has a placeholder key. Edit $d/auth.json and paste your sk- key first."
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
local cur ts; cur="$(current)"; ts=$(date +%Y%m%d-%H%M%S)
|
|
137
|
+
# Preserve the live (possibly token-refreshed) creds back into the outgoing profile,
|
|
138
|
+
# so switching back to 'official' restores fresh ChatGPT tokens — not a stale snapshot.
|
|
139
|
+
[ -f "$AUTH" ] && [ -d "$PROFILES/$cur" ] && cp -p "$AUTH" "$PROFILES/$cur/auth.json"
|
|
140
|
+
# Safety backups of the live files, keeping only the newest 10 of each.
|
|
141
|
+
cp -p "$CONFIG" "$CONFIG.bak.$ts"
|
|
142
|
+
[ -f "$AUTH" ] && cp -p "$AUTH" "$AUTH.bak.$ts"
|
|
143
|
+
ls -t "$CONFIG".bak.* 2>/dev/null | tail -n +11 | xargs -r rm -f
|
|
144
|
+
ls -t "$AUTH".bak.* 2>/dev/null | tail -n +11 | xargs -r rm -f
|
|
145
|
+
|
|
146
|
+
local blk; blk=$(mktemp); build_block "$d" > "$blk"
|
|
147
|
+
replace_block "$blk"; rm -f "$blk"
|
|
148
|
+
install -m 600 "$d/auth.json" "$AUTH"
|
|
149
|
+
echo "$name" > "$ACTIVE_FILE"
|
|
150
|
+
|
|
151
|
+
echo "✓ switched to: $name"
|
|
152
|
+
build_block "$d" | sed 's/^/ /'
|
|
153
|
+
echo "Restart the Codex app / start a new \`codex\` session to pick it up."
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
status(){
|
|
157
|
+
echo "active profile : $(current)"
|
|
158
|
+
echo "config block :"; awk -v b="$BEGIN" -v e="$END" '$0==b{p=1} p{print} $0==e{p=0}' "$CONFIG" | sed 's/^/ /'
|
|
159
|
+
if grep -q '"auth_mode"[[:space:]]*:[[:space:]]*"chatgpt"' "$AUTH" 2>/dev/null; then
|
|
160
|
+
echo "auth.json : ChatGPT login (OAuth tokens)"
|
|
161
|
+
else
|
|
162
|
+
local k; k=$(grep -o 'sk-[A-Za-z0-9]\{6\}' "$AUTH" 2>/dev/null | head -1)
|
|
163
|
+
echo "auth.json : apikey ${k:+($k…redacted)}"
|
|
164
|
+
fi
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Full-screen interactive picker.
|
|
168
|
+
# keyboard: ↑/↓ j/k move · Enter confirm · 1-9 direct · q/Esc cancel
|
|
169
|
+
# mouse: click to select · wheel to scroll · selection auto-copied to clipboard
|
|
170
|
+
pick(){
|
|
171
|
+
local profiles=() cur sel=0 i d
|
|
172
|
+
cur="$(current)"
|
|
173
|
+
for d in "$PROFILES"/*/; do [ -d "$d" ] || continue; profiles+=("$(basename "$d")"); done
|
|
174
|
+
[ ${#profiles[@]} -gt 0 ] || die "no profiles found in $PROFILES"
|
|
175
|
+
for i in "${!profiles[@]}"; do [ "${profiles[$i]}" = "$cur" ] && sel=$i; done
|
|
176
|
+
local rows=${#profiles[@]}
|
|
177
|
+
|
|
178
|
+
_info(){ ( unset MODE MODEL PROVIDER; . "$PROFILES/$1/provider.env" 2>/dev/null
|
|
179
|
+
if [ "${MODE:-}" = chatgpt ]; then printf 'ChatGPT 登录 · model=%s' "$MODEL"
|
|
180
|
+
else printf 'API key · %s · model=%s' "$PROVIDER" "$MODEL"; fi ); }
|
|
181
|
+
|
|
182
|
+
# Copy text to system clipboard: OSC 52 (iTerm2/Alacritty/kitty/Windows Terminal)
|
|
183
|
+
# with pbcopy fallback for macOS Terminal.app.
|
|
184
|
+
_copy(){
|
|
185
|
+
local b64; b64=$(printf '%s' "$1" | base64 | tr -d '\n')
|
|
186
|
+
printf '\e]52;c;%s\a' "$b64"
|
|
187
|
+
command -v pbcopy >/dev/null 2>&1 && printf '%s' "$1" | pbcopy 2>/dev/null || true
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# Restore terminal: mouse off, cursor visible, leave alternate screen.
|
|
191
|
+
_cleanup_tui(){
|
|
192
|
+
printf '\e[?1002l\e[?1006l'
|
|
193
|
+
tput cnorm 2>/dev/null || true
|
|
194
|
+
printf '\e[?1049l'
|
|
195
|
+
}
|
|
196
|
+
trap '_cleanup_tui' INT TERM
|
|
197
|
+
|
|
198
|
+
local cols clip_msg="" cmd_sel=0 focus="profiles"
|
|
199
|
+
cols=$(tput cols 2>/dev/null || echo 80)
|
|
200
|
+
local lines_t=$(tput lines 2>/dev/null || echo 24)
|
|
201
|
+
|
|
202
|
+
# Input reader that distinguishes arrow keys from SGR mouse events.
|
|
203
|
+
# Sets: KEY (the char/sequence), MOUSE_EVENT (0/1), MOUSE_BTN, MOUSE_X, MOUSE_Y.
|
|
204
|
+
# macOS ships bash 3.2 which doesn't support fractional read timeouts; use 1s there.
|
|
205
|
+
local _T="1"
|
|
206
|
+
[ "${BASH_VERSINFO[0]:-0}" -ge 4 ] 2>/dev/null && _T="0.05"
|
|
207
|
+
local KEY MOUSE_EVENT=0 MOUSE_BTN="" MOUSE_X="" MOUSE_Y=""
|
|
208
|
+
_read_input(){
|
|
209
|
+
KEY=""; MOUSE_EVENT=0
|
|
210
|
+
local c c1 c2 ch seq
|
|
211
|
+
IFS= read -rsn1 c 2>/dev/null || { KEY="EOF"; return 0; }
|
|
212
|
+
[ "$c" != $'\e' ] && { KEY="$c"; return 0; }
|
|
213
|
+
IFS= read -rsn1 -t "$_T" c1 2>/dev/null || { KEY="ESC"; return 0; }
|
|
214
|
+
if [ "$c1" = "[" ]; then
|
|
215
|
+
IFS= read -rsn1 -t "$_T" c2 2>/dev/null || { KEY="ESC"; return 0; }
|
|
216
|
+
if [ "$c2" = "<" ]; then
|
|
217
|
+
seq=""
|
|
218
|
+
while IFS= read -rsn1 -t "$_T" ch 2>/dev/null; do
|
|
219
|
+
[ "$ch" = "M" ] || [ "$ch" = "m" ] && break
|
|
220
|
+
seq+="$ch"
|
|
221
|
+
done
|
|
222
|
+
IFS=';' read -r MOUSE_BTN MOUSE_X MOUSE_Y <<< "$seq" || true
|
|
223
|
+
MOUSE_EVENT=1
|
|
224
|
+
return 0
|
|
225
|
+
else
|
|
226
|
+
KEY=$'\e['"$c2"; return 0
|
|
227
|
+
fi
|
|
228
|
+
elif [ "$c1" = "O" ]; then
|
|
229
|
+
IFS= read -rsn1 -t "$_T" c2 2>/dev/null || { KEY="ESC"; return 0; }
|
|
230
|
+
KEY=$'\eO'"$c2"; return 0
|
|
231
|
+
else
|
|
232
|
+
KEY="ESC"; return 0
|
|
233
|
+
fi
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# Command bar: clickable actions shown below the profile list.
|
|
237
|
+
local cmds=("list" "status" "add" "doctor" "help" "quit")
|
|
238
|
+
local cmd_actions=("list" "status" "add" "doctor" "help" "quit")
|
|
239
|
+
local ncmds=${#cmds[@]}
|
|
240
|
+
local cmd_pos=() cpos=3 ci
|
|
241
|
+
for ci in "${!cmds[@]}"; do
|
|
242
|
+
local clen=$(( ${#cmds[$ci]} + 2 ))
|
|
243
|
+
cmd_pos+=("$cpos $((cpos + clen - 1))")
|
|
244
|
+
cpos=$(( cpos + clen + 1 ))
|
|
245
|
+
done
|
|
246
|
+
|
|
247
|
+
# ── ASCII banner ──
|
|
248
|
+
_banner(){
|
|
249
|
+
cat <<'BANNER'
|
|
250
|
+
___ ___ ___ ___ _ _ ___ _
|
|
251
|
+
/ __| \| _ \/ __| | | | ___ | __|_ _ ___ _(_)_ __
|
|
252
|
+
| (__| |) | / (__ | | |/ _ \| _|| '_/ _ \ |_| \ \ /
|
|
253
|
+
\___|___/|_|_\\___| |_|_|\___/|___|_| \___/_(_)_\_\
|
|
254
|
+
BANNER
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
# ── Spinner frames for loading animation ──
|
|
258
|
+
local spin_frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
|
259
|
+
|
|
260
|
+
# ── Decorative separators ──
|
|
261
|
+
_sep(){
|
|
262
|
+
local w=$1 char="${2:-─}"
|
|
263
|
+
local s=""
|
|
264
|
+
while [ ${#s} -lt "$w" ]; do s+="$char"; done
|
|
265
|
+
printf '%s' "${s:0:$w}"
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Layout rows (1-based):
|
|
269
|
+
# 1: banner line 1 2: banner line 2 3: banner line 3 4: banner line 4
|
|
270
|
+
# 5: subtitle/cur 6: blank 7: section label
|
|
271
|
+
# 8..8+rows-1: profiles
|
|
272
|
+
# 8+rows: blank 9+rows: sep 10+rows: command bar
|
|
273
|
+
# 11+rows: blank 12+rows: help line 1 13+rows: help line 2
|
|
274
|
+
local sec_row=7
|
|
275
|
+
local prof_start=8
|
|
276
|
+
local cmd_row=$(( 8 + rows + 2 ))
|
|
277
|
+
|
|
278
|
+
_draw(){
|
|
279
|
+
printf '\e[H\e[2J'
|
|
280
|
+
# ASCII banner (gradient cyan → dim)
|
|
281
|
+
printf '\e[38;5;44m%s\e[0m\n' "$(_banner | head -1)"
|
|
282
|
+
printf '\e[38;5;38m%s\e[0m\n' "$(_banner | sed -n 2p)"
|
|
283
|
+
printf '\e[38;5;31m%s\e[0m\n' "$(_banner | sed -n 3p)"
|
|
284
|
+
printf '\e[38;5;24m%s\e[0m\n' "$(_banner | sed -n 4p)"
|
|
285
|
+
# Subtitle + current
|
|
286
|
+
printf ' \e[2m选择 Codex 配置\e[0m'
|
|
287
|
+
[ -n "$cur" ] && printf ' \e[2m●\e[0m 当前: \e[1;33m%s\e[0m' "$cur"
|
|
288
|
+
printf '\n'
|
|
289
|
+
printf '\n'
|
|
290
|
+
# Section label
|
|
291
|
+
printf ' \e[2m╭%s╮\e[0m\n' "$(_sep 32)"
|
|
292
|
+
printf ' \e[2m│\e[0m \e[1;36mPROFILES\e[0m%*s\e[2m│\e[0m\n' 23 ""
|
|
293
|
+
printf ' \e[2m╰%s╯\e[0m\n' "$(_sep 32)"
|
|
294
|
+
# Profile list — first item at row prof_start (8)
|
|
295
|
+
local j n info dot marker body
|
|
296
|
+
for j in "${!profiles[@]}"; do
|
|
297
|
+
n="${profiles[$j]}"; info=$(_info "$n")
|
|
298
|
+
dot=' '; [ "$n" = "$cur" ] && dot='\e[1;33m●\e[0m'
|
|
299
|
+
[ "$j" -eq "$sel" ] && marker='\e[1;36m▶\e[0m' || marker=' '
|
|
300
|
+
if [ "$j" -eq "$sel" ]; then
|
|
301
|
+
printf ' %b \e[7m %-12s %s \e[0m\n' "$marker" "$n" "$info"
|
|
302
|
+
else
|
|
303
|
+
printf ' %b \e[2m%-12s\e[0m \e[2m%s\e[0m\n' "$marker" "$n" "$info"
|
|
304
|
+
fi
|
|
305
|
+
done
|
|
306
|
+
printf '\n'
|
|
307
|
+
# Command bar separator
|
|
308
|
+
printf ' \e[2m%s\e[0m\n' "$(_sep $((cols-4)) ─)"
|
|
309
|
+
# Command bar — clickable buttons with icons
|
|
310
|
+
local ci btn icon
|
|
311
|
+
printf ' '
|
|
312
|
+
for ci in "${!cmds[@]}"; do
|
|
313
|
+
btn="${cmds[$ci]}"
|
|
314
|
+
case "$btn" in
|
|
315
|
+
list) icon='☰' ;;
|
|
316
|
+
status) icon='◉' ;;
|
|
317
|
+
add) icon='✚' ;;
|
|
318
|
+
doctor) icon='✕' ;;
|
|
319
|
+
help) icon='?' ;;
|
|
320
|
+
quit) icon='⏻' ;;
|
|
321
|
+
*) icon=' ' ;;
|
|
322
|
+
esac
|
|
323
|
+
if [ "$ci" -eq "$cmd_sel" ] && [ "$focus" = "cmds" ]; then
|
|
324
|
+
printf '\e[1;7;36m %s %s \e[0m ' "$icon" "$btn"
|
|
325
|
+
else
|
|
326
|
+
printf '\e[36m%s[%s]\e[0m ' "$icon" "$btn"
|
|
327
|
+
fi
|
|
328
|
+
done
|
|
329
|
+
printf '\n'
|
|
330
|
+
printf '\n'
|
|
331
|
+
# Help lines
|
|
332
|
+
if [ "$focus" = "profiles" ]; then
|
|
333
|
+
printf ' \e[1;36m▶\e[0m \e[2m↑/↓ j/k 移动 · Enter 切换 · 1-9 直选 · Tab 切到命令栏 · q 取消\e[0m\n'
|
|
334
|
+
else
|
|
335
|
+
printf ' \e[2m↑/↓ j/k 移动 · Enter 切换 · 1-9 直选 ·\e[0m \e[1;36mTab 切到命令栏\e[0m \e[2m· q 取消\e[0m\n'
|
|
336
|
+
fi
|
|
337
|
+
printf ' \e[2m🖱 点击/滚轮选择 · 点击命令执行 · 选中自动复制到剪贴板\e[0m\n'
|
|
338
|
+
[ -n "$clip_msg" ] && printf '\n \e[32m✓ %s\e[0m\n' "$clip_msg"
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
# ── Boot animation: spinner + banner reveal ──
|
|
342
|
+
_boot_anim(){
|
|
343
|
+
local i frame
|
|
344
|
+
printf '\e[H\e[2J'
|
|
345
|
+
for i in 0 1 2 3 4 5 6 7 8 9; do
|
|
346
|
+
frame="${spin_frames[$((i % 10))]}"
|
|
347
|
+
printf '\e[H'
|
|
348
|
+
printf '\n\n \e[36m%s\e[0m \e[2mloading profiles...\e[0m\n\n' "$frame"
|
|
349
|
+
printf ' \e[2m%s\e[0m\n' "$(_sep 40 ─)"
|
|
350
|
+
printf '\n'
|
|
351
|
+
# Reveal banner lines progressively
|
|
352
|
+
if [ "$i" -ge 1 ]; then printf ' \e[38;5;44m%s\e[0m\n' "$(_banner | head -1)"; fi
|
|
353
|
+
if [ "$i" -ge 3 ]; then printf ' \e[38;5;38m%s\e[0m\n' "$(_banner | sed -n 2p)"; fi
|
|
354
|
+
if [ "$i" -ge 5 ]; then printf ' \e[38;5;31m%s\e[0m\n' "$(_banner | sed -n 3p)"; fi
|
|
355
|
+
if [ "$i" -ge 7 ]; then printf ' \e[38;5;24m%s\e[0m\n' "$(_banner | sed -n 4p)"; fi
|
|
356
|
+
printf '\e[?25l'
|
|
357
|
+
sleep 0.06
|
|
358
|
+
done
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
# Change selection + copy to clipboard + redraw.
|
|
362
|
+
_move_sel(){
|
|
363
|
+
sel=$1
|
|
364
|
+
_copy "${profiles[$sel]}"
|
|
365
|
+
clip_msg="已复制: ${profiles[$sel]}"
|
|
366
|
+
_draw
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
# Run a command from the command bar. Exits TUI first, then dispatches.
|
|
370
|
+
# Return 0 means the picker should return (caller exits). Return 1 means
|
|
371
|
+
# the command was informational and we should re-enter the TUI.
|
|
372
|
+
_run_cmd(){
|
|
373
|
+
local action="$1"
|
|
374
|
+
_cleanup_tui; trap - INT TERM
|
|
375
|
+
printf '\n'
|
|
376
|
+
case "$action" in
|
|
377
|
+
list) list; printf '\n'; return 1 ;;
|
|
378
|
+
status) status; printf '\n'; return 1 ;;
|
|
379
|
+
add) add; printf '\n'; return 1 ;;
|
|
380
|
+
doctor) doctor; printf '\n'; return 1 ;;
|
|
381
|
+
help) sed -n '2,32p' "$0"; printf '\n'; return 1 ;;
|
|
382
|
+
quit) printf '已取消。\n'; return 0 ;;
|
|
383
|
+
esac
|
|
384
|
+
return 1
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
# Enter full-screen mode.
|
|
388
|
+
printf '\e[?1049h'
|
|
389
|
+
printf '\e[?1002h\e[?1006h'
|
|
390
|
+
tput civis 2>/dev/null || true
|
|
391
|
+
# Boot animation
|
|
392
|
+
_boot_anim
|
|
393
|
+
_copy "${profiles[$sel]}"
|
|
394
|
+
clip_msg="已复制: ${profiles[$sel]}"
|
|
395
|
+
_draw
|
|
396
|
+
|
|
397
|
+
while true; do
|
|
398
|
+
_read_input
|
|
399
|
+
# Mouse events: SGR mode reports \e[<btn;x;yM
|
|
400
|
+
if [ "$MOUSE_EVENT" = 1 ]; then
|
|
401
|
+
case "$MOUSE_BTN" in
|
|
402
|
+
0|1|2) # left click — profiles start at row prof_start (8)
|
|
403
|
+
local row=$(( MOUSE_Y - prof_start ))
|
|
404
|
+
if [ "$row" -ge 0 ] && [ "$row" -lt "$rows" ]; then
|
|
405
|
+
_move_sel "$row"
|
|
406
|
+
elif [ "$MOUSE_Y" -eq "$cmd_row" ]; then
|
|
407
|
+
# Click on the command bar — hit-test each button
|
|
408
|
+
local ci hit=0
|
|
409
|
+
for ci in "${!cmds[@]}"; do
|
|
410
|
+
local pos="${cmd_pos[$ci]}"
|
|
411
|
+
local start="${pos%% *}" end="${pos##* }"
|
|
412
|
+
if [ "$MOUSE_X" -ge "$start" ] && [ "$MOUSE_X" -le "$end" ]; then
|
|
413
|
+
cmd_sel=$ci; focus="cmds"
|
|
414
|
+
_draw
|
|
415
|
+
_run_cmd "${cmd_actions[$ci]}" || return 0
|
|
416
|
+
printf '\e[?1049h'; tput civis 2>/dev/null || true; _draw
|
|
417
|
+
hit=1; break
|
|
418
|
+
fi
|
|
419
|
+
done
|
|
420
|
+
fi
|
|
421
|
+
;;
|
|
422
|
+
64) _move_sel $(( (sel-1+rows) % rows )) ;; # wheel up
|
|
423
|
+
65) _move_sel $(( (sel+1) % rows )) ;; # wheel down
|
|
424
|
+
esac
|
|
425
|
+
continue
|
|
426
|
+
fi
|
|
427
|
+
# Keyboard
|
|
428
|
+
case "$KEY" in
|
|
429
|
+
$'\e[A'|$'\eOA')
|
|
430
|
+
if [ "$focus" = "profiles" ]; then _move_sel $(( (sel-1+rows) % rows ))
|
|
431
|
+
else cmd_sel=$(( (cmd_sel-1+ncmds) % ncmds )); _draw; fi ;;
|
|
432
|
+
$'\e[B'|$'\eOB')
|
|
433
|
+
if [ "$focus" = "profiles" ]; then _move_sel $(( (sel+1) % rows ))
|
|
434
|
+
else cmd_sel=$(( (cmd_sel+1) % ncmds )); _draw; fi ;;
|
|
435
|
+
$'\e[C'|$'\eOC') # right arrow — move focus to command bar, or right within it
|
|
436
|
+
if [ "$focus" = "profiles" ]; then focus="cmds"; _draw
|
|
437
|
+
else cmd_sel=$(( (cmd_sel+1) % ncmds )); _draw; fi ;;
|
|
438
|
+
$'\e[D'|$'\eOD') # left arrow
|
|
439
|
+
if [ "$focus" = "cmds" ] && [ "$cmd_sel" -eq 0 ]; then focus="profiles"; _draw
|
|
440
|
+
elif [ "$focus" = "cmds" ]; then cmd_sel=$(( (cmd_sel-1+ncmds) % ncmds )); _draw
|
|
441
|
+
fi ;;
|
|
442
|
+
k|K) _move_sel $(( (sel-1+rows) % rows )) ;;
|
|
443
|
+
j|J) _move_sel $(( (sel+1) % rows )) ;;
|
|
444
|
+
h|H) # vim-style left
|
|
445
|
+
if [ "$focus" = "cmds" ] && [ "$cmd_sel" -eq 0 ]; then focus="profiles"; _draw
|
|
446
|
+
elif [ "$focus" = "cmds" ]; then cmd_sel=$(( (cmd_sel-1+ncmds) % ncmds )); _draw
|
|
447
|
+
fi ;;
|
|
448
|
+
l|L) # vim-style right
|
|
449
|
+
if [ "$focus" = "profiles" ]; then focus="cmds"; _draw
|
|
450
|
+
else cmd_sel=$(( (cmd_sel+1) % ncmds )); _draw; fi ;;
|
|
451
|
+
$'\t') # Tab — toggle focus between profiles and command bar
|
|
452
|
+
if [ "$focus" = "profiles" ]; then focus="cmds"; else focus="profiles"; fi
|
|
453
|
+
_draw ;;
|
|
454
|
+
[1-9])
|
|
455
|
+
local k="$KEY"
|
|
456
|
+
if [ "$k" -ge 1 ] 2>/dev/null && [ "$k" -le "$rows" ]; then
|
|
457
|
+
sel=$((k-1)); break
|
|
458
|
+
fi
|
|
459
|
+
;;
|
|
460
|
+
'')
|
|
461
|
+
if [ "$focus" = "profiles" ]; then break # Enter — confirm selection
|
|
462
|
+
else _run_cmd "${cmd_actions[$cmd_sel]}" || return 0
|
|
463
|
+
printf '\e[?1049h'; tput civis 2>/dev/null || true; _draw
|
|
464
|
+
fi ;;
|
|
465
|
+
q|Q|ESC)
|
|
466
|
+
_cleanup_tui; trap - INT TERM
|
|
467
|
+
printf '已取消。\n'; return 0
|
|
468
|
+
;;
|
|
469
|
+
esac
|
|
470
|
+
done
|
|
471
|
+
|
|
472
|
+
_cleanup_tui; trap - INT TERM
|
|
473
|
+
local chosen="${profiles[$sel]}"
|
|
474
|
+
printf '\n'
|
|
475
|
+
if [ "$chosen" = "$cur" ]; then printf '仍是当前配置:%s(未改动)。\n' "$chosen"; return 0; fi
|
|
476
|
+
use "$chosen"
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
# Interactive wizard: create a new custom API-key profile + its config.toml provider table.
|
|
480
|
+
add(){
|
|
481
|
+
local name="${1:-}" reserved=" list ls status st pick menu i p use add new official help doctor fix "
|
|
482
|
+
while :; do
|
|
483
|
+
[ -n "$name" ] || read -r -p "新 profile 名称 (字母/数字/-/_): " name
|
|
484
|
+
if ! [[ "$name" =~ ^[A-Za-z0-9_-]+$ ]]; then echo " ✗ 只能含字母、数字、- 、_"; name=""; continue; fi
|
|
485
|
+
if [[ "$reserved" == *" $name "* ]]; then echo " ✗ '$name' 是保留字,换一个"; name=""; continue; fi
|
|
486
|
+
break
|
|
487
|
+
done
|
|
488
|
+
local d="$PROFILES/$name"
|
|
489
|
+
if [ -d "$d" ]; then
|
|
490
|
+
read -r -p "profile '$name' 已存在,覆盖? [y/N] " yn
|
|
491
|
+
[[ "$yn" =~ ^[Yy]$ ]] || { echo "已取消。"; return 0; }
|
|
492
|
+
fi
|
|
493
|
+
|
|
494
|
+
local label base wire model key
|
|
495
|
+
read -r -p "显示名 (provider name=, 默认 $name): " label; label="${label:-$name}"
|
|
496
|
+
read -r -p "base_url (如 https://anyrouter.top/v1): " base
|
|
497
|
+
base="${base%/}"; [ -n "$base" ] || { echo "✗ base_url 不能为空"; return 1; }
|
|
498
|
+
read -r -p "wire_api (默认 responses;新版 Codex 仅支持 responses): " wire; wire="${wire:-responses}"
|
|
499
|
+
if [ "$wire" != "responses" ]; then echo " ⚠️ Codex 已废弃 '$wire'(见 codex#7782),强制用 responses"; wire="responses"; fi
|
|
500
|
+
read -r -p "模型 model (如 gpt-5.5): " model; [ -n "$model" ] || { echo "✗ model 不能为空"; return 1; }
|
|
501
|
+
read -r -p "API key (sk-...): " key; [ -n "$key" ] || { echo "✗ key 不能为空"; return 1; }
|
|
502
|
+
|
|
503
|
+
mkdir -p "$d"
|
|
504
|
+
{ echo "MODE=apikey"; echo "MODEL=$model"; echo "PROVIDER=$name"; } > "$d/provider.env"
|
|
505
|
+
printf '{\n "OPENAI_API_KEY": "%s",\n "auth_mode": "apikey"\n}\n' "$key" > "$d/auth.json"
|
|
506
|
+
chmod 600 "$d/auth.json"
|
|
507
|
+
|
|
508
|
+
cp -p "$CONFIG" "$CONFIG.bak.$(date +%Y%m%d-%H%M%S)"
|
|
509
|
+
ls -t "$CONFIG".bak.* 2>/dev/null | tail -n +11 | xargs -r rm -f
|
|
510
|
+
if grep -q "^\[model_providers\.$name\]" "$CONFIG"; then
|
|
511
|
+
echo " config.toml 已有 [model_providers.$name],沿用现有端点(改 base_url 去那里改)。"
|
|
512
|
+
else
|
|
513
|
+
{ echo ""; echo "[model_providers.$name]"; echo "name = \"$label\""
|
|
514
|
+
echo "base_url = \"$base\""; echo "wire_api = \"$wire\""; } >> "$CONFIG"
|
|
515
|
+
echo " ✓ 已写入 [model_providers.$name] 到 config.toml"
|
|
516
|
+
fi
|
|
517
|
+
echo "✓ 已创建 profile: $name (model=$model · $base · $wire)"
|
|
518
|
+
|
|
519
|
+
read -r -p "用 GET $base/models 验证 key? [Y/n] " v
|
|
520
|
+
if ! [[ "$v" =~ ^[Nn]$ ]]; then
|
|
521
|
+
local code; code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "$base/models" -H "Authorization: Bearer $key" 2>/dev/null || echo ERR)
|
|
522
|
+
case "$code" in
|
|
523
|
+
200) echo " ✅ key 有效 (HTTP 200)" ;;
|
|
524
|
+
401|403) echo " ❌ 鉴权失败 (HTTP $code) — key 可能不对" ;;
|
|
525
|
+
*) echo " ⚠️ HTTP $code(端点未必支持 /models,不代表 key 无效;可之后 codex exec 实测)" ;;
|
|
526
|
+
esac
|
|
527
|
+
fi
|
|
528
|
+
|
|
529
|
+
read -r -p "现在就切到 $name? [y/N] " s
|
|
530
|
+
if [[ "$s" =~ ^[Yy]$ ]]; then echo; use "$name"; else echo "之后用: switch $name"; fi
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
cmd="${1:-status}"
|
|
534
|
+
case "$cmd" in
|
|
535
|
+
list|ls) list ;;
|
|
536
|
+
status|st|"") status ;;
|
|
537
|
+
pick|menu|i|-i|p) pick ;;
|
|
538
|
+
add|new) shift; add "${1:-}" ;;
|
|
539
|
+
doctor|fix) doctor ;;
|
|
540
|
+
use) shift; use "${1:?profile name required}" ;;
|
|
541
|
+
-h|--help|help) sed -n '2,32p' "$0" ;;
|
|
542
|
+
*) if [ -d "$PROFILES/$cmd" ]; then use "$cmd"
|
|
543
|
+
else echo "unknown: $cmd"; echo; list
|
|
544
|
+
echo; echo "usage: codex-switch [pick|add|list|status|doctor|<profile>]"; exit 1; fi ;;
|
|
545
|
+
esac
|
package/csw
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# csw — short CLI for Codex-Switch-Helper (package: codex-switch-helper).
|
|
3
|
+
# csw -> interactive arrow-key picker
|
|
4
|
+
# csw <profile> -> switch directly (e.g. csw official)
|
|
5
|
+
# csw add [name] -> wizard to create a new API-key profile
|
|
6
|
+
# csw list|status|doctor
|
|
7
|
+
#
|
|
8
|
+
# Resolves through symlinks so this works for both:
|
|
9
|
+
# - npm install -g (bin symlink into global prefix)
|
|
10
|
+
# - ./install.sh (symlink into PATH)
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
resolve_dir() {
|
|
14
|
+
local src="$1"
|
|
15
|
+
while [ -L "$src" ]; do
|
|
16
|
+
local dir link
|
|
17
|
+
dir="$(cd -P "$(dirname "$src")" && pwd)"
|
|
18
|
+
link="$(readlink "$src")"
|
|
19
|
+
case "$link" in
|
|
20
|
+
/*) src="$link" ;;
|
|
21
|
+
*) src="$dir/$link" ;;
|
|
22
|
+
esac
|
|
23
|
+
done
|
|
24
|
+
cd -P "$(dirname "$src")" && pwd
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
here="$(resolve_dir "${BASH_SOURCE[0]}")"
|
|
28
|
+
exec "$here/codex-switch" "${1:-pick}" "${@:2}"
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codex-switch-helper",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Codex-Switch-Helper: one-command switch between official ChatGPT login and third-party API-key providers for OpenAI Codex (zero deps, pure bash)",
|
|
5
|
+
"bin": {
|
|
6
|
+
"csw": "./csw"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"csw",
|
|
10
|
+
"codex-switch",
|
|
11
|
+
"examples",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"prepublishOnly": "chmod +x csw codex-switch"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"codex",
|
|
19
|
+
"openai",
|
|
20
|
+
"cli",
|
|
21
|
+
"provider",
|
|
22
|
+
"switch",
|
|
23
|
+
"api-key",
|
|
24
|
+
"csw"
|
|
25
|
+
],
|
|
26
|
+
"author": "VicZhang6 <realviczhang@gmail.com>",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/VicZhang6/codex-switch.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/VicZhang6/codex-switch/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/VicZhang6/codex-switch#readme",
|
|
36
|
+
"os": [
|
|
37
|
+
"darwin",
|
|
38
|
+
"linux"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=14"
|
|
42
|
+
},
|
|
43
|
+
"preferGlobal": true
|
|
44
|
+
}
|