@cc-x/cc-x 0.4.6 → 0.4.7

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.en.md CHANGED
@@ -1,25 +1,44 @@
1
- # ccx
1
+ <h1 align="center">CC-X</h1>
2
+
3
+ <p align="center">
4
+ <strong>No config files · Process isolation · Parallel terminals · Zero deps</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://github.com/becomeless/cc-x/releases/latest"><img src="https://img.shields.io/github/v/release/becomeless/cc-x?style=flat-square&color=blue" alt="version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/becomeless/cc-x?style=flat-square" alt="license"></a>
10
+ <a href="https://github.com/becomeless/cc-x/releases/latest"><img src="https://img.shields.io/github/downloads/becomeless/cc-x/total?style=flat-square&color=success" alt="downloads"></a>
11
+ <a href="https://github.com/becomeless/cc-x/actions"><img src="https://img.shields.io/github/actions/workflow/status/becomeless/cc-x/release.yml?style=flat-square&label=build" alt="build"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="README.md">🇨🇳 中文</a> · <a href="README.en.md">🇺🇸 English</a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="#install">Install</a> · <a href="#60-second-quick-start">Quick Start</a> · <a href="#two-modes-the-key-concept">Concepts</a> · <a href="#configuration">Config</a> · <a href="#faq">FAQ</a>
20
+ </p>
21
+
22
+ ---
2
23
 
3
24
  > `xx` — one command to switch Claude Code between APIs. **Zero config risk.**
4
- >
5
- > [简体中文](README.md) | English
6
25
 
7
26
  Switching Claude Code between the official account and third-party APIs means juggling
8
- environment variables — or trusting a tool that rewrites your Claude config. ccx takes a
27
+ environment variables — or trusting a tool that rewrites your Claude config. CC-X takes a
9
28
  different path: **switching happens purely at the environment-variable layer.** It never
10
29
  reads or writes any Claude Code config file. Your MCP, plugins, hooks — it won't touch them.
11
30
 
12
31
  ```text
13
- cc-x v0.4.6 · Claude Code API switcher (default = used by bare `claude` in new terminals)
32
+ CC-X v0.4.7 · Claude Code API switcher Default: Official
14
33
 
15
34
  ▶ Official (default)[Logged in]
16
- DeepSeek [Key set] — work
17
- 智谱GLM [No key]
18
- 小米MiMo [No key]
35
+ DeepSeek [Key set] — work
36
+ Zhipu GLM [No key]
37
+ Xiaomi MiMo [No key]
19
38
 
20
- New profile · 切换到中文 · Update check: off · Exit
39
+ New profile · Switch to 中文 · Update check: off · Exit
21
40
 
22
- ↑↓ move · Enter open · Shift+↑↓ reorder · q quit
41
+ ↑↓ move · Enter open · e edit · s session · d set-default · Shift+↑↓ reorder · q quit
23
42
  ```
24
43
 
25
44
  > **Two builds**: the **native Go build** is recommended — GitHub Releases provide a lightweight
@@ -32,6 +51,8 @@ reads or writes any Claude Code config file. Your MCP, plugins, hooks — it won
32
51
 
