├── .gitignore
├── example
├── settings.json
└── apiConfigs.json
├── 需求说明
├── package.json
├── ClaudeCode API站.md
├── README.md
├── health.js
├── notify.js
└── index.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
--------------------------------------------------------------------------------
/example/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "ANTHROPIC_BASE_URL": "https://zone.veloera.org/pg",
4 | "ANTHROPIC_AUTH_TOKEN": "sk-xxxxxxxx",
5 | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
6 | },
7 | "permissions": {
8 | "allow": [],
9 | "deny": []
10 | },
11 | "model": "opus"
12 | }
--------------------------------------------------------------------------------
/需求说明:
--------------------------------------------------------------------------------
1 | #指令: ccs health 健康检查行为
2 | 读取本机 ~/.claude/apiConfigs.json 文件
3 |
4 | ##测试可用性
5 | 端点: 测试 /v1/models 端点 (适用于 Claude API)。
6 | 成功标准: 同时接受 2xx (成功) 和 4xx (客户端错误) 状态码。
7 | 2xx 响应表明端点工作正常。
8 | 4xx 响应 (如 401, 403 等) 表明端点可达,但可能需要正确的身份验证。
9 | 失败标准 5xx 服务器错误表明端点存在问题。
10 | 弹性机制: 需要连续 2 次失败才会将端点标记为不健康。
11 | ## 测试网络延迟
12 |
13 | 依次输出 输出name,ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN 的可用性和网络延迟
14 |
15 | #代码开发要求:放在单独的health.js 文件中,index.js文件中只需要引入health.js 文件并调用
16 |
17 |
18 |
--------------------------------------------------------------------------------
/example/apiConfigs.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "wenwen-ai",
4 | "config": {
5 | "env": {
6 | "ANTHROPIC_AUTH_TOKEN": "sk-hsg5hOghxxxxxxxHEk1rKHRxx",
7 | "ANTHROPIC_BASE_URL": "https://code.wenwen-ai.com",
8 | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
9 | },
10 | "permissions": {
11 | "allow": [],
12 | "deny": []
13 | },
14 | "model": "opus"
15 | }
16 | },
17 | {
18 | "name": "zone",
19 | "config": {
20 | "env": {
21 | "ANTHROPIC_AUTH_TOKEN": "sk-xxxxxxxxxxxx",
22 | "ANTHROPIC_BASE_URL": "https://zone.veloera.org/pg",
23 | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
24 | },
25 | "permissions": {
26 | "allow": [],
27 | "deny": []
28 | },
29 | "model": "opus"
30 | }
31 | }
32 | ]
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "claude-config-switch",
3 | "version": "1.7.0",
4 | "description": "Claude配置切换工具及其他工具",
5 | "main": "index.js",
6 | "bin": {
7 | "ccs": "index.js"
8 | },
9 | "scripts": {
10 | "test": "echo \"Error: no test specified\" && exit 1"
11 | },
12 | "keywords": [
13 | "claude",
14 | "config",
15 | "switch",
16 | "api",
17 | "cli",
18 | "anthropic"
19 | ],
20 | "author": "canglong",
21 | "license": "MIT",
22 | "repository": {
23 | "type": "git",
24 | "url": "git+https://github.com/canglong/claude-code-switch.git"
25 | },
26 | "homepage": "https://github.com/canglong/claude-code-switch",
27 | "bugs": {
28 | "url": "https://github.com/canglong/claude-code-switch/issues"
29 | },
30 | "dependencies": {
31 | "chalk": "^4.1.2",
32 | "commander": "^11.1.0",
33 | "inquirer": "^8.2.5"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/ClaudeCode API站.md:
--------------------------------------------------------------------------------
1 | | 网站 | 访问 |
2 | | ------------------------------------------------------------------------------------------- | --------------------------------------------------- |
3 | | [https://instcopilot-api.com/register?aff=J2wX](https://instcopilot-api.com/register?aff=J2wX) | **强烈推荐这个**
倍率高5块钱相当于10美金 |
4 | | https://co.yes.vg/register?ref=ALVIN1MS | |
5 | | [https://a-generic.be-a.dev/register?aff=2khE](https://a-generic.be-a.dev/register?aff=2khE) | |
6 | | [https://anyrouter.top/register?aff=XkkW](https://anyrouter.top/register?aff=XkkW) | |
7 | | [https://b4u.qzz.io/register?aff=HuA0](https://b4u.qzz.io/register?aff=HuA0) | |
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Claude配置切换工具 (CCS)
2 |
3 | 一个用于在不同的Claude API配置之间进行切换的命令行工具。
4 |
5 | [ClaudeCode API 站点推荐](https://github.com/AlvinScrp/claude-code-switch/blob/main/ClaudeCode%20API%E7%AB%99.md)
6 |
7 | **强烈推荐这个**倍率高5块钱相当于10美金 :[https://instcopilot-api.com/register?aff=J2wX](https://instcopilot-api.com/register?aff=J2wX)
8 |
9 | ## 安装
10 |
11 | ### npm包安装
12 |
13 | 已发布到https://www.npmjs.com/package/claude-config-switch
14 |
15 | ```
16 | npm i claude-config-switch
17 | ```
18 |
19 | ### 本地安装
20 |
21 | ```bash
22 | # 克隆仓库
23 | git clone <仓库地址>
24 | cd claude-code-switch
25 |
26 | # 安装依赖
27 | npm install
28 |
29 | # 全局安装
30 | npm install -g .
31 | ```
32 |
33 | ### 依赖项
34 |
35 | - Node.js (>= 12.0.0)
36 | - npm (>= 6.0.0)
37 | - 依赖库:
38 | - commander: 命令行界面解析
39 | - chalk: 终端彩色输出
40 | - inquirer: 交互式命令行用户界面
41 |
42 | ## 使用方法
43 |
44 | ### 配置文件
45 |
46 | 工具需要三个配置文件,都位于 `~/.claude/` 目录下:
47 |
48 | #### 1. apiConfigs.json - API配置列表
49 |
50 | 存储所有可用的Claude API配置,格式如下:
51 |
52 | ```json
53 | [
54 | {
55 | "name": "wenwen-ai",
56 | "config": {
57 | "env": {
58 | "ANTHROPIC_AUTH_TOKEN": "sk-XXXXXXX",
59 | "ANTHROPIC_BASE_URL": "https://code.wenwen-ai.com",
60 | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
61 | },
62 | "permissions": {
63 | "allow": [],
64 | "deny": []
65 | },
66 | "model": "claude-sonnet-4-20250514"
67 | }
68 | },
69 | {
70 | "name": "zone",
71 | "config": {
72 | "env": {
73 | "ANTHROPIC_AUTH_TOKEN": "sk-XXXXXXX",
74 | "ANTHROPIC_BASE_URL": "https://zone.veloera.org/pg",
75 | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
76 | },
77 | "permissions": {
78 | "allow": [],
79 | "deny": []
80 | },
81 | "model": "claude-sonnet-4-20250514"
82 | }
83 | }
84 | ]
85 | ```
86 |
87 | #### 2. settings.json - 当前激活配置
88 |
89 | 存储当前使用的配置,格式如下:
90 |
91 | ```json
92 | {
93 | "env": {
94 | "ANTHROPIC_BASE_URL": "https://zone.veloera.org/pg",
95 | "ANTHROPIC_AUTH_TOKEN": "sk-XXXXXXX",
96 | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
97 | },
98 | "permissions": {
99 | "allow": [],
100 | "deny": []
101 | },
102 | "model": "claude-sonnet-4-20250514"
103 | }
104 | ```
105 |
106 | **注意**:切换配置时,整个 `settings.json` 文件会被选中配置的 `config` 对象完全替换。
107 |
108 | ### 命令
109 |
110 | #### 列出所有可用的API配置并提示选择
111 |
112 | ```bash
113 | ccs list
114 | # 或使用简写
115 | ccs ls
116 | ```
117 |
118 | 输出示例:
119 |
120 | ```
121 | ? 请选择要切换的配置: (Use arrow keys)
122 | > 1. [wenwen-ai ] sk-XXXXXXX https://code.wenwen-ai.com (当前)
123 | 2. [zone ] sk-XXXXXXX https://zone.veloera.org/pg
124 | 3. [co.yes.vg ] sk-XXXXXXX https://co.yes.vg/api
125 | 4. [a-generic ] sk-XXXXXXX https://a-generic.be-a.dev/api
126 | ──────────────
127 | 输入序号...
128 |
129 | ? 请选择要切换的配置: 2. [zone ] sk-XXXXXXX https://zone.veloera.org/pg
130 |
131 | 当前选择的配置:
132 | {
133 | "name": "zone",
134 | "config": {
135 | "env": {
136 | "ANTHROPIC_AUTH_TOKEN": "sk-xxxx",
137 | "ANTHROPIC_BASE_URL": "https://zone.veloera.org/pg",
138 | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
139 | },
140 | "permissions": {
141 | "allow": [],
142 | "deny": []
143 | },
144 | "model": "claude-sonnet-4-20250514"
145 | }
146 | }
147 |
148 | ? 确认切换到此配置? Yes
149 |
150 | 成功切换到配置: zone
151 | ```
152 |
153 | **交互方式**:
154 |
155 | 1. **光标选择**: 使用键盘上下箭头选择配置,按Enter确认
156 | 2. **手动输入**: 选择"输入序号..."选项,然后输入配置的序号
157 |
158 | #### 直接设置当前使用的API配置
159 |
160 | ```bash
161 | ccs use <序号>
162 | ```
163 |
164 | 例如:
165 |
166 | ```bash
167 | ccs use 2
168 | ```
169 |
170 | 输出示例:
171 |
172 | ```
173 | 当前选择的配置:
174 | {
175 | "name": "zone",
176 | "config": {
177 | "env": {
178 | "ANTHROPIC_AUTH_TOKEN": "sk-xxxxxx",
179 | "ANTHROPIC_BASE_URL": "https://zone.veloera.org/pg",
180 | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
181 | },
182 | "permissions": {
183 | "allow": [],
184 | "deny": []
185 | },
186 | "model": "claude-sonnet-4-20250514"
187 | }
188 | }
189 |
190 | ? 确认切换到此配置? Yes
191 |
192 | 成功切换到配置: zone
193 | ```
194 |
195 | #### 打开配置文件
196 |
197 | ```bash
198 | # 打开API配置文件 (apiConfigs.json)
199 | ccs o api
200 |
201 | # 打开设置配置文件 (settings.json)
202 | ccs o setting
203 | ```
204 |
205 | 这些命令会在默认编辑器中打开相应的配置文件,方便直接编辑配置。如果文件不存在,会自动创建包含示例内容的配置文件。
206 |
207 | **自动创建的示例内容**:
208 |
209 | - **API配置文件** (`apiConfigs.json`):包含一个示例配置,使用最新的Claude Sonnet 4模型
210 | - **设置配置文件** (`settings.json`):包含基本的环境变量和权限设置
211 |
212 | 只需将示例中的 `sk-YOUR_API_KEY_HERE` 替换为实际的API密钥即可使用。
213 |
214 | #### 健康检查功能
215 |
216 | 使用 `health` 命令可以检查所有配置的API端点的可用性和网络延迟:
217 |
218 | ```bash
219 | ccs health
220 | ```
221 |
222 | **功能特性**:
223 |
224 | - **智能端点检测**:自动尝试多种常见的API端点格式
225 | - **去重优化**:相同的 `ANTHROPIC_BASE_URL` 只检测一次
226 | - **实时反馈**:逐行显示检测进度和结果
227 | - **多端点支持**:支持以下端点格式:
228 | - `/v1/models` - Claude Models API (默认)
229 | - `/v1/chat/completions` - OpenAI 兼容 API
230 | - `/v1/models` (无 anthropic-version 头)
231 | - `/` - 根路径
232 | - `/health` - 健康检查端点
233 | - `/api/v1/models` - 备用API路径
234 |
235 | **输出示例**:
236 |
237 | ```
238 | 开始健康检查 (/v1/models)...
239 |
240 | | Name | Base URL | Token | Status | Latency |
241 | |----------------------|------------------------------------|--------------|--------------------------|---------|
242 | | instcopilot-config | https://sg.instcopilot-api.com | sk-JCzb7**** | Healthy (status: 200) | 414ms |
243 | | co.yes.vg-config | https://co.yes.vg | cr_2dc6**** | Healthy (status: 200) | 892ms |
244 | → Found working endpoint: /v1/chat/completions (OpenAI Compatible API)
245 | | anthropic-official | https://api.anthropic.com | sk-ant-**** | Unhealthy (status: 401) | 1205ms |
246 | Error: Unauthorized
247 | ```
248 |
249 | **状态说明**:
250 |
251 | - **绿色 Healthy (2xx)**:端点正常工作
252 | - **红色 Unhealthy (4xx/5xx)**:端点有问题或鉴权失败
253 | - **黄色 Unhealthy**:网络错误或超时
254 |
255 | **Token 显示格式**:前7位 + **** (如:`sk-JCzb7****`)
256 |
257 | 当检测到非默认端点可用时,会显示实际工作的端点信息,帮助用户了解服务的实际API格式。
258 |
259 | #### 企微通知配置
260 |
261 | ##### 设置企微通知
262 |
263 | ```bash
264 | ccs notify setup
265 | # 或使用简写
266 | ccs ntf setup
267 | ```
268 |
269 | 配置企微机器人通知功能:
270 |
271 | 1. 在企微群聊中添加机器人
272 | 2. 获取机器人的Webhook地址
273 | 3. 输入Webhook地址完成配置
274 | 4. 自动配置ClaudeCode Hooks
275 |
276 | notify.json - 通知配置
277 |
278 | 存储企微机器人等通知渠道的配置,格式如下:
279 |
280 | ```json
281 | {
282 | "wechatWork": {
283 | "webhookUrl": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
284 | "enabled": true
285 | },
286 | "telegram": {
287 | "enabled": false
288 | },
289 | "slack": {
290 | "enabled": false
291 | }
292 | }
293 | ```
294 |
295 | **说明**:
296 |
297 | - `wechatWork.webhookUrl`: 企微群机器人的Webhook地址
298 | - `wechatWork.enabled`: 是否启用企微通知
299 | - 其他通知渠道为预留配置,暂未实现
300 |
301 | 输出示例:
302 |
303 | ```
304 | 设置企微机器人通知配置:
305 | 请在企微群聊中添加机器人,获取Webhook地址
306 | 格式: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY
307 | 配置完成后,将自动通过ClaudeCode Hooks监听Notification和Stop事件
308 |
309 | 请输入企微机器人Webhook地址: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
310 |
311 | 通知配置已保存到: /Users/username/.claude/notify.json
312 | ✓ Hook脚本已创建: /Users/username/.claude/scripts/wechat-notify.js
313 | ✓ ClaudeCode Hooks配置已更新
314 |
315 | 配置完成!现在ClaudeCode将在以下事件时发送企微通知:
316 | - Notification事件: 当Claude需要用户关注时
317 | - Stop事件: 当Claude任务完成时
318 | ```
319 |
320 | ##### 查看通知状态
321 |
322 | ```bash
323 | ccs notify status
324 | ```
325 |
326 | 显示当前通知配置状态,包括:
327 |
328 | - 企微机器人启用状态
329 | - Hook事件配置状态
330 | - ClaudeCode hooks配置状态
331 |
332 | 输出示例:
333 |
334 | ```
335 | 当前通知配置状态:
336 | 企微机器人: 已启用
337 | Webhook地址: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=***
338 | Hook事件: 已启用
339 | Notification事件: 启用
340 | Stop事件: 启用
341 | ClaudeCode hooks配置: 已配置
342 | ```
343 |
344 | ##### 测试通知功能
345 |
346 | ```bash
347 | ccs notify test
348 | ```
349 |
350 | 发送测试通知到配置的企微群:
351 |
352 | 输出示例:
353 |
354 | ```
355 | 正在发送测试通知...
356 | ✓ 企微通知发送成功
357 | ```
358 |
359 | #### 显示版本信息
360 |
361 | ```bash
362 | ccs --version
363 | # 或
364 | ccs -v
365 | ```
366 |
367 | 输出示例:
368 |
369 | ```
370 | ccs 版本: 1.6.0
371 | ```
372 |
373 | #### 显示帮助信息
374 |
375 | ```bash
376 | ccs --help
377 | ```
378 |
379 | 输出示例:
380 |
381 | ```
382 | Usage: ccs [options] [command]
383 |
384 | Claude配置切换工具
385 |
386 | Options:
387 | -v, --version 显示版本信息
388 | -h, --help display help for command
389 |
390 | Commands:
391 | list, ls 列出所有可用的API配置并提示选择
392 | use 设置当前使用的API配置
393 | health 检查各API端点的可用性与网络延迟
394 | notify, ntf 配置企微通知设置
395 | setup 设置企微机器人webhook地址
396 | status 查看当前通知配置状态
397 | test 测试企微通知功能
398 | o 打开Claude配置文件
399 | api 打开API配置文件 (apiConfigs.json)
400 | setting 打开设置配置文件 (settings.json)
401 | help [command] display help for command
402 | ```
403 |
404 | #### 错误处理
405 |
406 | 当输入不存在的命令时,会显示错误信息和可用命令列表:
407 |
408 | ```bash
409 | ccs unknown
410 | ```
411 |
412 | 输出示例:
413 |
414 | ```
415 | 错误: 未知命令 'unknown'
416 |
417 | 可用命令:
418 | list
419 | ls
420 | use
421 | health
422 | notify
423 | ntf
424 | o
425 |
426 | 使用 --help 查看更多信息
427 | ```
428 |
429 | ## 注意事项
430 |
431 | - 确保 `~/.claude/apiConfigs.json` 和 `~/.claude/settings.json` 文件存在并包含有效的配置信息
432 | - 工具会自动创建 `~/.claude` 目录(如果不存在)
433 | - 确认操作时默认为"是",直接按Enter键即可确认
434 | - 切换配置时会完全替换 `settings.json` 文件内容
435 | - 使用 `ntf` 命令需要先在企微群中添加机器人并获取Webhook地址
436 | - `notify.json` 文件首次使用时会自动创建
437 |
438 | ## 更新日志
439 |
440 | ### 1.7.0 健康检查
441 |
442 | ccs health
443 |
444 | ### **1.6.0 通知功能**
445 |
446 | + **新增企微通知功能**: 支持通过企微机器人接收Claude Code通知
447 | + **智能Hook集成**: 自动配置ClaudeCode Hooks,无需手动监听
448 | + **事件通知支持**:
449 | - `Notification`事件: 当Claude需要用户关注时自动通知
450 | - `Stop`事件: 当Claude任务完成时自动通知
451 | + **完整通知管理**:
452 | - `ccs notify setup` - 配置企微机器人
453 | - `ccs notify status` - 查看通知配置状态
454 | - `ccs notify test` - 测试通知功能
455 | + **自动化配置**: 自动创建Hook脚本和更新Claude设置文件
456 | + **配置文件支持**: 新增 `notify.json`配置文件管理通知设置
457 |
458 | ### **1.5.0**
459 |
460 | + 新增配置切换成功后的详细信息显示(名称、API Key、Base URL、Model)
461 | + 新增自动询问是否启动 Claude CLI 功能:切换配置成功后会询问是否在当前目录运行 `claude` 命令
462 | + 改进用户体验:一站式完成配置切换和 Claude 启动流程
463 |
464 | ### 1.4.0
465 |
466 | - 优化配置文件打开功能:文件不存在时自动创建包含示例内容的配置文件
467 | - 更新默认模型为 `claude-sonnet-4-20250514`(Claude Sonnet 4)
468 | - 改进用户体验:新用户可以立即开始使用工具,无需手动创建配置文件
469 |
470 | ### 1.3.0
471 |
472 | - 新增 `ccs ls` 命令作为 `ccs list` 的简写
473 | - 新增 `ccs o api` 命令用于打开API配置文件 (apiConfigs.json)
474 | - 新增 `ccs o setting` 命令用于打开设置配置文件 (settings.json)
475 | - 改进配置文件编辑体验,可以直接在默认编辑器中修改配置
476 |
477 | ### 1.2.0
478 |
479 | - 支持完整的Claude配置对象格式
480 | - 新增配置深度比较功能,准确识别当前激活配置
481 | - 优化配置显示格式,对齐显示配置名称
482 | - 更新配置文件结构,支持env、permissions、model等完整配置项
483 |
484 | ### 1.1.0
485 |
486 | - 添加交互式菜单,支持光标上下移动选择
487 | - 保留原有的序号输入功能
488 | - 优化用户体验,确认操作时默认为"是"
489 |
490 | ### 1.0.0
491 |
492 | - 初始版本发布
493 | - 基本的API配置切换功能
494 |
--------------------------------------------------------------------------------
/health.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 健康检查模块
3 | * 提供 ccs health 命令:读取 ~/.claude/apiConfigs.json,依次检测各端点健康与网络延迟
4 | */
5 |
6 | const fs = require('fs');
7 | const path = require('path');
8 | const os = require('os');
9 | const https = require('https');
10 | const chalk = require('chalk');
11 |
12 | // 配置路径
13 | const CONFIG_DIR = path.join(os.homedir(), '.claude');
14 | const API_CONFIGS_FILE = path.join(CONFIG_DIR, 'apiConfigs.json');
15 |
16 | /**
17 | * 读取API配置文件
18 | * @returns {Array} API配置数组(可能为空)
19 | */
20 | function readApiConfigs() {
21 | try {
22 | if (!fs.existsSync(API_CONFIGS_FILE)) {
23 | console.log(chalk.yellow(`警告: API配置文件不存在 (${API_CONFIGS_FILE})`));
24 | return [];
25 | }
26 | const data = fs.readFileSync(API_CONFIGS_FILE, 'utf8');
27 | return JSON.parse(data);
28 | } catch (error) {
29 | console.error(chalk.red(`读取API配置文件失败: ${error.message}`));
30 | return [];
31 | }
32 | }
33 |
34 | /**
35 | * 探测指定端点
36 | * - 将 2xx 与 4xx 视为可达(成功)
37 | * - 将 5xx 或网络错误视为失败
38 | * - 返回 { ok, statusCode, latencyMs, error, endpoint }
39 | * @param {string} baseUrl 基础URL,如 https://api.anthropic.com
40 | * @param {string} authToken API密钥用于鉴权
41 | * @param {string} endpoint 端点路径,如 '/v1/models'
42 | * @param {object} options 额外选项
43 | * @returns {Promise<{ok:boolean,statusCode:number|undefined,latencyMs:number|undefined,error:Error|undefined,endpoint:string}>}
44 | */
45 | function probeEndpoint(baseUrl, authToken, endpoint = '/v1/models', options = {}) {
46 | return new Promise((resolve) => {
47 | let urlString = baseUrl.endsWith('/') ? `${baseUrl}${endpoint.substring(1)}` : `${baseUrl}${endpoint}`;
48 | let timedOut = false;
49 | const start = Date.now();
50 |
51 | try {
52 | const urlObj = new URL(urlString);
53 | const requestOptions = {
54 | hostname: urlObj.hostname,
55 | port: urlObj.port || 443,
56 | path: urlObj.pathname + urlObj.search,
57 | method: options.method || 'GET',
58 | headers: {
59 | 'Accept': 'application/json',
60 | 'Authorization': `Bearer ${authToken}`,
61 | // 'x-api-key': authToken,
62 | ...(options.includeAnthropicVersion !== false && { 'anthropic-version': '2023-06-01' }),
63 | ...(options.contentType && { 'Content-Type': options.contentType }),
64 | ...options.extraHeaders
65 | },
66 | timeout: 30000
67 | };
68 |
69 | const req = https.request(requestOptions, (res) => {
70 | // 消耗响应体以完成请求
71 | res.on('data', () => {});
72 | res.on('end', () => {
73 | const latencyMs = Date.now() - start;
74 | const status = res.statusCode || 0;
75 | const ok = status < 500; // 2xx/4xx 视为可达
76 | resolve({ ok, statusCode: status, latencyMs, error: undefined, endpoint });
77 | });
78 | });
79 |
80 | req.on('timeout', () => {
81 | timedOut = true;
82 | req.destroy(new Error('Request timeout'));
83 | });
84 |
85 | req.on('error', (err) => {
86 | const latencyMs = Date.now() - start;
87 | resolve({ ok: false, statusCode: undefined, latencyMs, error: timedOut ? new Error('timeout') : err, endpoint });
88 | });
89 |
90 | if (options.body) {
91 | req.write(options.body);
92 | }
93 | req.end();
94 | } catch (e) {
95 | resolve({ ok: false, statusCode: undefined, latencyMs: undefined, error: e, endpoint });
96 | }
97 | });
98 | }
99 |
100 | /**
101 | * 对单个配置执行智能健康检查
102 | * 如果 /v1/models 返回 404,会尝试其他常见端点
103 | * @param {object} configItem apiConfigs.json中单项
104 | * @returns {Promise<{name:string, baseUrl:string, tokenPresent:boolean, healthy:boolean, statusCodes:number[], latencies:number[], error:Error|undefined, endpoint:string}>}
105 | */
106 | async function checkConfigHealth(configItem) {
107 | const name = configItem?.name || 'unknown';
108 | const env = configItem?.config?.env || {};
109 | const baseUrl = env.ANTHROPIC_BASE_URL || '';
110 | const tokenPresent = Boolean(env.ANTHROPIC_AUTH_TOKEN);
111 | const authToken = env.ANTHROPIC_AUTH_TOKEN || '';
112 |
113 | // 定义要尝试的端点
114 | const endpointsToTry = [
115 | { path: '/v1/models', description: 'Claude Models API' },
116 | { path: '/v1/chat/completions', description: 'OpenAI Compatible API',
117 | options: {
118 | method: 'POST',
119 | contentType: 'application/json',
120 | body: JSON.stringify({
121 | model: 'claude-3-sonnet-20240229',
122 | messages: [{ role: 'user', content: 'test' }],
123 | max_tokens: 1
124 | })
125 | }
126 | },
127 | { path: '/v1/models', description: 'No Anthropic Version', options: { includeAnthropicVersion: false } },
128 | { path: '/', description: 'Root Path' },
129 | { path: '/health', description: 'Health Check' },
130 | { path: '/api/v1/models', description: 'Alternative API Path' }
131 | ];
132 |
133 | let bestResult = null;
134 | let testedEndpoints = [];
135 |
136 | for (const endpointConfig of endpointsToTry) {
137 | const result = await probeEndpoint(baseUrl, authToken, endpointConfig.path, endpointConfig.options || {});
138 | testedEndpoints.push(`${endpointConfig.path}:${result.statusCode}`);
139 |
140 | // 如果是 2xx 状态码,直接返回成功结果
141 | if (result.statusCode >= 200 && result.statusCode < 300) {
142 | return {
143 | name,
144 | baseUrl,
145 | tokenPresent,
146 | healthy: true,
147 | statusCodes: [result.statusCode],
148 | latencies: [result.latencyMs],
149 | error: undefined,
150 | endpoint: `${endpointConfig.path} (${endpointConfig.description})`
151 | };
152 | }
153 |
154 | // 记录最好的结果(最低的错误代码或最早的可达结果)
155 | if (!bestResult || (result.ok && !bestResult.ok) ||
156 | (result.statusCode && (!bestResult.statusCode || result.statusCode < bestResult.statusCode))) {
157 | bestResult = { ...result, description: endpointConfig.description };
158 | }
159 | }
160 |
161 | // 如果没有找到 2xx 响应,返回最好的结果
162 | const healthy = bestResult ? bestResult.ok : false;
163 | return {
164 | name,
165 | baseUrl,
166 | tokenPresent,
167 | healthy,
168 | statusCodes: bestResult ? [bestResult.statusCode] : [],
169 | latencies: bestResult ? [bestResult.latencyMs] : [],
170 | error: bestResult?.error,
171 | endpoint: bestResult ? `${bestResult.endpoint} (${bestResult.description})` : 'All endpoints failed'
172 | };
173 | }
174 |
175 | /**
176 | * 掩码显示API Key,显示前7位+****
177 | * @param {boolean} present 是否存在
178 | * @param {string} token 原始token
179 | * @returns {string} 掩码显示
180 | */
181 | function maskToken(present, token) {
182 | if (!present) return 'N/A';
183 | if (!token || token.length < 7) return '****';
184 | return `${token.slice(0, 7)}****`;
185 | }
186 |
187 | /**
188 | * 注册 health 命令
189 | * @param {import('commander').Command} program Commander实例
190 | */
191 | function registerHealthCommands(program) {
192 | program
193 | .command('health')
194 | .description('检查各API端点的可用性与网络延迟')
195 | .action(async () => {
196 | const apiConfigs = readApiConfigs();
197 | if (apiConfigs.length === 0) {
198 | console.log(chalk.yellow('没有找到可用的API配置'));
199 | return;
200 | }
201 |
202 | console.log(chalk.cyan('开始健康检查 (/v1/models)...\n'));
203 |
204 | // 去重URL,只检测每个URL的第一个配置
205 | const uniqueUrls = new Set();
206 | const configsToCheck = [];
207 |
208 | apiConfigs.forEach(item => {
209 | const env = item?.config?.env || {};
210 | const baseUrl = env.ANTHROPIC_BASE_URL || '';
211 | const token = env.ANTHROPIC_AUTH_TOKEN || '';
212 |
213 | // 只处理首次出现的URL
214 | if (!uniqueUrls.has(baseUrl) && baseUrl) {
215 | uniqueUrls.add(baseUrl);
216 | configsToCheck.push({
217 | item,
218 | token,
219 | baseUrl
220 | });
221 | }
222 | });
223 |
224 | // 固定宽度设置
225 | const nameWidth = 18;
226 | const fixedUrlWidth = 30;
227 | const tokenWidth = 12;
228 | const statusWidth = 22;
229 |
230 | // 表头
231 | const nameHeader = 'Name'.padEnd(nameWidth);
232 | const urlHeader = 'Base URL'.padEnd(fixedUrlWidth);
233 | const tokenHeader = 'Token'.padEnd(tokenWidth);
234 | const statusHeader = 'Status'.padEnd(statusWidth);
235 | const latencyHeader = 'Latency';
236 |
237 | console.log(chalk.bold(`| ${nameHeader} | ${urlHeader} | ${tokenHeader} | ${statusHeader} | ${latencyHeader} |`));
238 | console.log(`|${'-'.repeat(nameWidth + 2)}|${'-'.repeat(fixedUrlWidth + 2)}|${'-'.repeat(tokenWidth + 2)}|${'-'.repeat(statusWidth + 2)}|${'-'.repeat(10)}|`);
239 |
240 | // 逐个检测并输出
241 | for (const config of configsToCheck) {
242 | const name = (config.item?.name || 'unknown').length > nameWidth
243 | ? (config.item?.name || 'unknown').substring(0, nameWidth - 3) + '...'
244 | : (config.item?.name || 'unknown').padEnd(nameWidth);
245 | const url = config.baseUrl.length > fixedUrlWidth
246 | ? config.baseUrl.substring(0, fixedUrlWidth - 3) + '...'
247 | : config.baseUrl.padEnd(fixedUrlWidth);
248 | const tokenMasked = maskToken(Boolean(config.token), config.token).padEnd(tokenWidth);
249 |
250 | // 显示检测中状态
251 | const checkingStatus = chalk.yellow('Checking...').padEnd(statusWidth + (chalk.yellow('Checking...').length - 'Checking...'.length));
252 | process.stdout.write(`| ${name} | ${url} | ${tokenMasked} | ${checkingStatus} | ... |\r`);
253 |
254 | // 执行检测
255 | const endpointResult = await checkConfigHealth(config.item);
256 |
257 | const latencyText = endpointResult.latencies.length
258 | ? `${Math.round(endpointResult.latencies[0])}ms`
259 | : 'N/A';
260 |
261 | const statusCode = endpointResult.statusCodes.length ? endpointResult.statusCodes[0] : 'N/A';
262 | const healthStatus = endpointResult.healthy ? 'Healthy' : 'Unhealthy';
263 | const statusText = `${healthStatus} (status: ${statusCode})`;
264 |
265 | // 根据状态码着色
266 | let coloredStatus;
267 | if (statusCode >= 200 && statusCode < 300) {
268 | coloredStatus = chalk.green(statusText);
269 | } else if (statusCode >= 400 || (typeof statusCode === 'string' && statusCode !== 'N/A')) {
270 | coloredStatus = chalk.red(statusText);
271 | } else {
272 | coloredStatus = chalk.yellow(statusText);
273 | }
274 |
275 | const statusFormatted = coloredStatus.padEnd(statusWidth + (coloredStatus.length - statusText.length));
276 |
277 | // 清除当前行并输出最终结果
278 | process.stdout.write('\r\x1b[K');
279 | console.log(`| ${name} | ${url} | ${tokenMasked} | ${statusFormatted} | ${latencyText} |`);
280 |
281 | if (!endpointResult.healthy && endpointResult.error) {
282 | console.log(chalk.gray(` Error: ${endpointResult.error.message}`));
283 | }
284 |
285 | // // 显示找到的可用端点信息
286 | // if (endpointResult.endpoint && endpointResult.endpoint !== '/v1/models (Claude Models API)') {
287 | // console.log(chalk.cyan(` → Found working endpoint: ${endpointResult.endpoint}`));
288 | // }
289 | }
290 | });
291 | }
292 |
293 | module.exports = { registerHealthCommands };
294 |
295 |
--------------------------------------------------------------------------------
/notify.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 通知功能模块
3 | * 处理企微机器人通知相关的所有功能
4 | */
5 |
6 | const fs = require('fs');
7 | const path = require('path');
8 | const chalk = require('chalk');
9 | const os = require('os');
10 | const readline = require('readline');
11 | const https = require('https');
12 |
13 | // 配置文件路径
14 | const CONFIG_DIR = path.join(os.homedir(), '.claude');
15 | const NOTIFY_CONFIG_FILE = path.join(CONFIG_DIR, 'notify.json');
16 |
17 | /**
18 | * 创建readline接口
19 | * @returns {readline.Interface} readline接口
20 | */
21 | function createReadlineInterface() {
22 | return readline.createInterface({
23 | input: process.stdin,
24 | output: process.stdout
25 | });
26 | }
27 |
28 | /**
29 | * 确保配置目录存在
30 | */
31 | function ensureConfigDir() {
32 | if (!fs.existsSync(CONFIG_DIR)) {
33 | fs.mkdirSync(CONFIG_DIR, { recursive: true });
34 | console.log(chalk.green(`创建配置目录: ${CONFIG_DIR}`));
35 | }
36 | }
37 |
38 | /**
39 | * 读取通知配置文件
40 | * @returns {Object} 通知配置对象
41 | */
42 | function readNotifyConfig() {
43 | try {
44 | if (!fs.existsSync(NOTIFY_CONFIG_FILE)) {
45 | return null;
46 | }
47 |
48 | const data = fs.readFileSync(NOTIFY_CONFIG_FILE, 'utf8');
49 | return JSON.parse(data);
50 | } catch (error) {
51 | console.error(chalk.red(`读取通知配置文件失败: ${error.message}`));
52 | return null;
53 | }
54 | }
55 |
56 | /**
57 | * 保存通知配置文件
58 | * @param {Object} config 通知配置对象
59 | */
60 | function saveNotifyConfig(config) {
61 | try {
62 | ensureConfigDir();
63 | fs.writeFileSync(NOTIFY_CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
64 | console.log(chalk.green(`通知配置已保存到: ${NOTIFY_CONFIG_FILE}`));
65 | } catch (error) {
66 | console.error(chalk.red(`保存通知配置文件失败: ${error.message}`));
67 | process.exit(1);
68 | }
69 | }
70 |
71 | /**
72 | * 发送企微机器人通知
73 | * @param {string} webhookUrl 企微机器人webhook地址
74 | * @param {string} message 通知消息
75 | */
76 | function sendWeChatWorkNotification(webhookUrl, message) {
77 | const postData = JSON.stringify({
78 | msgtype: 'text',
79 | text: {
80 | content: message
81 | }
82 | });
83 |
84 | const url = new URL(webhookUrl);
85 | const options = {
86 | hostname: url.hostname,
87 | port: url.port || 443,
88 | path: url.pathname + url.search,
89 | method: 'POST',
90 | headers: {
91 | 'Content-Type': 'application/json',
92 | 'Content-Length': Buffer.byteLength(postData)
93 | }
94 | };
95 |
96 | const req = https.request(options, (res) => {
97 | let data = '';
98 | res.on('data', (chunk) => {
99 | data += chunk;
100 | });
101 | res.on('end', () => {
102 | try {
103 | const response = JSON.parse(data);
104 | if (response.errcode === 0) {
105 | console.log(chalk.green('✓ 企微通知发送成功'));
106 | } else {
107 | console.error(chalk.red(`企微通知发送失败: ${response.errmsg}`));
108 | }
109 | } catch (error) {
110 | console.error(chalk.red(`解析企微响应失败: ${error.message}`));
111 | }
112 | });
113 | });
114 |
115 | req.on('error', (error) => {
116 | console.error(chalk.red(`企微通知发送失败: ${error.message}`));
117 | });
118 |
119 | req.write(postData);
120 | req.end();
121 | }
122 |
123 | /**
124 | * 创建企微通知Hook脚本
125 | * @param {string} scriptPath Hook脚本的完整路径
126 | */
127 | function createWeChatNotifyScript(scriptPath) {
128 | const scriptContent = `#!/usr/bin/env node
129 | /**
130 | * ClaudeCode企微通知Hook脚本
131 | * 由claude-code-switch自动生成
132 | */
133 |
134 | const https = require('https');
135 | const fs = require('fs');
136 | const path = require('path');
137 |
138 | const NOTIFY_CONFIG_PATH = path.join(require('os').homedir(), '.claude', 'notify.json');
139 |
140 | /**
141 | * 发送企微机器人通知
142 | * @param {string} webhookUrl 企微机器人webhook地址
143 | * @param {string} message 通知消息
144 | */
145 | function sendWeChatWorkNotification(webhookUrl, message) {
146 | const postData = JSON.stringify({
147 | msgtype: 'text',
148 | text: {
149 | content: message
150 | }
151 | });
152 |
153 | const url = new URL(webhookUrl);
154 | const options = {
155 | hostname: url.hostname,
156 | port: url.port || 443,
157 | path: url.pathname + url.search,
158 | method: 'POST',
159 | headers: {
160 | 'Content-Type': 'application/json',
161 | 'Content-Length': Buffer.byteLength(postData)
162 | }
163 | };
164 |
165 | const req = https.request(options, (res) => {
166 | let data = '';
167 | res.on('data', (chunk) => {
168 | data += chunk;
169 | });
170 | res.on('end', () => {
171 | try {
172 | const response = JSON.parse(data);
173 | if (response.errcode !== 0) {
174 | console.error('企微通知发送失败:', response.errmsg);
175 | }
176 | } catch (error) {
177 | console.error('解析企微响应失败:', error.message);
178 | }
179 | });
180 | });
181 |
182 | req.on('error', (error) => {
183 | console.error('企微通知发送失败:', error.message);
184 | });
185 |
186 | req.write(postData);
187 | req.end();
188 | }
189 |
190 | /**
191 | * 读取通知配置
192 | */
193 | function readNotifyConfig() {
194 | try {
195 | if (fs.existsSync(NOTIFY_CONFIG_PATH)) {
196 | const data = fs.readFileSync(NOTIFY_CONFIG_PATH, 'utf8');
197 | return JSON.parse(data);
198 | }
199 | } catch (error) {
200 | console.error('读取通知配置失败:', error.message);
201 | }
202 | return null;
203 | }
204 |
205 | // 处理命令行参数
206 | const eventType = process.argv[2];
207 | const config = readNotifyConfig();
208 |
209 | // 检查是否启用通知
210 | if (!config || !config.wechatWork?.enabled || !config.wechatWork?.webhookUrl) {
211 | process.exit(0);
212 | }
213 |
214 | // 检查特定事件是否启用
215 | if (config.hooks?.events?.[eventType]?.enabled === false) {
216 | process.exit(0);
217 | }
218 |
219 | // 读取stdin获取事件数据
220 | let inputData = '';
221 | process.stdin.on('data', (chunk) => {
222 | inputData += chunk;
223 | });
224 |
225 | process.stdin.on('end', () => {
226 | try {
227 | const eventData = JSON.parse(inputData);
228 | let message = '';
229 |
230 | if (eventType === 'notification') {
231 | message = \`[Claude Code通知]\\n时间: \${new Date().toLocaleString()}\\n\${config.hooks?.events?.Notification?.message || 'Claude Code需要您的关注'}\\n会话ID: \${eventData.session_id || 'unknown'}\`;
232 |
233 | // 如果有payload消息,添加到通知中
234 | if (eventData.payload?.message) {
235 | message += \`\\n消息: \${eventData.payload.message.substring(0, 100)}\${eventData.payload.message.length > 100 ? '...' : ''}\`;
236 | }
237 | } else if (eventType === 'stop') {
238 | message = \`[Claude Code完成]\\n时间: \${new Date().toLocaleString()}\\n\${config.hooks?.events?.Stop?.message || 'Claude Code任务已完成'}\\n会话ID: \${eventData.session_id || 'unknown'}\`;
239 |
240 | if (eventData.cwd) {
241 | message += \`\\n工作目录: \${eventData.cwd}\`;
242 | }
243 | }
244 |
245 | if (message && config.wechatWork?.webhookUrl) {
246 | sendWeChatWorkNotification(config.wechatWork.webhookUrl, message);
247 | }
248 | } catch (error) {
249 | console.error('处理事件数据失败:', error.message);
250 | }
251 | });
252 | `;
253 |
254 | try {
255 | fs.writeFileSync(scriptPath, scriptContent, 'utf8');
256 | // 设置可执行权限
257 | fs.chmodSync(scriptPath, 0o755);
258 | console.log(chalk.green(`✓ Hook脚本已创建: ${scriptPath}`));
259 | } catch (error) {
260 | console.error(chalk.red(`创建Hook脚本失败: ${error.message}`));
261 | throw error;
262 | }
263 | }
264 |
265 | /**
266 | * 设置ClaudeCode Hooks配置
267 | * @param {Object} config 通知配置对象
268 | */
269 | function setupClaudeCodeHooks(config) {
270 | const claudeSettingsPath = path.join(os.homedir(), '.claude', 'settings.json');
271 | const scriptsDir = path.join(os.homedir(), '.claude', 'scripts');
272 | const hookScriptPath = path.join(scriptsDir, 'wechat-notify.js');
273 |
274 | console.log(chalk.cyan('\n正在配置ClaudeCode Hooks...'));
275 |
276 | // 确保scripts目录存在
277 | if (!fs.existsSync(scriptsDir)) {
278 | fs.mkdirSync(scriptsDir, { recursive: true });
279 | console.log(chalk.green(`创建scripts目录: ${scriptsDir}`));
280 | }
281 |
282 | // 检查hook脚本是否存在,不存在则创建
283 | if (!fs.existsSync(hookScriptPath)) {
284 | console.log(chalk.yellow(`Hook脚本不存在,正在创建: ${hookScriptPath}`));
285 | createWeChatNotifyScript(hookScriptPath);
286 | }
287 |
288 | // 读取现有的Claude设置
289 | let claudeSettings = {};
290 | if (fs.existsSync(claudeSettingsPath)) {
291 | try {
292 | const data = fs.readFileSync(claudeSettingsPath, 'utf8');
293 | claudeSettings = JSON.parse(data);
294 | } catch (error) {
295 | console.warn(chalk.yellow(`读取Claude设置文件失败,将创建新的设置: ${error.message}`));
296 | }
297 | }
298 |
299 | // 确保hooks配置存在
300 | if (!claudeSettings.hooks) {
301 | claudeSettings.hooks = {};
302 | }
303 |
304 | // 配置Notification和Stop事件的hooks
305 | claudeSettings.hooks.Notification = [
306 | {
307 | hooks: [
308 | {
309 | type: 'command',
310 | command: `node "${hookScriptPath}" notification`
311 | }
312 | ]
313 | }
314 | ];
315 |
316 | claudeSettings.hooks.Stop = [
317 | {
318 | hooks: [
319 | {
320 | type: 'command',
321 | command: `node "${hookScriptPath}" stop`
322 | }
323 | ]
324 | }
325 | ];
326 |
327 | // 保存Claude设置
328 | try {
329 | fs.writeFileSync(claudeSettingsPath, JSON.stringify(claudeSettings, null, 2), 'utf8');
330 | console.log(chalk.green('✓ ClaudeCode Hooks配置已更新'));
331 | console.log(chalk.white(`Hook脚本位置: ${hookScriptPath}`));
332 | console.log(chalk.green('\n配置完成!现在ClaudeCode将在以下事件时发送企微通知:'));
333 | console.log(chalk.white(' - Notification事件: 当Claude需要用户关注时'));
334 | console.log(chalk.white(' - Stop事件: 当Claude任务完成时'));
335 | } catch (error) {
336 | console.error(chalk.red(`保存Claude设置文件失败: ${error.message}`));
337 | }
338 | }
339 |
340 | /**
341 | * 设置通知配置
342 | */
343 | function setupNotifyConfig() {
344 | const rl = createReadlineInterface();
345 |
346 | console.log(chalk.cyan('\n设置企微机器人通知配置:'));
347 | console.log(chalk.white('请在企微群聊中添加机器人,获取Webhook地址'));
348 | console.log(chalk.white('格式: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY'));
349 | console.log(chalk.yellow('配置完成后,将自动通过ClaudeCode Hooks监听Notification和Stop事件'));
350 |
351 | rl.question(chalk.cyan('\n请输入企微机器人Webhook地址: '), (webhookUrl) => {
352 | if (!webhookUrl || !webhookUrl.startsWith('https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=')) {
353 | console.error(chalk.red('无效的企微机器人Webhook地址'));
354 | rl.close();
355 | return;
356 | }
357 |
358 | const config = {
359 | wechatWork: {
360 | webhookUrl: webhookUrl,
361 | enabled: true,
362 | events: ['Notification', 'Stop'] // 支持的事件类型
363 | },
364 | hooks: {
365 | enabled: true,
366 | defaultEnabled: true, // 默认开启通知
367 | events: {
368 | Notification: {
369 | enabled: true,
370 | message: 'Claude Code需要您的关注'
371 | },
372 | Stop: {
373 | enabled: true,
374 | message: 'Claude Code任务已完成'
375 | }
376 | }
377 | },
378 | // 为未来支持更多通知渠道预留配置结构
379 | telegram: {
380 | enabled: false
381 | },
382 | slack: {
383 | enabled: false
384 | }
385 | };
386 |
387 | saveNotifyConfig(config);
388 | setupClaudeCodeHooks(config);
389 | rl.close();
390 | });
391 | }
392 |
393 | /**
394 | * 显示通知配置状态
395 | */
396 | function showNotifyStatus() {
397 | const notifyConfig = readNotifyConfig();
398 |
399 | if (!notifyConfig) {
400 | console.log(chalk.yellow('未配置企微通知,请运行 ccs notify setup 进行配置'));
401 | return;
402 | }
403 |
404 | console.log(chalk.cyan('\n当前通知配置状态:'));
405 | console.log(chalk.white('企微机器人: ') + (notifyConfig.wechatWork?.enabled ? chalk.green('已启用') : chalk.red('未启用')));
406 |
407 | if (notifyConfig.wechatWork?.webhookUrl) {
408 | const maskedUrl = notifyConfig.wechatWork.webhookUrl.replace(/key=([^&]+)/, 'key=***');
409 | console.log(chalk.white('Webhook地址: ') + maskedUrl);
410 | }
411 |
412 | if (notifyConfig.hooks?.enabled) {
413 | console.log(chalk.white('Hook事件: ') + chalk.green('已启用'));
414 | console.log(chalk.white(' Notification事件: ') + (notifyConfig.hooks.events?.Notification?.enabled ? chalk.green('启用') : chalk.red('禁用')));
415 | console.log(chalk.white(' Stop事件: ') + (notifyConfig.hooks.events?.Stop?.enabled ? chalk.green('启用') : chalk.red('禁用')));
416 | } else {
417 | console.log(chalk.white('Hook事件: ') + chalk.red('未启用'));
418 | }
419 |
420 | // 检查ClaudeCode hooks配置文件
421 | const claudeHooksPath = path.join(os.homedir(), '.claude', 'settings.json');
422 | if (fs.existsSync(claudeHooksPath)) {
423 | try {
424 | const claudeSettings = JSON.parse(fs.readFileSync(claudeHooksPath, 'utf8'));
425 | if (claudeSettings.hooks && claudeSettings.hooks.Notification && claudeSettings.hooks.Stop) {
426 | console.log(chalk.white('ClaudeCode hooks配置: ') + chalk.green('已配置'));
427 | } else {
428 | console.log(chalk.white('ClaudeCode hooks配置: ') + chalk.yellow('未配置 - 需要恢复'));
429 | }
430 | } catch (error) {
431 | console.log(chalk.white('ClaudeCode hooks配置: ') + chalk.red('读取失败'));
432 | }
433 | } else {
434 | console.log(chalk.white('ClaudeCode hooks配置: ') + chalk.yellow('未找到'));
435 | }
436 | }
437 |
438 | /**
439 | * 测试企微通知功能
440 | */
441 | function testNotification() {
442 | const notifyConfig = readNotifyConfig();
443 |
444 | if (!notifyConfig || !notifyConfig.wechatWork || !notifyConfig.wechatWork.webhookUrl) {
445 | console.log(chalk.yellow('未配置企微通知,请先运行 ccs notify setup'));
446 | return;
447 | }
448 |
449 | console.log(chalk.cyan('正在发送测试通知...'));
450 | const testMessage = `[CCS测试通知]\n时间: ${new Date().toLocaleString()}\n这是一条来自claude-code-switch的测试消息`;
451 |
452 | sendWeChatWorkNotification(notifyConfig.wechatWork.webhookUrl, testMessage);
453 | }
454 |
455 |
456 | /**
457 | * 注册notify相关的CLI命令
458 | * @param {Object} program commander程序对象
459 | */
460 | function registerNotifyCommands(program) {
461 | // 新的notify命令,用于配置企微通知
462 | const notifyCommand = program
463 | .command('notify')
464 | .alias('ntf')
465 | .description('配置企微通知设置');
466 |
467 | notifyCommand
468 | .command('setup')
469 | .description('设置企微机器人webhook地址')
470 | .action(() => {
471 | ensureConfigDir();
472 | setupNotifyConfig();
473 | });
474 |
475 | notifyCommand
476 | .command('status')
477 | .description('查看当前通知配置状态')
478 | .action(() => {
479 | ensureConfigDir();
480 | showNotifyStatus();
481 | });
482 |
483 | notifyCommand
484 | .command('test')
485 | .description('测试企微通知功能')
486 | .action(() => {
487 | ensureConfigDir();
488 | testNotification();
489 | });
490 |
491 | // 保留原有的ntf命令作为兼容性(独立命令)
492 | program
493 | .command('ntfold')
494 | .description('(已废弃) 原ntf命令,请使用 ccs notify 命令')
495 | .action(() => {
496 | console.log(chalk.yellow('原ntf监听命令已废弃,现在使用ClaudeCode Hooks机制'));
497 | console.log(chalk.cyan('请使用以下命令:'));
498 | console.log(chalk.cyan(' ccs notify setup - 设置企微通知'));
499 | console.log(chalk.cyan(' ccs notify status - 查看通知状态'));
500 | console.log(chalk.cyan(' ccs notify test - 测试通知功能'));
501 | console.log(chalk.yellow('\n配置完成后,ClaudeCode将自动发送通知,无需手动启动监听'));
502 | });
503 | }
504 |
505 | module.exports = {
506 | readNotifyConfig,
507 | saveNotifyConfig,
508 | sendWeChatWorkNotification,
509 | setupClaudeCodeHooks,
510 | setupNotifyConfig,
511 | showNotifyStatus,
512 | testNotification,
513 | registerNotifyCommands,
514 | ensureConfigDir,
515 | createWeChatNotifyScript
516 | };
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Claude配置切换工具
5 | * 用于在不同的Claude API配置之间进行切换
6 | */
7 |
8 | const fs = require('fs');
9 | const path = require('path');
10 | const { program } = require('commander');
11 | const chalk = require('chalk');
12 | const os = require('os');
13 | const readline = require('readline');
14 | const inquirer = require('inquirer');
15 | const { spawn } = require('child_process');
16 | const notify = require('./notify');
17 | const health = require('./health');
18 |
19 | // 版本号
20 | const VERSION = '1.7.0';
21 |
22 | // 配置文件路径
23 | const CONFIG_DIR = path.join(os.homedir(), '.claude');
24 | const API_CONFIGS_FILE = path.join(CONFIG_DIR, 'apiConfigs.json');
25 | const SETTINGS_FILE = path.join(CONFIG_DIR, 'settings.json');
26 |
27 | /**
28 | * 创建readline接口
29 | * @returns {readline.Interface} readline接口
30 | */
31 | function createReadlineInterface() {
32 | return readline.createInterface({
33 | input: process.stdin,
34 | output: process.stdout
35 | });
36 | }
37 |
38 | /**
39 | * 确保配置目录存在
40 | */
41 | function ensureConfigDir() {
42 | if (!fs.existsSync(CONFIG_DIR)) {
43 | fs.mkdirSync(CONFIG_DIR, { recursive: true });
44 | console.log(chalk.green(`创建配置目录: ${CONFIG_DIR}`));
45 | }
46 | }
47 |
48 | /**
49 | * 读取API配置文件
50 | * @returns {Array} API配置数组
51 | */
52 | function readApiConfigs() {
53 | try {
54 | if (!fs.existsSync(API_CONFIGS_FILE)) {
55 | console.log(chalk.yellow(`警告: API配置文件不存在 (${API_CONFIGS_FILE})`));
56 | return [];
57 | }
58 |
59 | const data = fs.readFileSync(API_CONFIGS_FILE, 'utf8');
60 | return JSON.parse(data);
61 | } catch (error) {
62 | console.error(chalk.red(`读取API配置文件失败: ${error.message}`));
63 | return [];
64 | }
65 | }
66 |
67 | /**
68 | * 读取settings.json文件
69 | * @returns {Object} 设置对象
70 | */
71 | function readSettings() {
72 | try {
73 | if (!fs.existsSync(SETTINGS_FILE)) {
74 | return { env: {} };
75 | }
76 |
77 | const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
78 | return JSON.parse(data);
79 | } catch (error) {
80 | console.error(chalk.red(`读取设置文件失败: ${error.message}`));
81 | return { env: {} };
82 | }
83 | }
84 |
85 | /**
86 | * 保存settings.json文件
87 | * @param {Object} settings 设置对象
88 | */
89 | function saveSettings(settings) {
90 | try {
91 | fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf8');
92 | } catch (error) {
93 | console.error(chalk.red(`保存设置文件失败: ${error.message}`));
94 | process.exit(1);
95 | }
96 | }
97 |
98 | /**
99 | * 保存配置时保持现有的hooks和其他设置
100 | * @param {Object} newConfig 新的配置对象
101 | */
102 | function saveSettingsPreservingHooks(newConfig) {
103 | try {
104 | // 读取当前设置
105 | const currentSettings = readSettings();
106 |
107 | // 合并配置,只有当前设置中存在hooks时才保持
108 | const mergedSettings = {
109 | ...newConfig
110 | };
111 |
112 | // 如果当前设置中有hooks,则保持
113 | if (currentSettings.hooks) {
114 | mergedSettings.hooks = currentSettings.hooks;
115 | }
116 |
117 | // 如果当前设置中有statusLine,则保持
118 | if (currentSettings.statusLine) {
119 | mergedSettings.statusLine = currentSettings.statusLine;
120 | }
121 |
122 | fs.writeFileSync(SETTINGS_FILE, JSON.stringify(mergedSettings, null, 2), 'utf8');
123 | } catch (error) {
124 | console.error(chalk.red(`保存设置文件失败: ${error.message}`));
125 | process.exit(1);
126 | }
127 | }
128 |
129 | /**
130 | * 深度比较两个对象是否相等
131 | * @param {Object} obj1
132 | * @param {Object} obj2
133 | * @returns {boolean}
134 | */
135 | function deepEqual(obj1, obj2) {
136 | if (obj1 === obj2) return true;
137 |
138 | if (obj1 == null || obj2 == null) return false;
139 | if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return obj1 === obj2;
140 |
141 | const keys1 = Object.keys(obj1);
142 | const keys2 = Object.keys(obj2);
143 |
144 | if (keys1.length !== keys2.length) return false;
145 |
146 | for (let key of keys1) {
147 | if (!keys2.includes(key)) return false;
148 | if (!deepEqual(obj1[key], obj2[key])) return false;
149 | }
150 |
151 | return true;
152 | }
153 |
154 | /**
155 | * 获取当前激活的API配置
156 | * @returns {Object|null} 当前激活的配置对象或null(如果没有找到)
157 | */
158 | function getCurrentConfig() {
159 | const settings = readSettings();
160 |
161 | // 如果settings为空,返回null
162 | if (!settings || Object.keys(settings).length === 0) {
163 | return null;
164 | }
165 |
166 | // 查找匹配的配置,只比较核心字段
167 | const apiConfigs = readApiConfigs();
168 | return apiConfigs.find(config => {
169 | if (!config.config) return false;
170 |
171 | // 主要比较 env 字段中的关键配置
172 | const currentEnv = settings.env || {};
173 | const configEnv = config.config.env || {};
174 |
175 | return currentEnv.ANTHROPIC_BASE_URL === configEnv.ANTHROPIC_BASE_URL &&
176 | currentEnv.ANTHROPIC_AUTH_TOKEN === configEnv.ANTHROPIC_AUTH_TOKEN;
177 | }) || null;
178 | }
179 |
180 | /**
181 | * 列出所有可用的API配置并提示用户选择(同时支持交互式菜单和序号输入)
182 | */
183 | function listAndSelectConfig() {
184 | const apiConfigs = readApiConfigs();
185 |
186 | if (apiConfigs.length === 0) {
187 | console.log(chalk.yellow('没有找到可用的API配置'));
188 | process.exit(0);
189 | }
190 |
191 | // 获取当前激活的配置
192 | const currentConfig = getCurrentConfig();
193 |
194 | // 如果有当前激活的配置,显示它
195 | if (currentConfig) {
196 | console.log(chalk.green('当前激活的配置: ') + chalk.white(currentConfig.name));
197 | console.log();
198 | }
199 |
200 | // 找出最长的名称长度,用于对齐
201 | const maxNameLength = apiConfigs.reduce((max, config) =>
202 | Math.max(max, config.name.length), 0);
203 |
204 | // 准备选项列表
205 | const choices = apiConfigs.map((config, index) => {
206 | // 如果是当前激活的配置,添加标记
207 | const isActive = currentConfig && config.name === currentConfig.name;
208 |
209 | // 格式化配置信息:[name] key url,name对齐,密钥不格式化
210 | const paddedName = config.name.padEnd(maxNameLength, ' ');
211 | const configInfo = `[${paddedName}] ${config.config.env.ANTHROPIC_AUTH_TOKEN} ${config.config.env.ANTHROPIC_BASE_URL}`;
212 |
213 | return {
214 | name: `${index + 1}. ${configInfo}${isActive ? chalk.green(' (当前)') : ''}`,
215 | value: index
216 | };
217 | });
218 |
219 | // 添加一个输入选项
220 | choices.push(new inquirer.Separator());
221 | choices.push({
222 | name: '输入序号...',
223 | value: 'input',
224 | disabled: ' ' // 让输入序号选项不可选中
225 | });
226 |
227 | // 使用inquirer创建交互式菜单
228 | inquirer
229 | .prompt([
230 | {
231 | type: 'list',
232 | name: 'configIndex',
233 | message: '请选择要切换的配置:',
234 | choices: choices,
235 | pageSize: choices.length, // 显示所有选项,确保"输入序号..."始终在底部
236 | // 设置更宽的显示宽度以支持长配置信息
237 | prefix: '',
238 | suffix: '',
239 | }
240 | ])
241 | .then(answers => {
242 | // 如果用户选择了"输入序号"选项
243 | if (answers.configIndex === 'input') {
244 | // 显示配置列表以供参考
245 | console.log(chalk.cyan('\n可用的API配置:'));
246 | apiConfigs.forEach((config, index) => {
247 | const isActive = currentConfig && config.name === currentConfig.name;
248 | const activeMarker = isActive ? chalk.green(' (当前)') : '';
249 | const paddedName = config.name.padEnd(maxNameLength, ' ');
250 | const configInfo = `[${paddedName}] ${config.config.env.ANTHROPIC_AUTH_TOKEN} ${config.config.env.ANTHROPIC_BASE_URL}`;
251 | console.log(chalk.white(` ${index + 1}. ${configInfo}${activeMarker}`));
252 | });
253 |
254 | const rl = createReadlineInterface();
255 |
256 | rl.question(chalk.cyan('\n请输入配置序号 (1-' + apiConfigs.length + '): '), (indexAnswer) => {
257 | const index = parseInt(indexAnswer, 10);
258 |
259 | if (isNaN(index) || index < 1 || index > apiConfigs.length) {
260 | console.error(chalk.red(`无效的序号: ${indexAnswer},有效范围: 1-${apiConfigs.length}`));
261 | rl.close();
262 | return;
263 | }
264 |
265 | const selectedConfig = apiConfigs[index - 1];
266 |
267 | // 如果选择的配置就是当前激活的配置,提示用户
268 | if (currentConfig && selectedConfig.name === currentConfig.name) {
269 | console.log(chalk.yellow(`\n配置 "${selectedConfig.name}" 已经是当前激活的配置`));
270 | rl.close();
271 | return;
272 | }
273 |
274 | processSelectedConfig(selectedConfig);
275 | rl.close();
276 | });
277 | return;
278 | }
279 |
280 | // 用户通过交互式菜单选择了配置
281 | const selectedIndex = answers.configIndex;
282 | const selectedConfig = apiConfigs[selectedIndex];
283 |
284 | // 如果选择的配置就是当前激活的配置,提示用户
285 | if (currentConfig && selectedConfig.name === currentConfig.name) {
286 | console.log(chalk.yellow(`\n配置 "${selectedConfig.name}" 已经是当前激活的配置`));
287 | return;
288 | }
289 |
290 | processSelectedConfig(selectedConfig);
291 | })
292 | .catch(error => {
293 | console.error(chalk.red(`发生错误: ${error.message}`));
294 | });
295 | }
296 |
297 | /**
298 | * 处理用户选择的配置
299 | * @param {Object} selectedConfig 选择的配置对象
300 | */
301 | function processSelectedConfig(selectedConfig) {
302 | console.log(chalk.cyan('\n当前选择的配置:'));
303 | console.log(JSON.stringify(selectedConfig, null, 2));
304 |
305 | inquirer
306 | .prompt([
307 | {
308 | type: 'confirm',
309 | name: 'confirm',
310 | message: '确认切换到此配置?',
311 | default: true // 修改默认值为true,按Enter键表示确认
312 | }
313 | ])
314 | .then(confirmAnswer => {
315 | if (confirmAnswer.confirm) {
316 | // 保存配置时保持现有的hooks设置
317 | saveSettingsPreservingHooks(selectedConfig.config);
318 |
319 | console.log(chalk.green(`\n成功切换到配置: ${selectedConfig.name}`));
320 |
321 | // 显示当前配置信息
322 | console.log(chalk.cyan('\n当前激活配置详情:'));
323 | const { name, config } = selectedConfig;
324 | console.log(chalk.white(`名称: ${name}`));
325 | console.log(chalk.white(`API Key: ${config.env.ANTHROPIC_AUTH_TOKEN}`));
326 | console.log(chalk.white(`Base URL: ${config.env.ANTHROPIC_BASE_URL}`));
327 | console.log(chalk.white(`Model: ${config.model || 'default'}`));
328 |
329 | // 询问是否要在当前目录运行 Claude
330 | inquirer
331 | .prompt([
332 | {
333 | type: 'confirm',
334 | name: 'runClaude',
335 | message: '是否要在当前目录运行 claude?',
336 | default: true
337 | }
338 | ])
339 | .then(runAnswer => {
340 | if (runAnswer.runClaude) {
341 | console.log(chalk.green('\n正在启动 Claude...'));
342 |
343 | // 启动 Claude
344 | const claudeProcess = spawn('claude', [], {
345 | stdio: 'inherit',
346 | cwd: process.cwd()
347 | });
348 |
349 | claudeProcess.on('error', (error) => {
350 | console.error(chalk.red(`启动 Claude 失败: ${error.message}`));
351 | console.log(chalk.yellow('请确保 Claude CLI 已正确安装'));
352 | });
353 | } else {
354 | console.log(chalk.yellow('您可以稍后手动运行 claude 命令'));
355 | }
356 | })
357 | .catch(error => {
358 | console.error(chalk.red(`发生错误: ${error.message}`));
359 | });
360 | } else {
361 | console.log(chalk.yellow('\n操作已取消'));
362 | }
363 | });
364 | }
365 |
366 | /**
367 | * 列出所有可用的API配置
368 | */
369 | function listConfigs() {
370 | const apiConfigs = readApiConfigs();
371 |
372 | if (apiConfigs.length === 0) {
373 | console.log(chalk.yellow('没有找到可用的API配置'));
374 | return;
375 | }
376 |
377 | console.log(chalk.cyan('可用的API配置:'));
378 | apiConfigs.forEach((config, index) => {
379 | console.log(chalk.white(` ${index + 1}. ${config.name}`));
380 | });
381 | }
382 |
383 | /**
384 | * 设置当前使用的API配置(使用交互式确认)
385 | * @param {number} index 配置索引
386 | */
387 | function setConfig(index) {
388 | const apiConfigs = readApiConfigs();
389 |
390 | if (apiConfigs.length === 0) {
391 | console.log(chalk.yellow('没有找到可用的API配置'));
392 | return;
393 | }
394 |
395 | // 检查索引是否有效
396 | if (index < 1 || index > apiConfigs.length) {
397 | console.error(chalk.red(`无效的索引: ${index},有效范围: 1-${apiConfigs.length}`));
398 | return;
399 | }
400 |
401 | const selectedConfig = apiConfigs[index - 1];
402 |
403 | // 显示当前选择的配置
404 | console.log(chalk.cyan('当前选择的配置:'));
405 | console.log(JSON.stringify(selectedConfig, null, 2));
406 |
407 | // 使用inquirer进行确认
408 | inquirer
409 | .prompt([
410 | {
411 | type: 'confirm',
412 | name: 'confirm',
413 | message: '确认切换到此配置?',
414 | default: true // 修改默认值为true,按Enter键表示确认
415 | }
416 | ])
417 | .then(answers => {
418 | if (answers.confirm) {
419 | // 直接使用选择的配置替换整个settings.json
420 | saveSettingsPreservingHooks(selectedConfig.config);
421 |
422 | console.log(chalk.green(`\n成功切换到配置: ${selectedConfig.name}`));
423 | } else {
424 | console.log(chalk.yellow('\n操作已取消'));
425 | }
426 | })
427 | .catch(error => {
428 | console.error(chalk.red(`发生错误: ${error.message}`));
429 | });
430 | }
431 |
432 | /**
433 | * 获取API配置文件的示例内容
434 | */
435 | function getApiConfigTemplate() {
436 | return [
437 | {
438 | "name": "example-config",
439 | "config": {
440 | "env": {
441 | "ANTHROPIC_AUTH_TOKEN": "sk-YOUR_API_KEY_HERE",
442 | "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
443 | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
444 | },
445 | "permissions": {
446 | "allow": [],
447 | "deny": []
448 | }
449 | }
450 | }
451 | ];
452 | }
453 |
454 | /**
455 | * 获取设置文件的示例内容
456 | */
457 | function getSettingsTemplate() {
458 | return {
459 | "env": {
460 | "ANTHROPIC_AUTH_TOKEN": "sk-YOUR_API_KEY_HERE",
461 | "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
462 | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
463 | },
464 | "permissions": {
465 | "allow": [],
466 | "deny": []
467 | }
468 | };
469 | }
470 |
471 | /**
472 | * 打开指定的配置文件
473 | * @param {string} filePath 文件路径
474 | */
475 | function openConfigFile(filePath) {
476 | const fullPath = path.resolve(filePath);
477 |
478 | if (!fs.existsSync(fullPath)) {
479 | // 确保配置目录存在
480 | ensureConfigDir();
481 |
482 | // 创建示例配置文件
483 | let templateContent;
484 | if (fullPath === API_CONFIGS_FILE) {
485 | templateContent = JSON.stringify(getApiConfigTemplate(), null, 2);
486 | console.log(chalk.green(`创建API配置文件: ${fullPath}`));
487 | } else if (fullPath === SETTINGS_FILE) {
488 | templateContent = JSON.stringify(getSettingsTemplate(), null, 2);
489 | console.log(chalk.green(`创建设置配置文件: ${fullPath}`));
490 | } else {
491 | console.log(chalk.yellow(`配置文件不存在: ${fullPath}`));
492 | return;
493 | }
494 |
495 | try {
496 | fs.writeFileSync(fullPath, templateContent, 'utf8');
497 | console.log(chalk.green(`已创建示例配置文件,请根据需要修改配置内容`));
498 | } catch (error) {
499 | console.error(chalk.red(`创建配置文件失败: ${error.message}`));
500 | return;
501 | }
502 | }
503 |
504 | console.log(chalk.cyan(`正在打开: ${fullPath}`));
505 |
506 | // 使用spawn执行open命令
507 | const child = spawn('open', [fullPath], {
508 | stdio: 'inherit',
509 | detached: true
510 | });
511 |
512 | child.on('error', (error) => {
513 | console.error(chalk.red(`打开文件失败: ${error.message}`));
514 | });
515 |
516 | child.unref(); // 允许父进程独立于子进程退出
517 | }
518 |
519 | /**
520 | * 显示版本信息
521 | */
522 | function showVersion() {
523 | console.log(`ccs 版本: ${VERSION}`);
524 | }
525 |
526 |
527 | // 设置命令行程序
528 | program
529 | .name('ccs')
530 | .description('Claude配置切换工具')
531 | .version(VERSION, '-v, --version', '显示版本信息');
532 |
533 | program
534 | .command('list')
535 | .alias('ls')
536 | .description('列出所有可用的API配置并提示选择')
537 | .action(() => {
538 | ensureConfigDir();
539 | listAndSelectConfig();
540 | });
541 |
542 | // 根据需求移除 set/use 命令
543 |
544 | const openCommand = program
545 | .command('o')
546 | .description('打开Claude配置文件');
547 |
548 | openCommand
549 | .command('api')
550 | .description('打开API配置文件 (apiConfigs.json)')
551 | .action(() => {
552 | openConfigFile(API_CONFIGS_FILE);
553 | });
554 |
555 | openCommand
556 | .command('setting')
557 | .description('打开设置配置文件 (settings.json)')
558 | .action(() => {
559 | openConfigFile(SETTINGS_FILE);
560 | });
561 |
562 | // 注册notify相关命令
563 | notify.registerNotifyCommands(program);
564 |
565 | // 注册health相关命令
566 | health.registerHealthCommands(program);
567 |
568 | // 添加错误处理
569 | program.on('command:*', (operands) => {
570 | console.error(chalk.red(`错误: 未知命令 '${operands[0]}'`));
571 | const availableCommands = program.commands.map(cmd => cmd.name());
572 | console.log(chalk.cyan('\n可用命令:'));
573 | availableCommands.forEach(cmd => {
574 | console.log(` ${cmd}`);
575 | });
576 | console.log(chalk.cyan('\n使用 --help 查看更多信息'));
577 | process.exit(1);
578 | });
579 |
580 | // 如果没有提供命令,显示帮助信息
581 | if (!process.argv.slice(2).length) {
582 | program.outputHelp();
583 | process.exit(0); // 添加process.exit(0)确保程序在显示帮助信息后退出
584 | }
585 |
586 | program.parse(process.argv);
--------------------------------------------------------------------------------