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 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}"
@@ -0,0 +1,4 @@
1
+ {
2
+ "OPENAI_API_KEY": "sk-REPLACE_WITH_YOUR_KEY",
3
+ "auth_mode": "apikey"
4
+ }
@@ -0,0 +1,5 @@
1
+ # Example API-key profile. Copy to ~/.codex/profiles/<name>/provider.env
2
+ # (or just run `switch add` which writes this for you).
3
+ MODE=apikey
4
+ MODEL=gpt-5.5
5
+ PROVIDER=anyrouter # MUST equal the [model_providers.<name>] key in config.toml
@@ -0,0 +1,4 @@
1
+ # The 'official' profile = your original ChatGPT login (OAuth).
2
+ # Its auth.json is a snapshot of your ~/.codex/auth.json (OAuth tokens) — NOT in this repo.
3
+ MODE=chatgpt
4
+ MODEL=gpt-5.5
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
+ }