33
52
  > Install [Claude Code](https://claude.ai/code) first (`claude` on PATH). **Open a new terminal** after installing.
34
53
 
54
+ ### Step 1 · Install CC-X
55
+
35
56
  **Windows (native, recommended)**
36
57
 
37
58
  ```powershell
@@ -55,6 +76,19 @@ it prints a hint (the Unix installer deliberately doesn't edit your shell config
55
76
  npm install -g @cc-x/cc-x
56
77
  ```
57
78
 
79
+ ### Step 2 · Configure your API key
80
+
81
+ ```bash
82
+ xx # First run seeds 4 presets — pick one, edit, paste your key
83
+ ```
84
+
85
+ ### Step 3 · Start using it
86
+
87
+ ```bash
88
+ xx DeepSeek -s # Use this session, launch Claude now
89
+ xx DeepSeek # Set as default for new terminals
90
+ ```
91
+
58
92
  ---
59
93
 
60
94
  ## 60-second quick start
@@ -79,7 +113,7 @@ xx --help # all options
79
113
 
80
114
  ## Two modes (the key concept)
81
115
 
82
- Which API Claude uses is decided by **environment variables**. ccx offers two scopes:
116
+ Which API Claude uses is decided by **environment variables**. CC-X offers two scopes:
83
117
 
84
118
  | | Use this session (`-s`) | Set default |
85
119
  |---|---|---|
@@ -88,8 +122,11 @@ Which API Claude uses is decided by **environment variables**. ccx offers two sc
88
122
  | Running sessions | Unaffected | Unaffected (env freezes at process start) |
89
123
  | Best for | Parallel terminals on different APIs | Set your daily-driver API once |
90
124
 
91
- **Parallel example**: open 4 terminals and run `xx Official -s`, `xx DeepSeek -s`, `xx 智谱GLM -s`,
92
- `xx 小米MiMo -s`four Claudes running at once, each on its own API, zero interference.
125
+ > 💡 **Analogy**: "Use this session" is a quick oil change just for this trip. "Set default" is
126
+ > refilling the tank every new drive uses it from now on.
127
+
128
+ **Parallel example**: open 4 terminals and run `xx Official -s`, `xx DeepSeek -s`, `xx "Zhipu GLM" -s`,
129
+ `xx "Xiaomi MiMo" -s` — four Claudes running at once, each on its own API, zero interference.
93
130
 
94
131
  **Why not a global config file?** `settings.json` is shared globally; editing it hits running
95
132
  sessions (classic symptom: another terminal suddenly says `cannot be parsed as a URL`).
@@ -97,11 +134,21 @@ Environment variables are naturally process-isolated.
97
134
 
98
135
  ---
99
136
 
100
- ## ccx vs cc-switch
137
+ ## When CC-X is NOT the right tool
138
+
139
+ - You need to manage MCP, hooks, plugins, or multiple CLIs → use [cc-switch](https://github.com/farion1231/cc-switch)
140
+ - You only use the official API, never switch → you don't need CC-X
141
+ - You want automatic config migration/backup → that's outside CC-X's scope
142
+
143
+ CC-X cares more about boundaries than features. It does one thing: **switch APIs**.
144
+
145
+ ---
146
+
147
+ ## CC-X vs cc-switch
101
148
 
102
- cc-switch is an excellent full-featured GUI; ccx takes the opposite, minimal approach.
149
+ cc-switch is an excellent full-featured GUI; CC-X takes the opposite, minimal approach.
103
150
 
104
- | | ccx (`xx`) | cc-switch |
151
+ | | CC-X (`xx`) | cc-switch |
105
152
  |---|---|---|
106
153
  | Form | Terminal command (lightweight) | Desktop GUI (full-featured) |
107
154
  | Scope | Just API switching | API + MCP + multiple CLIs + prompts… |
@@ -109,18 +156,18 @@ cc-switch is an excellent full-featured GUI; ccx takes the opposite, minimal app
109
156
  | Can lose MCP? | **Physically impossible** | Users have reported it |
110
157
  | Parallel terminals | **Native** (process isolation) | Global switch; sessions can clash |
111
158
 
112
- - → **ccx**: terminal natives, parallel-session runners, anyone burned by a config-wrecking switcher, "just switch the API" people
159
+ - → **CC-X**: terminal natives, parallel-session runners, anyone burned by a config-wrecking switcher, "just switch the API" people
113
160
  - → **cc-switch**: GUI preference, all-in-one MCP + multi-CLI management
114
161
 
115
162
  ---
116
163
 
117
164
  ## Design philosophy
118
165
 
119
- > ccx cares more about boundaries than features.
166
+ > CC-X cares more about boundaries than features.
120
167
 
121
- Claude Code already has its own config system, MCP ecosystem, and session state. ccx is not trying to become a control panel above it, or to copy user config into another database. It stands at one narrow point before Claude Code starts: prepare the 7 managed environment variables, then let Claude Code run.
168
+ Claude Code already has its own config system, MCP ecosystem, and session state. CC-X is not trying to become a control panel above it, or to copy user config into another database. It stands at one narrow point before Claude Code starts: prepare the 7 managed environment variables, then let Claude Code run.
122
169
 
123
- That constraint is deliberate: no writes to Claude Code config files, no MCP management, no automatic migration, no resident background controller. If process environment variables can solve it, ccx avoids global files; if a choice matters, the user makes it explicitly. Doing less keeps the failure surface small.
170
+ That constraint is deliberate: no writes to Claude Code config files, no MCP management, no automatic migration, no resident background controller. If process environment variables can solve it, CC-X avoids global files; if a choice matters, the user makes it explicitly. Doing less keeps the failure surface small.
124
171
 
125
172
  Issues / PRs are welcome, but the direction is clear: **make switching steadier, clearer, and less intrusive** before adding broader management power. Anything that writes a Claude Code config file will not be accepted.
126
173
 
@@ -140,7 +187,7 @@ Issues / PRs are welcome, but the direction is clear: **make switching steadier,
140
187
  | haiku → model | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | |
141
188
  | effort level | `CLAUDE_CODE_EFFORT_LEVEL` | `low`–`max`; `auto` = model default; empty = unset. Third parties may not honor it |
142
189
 
143
- > ccx **deliberately does not set** `ANTHROPIC_MODEL`. Use `/model opus|sonnet|haiku` in-session;
190
+ > CC-X **deliberately does not set** `ANTHROPIC_MODEL`. Use `/model opus|sonnet|haiku` in-session;
144
191
  > the mapping table translates to the provider's real model name.
145
192
 
146
193
  ### Auth field: AUTH_TOKEN vs API_KEY
@@ -186,7 +233,7 @@ Issues / PRs are welcome, but the direction is clear: **make switching steadier,
186
233
  - Same semantics either way: **only affects new terminals**; switching to "Official" clears all managed vars
187
234
  - **No Claude Code config file is ever modified.**
188
235
 
189
- ccx only touches these 7 "managed" variables (and clears the ones a target profile doesn't use):
236
+ CC-X only touches these 7 "managed" variables (and clears the ones a target profile doesn't use):
190
237
  `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_OPUS_MODEL`,
191
238
  `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `CLAUDE_CODE_EFFORT_LEVEL`.
192
239
 
package/README.md CHANGED
@@ -1,15 +1,34 @@
1
- # ccx
1
+ <h1 align="center">CC-X</h1>
2
+
3
+ <p align="center">
4
+ <strong>不碰配置 · 进程隔离 · 多端并行 · 零依赖</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://github.com/becomeless/cc-x/releases/latest"><img src="https://img.shields.io/github/v/release/becomeless/cc-x?style=flat-square&color=blue" alt="version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/becomeless/cc-x?style=flat-square" alt="license"></a>
10
+ <a href="https://github.com/becomeless/cc-x/releases/latest"><img src="https://img.shields.io/github/downloads/becomeless/cc-x/total?style=flat-square&color=success" alt="downloads"></a>
11
+ <a href="https://github.com/becomeless/cc-x/actions"><img src="https://img.shields.io/github/actions/workflow/status/becomeless/cc-x/release.yml?style=flat-square&label=build" alt="build"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="README.md">🇨🇳 中文</a> · <a href="README.en.md">🇺🇸 English</a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="#安装">安装</a> · <a href="#60-秒上手">上手</a> · <a href="#两种模式核心概念">概念</a> · <a href="#配置说明">配置</a> · <a href="#faq">FAQ</a>
20
+ </p>
21
+
22
+ ---
2
23
 
3
24
  > `xx` — Claude Code 多 API 切换,一个命令搞定。**不碰配置,不怕翻车。**
4
- >
5
- > 简体中文 | [English](README.en.md)
6
25
 
7
26
  用 Claude Code 连第三方 API?每次手打环境变量太烦,换工具切又怕弄丢 MCP。
8
- ccx 把这事儿做到了最简——切换只在环境变量层,**不读写任何 Claude Code 配置文件**。
27
+ CC-X 把这事儿做到了最简——切换只在环境变量层,**不读写任何 Claude Code 配置文件**。
9
28
  你的 MCP、插件、hooks,它碰都不会碰。
10
29
 
11
30
  ```text
12
- cc-x v0.4.6 · Claude Code API 切换器 (默认 = 新终端裸敲 claude 用的)
31
+ CC-X v0.4.7 · Claude Code API 切换器 默认:官方
13
32
 
14
33
  ▶ 官方 (默认)[登录态]
15
34
  DeepSeek [密钥已设] — 公司
@@ -18,7 +37,7 @@ ccx 把这事儿做到了最简——切换只在环境变量层,**不读写
18
37
 
19
38
  新增配置 · 切换到 English · 更新检查:关闭 · 退出
20
39
 
21
- ↑↓ 选择 · Enter 进入 · Shift+↑↓ 排序 · q 退出
40
+ ↑↓ 选择 · Enter 进入 · e 编辑 · s 启动 · d 设默认 · Shift+↑↓ 排序 · q 退出
22
41
  ```
23
42
 
24
43
  > **两个版本**:推荐 **Go 原生版**——GitHub Release 提供轻量 `xx` / `xx.exe`,无需 Node.js,
@@ -31,6 +50,8 @@ ccx 把这事儿做到了最简——切换只在环境变量层,**不读写
31
50
 
32
51
  > 先装好 [Claude Code](https://claude.ai/code)(`claude` 在 PATH 中)。装完**新开一个终端**。
33
52
 
53
+ ### Step 1 · 安装 CC-X
54
+
34
55
  **Windows(推荐原生版)**
35
56
 
36
57
  ```powershell
@@ -53,6 +74,19 @@ curl -fsSL https://github.com/becomeless/cc-x/releases/latest/download/install.s
53
74
  npm install -g @cc-x/cc-x
54
75
  ```
55
76
 
77
+ ### Step 2 · 配置 API 密钥
78
+
79
+ ```bash
80
+ xx # 首次运行自动生成 4 个预设,选一个 → 编辑 → 填入你的 key
81
+ ```
82
+
83
+ ### Step 3 · 开始使用
84
+
85
+ ```bash
86
+ xx DeepSeek -s # 本次启用,立即启动 Claude
87
+ xx DeepSeek # 设为默认,以后新终端自动生效
88
+ ```
89
+
56
90
  ---
57
91
 
58
92
  ## 60 秒上手
@@ -76,7 +110,7 @@ xx --help # 全部参数
76
110
 
77
111
  ## 两种模式(核心概念)
78
112
 
79
- Claude 用哪个 API 由**环境变量**决定。ccx 提供两种作用范围:
113
+ Claude 用哪个 API 由**环境变量**决定。CC-X 提供两种作用范围:
80
114
 
81
115
  | | 本次启用 (`-s`) | 设为默认 |
82
116
  |---|---|---|
@@ -85,17 +119,29 @@ Claude 用哪个 API 由**环境变量**决定。ccx 提供两种作用范围:
85
119
  | 对正在跑的会话 | 零影响 | 零影响(进程启动时已定型) |
86
120
  | 适合 | 多终端并行,各跑各的 API | 定好主力 API,不用老切 |
87
121
 
122
+ > 💡 **打个比方**:「本次启用」是临时换油——只管这一趟;「设为默认」是换了油箱里的油——以后新上车都用这个。
123
+
88
124
  **并行示例**:开 4 个终端分别 `xx 官方 -s`、`xx DeepSeek -s`、`xx 智谱GLM -s`、`xx 小米MiMo -s`——四个 Claude 同时干活、各用各的 API、互不打架。
89
125
 
90
126
  **为什么不用配置文件?** `settings.json` 全局共享,改它会波及正在跑的会话(典型症状:另一终端突然报 `cannot be parsed as a URL`)。环境变量天然进程隔离,避开了这个坑。
91
127
 
92
128
  ---
93
129
 
130
+ ## 什么时候不该用 CC-X
131
+
132
+ - 你需要管理 MCP、hooks、插件、多 CLI → 用 [cc-switch](https://github.com/farion1231/cc-switch)
133
+ - 你只用官方 API,不切第三方 → 不需要 CC-X
134
+ - 你要自动迁移/备份配置 → 不在 CC-X 范围内
135
+
136
+ CC-X 的边界比功能更重要。它只做一件事:**切 API**。
137
+
138
+ ---
139
+
94
140
  ## 和 cc-switch 怎么选
95
141
 
96
- cc-switch 是优秀的全能 GUI;ccx 走相反的极简路线。
142
+ cc-switch 是优秀的全能 GUI;CC-X 走相反的极简路线。
97
143
 
98
- | | ccx (`xx`) | cc-switch |
144
+ | | CC-X (`xx`) | cc-switch |
99
145
  |---|---|---|
100
146
  | 形态 | 终端命令(轻量) | 桌面 GUI(全能) |
101
147
  | 职责 | 只切 API | API + MCP + 多 CLI + 提示词… |
@@ -103,16 +149,16 @@ cc-switch 是优秀的全能 GUI;ccx 走相反的极简路线。
103
149
  | 能弄丢 MCP? | **不可能** | 有用户反馈被覆盖 |
104
150
  | 多终端并行 | **原生支持**(进程隔离) | 全局切换,容易互扰 |
105
151
 
106
- - → **ccx**:命令行党、常多开终端、被切配置坑过、只想要「切 API」一件事
152
+ - → **CC-X**:命令行党、常多开终端、被切配置坑过、只想要「切 API」一件事
107
153
  - → **cc-switch**:要 GUI、要一站式管 MCP 和多 CLI
108
154
 
109
155
  ---
110
156
 
111
157
  ## 设计哲学
112
158
 
113
- > ccx 的边界比功能更重要。
159
+ > CC-X 的边界比功能更重要。
114
160
 
115
- Claude Code 已经有自己的配置系统、MCP 生态和会话状态。ccx 不想再造一个“上层控制台”,也不想把用户的配置收编进自己的数据库。它只站在 Claude Code 进程启动前的那一小步:把 7 个受管环境变量准备好,然后让 Claude Code 自己工作。
161
+ Claude Code 已经有自己的配置系统、MCP 生态和会话状态。CC-X 不想再造一个"上层控制台",也不想把用户的配置收编进自己的数据库。它只站在 Claude Code 进程启动前的那一小步:把 7 个受管环境变量准备好,然后让 Claude Code 自己工作。
116
162
 
117
163
  所以它的取舍是有意的:不写 Claude Code 配置文件,不接管 MCP,不做自动迁移,不做后台常驻管理。能用进程环境变量解决,就不碰全局文件;能让用户显式选择,就不替用户自动决定。少做一点,是为了把风险面压到足够小。
118
164
 
@@ -134,7 +180,7 @@ Claude Code 已经有自己的配置系统、MCP 生态和会话状态。ccx 不
134
180
  | haiku → 模型 | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | |
135
181
  | effort 思考档 | `CLAUDE_CODE_EFFORT_LEVEL` | `low` ~ `max`;`auto`=模型默认;留空=不设。第三方不一定生效 |
136
182
 
137
- > ccx **刻意不设** `ANTHROPIC_MODEL`。在会话里用 `/model opus|sonnet|haiku` 选档,映射表负责翻译成对应供应商的模型名。
183
+ > CC-X **刻意不设** `ANTHROPIC_MODEL`。在会话里用 `/model opus|sonnet|haiku` 选档,映射表负责翻译成对应供应商的模型名。
138
184
 
139
185
  ### 认证字段:AUTH_TOKEN vs API_KEY
140
186
 
@@ -173,7 +219,7 @@ Claude Code 已经有自己的配置系统、MCP 生态和会话状态。ccx 不
173
219
  - 语义一致:**只影响新终端**;切到「官方」会清除全部受管变量
174
220
  - **不修改任何 Claude Code 配置文件。**
175
221
 
176
- ccx 只动这 7 个「受管」环境变量,切换时清掉目标不用的:
222
+ CC-X 只动这 7 个「受管」环境变量,切换时清掉目标不用的:
177
223
  `ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN`、`ANTHROPIC_API_KEY`、`ANTHROPIC_DEFAULT_OPUS_MODEL`、`ANTHROPIC_DEFAULT_SONNET_MODEL`、`ANTHROPIC_DEFAULT_HAIKU_MODEL`、`CLAUDE_CODE_EFFORT_LEVEL`。
178
224
 
179
225
  > 💡 需要改 `settings.json`?直接用 Claude Code 的 `/update-config` 说需求(如"允许 npm 命令"),比让外部工具改可靠。
package/dist/actions.js CHANGED
@@ -20,6 +20,7 @@ export function launchSession(p) {
20
20
  const res = sessionLaunch(p);
21
21
  if (res.claudeMissing) {
22
22
  console.error(` ${T('session.noClaude')}`);
23
+ console.error(` ${T('session.noClaudeHint')}`);
23
24
  process.exitCode = 1;
24
25
  return;
25
26
  }
package/dist/check.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Minimal read-only connectivity probe for a profile.
3
+ */
4
+ import { getProviderEnvMap } from './config/store.js';
5
+ import { T } from './i18n/index.js';
6
+ const TIMEOUT_MS = 5000;
7
+ /** Probe GET {base}/v1/models with the profile's configured auth. */
8
+ export async function checkProfile(p) {
9
+ const m = getProviderEnvMap(p);
10
+ const base = (m.ANTHROPIC_BASE_URL ?? '').trim();
11
+ if (!base)
12
+ return { ok: false, message: T('check.noUrl') };
13
+ const headers = authHeaders(m);
14
+ if (!headers)
15
+ return { ok: false, message: T('check.noKey') };
16
+ const controller = new AbortController();
17
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
18
+ try {
19
+ const resp = await fetch(`${base.replace(/\/+$/, '')}/v1/models`, {
20
+ method: 'GET',
21
+ headers: { ...headers, 'anthropic-version': '2023-06-01' },
22
+ signal: controller.signal,
23
+ });
24
+ return classifyHttp(resp.status);
25
+ }
26
+ catch (e) {
27
+ if (e.name === 'AbortError')
28
+ return { ok: false, message: T('check.timeout') };
29
+ const code = e.cause && typeof e.cause === 'object'
30
+ ? e.cause.code
31
+ : e.code;
32
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN')
33
+ return { ok: false, message: T('check.dns') };
34
+ return { ok: false, message: T('check.network') };
35
+ }
36
+ finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+ /** HTTP 状态码 -> 结果分层(纯函数,便于测试)。 */
41
+ export function classifyHttp(status) {
42
+ const code = String(status);
43
+ if (status >= 200 && status < 300)
44
+ return { ok: true, message: T('check.ok', code) };
45
+ if (status === 401 || status === 403)
46
+ return { ok: false, message: T('check.auth', code) };
47
+ if (status === 404)
48
+ return { ok: false, message: T('check.notFound', code) };
49
+ return { ok: false, message: T('check.http', code) };
50
+ }
51
+ export function authHeaders(m) {
52
+ const apiKey = (m.ANTHROPIC_API_KEY ?? '').trim();
53
+ if (apiKey)
54
+ return { 'x-api-key': apiKey };
55
+ const token = (m.ANTHROPIC_AUTH_TOKEN ?? '').trim();
56
+ if (token)
57
+ return { Authorization: `Bearer ${token}` };
58
+ return undefined;
59
+ }
@@ -49,5 +49,13 @@ export function resolveLang(explicit, storeLang) {
49
49
  * 其余是专有名词,原样显示 `name`。
50
50
  */
51
51
  export function providerDisplayName(p) {
52
- return isOfficial(p) ? T('provider.official') : p.name;
52
+ if (isOfficial(p))
53
+ return T('provider.official');
54
+ if (p.name === 'DeepSeek')
55
+ return T('provider.deepseek');
56
+ if (p.name === '智谱GLM')
57
+ return T('provider.zhipu');
58
+ if (p.name === '小米MiMo')
59
+ return T('provider.mimo');
60
+ return p.name;
53
61
  }
@@ -24,17 +24,25 @@ export const messages = {
24
24
  'state.noKey': { zh: '密钥未填', en: 'No key' },
25
25
  'state.apiKey': { zh: '密钥·API_KEY', en: 'Key · API_KEY' },
26
26
  'state.hasKey': { zh: '密钥已设', en: 'Key set' },
27
- // —— 供应商显示名(仅官方这种普通名词需要翻译;DeepSeek/GLM/MiMo 是专有名词不翻)——
27
+ // —— 当前终端环境(只读)——
28
+ 'terminal.current': { zh: '当前终端:{0}', en: 'Current terminal: {0}' },
29
+ 'terminal.official': { zh: '未设置 / 官方登录态', en: 'not set / official login' },
30
+ 'terminal.matched': { zh: '{0} → {1}', en: '{0} -> {1}' },
31
+ 'terminal.unmatched': { zh: '{0}(未匹配配置)', en: '{0} (no matching profile)' },
32
+ // —— 供应商显示名(只影响显示,不改变 providers.json 里的 profile key)——
28
33
  'provider.official': { zh: '官方', en: 'Official' },
34
+ 'provider.deepseek': { zh: 'DeepSeek', en: 'DeepSeek' },
35
+ 'provider.zhipu': { zh: '智谱GLM', en: 'Zhipu GLM' },
36
+ 'provider.mimo': { zh: '小米MiMo', en: 'Xiaomi MiMo' },
29
37
  // —— 菜单通用 ——
30
38
  'menu.prompt': { zh: '输入序号 (q 取消): ', en: 'Enter number (q to cancel): ' },
31
39
  'menu.mainTitle': {
32
- zh: 'cc-x v{0} · Claude Code API 切换器 (默认 = 新终端裸敲 claude 用的)',
33
- en: 'cc-x v{0} · Claude Code API switcher (default = used by bare `claude` in new terminals)',
40
+ zh: 'CC-X v{0} · Claude Code API 切换器 默认:{1}',
41
+ en: 'CC-X v{0} · Claude Code API switcher Default: {1}',
34
42
  },
35
43
  'menu.mainHint': {
36
- zh: '↑↓ 选择 · Enter 进入 · Shift+↑↓(或 PgUp/PgDn)排序 · q 退出',
37
- en: '↑↓ move · Enter open · Shift+↑↓ (or PgUp/PgDn) reorder · q quit',
44
+ zh: '↑↓ 选择 · Enter 进入 · e 编辑 · s 启动 · d 设默认 · Shift+↑↓ 排序 · q 退出',
45
+ en: '↑↓ move · Enter open · e edit · s session · d set-default · Shift+↑↓ reorder · q quit',
38
46
  },
39
47
  'menu.newProfile': { zh: '新增配置', en: 'New profile' },
40
48
  'menu.exit': { zh: '退出', en: 'Exit' },
@@ -44,6 +52,7 @@ export const messages = {
44
52
  'menu.updateOff': { zh: '更新检查:关闭', en: 'Update check: off' },
45
53
  'menu.updateNotify': { zh: '更新检查:提醒', en: 'Update check: notify' },
46
54
  'menu.updateAvailable': { zh: '有新版本 {0} · 升级:{1}', en: 'New version {0} · upgrade: {1}' },
55
+ 'menu.firstRunHint': { zh: '首次配置:① 选供应商 → ② 填密钥 → ③ 本次启用', en: 'First setup: 1. pick a provider -> 2. paste key -> 3. use this session' },
47
56
  // —— 动作菜单 ——
48
57
  'action.titlePrefix': { zh: '配置:', en: 'Profile: ' },
49
58
  'action.session': {
@@ -54,13 +63,14 @@ export const messages = {
54
63
  zh: '设为默认 — 新终端裸敲 claude 默认用它(不影响运行中会话)',
55
64
  en: 'Set default — used by bare claude in new terminals (running sessions unaffected)',
56
65
  },
66
+ 'action.check': { zh: '配置自检 — 只读探测 API 地址与密钥', en: 'Check config — read-only probe for API URL and key' },
57
67
  'action.edit': { zh: '编辑', en: 'Edit' },
58
68
  'action.delete': { zh: '删除', en: 'Delete' },
59
69
  'action.back': { zh: '返回', en: 'Back' },
60
70
  'action.hint': { zh: '↑↓ 选择 · Enter 确认 · q 返回', en: '↑↓ move · Enter select · q back' },
61
71
  'action.deleteConfirm': { zh: '确认删除 [{0}]? (y/N): ', en: 'Delete [{0}]? (y/N): ' },
62
72
  'action.deleteOfficialWarn': { zh: '建议保留『官方』。', en: 'Keeping "Official" is recommended.' },
63
- 'menu.language': { zh: '切换到 English', en: '切换到中文' },
73
+ 'menu.language': { zh: '切换到 English', en: 'Switch to 中文' },
64
74
  // —— 通用占位 ——
65
75
  'empty.paren': { zh: '(空)', en: '(empty)' },
66
76
  // —— 编辑表单 ——
@@ -107,6 +117,7 @@ export const messages = {
107
117
  // —— 错误 ——
108
118
  'error.notFound': { zh: '找不到配置:{0}', en: 'Profile not found: {0}' },
109
119
  'error.existing': { zh: '现有:{0}', en: 'Existing: {0}' },
120
+ 'error.notFoundHint': { zh: '下一步:运行 `xx -l` 查看配置,或运行 `xx` 打开菜单。', en: 'Next: run `xx -l` to list profiles, or `xx` to open the menu.' },
110
121
  'error.storeRead': { zh: '配置文件读取失败:{0}', en: 'Failed to read config file: {0}' },
111
122
  'error.storeCorrupt': { zh: '配置文件解析失败(JSON 语法错误):{0}', en: 'Failed to parse config file (invalid JSON): {0}' },
112
123
  'error.storeFormat': { zh: '配置文件结构不正确(顶层须为对象、providers 须为数组且条目结构合法):{0}', en: 'Config file has invalid structure (top-level must be an object, providers must be an array with valid profile entries): {0}' },
@@ -114,6 +125,7 @@ export const messages = {
114
125
  zh: '为避免误删,未对它做任何改动。请修复后重试;或删除该文件以重新生成默认配置(会丢失已填的密钥)。',
115
126
  en: 'Left untouched to avoid data loss. Fix it and retry; or delete the file to regenerate defaults (loses any saved keys).',
116
127
  },
128
+ 'error.storeBackupHint': { zh: '修复前先备份:{0}', en: 'Back it up first: {0}' },
117
129
  // —— 本次启用(session)——
118
130
  'session.noKey': { zh: '⚠ 配置 [{0}] 还没填密钥。', en: '⚠ Profile [{0}] has no key set.' },
119
131
  'session.launch': {
@@ -125,12 +137,28 @@ export const messages = {
125
137
  en: 'Launching Claude… (returns here after Claude exits)',
126
138
  },
127
139
  'session.noClaude': { zh: '未找到 claude 命令,请确认它在 PATH 中。', en: 'claude not found on PATH.' },
140
+ 'session.noClaudeHint': { zh: '下一步:安装 Claude Code 后新开终端;npm 用户可运行 `npm install -g @anthropic-ai/claude-code`。', en: 'Next: install Claude Code and open a new terminal; npm users can run `npm install -g @anthropic-ai/claude-code`.' },
141
+ // —— 配置自检(只读网络探测)——
142
+ 'check.noUrl': { zh: '官方登录态 / 未设置 API 地址,无需自检。', en: 'Official login / no API URL set; nothing to probe.' },
143
+ 'check.noKey': { zh: '还没填密钥,无法自检。', en: 'No key set; cannot probe.' },
144
+ 'check.ok': { zh: '✓ 配置自检通过(HTTP {0})', en: '✓ Config check passed (HTTP {0})' },
145
+ 'check.auth': { zh: '鉴权失败(HTTP {0}),检查密钥和认证字段。', en: 'Authentication failed (HTTP {0}); check key and auth field.' },
146
+ 'check.dns': { zh: 'DNS 解析失败,检查 API 地址或网络。', en: 'DNS lookup failed; check API URL or network.' },
147
+ 'check.timeout': { zh: '连接超时,请稍后重试或检查网络。', en: 'Connection timed out; retry later or check network.' },
148
+ 'check.notFound': { zh: '接口不存在(HTTP {0}),检查 API 地址是否缺少 /anthropic 等后缀。', en: 'Endpoint not found (HTTP {0}); check whether the API URL is missing a suffix like /anthropic.' },
149
+ 'check.http': { zh: '请求返回 HTTP {0},检查 API 地址、密钥或账户状态。', en: 'Request returned HTTP {0}; check API URL, key, or account state.' },
150
+ 'check.network': { zh: '网络请求失败,检查网络或 curl 是否可用。', en: 'Network request failed; check network or curl availability.' },
128
151
  // —— 设为默认(default)——
129
152
  'default.writing': { zh: '正在写入用户环境变量…', en: 'Writing user environment variables…' },
153
+ 'default.noKey': {
154
+ zh: '⚠ {0} 还没填密钥;设为默认后,新终端的 claude 仍不可用。',
155
+ en: '⚠ {0} has no key; after setting it as default, claude in new terminals will still fail.',
156
+ },
130
157
  'default.done': {
131
158
  zh: '✓ 已设为默认:{0} · 新开终端裸敲 claude 生效(不影响运行中会话)',
132
159
  en: '✓ Default set: {0} · effective in newly opened terminals (running sessions unaffected)',
133
160
  },
161
+ 'default.hintSession': { zh: '若只想本次使用:xx -s {0}', en: 'To use this session only: xx -s {0}' },
134
162
  'default.dryRun': {
135
163
  zh: '(dry-run:--default-scope process,未改系统,仅更新存储)',
136
164
  en: '(dry-run: --default-scope process; system untouched, store only)',
package/dist/index.js CHANGED
@@ -8,11 +8,12 @@
8
8
  */
9
9
  import { createRequire } from 'node:module';
10
10
  import { Command, Option } from 'commander';
11
- import { launchSession, warnIfNoKey } from './actions.js';
12
- import { loadStore, peekStoreLang, resolveStorePaths, StoreError } from './config/store.js';
11
+ import { launchSession } from './actions.js';
12
+ import { getProviderState, loadStore, peekStoreLang, resolveStorePaths, StoreError } from './config/store.js';
13
13
  import { loadPresets } from './config/presets.js';
14
14
  import { setDefault } from './env/default.js';
15
15
  import { providerDisplayName, resolveLang, setLang, T } from './i18n/index.js';
16
+ import { currentTerminalLine } from './runtime-info.js';
16
17
  import { noteSuffix, stateLabel } from './ui/format.js';
17
18
  import { openMenu } from './ui/menus.js';
18
19
  import { padDisplay } from './utils/display.js';
@@ -75,6 +76,9 @@ async function dispatch(name, opts) {
75
76
  const head = e.kind === 'read' ? 'error.storeRead' : e.kind === 'format' ? 'error.storeFormat' : 'error.storeCorrupt';
76
77
  console.error(` ${T(head, e.file)}`);
77
78
  console.error(` ${T('error.storeCorruptHint')}`);
79
+ if (e.kind === 'parse' || e.kind === 'format') {
80
+ console.error(` ${T('error.storeBackupHint', backupCommand(e.file))}`);
81
+ }
78
82
  process.exitCode = 1;
79
83
  return;
80
84
  }
@@ -87,10 +91,11 @@ async function dispatch(name, opts) {
87
91
  return;
88
92
  }
89
93
  if (name) {
90
- const target = store.providers.find((p) => p.name === name);
94
+ const target = findProviderForCli(store, name);
91
95
  if (!target) {
92
96
  console.error(` ${T('error.notFound', name)}`);
93
- console.error(` ${T('error.existing', store.providers.map((p) => p.name).join(', '))}`);
97
+ console.error(` ${T('error.existing', store.providers.map((p) => providerDisplayName(p)).join(', '))}`);
98
+ console.error(` ${T('error.notFoundHint')}`);
94
99
  process.exitCode = 1;
95
100
  return;
96
101
  }
@@ -102,11 +107,15 @@ async function dispatch(name, opts) {
102
107
  }
103
108
  await openMenu(paths, store, opts.defaultScope, pkg.version, loadPresets(opts.storeDir));
104
109
  }
110
+ function findProviderForCli(store, name) {
111
+ return store.providers.find((p) => p.name === name) ?? store.providers.find((p) => providerDisplayName(p) === name);
112
+ }
105
113
  /** `--list`:列出所有配置及状态。官方档显示名走 i18n(评审①),其余原样。 */
106
114
  function runList(store) {
107
115
  const cur = store.providers.find((p) => p.name === store.current);
108
116
  console.log('');
109
117
  console.log(` ${T('list.default', cur ? providerDisplayName(cur) : store.current)}`);
118
+ console.log(` ${currentTerminalLine(store)}`);
110
119
  for (const p of store.providers) {
111
120
  const mark = p.name === store.current ? '▶' : ' ';
112
121
  console.log(` ${mark} ${padDisplay(providerDisplayName(p), 18)}[${stateLabel(p)}]${noteSuffix(p)}`);
@@ -115,12 +124,13 @@ function runList(store) {
115
124
  }
116
125
  /** 设为默认:写用户环境变量(或 dry-run)+ 更新 store.current。 */
117
126
  function runDefault(paths, store, p, scope) {
118
- warnIfNoKey(p);
127
+ warnIfNoKeyForDefault(p);
119
128
  const name = providerDisplayName(p);
120
129
  const r = setDefault(paths, store, p, scope);
121
130
  if (r.dryRun) {
122
131
  console.log(` ${T('default.done', name)}`);
123
132
  console.log(` ${T('default.dryRun')}`);
133
+ console.log(` ${T('default.hintSession', quoteArg(p.name))}`);
124
134
  return;
125
135
  }
126
136
  if (r.windows && !r.windows.ok) {
@@ -135,5 +145,27 @@ function runDefault(paths, store, p, scope) {
135
145
  console.log(` ${T('default.done', name)}`);
136
146
  if (r.unix)
137
147
  console.log(` ${T('default.unixWrote', r.unix.file)}`);
148
+ console.log(` ${T('default.hintSession', quoteArg(p.name))}`);
149
+ }
150
+ function warnIfNoKeyForDefault(p) {
151
+ if (getProviderState(p).key === 'noKey') {
152
+ console.log(` ${T('default.noKey', providerDisplayName(p))}`);
153
+ }
154
+ }
155
+ function backupCommand(file) {
156
+ const dst = `${file}.bak`;
157
+ if (process.platform === 'win32') {
158
+ return `Copy-Item -LiteralPath ${psQuote(file)} -Destination ${psQuote(dst)}`;
159
+ }
160
+ return `cp ${shQuote(file)} ${shQuote(dst)}`;
161
+ }
162
+ function psQuote(s) {
163
+ return `'${s.replaceAll("'", "''")}'`;
164
+ }
165
+ function shQuote(s) {
166
+ return `'${s.replaceAll("'", `'"'"'`)}'`;
167
+ }
168
+ function quoteArg(s) {
169
+ return `"${s.replaceAll('"', '\\"')}"`;
138
170
  }
139
171
  main();
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Read-only facts about the current terminal process.
3
+ */
4
+ import { getProviderEnvMap } from './config/store.js';
5
+ import { providerDisplayName, T } from './i18n/index.js';
6
+ /** Key-safe description of the API currently visible to this terminal process. */
7
+ export function currentTerminalLine(store) {
8
+ return T('terminal.current', currentTerminalTarget(store));
9
+ }
10
+ function currentTerminalTarget(store) {
11
+ const base = (process.env.ANTHROPIC_BASE_URL ?? '').trim();
12
+ if (!base)
13
+ return T('terminal.official');
14
+ const host = hostOf(base);
15
+ const match = store.providers.find((p) => sameBase(base, getProviderEnvMap(p).ANTHROPIC_BASE_URL ?? ''));
16
+ if (match)
17
+ return T('terminal.matched', host, providerDisplayName(match));
18
+ return T('terminal.unmatched', host);
19
+ }
20
+ function sameBase(a, b) {
21
+ return a.trim().replace(/\/+$/, '') === b.trim().replace(/\/+$/, '');
22
+ }
23
+ /** 从 API 地址提取 host(解析失败则原样返回),供菜单行尾显示复用。 */
24
+ export function hostOf(raw) {
25
+ const r = raw.trim();
26
+ try {
27
+ const u = new URL(r);
28
+ return u.host || r;
29
+ }
30
+ catch {
31
+ return r;
32
+ }
33
+ }
package/dist/ui/edit.js CHANGED
@@ -24,11 +24,15 @@ function fromProvider(p) {
24
24
  effort: m.CLAUDE_CODE_EFFORT_LEVEL ?? '',
25
25
  };
26
26
  }
27
- /** 编辑 `prov`(就地修改);保存返回 true,放弃返回 false。 */
28
- export async function editForm(prov, store, catalog) {
27
+ /**
28
+ * 编辑 `prov`(就地修改);保存返回 true,放弃返回 false。
29
+ * focusKey=true 时初始光标落在密钥行(#9:无 key 配置 Enter 直达填密钥的最短路径)。
30
+ */
31
+ export async function editForm(prov, store, catalog, focusKey = false) {
29
32
  const W = fromProvider(prov);
30
33
  let showSecret = false;
31
- let start = 0;
34
+ // rows 布局固定:provider,note,base,auth,key,…(密钥行索引为 4)。
35
+ let start = focusKey ? 4 : 0;
32
36
  for (;;) {
33
37
  const v = (x) => (x === '' ? T('empty.paren') : x);
34
38
  const keyDisp = W.token === '' ? T('empty.paren') : showSecret ? W.token : '********';
package/dist/ui/menus.js CHANGED
@@ -5,30 +5,32 @@
5
5
  * 动作菜单:本次启用 / 设为默认(绿条 toast)/ 编辑 / 删除(二次确认)/ 返回。
6
6
  * 编辑表单见 ui/edit.ts(含密钥明文切换)。
7
7
  */
8
- import { createInterface } from 'node:readline';
9
8
  import { launchSession } from '../actions.js';
10
- import { isOfficial, reconcileCurrent, saveStore } from '../config/store.js';
9
+ import { checkProfile } from '../check.js';
10
+ import { getProviderEnvMap, getProviderState, isOfficial, reconcileCurrent, saveStore } from '../config/store.js';
11
11
  import { setDefault } from '../env/default.js';
12
12
  import { getLang, providerDisplayName, setLang, T } from '../i18n/index.js';
13
+ import { currentTerminalLine, hostOf } from '../runtime-info.js';
13
14
  import { banner as updateBanner, maybeRefresh, MODE_NOTIFY, upgradeCommand } from '../update/update.js';
15
+ import { paint } from '../utils/ansi.js';
14
16
  import { padDisplay } from '../utils/display.js';
15
17
  import { editForm } from './edit.js';
16
18
  import { noteSuffix, stateLabel } from './format.js';
17
- import { selectMenu } from './select.js';
18
- async function readLine(prompt) {
19
- const rl = createInterface({ input: process.stdin, output: process.stdout });
20
- const ans = await new Promise((res) => rl.question(prompt, res));
21
- rl.close();
22
- return ans.trim();
23
- }
19
+ import { confirmKey, selectMenu } from './select.js';
24
20
  /** 一级 · 主菜单。布局:[profiles…] '' 新增 语言 '' 退出。 */
25
21
  export async function openMenu(paths, store, scope, version, catalog) {
26
22
  let sel = 0;
27
23
  let refreshed = false;
24
+ let flash;
25
+ let warnFlash;
28
26
  for (;;) {
29
27
  const n = store.providers.length;
30
28
  // 更新检查(仅 notify 模式):首轮触发一次后台刷新;横幅永远读缓存(瞬时、不阻塞)。
31
- let notice;
29
+ const notices = [currentTerminalLine(store)];
30
+ if (needsFirstRunHint(store))
31
+ notices.push(T('menu.firstRunHint'));
32
+ if (warnFlash)
33
+ notices.push(warnFlash);
32
34
  if (store.update === MODE_NOTIFY) {
33
35
  if (!refreshed) {
34
36
  maybeRefresh(paths.dir);
@@ -36,13 +38,13 @@ export async function openMenu(paths, store, scope, version, catalog) {
36
38
  }
37
39
  const latest = updateBanner(paths.dir, version);
38
40
  if (latest)
39
- notice = T('menu.updateAvailable', latest, upgradeCommand());
41
+ notices.push(T('menu.updateAvailable', latest, upgradeCommand()));
40
42
  }
41
43
  const updLabel = store.update === MODE_NOTIFY ? T('menu.updateNotify') : T('menu.updateOff');
42
44
  const buildItems = () => {
43
45
  const labels = store.providers.map((p) => {
44
46
  const dft = p.name === store.current ? T('menu.default') : '';
45
- return `${padDisplay(providerDisplayName(p), 16)}${padDisplay(dft, 8)}[${stateLabel(p)}]${noteSuffix(p)}`;
47
+ return `${padDisplay(providerDisplayName(p), 16)}${padDisplay(dft, 8)}[${stateLabel(p)}]${noteSuffix(p)}${hostSuffix(p)}`;
46
48
  });
47
49
  return [...labels, '', T('menu.newProfile'), T('menu.language'), updLabel, '', T('menu.exit')];
48
50
  };
@@ -57,17 +59,51 @@ export async function openMenu(paths, store, scope, version, catalog) {
57
59
  }
58
60
  return buildItems();
59
61
  };
62
+ const defaultName = defaultDisplayName(store);
63
+ let shortcut = '';
60
64
  sel = await selectMenu({
61
- title: T('menu.mainTitle', version),
62
- ...(notice ? { notice } : {}),
65
+ title: T('menu.mainTitle', version, defaultName),
66
+ notice: notices.join('\n'),
67
+ ...(flash ? { status: flash } : {}),
63
68
  items: buildItems(),
64
69
  colors: { [n + 1]: 'yellow' },
65
70
  start: sel,
66
71
  movableCount: n,
67
72
  onMove,
73
+ onKey: (r, idx) => {
74
+ if (idx >= n)
75
+ return -1;
76
+ if (r === 'e' || r === 's' || r === 'd') {
77
+ shortcut = r;
78
+ return idx;
79
+ }
80
+ return -1;
81
+ },
68
82
  hint: T('menu.mainHint'),
69
83
  noNumber: true,
70
84
  });
85
+ flash = undefined;
86
+ warnFlash = undefined;
87
+ if (shortcut && sel >= 0 && sel < n) {
88
+ const target = store.providers[sel];
89
+ if (!target)
90
+ continue;
91
+ if (shortcut === 'e') {
92
+ const old = target.name;
93
+ if (await editForm(target, store, catalog)) {
94
+ if (store.current === old)
95
+ store.current = target.name;
96
+ saveStore(paths, store);
97
+ }
98
+ }
99
+ else if (shortcut === 's') {
100
+ launchSession(target);
101
+ }
102
+ else if (shortcut === 'd') {
103
+ ({ warn: warnFlash, toast: flash } = applyDefault(paths, store, target, scope));
104
+ }
105
+ continue;
106
+ }
71
107
  if (sel < 0 || sel === n + 5)
72
108
  return; // 退出 / Esc / q
73
109
  if (sel === n + 1) {
@@ -96,8 +132,20 @@ export async function openMenu(paths, store, scope, version, catalog) {
96
132
  }
97
133
  else if (sel < n) {
98
134
  const target = store.providers[sel];
99
- if (target)
100
- await actionMenu(paths, store, target, scope, catalog);
135
+ if (target) {
136
+ if (!isOfficial(target) && getProviderState(target).key === 'noKey') {
137
+ // #9:无密钥的第三方配置,Enter 直达编辑并聚焦密钥行(铺平首次成功路径)。
138
+ const old = target.name;
139
+ if (await editForm(target, store, catalog, true)) {
140
+ if (store.current === old)
141
+ store.current = target.name;
142
+ saveStore(paths, store);
143
+ }
144
+ }
145
+ else {
146
+ await actionMenu(paths, store, target, scope, catalog);
147
+ }
148
+ }
101
149
  if (sel >= store.providers.length)
102
150
  sel = Math.max(0, store.providers.length - 1); // 删除后夹取
103
151
  }
@@ -107,19 +155,36 @@ export async function openMenu(paths, store, scope, version, catalog) {
107
155
  async function actionMenu(paths, store, p, scope, catalog) {
108
156
  let sel = 0;
109
157
  let flash;
158
+ let warnFlash; // 黄字警告(如缺密钥),走 notice 与绿色 status 区分
110
159
  for (;;) {
111
160
  const dft = p.name === store.current ? T('menu.default') : '';
112
161
  const title = `${T('action.titlePrefix')}${providerDisplayName(p)}${dft}${noteSuffix(p)} [${stateLabel(p)}]`;
113
- const items = [T('action.session'), T('action.setDefault'), T('action.edit'), T('action.delete'), T('action.back')];
114
- sel = await selectMenu({ title, items, start: sel, ...(flash ? { status: flash } : {}), hint: T('action.hint'), noNumber: true });
162
+ const items = [T('action.session'), T('action.setDefault'), T('action.check'), T('action.edit'), T('action.delete'), T('action.back')];
163
+ sel = await selectMenu({
164
+ title,
165
+ items,
166
+ start: sel,
167
+ ...(warnFlash ? { notice: warnFlash } : {}),
168
+ ...(flash ? { status: flash } : {}),
169
+ hint: T('action.hint'),
170
+ noNumber: true,
171
+ });
115
172
  flash = undefined;
173
+ warnFlash = undefined;
116
174
  if (sel === 0) {
117
175
  launchSession(p);
118
176
  }
119
177
  else if (sel === 1) {
120
- flash = applyDefault(paths, store, p, scope);
178
+ ({ warn: warnFlash, toast: flash } = applyDefault(paths, store, p, scope));
121
179
  }
122
180
  else if (sel === 2) {
181
+ const result = await checkProfile(p);
182
+ if (result.ok)
183
+ flash = result.message;
184
+ else
185
+ warnFlash = result.message;
186
+ }
187
+ else if (sel === 3) {
123
188
  const old = p.name;
124
189
  if (await editForm(p, store, catalog)) {
125
190
  if (store.current === old)
@@ -127,11 +192,10 @@ async function actionMenu(paths, store, p, scope, catalog) {
127
192
  saveStore(paths, store);
128
193
  }
129
194
  }
130
- else if (sel === 3) {
195
+ else if (sel === 4) {
131
196
  if (isOfficial(p))
132
197
  console.log(` ${T('action.deleteOfficialWarn')}`);
133
- const ans = await readLine(` ${T('action.deleteConfirm', providerDisplayName(p))}`);
134
- if (ans === 'y' || ans === 'Y') {
198
+ if (await confirmKey(T('action.deleteConfirm', providerDisplayName(p)))) {
135
199
  store.providers = store.providers.filter((x) => x !== p);
136
200
  reconcileCurrent(store);
137
201
  saveStore(paths, store);
@@ -143,15 +207,43 @@ async function actionMenu(paths, store, p, scope, catalog) {
143
207
  }
144
208
  }
145
209
  }
146
- /** 设为默认并返回一行 toast 文案。 */
210
+ function defaultDisplayName(store) {
211
+ if (!store.current)
212
+ return '—';
213
+ return providerDisplayName(store.providers.find((p) => p.name === store.current) ?? { name: store.current, env: {} });
214
+ }
215
+ /**
216
+ * 设为默认,返回 { warn, toast }:warn 为黄字警告(缺密钥),toast 为绿色结果。
217
+ * 分开返回让调用方各自上色,避免警告被染成「成功」绿。
218
+ */
147
219
  function applyDefault(paths, store, p, scope) {
148
220
  const name = providerDisplayName(p);
221
+ const warn = getProviderState(p).key === 'noKey' ? T('default.noKey', name) : '';
149
222
  const r = setDefault(paths, store, p, scope);
150
223
  if (r.dryRun)
151
- return `${T('default.done', name)} ${T('default.dryRun')}`;
224
+ return { warn, toast: `${T('default.done', name)} ${T('default.dryRun')}` };
152
225
  if (r.windows && !r.windows.ok)
153
- return T('default.failed', r.windows.error ?? '');
226
+ return { warn, toast: T('default.failed', r.windows.error ?? '') };
154
227
  if (r.unix?.unsupported)
155
- return T('default.fishUnsupported');
156
- return T('default.done', name);
228
+ return { warn, toast: T('default.fishUnsupported') };
229
+ return { warn, toast: T('default.done', name) };
230
+ }
231
+ // hostSuffix 返回行尾的灰字 host(如 ` · api.deepseek.com`);无 base(官方/未填)返回空。
232
+ // 超宽时由 selectMenu 的 ANSI-aware 截断从行尾裁掉,不会切坏颜色。
233
+ function hostSuffix(p) {
234
+ const base = (getProviderEnvMap(p).ANTHROPIC_BASE_URL ?? '').trim();
235
+ if (!base)
236
+ return '';
237
+ return paint(` · ${hostOf(base)}`, 'dim');
238
+ }
239
+ function needsFirstRunHint(store) {
240
+ let hasThirdParty = false;
241
+ for (const p of store.providers) {
242
+ if (isOfficial(p))
243
+ continue;
244
+ hasThirdParty = true;
245
+ if (getProviderState(p).key !== 'noKey')
246
+ return false;
247
+ }
248
+ return hasThirdParty;
157
249
  }
package/dist/ui/select.js CHANGED
@@ -42,10 +42,14 @@ export async function selectMenu(opts) {
42
42
  lines.push(` ${paint(opts.title, 'cyan')}`, '');
43
43
  }
44
44
  if (opts.notice) {
45
- lines.push(` ${paint(opts.notice, 'yellow')}`, '');
45
+ for (const line of splitNonEmptyLines(opts.notice))
46
+ lines.push(` ${paint(line, 'yellow')}`);
47
+ lines.push('');
46
48
  }
47
49
  if (opts.status) {
48
- lines.push(` ${paint(opts.status, 'green')}`, '');
50
+ for (const line of splitNonEmptyLines(opts.status))
51
+ lines.push(` ${paint(line, 'green')}`);
52
+ lines.push('');
49
53
  }
50
54
  for (let i = 0; i < items.length; i++) {
51
55
  const it = items[i] ?? '';
@@ -124,8 +128,15 @@ export async function selectMenu(opts) {
124
128
  cleanup(n - 1);
125
129
  return;
126
130
  }
127
- if (ch === 'q')
131
+ if (ch === 'q') {
128
132
  cleanup(-1);
133
+ return;
134
+ }
135
+ if (opts.onKey) {
136
+ const r = opts.onKey(ch, idx);
137
+ if (r >= 0)
138
+ cleanup(r);
139
+ }
129
140
  };
130
141
  stdin.on('keypress', onKey);
131
142
  render();
@@ -137,9 +148,20 @@ async function fallbackSelect(opts, items) {
137
148
  stdout.write('\n');
138
149
  if (opts.title)
139
150
  stdout.write(` ${opts.title}\n\n`);
151
+ if (opts.notice) {
152
+ splitNonEmptyLines(opts.notice).forEach((line) => stdout.write(` ${line}\n`));
153
+ stdout.write('\n');
154
+ }
155
+ if (opts.status) {
156
+ splitNonEmptyLines(opts.status).forEach((line) => stdout.write(` ${line}\n`));
157
+ stdout.write('\n');
158
+ }
159
+ const indexMap = [];
140
160
  items.forEach((it, i) => {
141
- if (it !== '')
142
- stdout.write(` ${i + 1}. ${it}\n`);
161
+ if (it !== '') {
162
+ indexMap.push(i);
163
+ stdout.write(` ${indexMap.length}. ${it}\n`);
164
+ }
143
165
  });
144
166
  const rl = createInterface({ input: process.stdin, output: process.stdout });
145
167
  const ans = await new Promise((res) => rl.question(` ${T('menu.prompt')}`, res));
@@ -149,8 +171,49 @@ async function fallbackSelect(opts, items) {
149
171
  return -1;
150
172
  if (/^\d+$/.test(t)) {
151
173
  const n = Number.parseInt(t, 10);
152
- if (n >= 1 && n <= items.length && items[n - 1] !== '')
153
- return n - 1;
174
+ if (n >= 1 && n <= indexMap.length)
175
+ return indexMap[n - 1] ?? -1;
154
176
  }
155
177
  return -1;
156
178
  }
179
+ function splitNonEmptyLines(s) {
180
+ return s
181
+ .split('\n')
182
+ .map((line) => line.trimEnd())
183
+ .filter((line) => line.trim() !== '');
184
+ }
185
+ /**
186
+ * raw 模式下读一个按键确认(y/Y=是,其余任意键=否),无需回车,与菜单 raw 体验一致。
187
+ * 非 TTY 时回退到 cooked 读行。对应 Go 版 confirmKey。
188
+ */
189
+ export async function confirmKey(prompt) {
190
+ const stdin = process.stdin;
191
+ const stdout = process.stdout;
192
+ if (!stdin.isTTY || !stdout.isTTY) {
193
+ const rl = createInterface({ input: stdin, output: stdout });
194
+ const ans = await new Promise((res) => rl.question(` ${prompt}`, res));
195
+ rl.close();
196
+ const t = ans.trim();
197
+ return t === 'y' || t === 'Y';
198
+ }
199
+ stdout.write(` ${prompt}`);
200
+ emitKeypressEvents(stdin);
201
+ const wasRaw = stdin.isRaw ?? false;
202
+ stdin.setRawMode(true);
203
+ stdin.resume();
204
+ return new Promise((resolve) => {
205
+ const onKey = (str, key) => {
206
+ stdin.off('keypress', onKey);
207
+ if (!wasRaw)
208
+ stdin.setRawMode(false);
209
+ stdin.pause();
210
+ stdout.write('\n');
211
+ if (key?.ctrl && key.name === 'c') {
212
+ resolve(false);
213
+ return;
214
+ }
215
+ resolve((str ?? '').toLowerCase() === 'y');
216
+ };
217
+ stdin.on('keypress', onKey);
218
+ });
219
+ }
@@ -11,18 +11,47 @@ export function padDisplay(s, width) {
11
11
  const w = stringWidth(s);
12
12
  return w < width ? s + ' '.repeat(width - w) : s;
13
13
  }
14
- /** 按显示宽度截断到 `max`(防止超宽行在终端换行打乱原地重绘的行数计算)。 */
14
+ /**
15
+ * 按显示宽度截断到 `max`(防止超宽行在终端换行打乱原地重绘的行数计算)。
16
+ * ANSI-aware:转义序列(\x1b[…m)不计入宽度且整段保留;着色中途截断时补 \x1b[0m 防颜色泄漏。
17
+ * (`stringWidth` 本身会剥离 ANSI,故首行判断已是可见宽度;逐字符循环需自行跳过转义序列。)
18
+ */
15
19
  export function truncateDisplay(s, max) {
16
20
  if (stringWidth(s) <= max)
17
21
  return s;
18
- let w = 0;
22
+ const chars = [...s];
19
23
  let out = '';
20
- for (const ch of s) {
21
- const cw = stringWidth(ch);
24
+ let w = 0;
25
+ let colored = false;
26
+ for (let i = 0; i < chars.length; i++) {
27
+ if (chars[i] === '\x1b') {
28
+ const end = csiEnd(chars, i);
29
+ for (let k = i; k <= end; k++)
30
+ out += chars[k];
31
+ colored = true;
32
+ i = end;
33
+ continue;
34
+ }
35
+ const cw = stringWidth(chars[i] ?? '');
22
36
  if (w + cw > max)
23
37
  break;
24
- out += ch;
38
+ out += chars[i];
25
39
  w += cw;
26
40
  }
41
+ if (colored)
42
+ out += '\x1b[0m';
27
43
  return out;
28
44
  }
45
+ /** 从 ESC(chars[i]==='\x1b')起返回 CSI 序列的最后一个下标(含终止字节 0x40–0x7E)。 */
46
+ function csiEnd(chars, i) {
47
+ if (i + 1 >= chars.length || chars[i + 1] !== '[')
48
+ return i; // 孤立 ESC
49
+ let j = i + 2; // 跳过 ESC 和引导符 '['
50
+ while (j < chars.length) {
51
+ const c = chars[j] ?? '';
52
+ if (c >= '@' && c <= '~')
53
+ return j; // 终止字节
54
+ j++;
55
+ }
56
+ return chars.length - 1;
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cc-x/cc-x",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Claude Code API 切换器(命令 xx):在官方账号与第三方 Anthropic 兼容 API 间切换,纯环境变量、不碰 Claude Code 配置文件。",
5
5
  "keywords": [
6
6
  "claude",
@@ -29,6 +29,7 @@
29
29
  "files": [
30
30
  "dist/**/*.js",
31
31
  "!dist/release/**",
32
+ "!dist/**/*.test.js",
32
33
  "!dist/**/*.exe",
33
34
  "!dist/**/*.zip",
34
35
  "!dist/**/*.md",
@@ -47,6 +48,7 @@
47
48
  "dev": "tsx src/index.ts",
48
49
  "start": "node dist/index.js",
49
50
  "typecheck": "tsc --noEmit",
51
+ "test": "node --import tsx --test src/check.test.ts src/runtime-info.test.ts",
50
52
  "prepublishOnly": "npm run build"
51
53
  },
52
54
  "dependencies": {