@cc-x/cc-x 0.4.6 → 0.4.8
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 +82 -22
- package/README.md +70 -14
- package/dist/actions.js +1 -0
- package/dist/check.js +59 -0
- package/dist/env/default.js +15 -5
- package/dist/i18n/index.js +9 -1
- package/dist/i18n/messages.js +35 -6
- package/dist/index.js +37 -5
- package/dist/runtime-info.js +33 -0
- package/dist/ui/edit.js +7 -3
- package/dist/ui/menus.js +137 -34
- package/dist/ui/select.js +70 -7
- package/dist/utils/display.js +34 -5
- package/package.json +3 -1
package/README.en.md
CHANGED
|
@@ -1,25 +1,44 @@
|
|
|
1
|
-
|
|
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.
|
|
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
|
-
|
|
32
|
+
CC-X v0.4.7 · Claude Code API switcher Default: Official
|
|
14
33
|
|
|
15
34
|
▶ Official (default)[Logged in]
|
|
16
|
-
DeepSeek
|
|
17
|
-
|
|
18
|
-
|
|
35
|
+
DeepSeek [Key set] — work
|
|
36
|
+
Zhipu GLM [No key]
|
|
37
|
+
Xiaomi MiMo [No key]
|
|
19
38
|
|
|
20
|
-
New profile ·
|
|
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,32 @@ 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
|
+
|
|
92
|
+
### Updating
|
|
93
|
+
|
|
94
|
+
Updating just means **re-running the install command** — the installer downloads the latest
|
|
95
|
+
release and overwrites the old binary in place, no uninstall needed. **Open a new terminal**
|
|
96
|
+
afterward; `xx --version` should show the new version.
|
|
97
|
+
|
|
98
|
+
- **Windows**: `irm https://github.com/becomeless/cc-x/releases/latest/download/install.ps1 | iex`
|
|
99
|
+
- **macOS / Linux**: `curl -fsSL https://github.com/becomeless/cc-x/releases/latest/download/install.sh | sh`
|
|
100
|
+
- **npm**: `npm i -g @cc-x/cc-x@latest`
|
|
101
|
+
|
|
102
|
+
> With the menu's "Update check" set to "notify", CC-X shows a banner at the top of the menu
|
|
103
|
+
> when a new version is out, with the matching upgrade command for your platform.
|
|
104
|
+
|
|
58
105
|
---
|
|
59
106
|
|
|
60
107
|
## 60-second quick start
|
|
@@ -79,7 +126,7 @@ xx --help # all options
|
|
|
79
126
|
|
|
80
127
|
## Two modes (the key concept)
|
|
81
128
|
|
|
82
|
-
Which API Claude uses is decided by **environment variables**.
|
|
129
|
+
Which API Claude uses is decided by **environment variables**. CC-X offers two scopes:
|
|
83
130
|
|
|
84
131
|
| | Use this session (`-s`) | Set default |
|
|
85
132
|
|---|---|---|
|
|
@@ -88,8 +135,11 @@ Which API Claude uses is decided by **environment variables**. ccx offers two sc
|
|
|
88
135
|
| Running sessions | Unaffected | Unaffected (env freezes at process start) |
|
|
89
136
|
| Best for | Parallel terminals on different APIs | Set your daily-driver API once |
|
|
90
137
|
|
|
91
|
-
**
|
|
92
|
-
|
|
138
|
+
> 💡 **Analogy**: "Use this session" is a quick oil change — just for this trip. "Set default" is
|
|
139
|
+
> refilling the tank — every new drive uses it from now on.
|
|
140
|
+
|
|
141
|
+
**Parallel example**: open 4 terminals and run `xx Official -s`, `xx DeepSeek -s`, `xx "Zhipu GLM" -s`,
|
|
142
|
+
`xx "Xiaomi MiMo" -s` — four Claudes running at once, each on its own API, zero interference.
|
|
93
143
|
|
|
94
144
|
**Why not a global config file?** `settings.json` is shared globally; editing it hits running
|
|
95
145
|
sessions (classic symptom: another terminal suddenly says `cannot be parsed as a URL`).
|
|
@@ -97,11 +147,21 @@ Environment variables are naturally process-isolated.
|
|
|
97
147
|
|
|
98
148
|
---
|
|
99
149
|
|
|
100
|
-
##
|
|
150
|
+
## When CC-X is NOT the right tool
|
|
151
|
+
|
|
152
|
+
- You need to manage MCP, hooks, plugins, or multiple CLIs → use [cc-switch](https://github.com/farion1231/cc-switch)
|
|
153
|
+
- You only use the official API, never switch → you don't need CC-X
|
|
154
|
+
- You want automatic config migration/backup → that's outside CC-X's scope
|
|
155
|
+
|
|
156
|
+
CC-X cares more about boundaries than features. It does one thing: **switch APIs**.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## CC-X vs cc-switch
|
|
101
161
|
|
|
102
|
-
cc-switch is an excellent full-featured GUI;
|
|
162
|
+
cc-switch is an excellent full-featured GUI; CC-X takes the opposite, minimal approach.
|
|
103
163
|
|
|
104
|
-
| |
|
|
164
|
+
| | CC-X (`xx`) | cc-switch |
|
|
105
165
|
|---|---|---|
|
|
106
166
|
| Form | Terminal command (lightweight) | Desktop GUI (full-featured) |
|
|
107
167
|
| Scope | Just API switching | API + MCP + multiple CLIs + prompts… |
|
|
@@ -109,18 +169,18 @@ cc-switch is an excellent full-featured GUI; ccx takes the opposite, minimal app
|
|
|
109
169
|
| Can lose MCP? | **Physically impossible** | Users have reported it |
|
|
110
170
|
| Parallel terminals | **Native** (process isolation) | Global switch; sessions can clash |
|
|
111
171
|
|
|
112
|
-
- → **
|
|
172
|
+
- → **CC-X**: terminal natives, parallel-session runners, anyone burned by a config-wrecking switcher, "just switch the API" people
|
|
113
173
|
- → **cc-switch**: GUI preference, all-in-one MCP + multi-CLI management
|
|
114
174
|
|
|
115
175
|
---
|
|
116
176
|
|
|
117
177
|
## Design philosophy
|
|
118
178
|
|
|
119
|
-
>
|
|
179
|
+
> CC-X cares more about boundaries than features.
|
|
120
180
|
|
|
121
|
-
Claude Code already has its own config system, MCP ecosystem, and session state.
|
|
181
|
+
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
182
|
|
|
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,
|
|
183
|
+
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
184
|
|
|
125
185
|
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
186
|
|
|
@@ -140,7 +200,7 @@ Issues / PRs are welcome, but the direction is clear: **make switching steadier,
|
|
|
140
200
|
| haiku → model | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | |
|
|
141
201
|
| effort level | `CLAUDE_CODE_EFFORT_LEVEL` | `low`–`max`; `auto` = model default; empty = unset. Third parties may not honor it |
|
|
142
202
|
|
|
143
|
-
>
|
|
203
|
+
> CC-X **deliberately does not set** `ANTHROPIC_MODEL`. Use `/model opus|sonnet|haiku` in-session;
|
|
144
204
|
> the mapping table translates to the provider's real model name.
|
|
145
205
|
|
|
146
206
|
### Auth field: AUTH_TOKEN vs API_KEY
|
|
@@ -186,7 +246,7 @@ Issues / PRs are welcome, but the direction is clear: **make switching steadier,
|
|
|
186
246
|
- Same semantics either way: **only affects new terminals**; switching to "Official" clears all managed vars
|
|
187
247
|
- **No Claude Code config file is ever modified.**
|
|
188
248
|
|
|
189
|
-
|
|
249
|
+
CC-X only touches these 7 "managed" variables (and clears the ones a target profile doesn't use):
|
|
190
250
|
`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_OPUS_MODEL`,
|
|
191
251
|
`ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `CLAUDE_CODE_EFFORT_LEVEL`.
|
|
192
252
|
|
package/README.md
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
27
|
+
CC-X 把这事儿做到了最简——切换只在环境变量层,**不读写任何 Claude Code 配置文件**。
|
|
9
28
|
你的 MCP、插件、hooks,它碰都不会碰。
|
|
10
29
|
|
|
11
30
|
```text
|
|
12
|
-
|
|
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,29 @@ 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
|
+
|
|
90
|
+
### 更新到新版本
|
|
91
|
+
|
|
92
|
+
更新就是**重新跑一遍安装命令**——安装器会下载最新版覆盖旧的,不用先卸载。跑完**新开一个终端**,`xx --version` 即为新版本。
|
|
93
|
+
|
|
94
|
+
- **Windows**:`irm https://github.com/becomeless/cc-x/releases/latest/download/install.ps1 | iex`
|
|
95
|
+
- **macOS / Linux**:`curl -fsSL https://github.com/becomeless/cc-x/releases/latest/download/install.sh | sh`
|
|
96
|
+
- **npm**:`npm i -g @cc-x/cc-x@latest`
|
|
97
|
+
|
|
98
|
+
> 把菜单里的「更新检查」开到「提醒」后,有新版时 CC-X 会在菜单顶部横幅提示,并直接给出上面对应平台的升级命令。
|
|
99
|
+
|
|
56
100
|
---
|
|
57
101
|
|
|
58
102
|
## 60 秒上手
|
|
@@ -76,7 +120,7 @@ xx --help # 全部参数
|
|
|
76
120
|
|
|
77
121
|
## 两种模式(核心概念)
|
|
78
122
|
|
|
79
|
-
Claude 用哪个 API 由**环境变量**决定。
|
|
123
|
+
Claude 用哪个 API 由**环境变量**决定。CC-X 提供两种作用范围:
|
|
80
124
|
|
|
81
125
|
| | 本次启用 (`-s`) | 设为默认 |
|
|
82
126
|
|---|---|---|
|
|
@@ -85,17 +129,29 @@ Claude 用哪个 API 由**环境变量**决定。ccx 提供两种作用范围:
|
|
|
85
129
|
| 对正在跑的会话 | 零影响 | 零影响(进程启动时已定型) |
|
|
86
130
|
| 适合 | 多终端并行,各跑各的 API | 定好主力 API,不用老切 |
|
|
87
131
|
|
|
132
|
+
> 💡 **打个比方**:「本次启用」是临时换油——只管这一趟;「设为默认」是换了油箱里的油——以后新上车都用这个。
|
|
133
|
+
|
|
88
134
|
**并行示例**:开 4 个终端分别 `xx 官方 -s`、`xx DeepSeek -s`、`xx 智谱GLM -s`、`xx 小米MiMo -s`——四个 Claude 同时干活、各用各的 API、互不打架。
|
|
89
135
|
|
|
90
136
|
**为什么不用配置文件?** `settings.json` 全局共享,改它会波及正在跑的会话(典型症状:另一终端突然报 `cannot be parsed as a URL`)。环境变量天然进程隔离,避开了这个坑。
|
|
91
137
|
|
|
92
138
|
---
|
|
93
139
|
|
|
140
|
+
## 什么时候不该用 CC-X
|
|
141
|
+
|
|
142
|
+
- 你需要管理 MCP、hooks、插件、多 CLI → 用 [cc-switch](https://github.com/farion1231/cc-switch)
|
|
143
|
+
- 你只用官方 API,不切第三方 → 不需要 CC-X
|
|
144
|
+
- 你要自动迁移/备份配置 → 不在 CC-X 范围内
|
|
145
|
+
|
|
146
|
+
CC-X 的边界比功能更重要。它只做一件事:**切 API**。
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
94
150
|
## 和 cc-switch 怎么选
|
|
95
151
|
|
|
96
|
-
cc-switch 是优秀的全能 GUI;
|
|
152
|
+
cc-switch 是优秀的全能 GUI;CC-X 走相反的极简路线。
|
|
97
153
|
|
|
98
|
-
| |
|
|
154
|
+
| | CC-X (`xx`) | cc-switch |
|
|
99
155
|
|---|---|---|
|
|
100
156
|
| 形态 | 终端命令(轻量) | 桌面 GUI(全能) |
|
|
101
157
|
| 职责 | 只切 API | API + MCP + 多 CLI + 提示词… |
|
|
@@ -103,16 +159,16 @@ cc-switch 是优秀的全能 GUI;ccx 走相反的极简路线。
|
|
|
103
159
|
| 能弄丢 MCP? | **不可能** | 有用户反馈被覆盖 |
|
|
104
160
|
| 多终端并行 | **原生支持**(进程隔离) | 全局切换,容易互扰 |
|
|
105
161
|
|
|
106
|
-
- → **
|
|
162
|
+
- → **CC-X**:命令行党、常多开终端、被切配置坑过、只想要「切 API」一件事
|
|
107
163
|
- → **cc-switch**:要 GUI、要一站式管 MCP 和多 CLI
|
|
108
164
|
|
|
109
165
|
---
|
|
110
166
|
|
|
111
167
|
## 设计哲学
|
|
112
168
|
|
|
113
|
-
>
|
|
169
|
+
> CC-X 的边界比功能更重要。
|
|
114
170
|
|
|
115
|
-
Claude Code 已经有自己的配置系统、MCP 生态和会话状态。
|
|
171
|
+
Claude Code 已经有自己的配置系统、MCP 生态和会话状态。CC-X 不想再造一个"上层控制台",也不想把用户的配置收编进自己的数据库。它只站在 Claude Code 进程启动前的那一小步:把 7 个受管环境变量准备好,然后让 Claude Code 自己工作。
|
|
116
172
|
|
|
117
173
|
所以它的取舍是有意的:不写 Claude Code 配置文件,不接管 MCP,不做自动迁移,不做后台常驻管理。能用进程环境变量解决,就不碰全局文件;能让用户显式选择,就不替用户自动决定。少做一点,是为了把风险面压到足够小。
|
|
118
174
|
|
|
@@ -134,7 +190,7 @@ Claude Code 已经有自己的配置系统、MCP 生态和会话状态。ccx 不
|
|
|
134
190
|
| haiku → 模型 | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | |
|
|
135
191
|
| effort 思考档 | `CLAUDE_CODE_EFFORT_LEVEL` | `low` ~ `max`;`auto`=模型默认;留空=不设。第三方不一定生效 |
|
|
136
192
|
|
|
137
|
-
>
|
|
193
|
+
> CC-X **刻意不设** `ANTHROPIC_MODEL`。在会话里用 `/model opus|sonnet|haiku` 选档,映射表负责翻译成对应供应商的模型名。
|
|
138
194
|
|
|
139
195
|
### 认证字段:AUTH_TOKEN vs API_KEY
|
|
140
196
|
|
|
@@ -173,7 +229,7 @@ Claude Code 已经有自己的配置系统、MCP 生态和会话状态。ccx 不
|
|
|
173
229
|
- 语义一致:**只影响新终端**;切到「官方」会清除全部受管变量
|
|
174
230
|
- **不修改任何 Claude Code 配置文件。**
|
|
175
231
|
|
|
176
|
-
|
|
232
|
+
CC-X 只动这 7 个「受管」环境变量,切换时清掉目标不用的:
|
|
177
233
|
`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
234
|
|
|
179
235
|
> 💡 需要改 `settings.json`?直接用 Claude Code 的 `/update-config` 说需求(如"允许 npm 命令"),比让外部工具改可靠。
|
package/dist/actions.js
CHANGED
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
|
+
}
|
package/dist/env/default.js
CHANGED
|
@@ -18,23 +18,33 @@ export function computeManagedVals(p) {
|
|
|
18
18
|
}
|
|
19
19
|
return vals;
|
|
20
20
|
}
|
|
21
|
-
export function
|
|
21
|
+
export function persistDefaultEnv(p, scope) {
|
|
22
22
|
const vals = computeManagedVals(p);
|
|
23
23
|
const dryRun = scope === 'process';
|
|
24
24
|
const result = { scope, dryRun };
|
|
25
|
-
let persisted = true;
|
|
26
25
|
if (!dryRun) {
|
|
27
26
|
if (process.platform === 'win32') {
|
|
28
27
|
result.windows = persistWindows(vals);
|
|
29
|
-
persisted = result.windows.ok;
|
|
30
28
|
}
|
|
31
29
|
else {
|
|
32
30
|
result.unix = persistUnix(vals);
|
|
33
|
-
persisted = !result.unix.unsupported; // fish 未写入 → 不算持久化成功
|
|
34
31
|
}
|
|
35
32
|
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
function envPersisted(result) {
|
|
36
|
+
if (result.dryRun)
|
|
37
|
+
return true;
|
|
38
|
+
if (result.windows)
|
|
39
|
+
return result.windows.ok;
|
|
40
|
+
if (result.unix)
|
|
41
|
+
return !result.unix.unsupported; // fish 未写入 → 不算持久化成功
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
export function setDefault(paths, store, p, scope) {
|
|
45
|
+
const result = persistDefaultEnv(p, scope);
|
|
36
46
|
// [P1] 仅当持久化成功(或 dry-run)才改默认指向并落盘,避免「报失败却已改默认」的不一致。
|
|
37
|
-
if (
|
|
47
|
+
if (envPersisted(result)) {
|
|
38
48
|
store.current = p.name;
|
|
39
49
|
saveStore(paths, store);
|
|
40
50
|
}
|
package/dist/i18n/index.js
CHANGED
|
@@ -49,5 +49,13 @@ export function resolveLang(explicit, storeLang) {
|
|
|
49
49
|
* 其余是专有名词,原样显示 `name`。
|
|
50
50
|
*/
|
|
51
51
|
export function providerDisplayName(p) {
|
|
52
|
-
|
|
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
|
}
|
package/dist/i18n/messages.js
CHANGED
|
@@ -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
|
-
// ——
|
|
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: '
|
|
33
|
-
en: '
|
|
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
|
|
37
|
-
en: '↑↓ move · Enter open · Shift+↑↓
|
|
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,13 +117,16 @@ 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}' },
|
|
124
|
+
'error.storeSave': { zh: '配置文件保存失败:{0}', en: 'Failed to save config file: {0}' },
|
|
113
125
|
'error.storeCorruptHint': {
|
|
114
126
|
zh: '为避免误删,未对它做任何改动。请修复后重试;或删除该文件以重新生成默认配置(会丢失已填的密钥)。',
|
|
115
127
|
en: 'Left untouched to avoid data loss. Fix it and retry; or delete the file to regenerate defaults (loses any saved keys).',
|
|
116
128
|
},
|
|
129
|
+
'error.storeBackupHint': { zh: '修复前先备份:{0}', en: 'Back it up first: {0}' },
|
|
117
130
|
// —— 本次启用(session)——
|
|
118
131
|
'session.noKey': { zh: '⚠ 配置 [{0}] 还没填密钥。', en: '⚠ Profile [{0}] has no key set.' },
|
|
119
132
|
'session.launch': {
|
|
@@ -125,12 +138,28 @@ export const messages = {
|
|
|
125
138
|
en: 'Launching Claude… (returns here after Claude exits)',
|
|
126
139
|
},
|
|
127
140
|
'session.noClaude': { zh: '未找到 claude 命令,请确认它在 PATH 中。', en: 'claude not found on PATH.' },
|
|
141
|
+
'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`.' },
|
|
142
|
+
// —— 配置自检(只读网络探测)——
|
|
143
|
+
'check.noUrl': { zh: '官方登录态 / 未设置 API 地址,无需自检。', en: 'Official login / no API URL set; nothing to probe.' },
|
|
144
|
+
'check.noKey': { zh: '还没填密钥,无法自检。', en: 'No key set; cannot probe.' },
|
|
145
|
+
'check.ok': { zh: '✓ 配置自检通过(HTTP {0})', en: '✓ Config check passed (HTTP {0})' },
|
|
146
|
+
'check.auth': { zh: '鉴权失败(HTTP {0}),检查密钥和认证字段。', en: 'Authentication failed (HTTP {0}); check key and auth field.' },
|
|
147
|
+
'check.dns': { zh: 'DNS 解析失败,检查 API 地址或网络。', en: 'DNS lookup failed; check API URL or network.' },
|
|
148
|
+
'check.timeout': { zh: '连接超时,请稍后重试或检查网络。', en: 'Connection timed out; retry later or check network.' },
|
|
149
|
+
'check.notFound': { zh: '接口不存在(HTTP {0}),检查 API 地址是否缺少 /anthropic 等后缀。', en: 'Endpoint not found (HTTP {0}); check whether the API URL is missing a suffix like /anthropic.' },
|
|
150
|
+
'check.http': { zh: '请求返回 HTTP {0},检查 API 地址、密钥或账户状态。', en: 'Request returned HTTP {0}; check API URL, key, or account state.' },
|
|
151
|
+
'check.network': { zh: '网络请求失败,检查网络或 curl 是否可用。', en: 'Network request failed; check network or curl availability.' },
|
|
128
152
|
// —— 设为默认(default)——
|
|
129
153
|
'default.writing': { zh: '正在写入用户环境变量…', en: 'Writing user environment variables…' },
|
|
154
|
+
'default.noKey': {
|
|
155
|
+
zh: '⚠ {0} 还没填密钥;设为默认后,新终端的 claude 仍不可用。',
|
|
156
|
+
en: '⚠ {0} has no key; after setting it as default, claude in new terminals will still fail.',
|
|
157
|
+
},
|
|
130
158
|
'default.done': {
|
|
131
159
|
zh: '✓ 已设为默认:{0} · 新开终端裸敲 claude 生效(不影响运行中会话)',
|
|
132
160
|
en: '✓ Default set: {0} · effective in newly opened terminals (running sessions unaffected)',
|
|
133
161
|
},
|
|
162
|
+
'default.hintSession': { zh: '若只想本次使用:xx -s {0}', en: 'To use this session only: xx -s {0}' },
|
|
134
163
|
'default.dryRun': {
|
|
135
164
|
zh: '(dry-run:--default-scope process,未改系统,仅更新存储)',
|
|
136
165
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
28
|
-
|
|
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
|
-
|
|
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 {
|
|
11
|
-
import {
|
|
9
|
+
import { checkProfile } from '../check.js';
|
|
10
|
+
import { getProviderEnvMap, getProviderState, isOfficial, reconcileCurrent, saveStore } from '../config/store.js';
|
|
11
|
+
import { persistDefaultEnv, 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
|
-
|
|
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
|
-
|
|
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,49 @@ 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
|
-
|
|
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
|
+
({ warn: warnFlash, toast: flash } = saveEditedProfile(paths, store, target, old, scope));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (shortcut === 's') {
|
|
98
|
+
launchSession(target);
|
|
99
|
+
}
|
|
100
|
+
else if (shortcut === 'd') {
|
|
101
|
+
({ warn: warnFlash, toast: flash } = applyDefault(paths, store, target, scope));
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
71
105
|
if (sel < 0 || sel === n + 5)
|
|
72
106
|
return; // 退出 / Esc / q
|
|
73
107
|
if (sel === n + 1) {
|
|
@@ -96,8 +130,18 @@ export async function openMenu(paths, store, scope, version, catalog) {
|
|
|
96
130
|
}
|
|
97
131
|
else if (sel < n) {
|
|
98
132
|
const target = store.providers[sel];
|
|
99
|
-
if (target)
|
|
100
|
-
|
|
133
|
+
if (target) {
|
|
134
|
+
if (!isOfficial(target) && getProviderState(target).key === 'noKey') {
|
|
135
|
+
// #9:无密钥的第三方配置,Enter 直达编辑并聚焦密钥行(铺平首次成功路径)。
|
|
136
|
+
const old = target.name;
|
|
137
|
+
if (await editForm(target, store, catalog, true)) {
|
|
138
|
+
({ warn: warnFlash, toast: flash } = saveEditedProfile(paths, store, target, old, scope));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
await actionMenu(paths, store, target, scope, catalog);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
101
145
|
if (sel >= store.providers.length)
|
|
102
146
|
sel = Math.max(0, store.providers.length - 1); // 删除后夹取
|
|
103
147
|
}
|
|
@@ -107,31 +151,45 @@ export async function openMenu(paths, store, scope, version, catalog) {
|
|
|
107
151
|
async function actionMenu(paths, store, p, scope, catalog) {
|
|
108
152
|
let sel = 0;
|
|
109
153
|
let flash;
|
|
154
|
+
let warnFlash; // 黄字警告(如缺密钥),走 notice 与绿色 status 区分
|
|
110
155
|
for (;;) {
|
|
111
156
|
const dft = p.name === store.current ? T('menu.default') : '';
|
|
112
157
|
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({
|
|
158
|
+
const items = [T('action.session'), T('action.setDefault'), T('action.check'), T('action.edit'), T('action.delete'), T('action.back')];
|
|
159
|
+
sel = await selectMenu({
|
|
160
|
+
title,
|
|
161
|
+
items,
|
|
162
|
+
start: sel,
|
|
163
|
+
...(warnFlash ? { notice: warnFlash } : {}),
|
|
164
|
+
...(flash ? { status: flash } : {}),
|
|
165
|
+
hint: T('action.hint'),
|
|
166
|
+
noNumber: true,
|
|
167
|
+
});
|
|
115
168
|
flash = undefined;
|
|
169
|
+
warnFlash = undefined;
|
|
116
170
|
if (sel === 0) {
|
|
117
171
|
launchSession(p);
|
|
118
172
|
}
|
|
119
173
|
else if (sel === 1) {
|
|
120
|
-
flash = applyDefault(paths, store, p, scope);
|
|
174
|
+
({ warn: warnFlash, toast: flash } = applyDefault(paths, store, p, scope));
|
|
121
175
|
}
|
|
122
176
|
else if (sel === 2) {
|
|
177
|
+
const result = await checkProfile(p);
|
|
178
|
+
if (result.ok)
|
|
179
|
+
flash = result.message;
|
|
180
|
+
else
|
|
181
|
+
warnFlash = result.message;
|
|
182
|
+
}
|
|
183
|
+
else if (sel === 3) {
|
|
123
184
|
const old = p.name;
|
|
124
185
|
if (await editForm(p, store, catalog)) {
|
|
125
|
-
|
|
126
|
-
store.current = p.name; // 改了名/供应商时同步默认指向
|
|
127
|
-
saveStore(paths, store);
|
|
186
|
+
({ warn: warnFlash, toast: flash } = saveEditedProfile(paths, store, p, old, scope));
|
|
128
187
|
}
|
|
129
188
|
}
|
|
130
|
-
else if (sel ===
|
|
189
|
+
else if (sel === 4) {
|
|
131
190
|
if (isOfficial(p))
|
|
132
191
|
console.log(` ${T('action.deleteOfficialWarn')}`);
|
|
133
|
-
|
|
134
|
-
if (ans === 'y' || ans === 'Y') {
|
|
192
|
+
if (await confirmKey(T('action.deleteConfirm', providerDisplayName(p)))) {
|
|
135
193
|
store.providers = store.providers.filter((x) => x !== p);
|
|
136
194
|
reconcileCurrent(store);
|
|
137
195
|
saveStore(paths, store);
|
|
@@ -143,15 +201,60 @@ async function actionMenu(paths, store, p, scope, catalog) {
|
|
|
143
201
|
}
|
|
144
202
|
}
|
|
145
203
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
204
|
+
function defaultDisplayName(store) {
|
|
205
|
+
if (!store.current)
|
|
206
|
+
return '—';
|
|
207
|
+
return providerDisplayName(store.providers.find((p) => p.name === store.current) ?? { name: store.current, env: {} });
|
|
208
|
+
}
|
|
209
|
+
function saveEditedProfile(paths, store, p, oldName, scope) {
|
|
210
|
+
const wasDefault = store.current === oldName;
|
|
211
|
+
if (wasDefault)
|
|
212
|
+
store.current = p.name; // 改了名/供应商时同步默认指向
|
|
213
|
+
saveStore(paths, store);
|
|
214
|
+
if (wasDefault)
|
|
215
|
+
return syncDefaultEnv(p, scope);
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
function defaultWarning(p) {
|
|
219
|
+
return getProviderState(p).key === 'noKey' ? T('default.noKey', providerDisplayName(p)) : '';
|
|
220
|
+
}
|
|
221
|
+
function defaultResultMessage(warn, name, r) {
|
|
150
222
|
if (r.dryRun)
|
|
151
|
-
return `${T('default.done', name)} ${T('default.dryRun')}
|
|
223
|
+
return { warn, toast: `${T('default.done', name)} ${T('default.dryRun')}` };
|
|
152
224
|
if (r.windows && !r.windows.ok)
|
|
153
|
-
return T('default.failed', r.windows.error ?? '');
|
|
225
|
+
return { warn, toast: T('default.failed', r.windows.error ?? '') };
|
|
154
226
|
if (r.unix?.unsupported)
|
|
155
|
-
return T('default.fishUnsupported');
|
|
156
|
-
return T('default.done', name);
|
|
227
|
+
return { warn, toast: T('default.fishUnsupported') };
|
|
228
|
+
return { warn, toast: T('default.done', name) };
|
|
229
|
+
}
|
|
230
|
+
function syncDefaultEnv(p, scope) {
|
|
231
|
+
const name = providerDisplayName(p);
|
|
232
|
+
return defaultResultMessage(defaultWarning(p), name, persistDefaultEnv(p, scope));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* 设为默认,返回 { warn, toast }:warn 为黄字警告(缺密钥),toast 为绿色结果。
|
|
236
|
+
* 分开返回让调用方各自上色,避免警告被染成「成功」绿。
|
|
237
|
+
*/
|
|
238
|
+
function applyDefault(paths, store, p, scope) {
|
|
239
|
+
const name = providerDisplayName(p);
|
|
240
|
+
return defaultResultMessage(defaultWarning(p), name, setDefault(paths, store, p, scope));
|
|
241
|
+
}
|
|
242
|
+
// hostSuffix 返回行尾的灰字 host(如 ` · api.deepseek.com`);无 base(官方/未填)返回空。
|
|
243
|
+
// 超宽时由 selectMenu 的 ANSI-aware 截断从行尾裁掉,不会切坏颜色。
|
|
244
|
+
function hostSuffix(p) {
|
|
245
|
+
const base = (getProviderEnvMap(p).ANTHROPIC_BASE_URL ?? '').trim();
|
|
246
|
+
if (!base)
|
|
247
|
+
return '';
|
|
248
|
+
return paint(` · ${hostOf(base)}`, 'dim');
|
|
249
|
+
}
|
|
250
|
+
function needsFirstRunHint(store) {
|
|
251
|
+
let hasThirdParty = false;
|
|
252
|
+
for (const p of store.providers) {
|
|
253
|
+
if (isOfficial(p))
|
|
254
|
+
continue;
|
|
255
|
+
hasThirdParty = true;
|
|
256
|
+
if (getProviderState(p).key !== 'noKey')
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
return hasThirdParty;
|
|
157
260
|
}
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 <=
|
|
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
|
+
}
|
package/dist/utils/display.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
22
|
+
const chars = [...s];
|
|
19
23
|
let out = '';
|
|
20
|
-
|
|
21
|
-
|
|
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 +=
|
|
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.
|
|
3
|
+
"version": "0.4.8",
|
|
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": {
|