claude360 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -13
- package/package.json +5 -1
- package/src/diagnostics.js +2 -2
- package/src/index.js +96 -67
- package/src/init-config.js +52 -34
- package/src/mcp-skill.js +55 -10
- package/src/menu.js +21 -12
- package/src/prompts.js +338 -0
- package/src/topup.js +28 -5
- package/src/ui.js +71 -0
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
| 功能 | 说明 |
|
|
30
30
|
| --- | --- |
|
|
31
31
|
| 一行命令安装 | Windows PowerShell / macOS / Linux bash 一行命令拉起 bootstrap,自动检测 Node/npm 与全局 npm 权限 |
|
|
32
|
+
| 方向键交互 | 全部菜单 / 选择 / 确认使用方向键导航(↑↓ 移动、Enter 确认、Esc 返回、空格多选、←→ 切换 YES/NO),非 TTY / CI 环境自动降级为编号输入 |
|
|
33
|
+
| 真实模型选择 | 模型列表来自 `GET /api/cli/models`(用户可用分组 × 模型元数据),展示模型 ID / 名称 / 标签 / 说明,保存真实 model id |
|
|
32
34
|
| 浏览器设备授权 | 通过 `/api/cli/auth/*` 设备码流程换取受限 CLI 凭证,不接触账号密码 |
|
|
33
35
|
| API Key 选择/创建 | 已有 Key 列表展示并选择;无 Key 时按后端分组倍率向导创建 |
|
|
34
36
|
| 余额与分组展示 | 日常主菜单展示账户余额、当前 Key、当前分组与倍率(来自后端) |
|
|
@@ -113,25 +115,37 @@ claude360
|
|
|
113
115
|
|
|
114
116
|
## 日常使用
|
|
115
117
|
|
|
118
|
+
主菜单按分组展示,TTY 环境下用 **方向键 + Enter** 选择(Esc 返回);非 TTY / CI 环境自动降级为编号输入,编号以实际输出为准:
|
|
119
|
+
|
|
116
120
|
```
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
请选择功能:
|
|
122
|
+
─── 常用操作 ───
|
|
123
|
+
❯ 启动 Claude Code 使用 Claude360 接入配置直接启动
|
|
124
|
+
启动 Codex 使用 Claude360 接入配置直接启动
|
|
125
|
+
─── 账户与充值 ───
|
|
126
|
+
余额与充值 查看余额用量,支持微信扫码充值
|
|
127
|
+
切换 Key / 模型 切换当前 API Key 或默认模型
|
|
128
|
+
创建新的 API Key 在当前账号下新建一个 API Key
|
|
129
|
+
打开 Claude360 控制台 在浏览器中打开网页控制台
|
|
130
|
+
─── Key / 模型 / MCP 配置 ───
|
|
131
|
+
Claude Code 配置 完整初始化、工作流、MCP、模型与记忆配置
|
|
132
|
+
Codex 配置 完整初始化、Provider、工作流与 MCP 配置
|
|
133
|
+
一键完整初始化 Claude Code / Codex 完整初始化向导
|
|
134
|
+
推荐 MCP / Skill 安装推荐的 MCP、工作流与 Skill 增强
|
|
135
|
+
生成 cc-switch 配置 生成可导入 cc-switch 的供应商配置
|
|
136
|
+
─── 维护与设置 ───
|
|
137
|
+
安装或更新工具 安装或升级 Claude Code / Codex / 本 CLI
|
|
138
|
+
诊断与修复 一键诊断环境与配置问题并尝试修复
|
|
139
|
+
重新登录 清除本地登录态后重新浏览器授权
|
|
140
|
+
退出 退出 claude360 CLI
|
|
126
141
|
```
|
|
127
142
|
|
|
128
|
-
-
|
|
129
|
-
- **微信扫码充值**:先取 `/api/cli/topup/options`,按后端校验通过的金额或最低额提交,再用 `qrcode-terminal` 在终端渲染 `code_url`,渲染失败时降级为纯文本 URL。轮询 `/api/cli/topup/order?order_id=`,支付完成自动刷新余额。
|
|
143
|
+
- **余额与充值**:调用 `GET /api/cli/me`,展示账号、余额、已用、当前 Key 名称与分组;充值先取 `/api/cli/topup/options`,按后端校验通过的金额或最低额提交,再用 `qrcode-terminal` 在终端渲染 `code_url`,渲染失败时降级为纯文本 URL。轮询 `/api/cli/topup/order?order_id=`,支付完成自动刷新余额。
|
|
130
144
|
- **启动 Claude Code**:调用 `claude` 子进程,并通过 `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` 注入。
|
|
131
145
|
- **启动 Codex**:写入 `~/.codex/config.toml` 中 `[model_providers.claude360]` 与 `[profiles.claude360]`,发现既有冲突字段会先要求用户确认再覆盖,然后 `codex --profile claude360`,并通过 `CLAUDE360_API_KEY` 注入。
|
|
132
146
|
- **安装或更新工具**:三选一菜单(仅 Claude Code / 仅 Codex / 两者),每步都需要确认。
|
|
133
|
-
- **切换
|
|
134
|
-
-
|
|
147
|
+
- **切换 Key / 模型**:重新进入 Token 向导,或从 `/api/cli/models` 拉取真实模型列表选择默认模型。
|
|
148
|
+
- **诊断与修复**:见下方“诊断与故障排查”。
|
|
135
149
|
|
|
136
150
|
## 本地配置
|
|
137
151
|
|
|
@@ -173,6 +187,7 @@ CLI 仅依赖一组隔离的 `/api/cli` 接口,由 `newapi/controller/cli.go`
|
|
|
173
187
|
| POST | `/api/cli/tokens/:id/reveal` | 校验归属并返回明文 Key,被限频且禁用缓存 |
|
|
174
188
|
| POST | `/api/cli/tokens` | 按分组创建新 Key(受 `operation_setting.GetMaxUserTokens()` 约束) |
|
|
175
189
|
| GET | `/api/cli/groups` | 用户可用分组(含 `display_name`、`ratio`、`description`) |
|
|
190
|
+
| GET | `/api/cli/models` | 当前用户可用模型列表(含 `display_name`、`tags`、`description`、`recommended`;`tool` 参数预留) |
|
|
176
191
|
| GET | `/api/cli/topup/options` | 后端配置的充值金额选项与最低额,包含 WeChat 启用状态 |
|
|
177
192
|
| POST | `/api/cli/topup/wechat` | 创建微信 Native 订单,返回 `code_url`、`order_id`、`pay_money` |
|
|
178
193
|
| GET | `/api/cli/topup/:order_id` | 查询订单状态(仅订单所属用户) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude360",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Interactive Claude360 CLI for browser auth, API key setup, balance checks, top-up, Claude Code and Codex launch.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,10 +16,14 @@
|
|
|
16
16
|
"node": ">=18"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
+
"@inquirer/core": "^11.2.1",
|
|
19
20
|
"qrcode-terminal": "^0.12.0",
|
|
20
21
|
"smol-toml": "^1.6.1"
|
|
21
22
|
},
|
|
22
23
|
"scripts": {
|
|
23
24
|
"test": "node ./scripts/test.js"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@inquirer/testing": "^3.3.9"
|
|
24
28
|
}
|
|
25
29
|
}
|
package/src/diagnostics.js
CHANGED
|
@@ -5,7 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
|
|
6
6
|
import { resolveCodexConfigPath } from "./tool-launcher.js";
|
|
7
7
|
import { sanitizeError, sanitizeText } from "./sanitize.js";
|
|
8
|
-
import { renderSectionTitle, renderTable } from "./ui.js";
|
|
8
|
+
import { renderHeader, renderSectionTitle, renderTable } from "./ui.js";
|
|
9
9
|
|
|
10
10
|
export async function runDiagnostics({
|
|
11
11
|
config = {},
|
|
@@ -179,7 +179,7 @@ export function formatDiagnosticsSummary(report, { width = 0 } = {}) {
|
|
|
179
179
|
},
|
|
180
180
|
];
|
|
181
181
|
|
|
182
|
-
const lines = ["诊断报告"];
|
|
182
|
+
const lines = [renderHeader("诊断报告")];
|
|
183
183
|
for (const group of groups) {
|
|
184
184
|
lines.push("", renderSectionTitle(group.title));
|
|
185
185
|
lines.push(renderTable({
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import readline from "node:readline/promises";
|
|
2
|
-
import { stdin as input, stdout as output } from "node:process";
|
|
3
1
|
import { execFile } from "node:child_process";
|
|
4
2
|
import { readFile as fsReadFile } from "node:fs/promises";
|
|
5
3
|
import { createRequire } from "node:module";
|
|
@@ -11,6 +9,8 @@ import { formatAccountStatus, loadAccountStatus } from "./account-status.js";
|
|
|
11
9
|
import { colorEnabled, formatCheckLine, playBanner, renderBanner } from "./banner.js";
|
|
12
10
|
import { runCcSwitchGenerator } from "./cc-switch.js";
|
|
13
11
|
import { createConfigStore } from "./config-store.js";
|
|
12
|
+
import { createPrompts, isInteractive } from "./prompts.js";
|
|
13
|
+
import { renderHeader } from "./ui.js";
|
|
14
14
|
import {
|
|
15
15
|
commandVersion,
|
|
16
16
|
defaultExecCommand,
|
|
@@ -72,9 +72,9 @@ export async function runCli({
|
|
|
72
72
|
authenticateWithBrowser: authWithBrowser = authenticateWithBrowser,
|
|
73
73
|
chooseOrCreateToken: chooseToken = chooseOrCreateToken,
|
|
74
74
|
createToken = createNewToken,
|
|
75
|
-
promptSelect
|
|
76
|
-
promptInput
|
|
77
|
-
confirm
|
|
75
|
+
promptSelect,
|
|
76
|
+
promptInput,
|
|
77
|
+
confirm,
|
|
78
78
|
openBrowser = defaultOpenBrowser,
|
|
79
79
|
installOrUpdateTools = installTools,
|
|
80
80
|
launchClaudeCode = startClaudeCode,
|
|
@@ -94,7 +94,7 @@ export async function runCli({
|
|
|
94
94
|
configureClaudeModel = configureClaudeDefaultModel,
|
|
95
95
|
configureCodexModel = configureCodexDefaultModel,
|
|
96
96
|
importEnvPermissions = importClaudeEnvPermissions,
|
|
97
|
-
multiSelectInput
|
|
97
|
+
multiSelectInput,
|
|
98
98
|
readFileImpl = fsReadFile,
|
|
99
99
|
claudeDir = resolveClaudeDir(),
|
|
100
100
|
codexDir = resolveCodexDir(),
|
|
@@ -114,6 +114,22 @@ export async function runCli({
|
|
|
114
114
|
let api = createApiClient({ baseUrl, cliToken: config.cliToken || "" });
|
|
115
115
|
// 仅在真实终端(writeLine 未被测试替换)启用彩色与动效
|
|
116
116
|
const fancyOutput = writeLine === console.log && colorEnabled();
|
|
117
|
+
// 方向键交互:真实 TTY 且交互未被测试替换时启用(优化需求第五节),
|
|
118
|
+
// 非交互环境降级为编号输入,由 prompts.js 统一处理
|
|
119
|
+
const interactiveUi = writeLine === console.log && isInteractive();
|
|
120
|
+
if (!promptSelect || !promptInput || !confirm) {
|
|
121
|
+
const defaults = createPrompts({ interactive: interactiveUi, color: fancyOutput });
|
|
122
|
+
promptSelect = promptSelect || defaults.promptSelect;
|
|
123
|
+
promptInput = promptInput || defaults.promptInput;
|
|
124
|
+
confirm = confirm || defaults.confirm;
|
|
125
|
+
if (!multiSelectInput && interactiveUi) {
|
|
126
|
+
multiSelectInput = ({ message, choices, preselected }) => defaults.multiSelect({ message, choices, preselected });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
multiSelectInput = multiSelectInput || promptMultiSelect;
|
|
130
|
+
// 覆盖 ~/.codex/config.toml 冲突字段属于危险操作:默认 NO,
|
|
131
|
+
// 交互模式下 NO 使用危险色高亮(审查 P2-3)
|
|
132
|
+
const confirmDanger = (message) => confirm(message, { danger: true });
|
|
117
133
|
|
|
118
134
|
if (showBanner) {
|
|
119
135
|
if (fancyOutput) {
|
|
@@ -121,7 +137,10 @@ export async function runCli({
|
|
|
121
137
|
} else {
|
|
122
138
|
writeLine(renderBanner({ version, baseUrl }));
|
|
123
139
|
}
|
|
124
|
-
|
|
140
|
+
// 首页保持低高度(优化需求 1):环境检查仅在未登录 / 强制配置时展示
|
|
141
|
+
if (!config.cliToken || forceSetup) {
|
|
142
|
+
await showEnvironmentChecks();
|
|
143
|
+
}
|
|
125
144
|
}
|
|
126
145
|
|
|
127
146
|
// ──────────────────────────────────────────────
|
|
@@ -373,7 +392,7 @@ export async function runCli({
|
|
|
373
392
|
// Claude360 Provider 菜单(PRD 8.5)
|
|
374
393
|
async function runCodexProviderMenu() {
|
|
375
394
|
while (true) {
|
|
376
|
-
writeLine("配置
|
|
395
|
+
writeLine(renderHeader("Codex Provider 配置", { color: fancyOutput }));
|
|
377
396
|
writeLine("当前 Provider:claude360");
|
|
378
397
|
writeLine("当前 Profile:claude360\n");
|
|
379
398
|
const action = await promptSelect("请选择:", [
|
|
@@ -452,12 +471,12 @@ export async function runCli({
|
|
|
452
471
|
configureLanguage: () => configureLanguage({ baseDir: claudeDir, filePath: claudeMemoryPath, promptSelect, writeLine }),
|
|
453
472
|
configureStyle: () => configureStyle({ baseDir: claudeDir, filePath: claudeMemoryPath, promptSelect, writeLine }),
|
|
454
473
|
installWorkflows: () => installClaudeWfs({ claudeDir, multiSelect, confirm, writeLine }),
|
|
455
|
-
installMcps: () => installMcps({ multiSelect, confirm, writeLine }),
|
|
474
|
+
installMcps: () => installMcps({ api, multiSelect, confirm, writeLine }),
|
|
456
475
|
configureModel: () => configureClaudeModel({ api, claudeDir, promptSelect, writeLine }),
|
|
457
476
|
importEnvPermissions: () => importEnvPermissions({ claudeDir, confirm, writeLine }),
|
|
458
477
|
testConnection: testConnectionStep,
|
|
459
478
|
askLaunch: async () => {
|
|
460
|
-
if (await confirm("初始化完成。是否立即启动 Claude Code?")) {
|
|
479
|
+
if (await confirm("初始化完成。是否立即启动 Claude Code?", { defaultYes: true })) {
|
|
461
480
|
await doLaunchClaude();
|
|
462
481
|
}
|
|
463
482
|
},
|
|
@@ -481,15 +500,15 @@ export async function runCli({
|
|
|
481
500
|
installWorkflows: () => installCodexWfs({ codexDir, multiSelect, confirm, writeLine }),
|
|
482
501
|
configureProvider: async () => {
|
|
483
502
|
await ensureApiKey();
|
|
484
|
-
await configureCodex({ config, confirmConflict:
|
|
503
|
+
await configureCodex({ config, confirmConflict: confirmDanger, writeLine });
|
|
485
504
|
await markConfigured("codex");
|
|
486
505
|
writeLine("✓ 已写入 Codex claude360 provider/profile(~/.codex/config.toml)");
|
|
487
506
|
},
|
|
488
507
|
checkCompat: checkCodexCompatStep,
|
|
489
|
-
installMcps: () => installCodexMcpServers({ codexDir, multiSelect, confirm, writeLine }),
|
|
508
|
+
installMcps: () => installCodexMcpServers({ api, codexDir, multiSelect, confirm, writeLine }),
|
|
490
509
|
testConnection: testConnectionStep,
|
|
491
510
|
askLaunch: async () => {
|
|
492
|
-
if (await confirm("初始化完成。是否立即启动 Codex?")) {
|
|
511
|
+
if (await confirm("初始化完成。是否立即启动 Codex?", { defaultYes: true })) {
|
|
493
512
|
await doLaunchCodex();
|
|
494
513
|
}
|
|
495
514
|
},
|
|
@@ -624,7 +643,7 @@ export async function runCli({
|
|
|
624
643
|
}
|
|
625
644
|
await markConfigured("codex");
|
|
626
645
|
writeLine("正在启动 Codex...");
|
|
627
|
-
await launchCodex({ config, confirmConflict:
|
|
646
|
+
await launchCodex({ config, confirmConflict: confirmDanger, writeLine });
|
|
628
647
|
return true;
|
|
629
648
|
}
|
|
630
649
|
|
|
@@ -635,7 +654,7 @@ export async function runCli({
|
|
|
635
654
|
return false;
|
|
636
655
|
}
|
|
637
656
|
await ensureApiKey();
|
|
638
|
-
await configureCodex({ config, confirmConflict:
|
|
657
|
+
await configureCodex({ config, confirmConflict: confirmDanger, writeLine });
|
|
639
658
|
await markConfigured("codex");
|
|
640
659
|
writeLine("✓ 已写入 Codex claude360 provider/profile(~/.codex/config.toml)");
|
|
641
660
|
return true;
|
|
@@ -700,6 +719,7 @@ export async function runCli({
|
|
|
700
719
|
}
|
|
701
720
|
const approved = await confirm(
|
|
702
721
|
"将退出登录并清除本地保存的 CLI Token 与 API Key。\n不会删除 Claude360 网站上的账号或 API Key。\n是否继续?",
|
|
722
|
+
{ danger: true },
|
|
703
723
|
);
|
|
704
724
|
if (!approved) {
|
|
705
725
|
writeLine("已取消。");
|
|
@@ -713,7 +733,7 @@ export async function runCli({
|
|
|
713
733
|
}
|
|
714
734
|
|
|
715
735
|
async function doRelogin() {
|
|
716
|
-
const approved = await confirm("将重新进行浏览器授权并覆盖本地登录状态。是否继续?");
|
|
736
|
+
const approved = await confirm("将重新进行浏览器授权并覆盖本地登录状态。是否继续?", { danger: true });
|
|
717
737
|
if (!approved) {
|
|
718
738
|
return false;
|
|
719
739
|
}
|
|
@@ -725,6 +745,7 @@ export async function runCli({
|
|
|
725
745
|
async function doClearConfig() {
|
|
726
746
|
const approved = await confirm(
|
|
727
747
|
"该操作会清除本地 Claude360 CLI 登录状态和已保存 Key。\n不会删除 Claude360 网站上的账号或 API Key。\n是否继续?",
|
|
748
|
+
{ danger: true },
|
|
728
749
|
);
|
|
729
750
|
if (!approved) {
|
|
730
751
|
writeLine("已取消。");
|
|
@@ -743,7 +764,7 @@ export async function runCli({
|
|
|
743
764
|
|
|
744
765
|
async function runBalanceTopUpMenu() {
|
|
745
766
|
while (true) {
|
|
746
|
-
writeLine("
|
|
767
|
+
writeLine(renderHeader("余额与充值", { color: fancyOutput }));
|
|
747
768
|
const me = await showBalanceAndUsage();
|
|
748
769
|
if (me?.low_balance) {
|
|
749
770
|
writeLine("状态:余额偏低,建议充值");
|
|
@@ -767,7 +788,7 @@ export async function runCli({
|
|
|
767
788
|
|
|
768
789
|
async function doCreateKey() {
|
|
769
790
|
const token = await createToken({ api, promptSelect, promptInput });
|
|
770
|
-
const useNow = await confirm(`已创建 API Key:${token.tokenName}。是否将其设为当前使用的 Key
|
|
791
|
+
const useNow = await confirm(`已创建 API Key:${token.tokenName}。是否将其设为当前使用的 Key?`, { defaultYes: true });
|
|
771
792
|
if (useNow) {
|
|
772
793
|
await saveConfig({
|
|
773
794
|
apiKey: token.apiKey,
|
|
@@ -809,7 +830,7 @@ export async function runCli({
|
|
|
809
830
|
break;
|
|
810
831
|
case "mcp":
|
|
811
832
|
if (await confirmNotice()) {
|
|
812
|
-
await installMcps({ multiSelect, confirm, writeLine });
|
|
833
|
+
await installMcps({ api, multiSelect, confirm, writeLine });
|
|
813
834
|
}
|
|
814
835
|
break;
|
|
815
836
|
case "model":
|
|
@@ -863,7 +884,7 @@ export async function runCli({
|
|
|
863
884
|
break;
|
|
864
885
|
case "mcp":
|
|
865
886
|
if (await confirmNotice()) {
|
|
866
|
-
await installCodexMcpServers({ codexDir, multiSelect, confirm, writeLine });
|
|
887
|
+
await installCodexMcpServers({ api, codexDir, multiSelect, confirm, writeLine });
|
|
867
888
|
}
|
|
868
889
|
break;
|
|
869
890
|
case "model":
|
|
@@ -902,7 +923,7 @@ export async function runCli({
|
|
|
902
923
|
]);
|
|
903
924
|
switch (action) {
|
|
904
925
|
case "claude_mcp":
|
|
905
|
-
await installMcps({ multiSelect, confirm, writeLine });
|
|
926
|
+
await installMcps({ api, multiSelect, confirm, writeLine });
|
|
906
927
|
break;
|
|
907
928
|
case "claude_skill": {
|
|
908
929
|
const kind = await promptSelect("请选择要安装的内容:", [
|
|
@@ -918,7 +939,7 @@ export async function runCli({
|
|
|
918
939
|
break;
|
|
919
940
|
}
|
|
920
941
|
case "codex_mcp":
|
|
921
|
-
await installCodexMcpServers({ codexDir, multiSelect, confirm, writeLine });
|
|
942
|
+
await installCodexMcpServers({ api, codexDir, multiSelect, confirm, writeLine });
|
|
922
943
|
break;
|
|
923
944
|
case "codex_agents":
|
|
924
945
|
await installAgents({ confirm, writeLine });
|
|
@@ -1019,7 +1040,7 @@ export async function runCli({
|
|
|
1019
1040
|
{ label: "跳过", value: "skip" },
|
|
1020
1041
|
]);
|
|
1021
1042
|
if (wantMcp === "mcp") {
|
|
1022
|
-
await installMcps({
|
|
1043
|
+
await installMcps({ api, multiSelect, confirm, writeLine });
|
|
1023
1044
|
} else if (wantMcp === "skill") {
|
|
1024
1045
|
await installSkills({ promptSelect, confirm, writeLine });
|
|
1025
1046
|
}
|
|
@@ -1042,7 +1063,7 @@ export async function runCli({
|
|
|
1042
1063
|
if (targets.length === 1) {
|
|
1043
1064
|
const tool = targets[0];
|
|
1044
1065
|
const label = tool === "claude" ? "Claude Code" : "Codex";
|
|
1045
|
-
if (await confirm(`配置完成。是否立即启动 ${label}
|
|
1066
|
+
if (await confirm(`配置完成。是否立即启动 ${label}?`, { defaultYes: true })) {
|
|
1046
1067
|
return tool === "claude" ? doLaunchClaude() : doLaunchCodex();
|
|
1047
1068
|
}
|
|
1048
1069
|
return true;
|
|
@@ -1071,7 +1092,12 @@ export async function runCli({
|
|
|
1071
1092
|
}
|
|
1072
1093
|
}
|
|
1073
1094
|
while (true) {
|
|
1074
|
-
const action = await promptMenu({
|
|
1095
|
+
const action = await promptMenu({
|
|
1096
|
+
menu: buildFirstRunMenu(),
|
|
1097
|
+
promptInput,
|
|
1098
|
+
writeLine,
|
|
1099
|
+
select: interactiveUi ? promptSelect : undefined,
|
|
1100
|
+
});
|
|
1075
1101
|
switch (action) {
|
|
1076
1102
|
case "setup_claude":
|
|
1077
1103
|
if (await runFirstSetup(["claude"])) {
|
|
@@ -1121,12 +1147,53 @@ export async function runCli({
|
|
|
1121
1147
|
// 日常菜单循环(PRD 第 11 章)
|
|
1122
1148
|
// ──────────────────────────────────────────────
|
|
1123
1149
|
|
|
1150
|
+
// 切换 Key / 模型(优化需求 1:常用操作直达入口)
|
|
1151
|
+
async function runSwitchKeyModelMenu() {
|
|
1152
|
+
while (true) {
|
|
1153
|
+
const action = await promptSelect("切换 Key / 模型", [
|
|
1154
|
+
{ label: `切换 API Key(当前:${config.tokenName || "未选择"})`, value: "key" },
|
|
1155
|
+
{ label: "Claude Code 默认模型", value: "claude_model" },
|
|
1156
|
+
{ label: "Codex 默认模型", value: "codex_model" },
|
|
1157
|
+
{ label: "返回", value: "back" },
|
|
1158
|
+
]);
|
|
1159
|
+
switch (action) {
|
|
1160
|
+
case "key": {
|
|
1161
|
+
const token = await chooseToken({ api, promptSelect, promptInput, writeLine });
|
|
1162
|
+
await saveConfig({
|
|
1163
|
+
apiKey: token.apiKey,
|
|
1164
|
+
selectedTokenId: token.tokenId,
|
|
1165
|
+
tokenName: token.tokenName,
|
|
1166
|
+
group: token.group,
|
|
1167
|
+
});
|
|
1168
|
+
writeLine(`✓ 当前 Key 已切换为 ${token.tokenName}`);
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
case "claude_model":
|
|
1172
|
+
await configureClaudeModel({ api, claudeDir, promptSelect, writeLine });
|
|
1173
|
+
break;
|
|
1174
|
+
case "codex_model":
|
|
1175
|
+
await configureCodexModel({ api, codexDir, promptSelect, writeLine });
|
|
1176
|
+
break;
|
|
1177
|
+
default:
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1124
1183
|
async function runDailyFlow() {
|
|
1125
1184
|
while (true) {
|
|
1185
|
+
if (interactiveUi) {
|
|
1186
|
+
writeLine(renderHeader("主菜单", { color: fancyOutput }));
|
|
1187
|
+
}
|
|
1126
1188
|
const status = await loadAccountStatus({ api, config });
|
|
1127
1189
|
writeLine(formatAccountStatus({ ...status, width: outputWidth() }));
|
|
1128
1190
|
writeLine("");
|
|
1129
|
-
const action = await promptMenu({
|
|
1191
|
+
const action = await promptMenu({
|
|
1192
|
+
menu: buildDailyMenu(),
|
|
1193
|
+
promptInput,
|
|
1194
|
+
writeLine,
|
|
1195
|
+
select: interactiveUi ? promptSelect : undefined,
|
|
1196
|
+
});
|
|
1130
1197
|
switch (action) {
|
|
1131
1198
|
case "launch_claude":
|
|
1132
1199
|
if (await doLaunchClaude()) {
|
|
@@ -1141,6 +1208,9 @@ export async function runCli({
|
|
|
1141
1208
|
case "balance_topup":
|
|
1142
1209
|
await runBalanceTopUpMenu();
|
|
1143
1210
|
break;
|
|
1211
|
+
case "switch_key_model":
|
|
1212
|
+
await runSwitchKeyModelMenu();
|
|
1213
|
+
break;
|
|
1144
1214
|
case "create_key":
|
|
1145
1215
|
await doCreateKey();
|
|
1146
1216
|
break;
|
|
@@ -1251,47 +1321,6 @@ function formatCliUsage() {
|
|
|
1251
1321
|
].join("\n");
|
|
1252
1322
|
}
|
|
1253
1323
|
|
|
1254
|
-
async function defaultPromptSelect(message, choices) {
|
|
1255
|
-
const rl = readline.createInterface({ input, output });
|
|
1256
|
-
try {
|
|
1257
|
-
writeChoices(message, choices);
|
|
1258
|
-
const answer = await rl.question("> ");
|
|
1259
|
-
const index = Number(answer) - 1;
|
|
1260
|
-
if (Number.isInteger(index) && choices[index]) {
|
|
1261
|
-
return choices[index].value;
|
|
1262
|
-
}
|
|
1263
|
-
const matched = choices.find((choice) => choice.value === answer);
|
|
1264
|
-
if (matched) {
|
|
1265
|
-
return matched.value;
|
|
1266
|
-
}
|
|
1267
|
-
throw new Error("选择无效");
|
|
1268
|
-
} finally {
|
|
1269
|
-
rl.close();
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
async function defaultPromptInput(message, defaultValue = "") {
|
|
1274
|
-
const rl = readline.createInterface({ input, output });
|
|
1275
|
-
try {
|
|
1276
|
-
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
1277
|
-
const answer = await rl.question(`${message}${suffix}: `);
|
|
1278
|
-
return answer.trim() || defaultValue;
|
|
1279
|
-
} finally {
|
|
1280
|
-
rl.close();
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
async function defaultConfirm(message) {
|
|
1285
|
-
const answer = await defaultPromptInput(`${message}\n输入 yes 确认`, "no");
|
|
1286
|
-
return ["yes", "y", "确认", "是", "继续"].includes(answer.trim().toLowerCase());
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
function writeChoices(message, choices) {
|
|
1290
|
-
console.log(message);
|
|
1291
|
-
choices.forEach((choice, index) => {
|
|
1292
|
-
console.log(`${index + 1}. ${choice.label}`);
|
|
1293
|
-
});
|
|
1294
|
-
}
|
|
1295
1324
|
|
|
1296
1325
|
function defaultOpenBrowser(url) {
|
|
1297
1326
|
const commands = {
|
package/src/init-config.js
CHANGED
|
@@ -10,6 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
import { createBackup } from "./backup.js";
|
|
11
11
|
import { upsertMarkedBlock } from "./mcp-skill.js";
|
|
12
12
|
import { upsertProfileKey, validateBasicToml } from "./tool-launcher.js";
|
|
13
|
+
import { renderModelTable } from "./ui.js";
|
|
13
14
|
|
|
14
15
|
const defaultFs = { copyFile, cp, mkdir, readFile, stat, writeFile };
|
|
15
16
|
|
|
@@ -190,32 +191,52 @@ export async function configurePromptStyle({
|
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
// ──────────────────────────────────────────────
|
|
193
|
-
// Claude Code 默认模型(PRD 6.7
|
|
194
|
-
//
|
|
195
|
-
// CLI
|
|
194
|
+
// Claude Code 默认模型(PRD 6.7 / 优化需求第 3 节):
|
|
195
|
+
// 模型列表从后端 GET /api/cli/models 拉取,展示真实模型 ID / 名称 /
|
|
196
|
+
// 标签 / 说明,用户选择后保存真实 model id;CLI 不硬编码模型与档位。
|
|
197
|
+
// 接口不可用时 Claude Code 回退官方别名(sonnet/opus/haiku)并明确提示,
|
|
198
|
+
// 「推荐 / 高性能 / 经济」只作为标签注记,不代替真实模型。
|
|
196
199
|
// ──────────────────────────────────────────────
|
|
197
200
|
|
|
198
|
-
export const
|
|
199
|
-
{ id: "
|
|
200
|
-
{ id: "
|
|
201
|
-
{ id: "
|
|
201
|
+
export const FALLBACK_CLAUDE_ALIASES = [
|
|
202
|
+
{ id: "sonnet", display_name: "Sonnet(官方别名)", tags: ["推荐"], description: "适合大多数代码任务" },
|
|
203
|
+
{ id: "opus", display_name: "Opus(官方别名)", tags: ["高性能"], description: "适合复杂架构和长上下文任务" },
|
|
204
|
+
{ id: "haiku", display_name: "Haiku(官方别名)", tags: ["经济"], description: "适合日常轻量任务" },
|
|
202
205
|
];
|
|
203
206
|
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
+
// 后端接口 GET /api/cli/models?tool=<claude_code|codex>:
|
|
208
|
+
// { models: [{ id, display_name, description, tags, recommended }] }
|
|
209
|
+
// (后端暂无上下文长度数据源,契约中不含 context_length)
|
|
210
|
+
export async function loadAvailableModels(api, tool = "") {
|
|
207
211
|
if (api && typeof api.get === "function") {
|
|
208
212
|
try {
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
+
const query = tool ? `?tool=${encodeURIComponent(tool)}` : "";
|
|
214
|
+
const data = await api.get(`/api/cli/models${query}`);
|
|
215
|
+
const models = (Array.isArray(data?.models) ? data.models : [])
|
|
216
|
+
.filter((model) => model && typeof model.id === "string" && model.id !== "");
|
|
217
|
+
if (models.length > 0) {
|
|
218
|
+
return { models, source: "backend" };
|
|
213
219
|
}
|
|
214
220
|
} catch {
|
|
215
|
-
//
|
|
221
|
+
// 后端暂未提供模型列表接口或网络失败,由调用方决定回退策略
|
|
216
222
|
}
|
|
217
223
|
}
|
|
218
|
-
return {
|
|
224
|
+
return { models: [], source: "unavailable" };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 选项标签:真实模型 id 优先,display_name 与标签只作补充说明
|
|
228
|
+
function formatModelChoiceLabel(model) {
|
|
229
|
+
const name = model.display_name && model.display_name !== model.id ? `(${model.display_name})` : "";
|
|
230
|
+
const tags = Array.isArray(model.tags) && model.tags.length > 0 ? ` [${model.tags.join("/")}]` : "";
|
|
231
|
+
return `${model.id}${name}${tags}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function selectModelFromList({ models, promptSelect, writeLine }) {
|
|
235
|
+
writeLine(renderModelTable(models, { width: process.stdout.columns || 0 }));
|
|
236
|
+
return promptSelect("请选择默认模型:", [
|
|
237
|
+
...models.map((model) => ({ label: formatModelChoiceLabel(model), value: model.id, hint: model.description || "" })),
|
|
238
|
+
{ label: "跳过", value: "skip" },
|
|
239
|
+
]);
|
|
219
240
|
}
|
|
220
241
|
|
|
221
242
|
async function readJsonIfExists(fs, filePath) {
|
|
@@ -250,22 +271,23 @@ export async function configureClaudeDefaultModel({
|
|
|
250
271
|
if (typeof promptSelect !== "function") {
|
|
251
272
|
throw new Error("缺少选择输入");
|
|
252
273
|
}
|
|
253
|
-
const {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
274
|
+
const { models, source } = await loadAvailableModels(api, "claude_code");
|
|
275
|
+
let list = models;
|
|
276
|
+
if (source !== "backend") {
|
|
277
|
+
writeLine("Claude360 后端暂未提供模型列表,以下为 Claude Code 官方模型别名:");
|
|
278
|
+
list = FALLBACK_CLAUDE_ALIASES;
|
|
279
|
+
}
|
|
280
|
+
const selected = await selectModelFromList({ models: list, promptSelect, writeLine });
|
|
258
281
|
if (selected === "skip") {
|
|
259
282
|
writeLine("已跳过默认模型配置。");
|
|
260
283
|
return { skipped: true };
|
|
261
284
|
}
|
|
262
|
-
const tier = tiers.find((item) => item.id === selected);
|
|
263
285
|
const settingsPath = path.join(claudeDir, "settings.json");
|
|
264
286
|
const settings = await readJsonIfExists(fs, settingsPath);
|
|
265
|
-
settings.model =
|
|
287
|
+
settings.model = selected;
|
|
266
288
|
await writeJsonWithBackup({ baseDir: claudeDir, filePath: settingsPath, json: settings, writeLine, fs, now });
|
|
267
|
-
writeLine(`✓ 默认模型已设置为 ${
|
|
268
|
-
return { skipped: false, model:
|
|
289
|
+
writeLine(`✓ 默认模型已设置为 ${selected}(${settingsPath})`);
|
|
290
|
+
return { skipped: false, model: selected };
|
|
269
291
|
}
|
|
270
292
|
|
|
271
293
|
// ──────────────────────────────────────────────
|
|
@@ -349,24 +371,20 @@ export async function configureCodexDefaultModel({
|
|
|
349
371
|
if (typeof promptSelect !== "function") {
|
|
350
372
|
throw new Error("缺少选择输入");
|
|
351
373
|
}
|
|
352
|
-
const {
|
|
374
|
+
const { models, source } = await loadAvailableModels(api, "codex");
|
|
353
375
|
if (source !== "backend") {
|
|
354
376
|
writeLine("Claude360 后端暂未提供 Codex 模型列表,已跳过默认模型写入。");
|
|
355
377
|
writeLine("可在启动 Codex 后使用 /model 命令选择模型。");
|
|
356
378
|
return { skipped: true };
|
|
357
379
|
}
|
|
358
|
-
const selected = await
|
|
359
|
-
...tiers.map((tier) => ({ label: tier.label, value: tier.id })),
|
|
360
|
-
{ label: "跳过", value: "skip" },
|
|
361
|
-
]);
|
|
380
|
+
const selected = await selectModelFromList({ models, promptSelect, writeLine });
|
|
362
381
|
if (selected === "skip") {
|
|
363
382
|
writeLine("已跳过默认模型配置。");
|
|
364
383
|
return { skipped: true };
|
|
365
384
|
}
|
|
366
|
-
const tier = tiers.find((item) => item.id === selected);
|
|
367
385
|
const configPath = path.join(codexDir, "config.toml");
|
|
368
386
|
const current = await readFileIfExists(fs, configPath);
|
|
369
|
-
const next = `${upsertProfileKey(current, "claude360", "model",
|
|
387
|
+
const next = `${upsertProfileKey(current, "claude360", "model", selected).trimEnd()}\n`;
|
|
370
388
|
const tomlError = validateBasicToml(next);
|
|
371
389
|
if (tomlError) {
|
|
372
390
|
writeLine(`× 生成的 Codex 配置 TOML 校验失败:${tomlError}\n已放弃写入,原配置保持不变。`);
|
|
@@ -378,6 +396,6 @@ export async function configureCodexDefaultModel({
|
|
|
378
396
|
}
|
|
379
397
|
await fs.mkdir(codexDir, { recursive: true });
|
|
380
398
|
await fs.writeFile(configPath, next, "utf8");
|
|
381
|
-
writeLine(`✓ Codex 默认模型已设置为 ${
|
|
382
|
-
return { skipped: false, model:
|
|
399
|
+
writeLine(`✓ Codex 默认模型已设置为 ${selected}(${configPath})`);
|
|
400
|
+
return { skipped: false, model: selected };
|
|
383
401
|
}
|
package/src/mcp-skill.js
CHANGED
|
@@ -78,6 +78,46 @@ export function resolveClaudeJsonPath({ homedir = os.homedir } = {}) {
|
|
|
78
78
|
return path.join(homedir(), ".claude.json");
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// 安全边界:远程接口只允许下发展示层元数据(label / desc / recommended /
|
|
82
|
+
// platforms),安装命令(claudeArgs / codex)必须来自 CLI 内置受信清单;
|
|
83
|
+
// 未知 id 直接丢弃,防止后端被攻破或响应被篡改时执行任意命令。
|
|
84
|
+
export function normalizeRemoteMcp(mcp) {
|
|
85
|
+
if (!mcp || typeof mcp.id !== "string") {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const builtin = RECOMMENDED_MCPS.find((item) => item.id === mcp.id);
|
|
89
|
+
if (!builtin) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
...builtin,
|
|
94
|
+
label: typeof mcp.label === "string" && mcp.label !== "" ? mcp.label : builtin.label,
|
|
95
|
+
desc: typeof mcp.desc === "string" ? mcp.desc : builtin.desc,
|
|
96
|
+
recommended: Boolean(mcp.recommended ?? builtin.recommended),
|
|
97
|
+
platforms: typeof mcp.platforms === "string" ? mcp.platforms : builtin.platforms,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// MCP 列表远程优先(优化需求第 4 节):GET /api/cli/mcps 返回
|
|
102
|
+
// { mcps: [...] };接口不可用或数据不合法时回退内置 RECOMMENDED_MCPS,
|
|
103
|
+
// 避免列表长期写死在 CLI。
|
|
104
|
+
export async function loadRecommendedMcps(api) {
|
|
105
|
+
if (api && typeof api.get === "function") {
|
|
106
|
+
try {
|
|
107
|
+
const data = await api.get("/api/cli/mcps");
|
|
108
|
+
const mcps = (Array.isArray(data?.mcps) ? data.mcps : [])
|
|
109
|
+
.map(normalizeRemoteMcp)
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
if (mcps.length > 0) {
|
|
112
|
+
return { mcps, source: "remote" };
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// 后端暂未提供 MCP 列表接口,回退内置推荐
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { mcps: RECOMMENDED_MCPS, source: "builtin" };
|
|
119
|
+
}
|
|
120
|
+
|
|
81
121
|
// 已安装的 Claude Code 用户级 MCP(读取 ~/.claude.json 的 mcpServers 键)
|
|
82
122
|
export async function readInstalledClaudeMcps({
|
|
83
123
|
claudeJsonPath = resolveClaudeJsonPath(),
|
|
@@ -95,18 +135,19 @@ export async function readInstalledClaudeMcps({
|
|
|
95
135
|
}
|
|
96
136
|
}
|
|
97
137
|
|
|
98
|
-
// 多选推荐 MCP
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
138
|
+
// 多选推荐 MCP;展示推荐标识 ★、说明与平台兼容性,
|
|
139
|
+
// 已安装项展示为「已安装」且不可重复安装(PRD 6.6 / 优化需求第 4 节)
|
|
140
|
+
async function selectMcps({ mcps, multiSelect, installedIds, writeLine }) {
|
|
141
|
+
const choices = mcps.map((mcp) => ({
|
|
142
|
+
label: `${mcp.recommended ? "★ " : ""}${mcp.label}${installedIds.includes(mcp.id) ? "(已安装)" : ""}`,
|
|
102
143
|
value: mcp.id,
|
|
103
|
-
hint: mcp.desc
|
|
144
|
+
hint: `${mcp.desc || ""} · ${mcp.platforms || "全平台"}`,
|
|
104
145
|
}));
|
|
105
|
-
const preselected =
|
|
146
|
+
const preselected = mcps
|
|
106
147
|
.filter((mcp) => mcp.recommended && !installedIds.includes(mcp.id))
|
|
107
148
|
.map((mcp) => mcp.id);
|
|
108
149
|
const selected = await multiSelect({
|
|
109
|
-
message: "请选择要安装的 MCP
|
|
150
|
+
message: "请选择要安装的 MCP 服务(★ 为推荐项,已预选):",
|
|
110
151
|
choices,
|
|
111
152
|
preselected,
|
|
112
153
|
});
|
|
@@ -116,7 +157,7 @@ async function selectMcps({ multiSelect, installedIds, writeLine }) {
|
|
|
116
157
|
writeLine(`已安装,跳过:${id}`);
|
|
117
158
|
continue;
|
|
118
159
|
}
|
|
119
|
-
fresh.push(
|
|
160
|
+
fresh.push(mcps.find((mcp) => mcp.id === id));
|
|
120
161
|
}
|
|
121
162
|
return fresh;
|
|
122
163
|
}
|
|
@@ -140,6 +181,7 @@ async function confirmHeavyMcps(mcps, confirm, writeLine) {
|
|
|
140
181
|
}
|
|
141
182
|
|
|
142
183
|
export async function installRecommendedMcps({
|
|
184
|
+
api,
|
|
143
185
|
multiSelect,
|
|
144
186
|
confirm,
|
|
145
187
|
execCommand = defaultExec,
|
|
@@ -150,8 +192,9 @@ export async function installRecommendedMcps({
|
|
|
150
192
|
if (typeof multiSelect !== "function" || typeof confirm !== "function") {
|
|
151
193
|
throw new Error("缺少交互输入");
|
|
152
194
|
}
|
|
195
|
+
const { mcps } = await loadRecommendedMcps(api);
|
|
153
196
|
const installedIds = await readInstalledClaudeMcps({ claudeJsonPath, fs });
|
|
154
|
-
const fresh = await selectMcps({ multiSelect, installedIds, writeLine });
|
|
197
|
+
const fresh = await selectMcps({ mcps, multiSelect, installedIds, writeLine });
|
|
155
198
|
if (fresh.length === 0) {
|
|
156
199
|
writeLine("已跳过 MCP 安装。");
|
|
157
200
|
return [];
|
|
@@ -225,6 +268,7 @@ export function buildCodexMcpTable(mcp) {
|
|
|
225
268
|
}
|
|
226
269
|
|
|
227
270
|
export async function installCodexMcps({
|
|
271
|
+
api,
|
|
228
272
|
multiSelect,
|
|
229
273
|
confirm,
|
|
230
274
|
writeLine = console.log,
|
|
@@ -236,8 +280,9 @@ export async function installCodexMcps({
|
|
|
236
280
|
throw new Error("缺少交互输入");
|
|
237
281
|
}
|
|
238
282
|
const configPath = path.join(codexDir, "config.toml");
|
|
283
|
+
const { mcps } = await loadRecommendedMcps(api);
|
|
239
284
|
const installedIds = await readInstalledCodexMcps({ codexDir, fs });
|
|
240
|
-
const fresh = await selectMcps({ multiSelect, installedIds, writeLine });
|
|
285
|
+
const fresh = await selectMcps({ mcps, multiSelect, installedIds, writeLine });
|
|
241
286
|
if (fresh.length === 0) {
|
|
242
287
|
writeLine("已跳过 MCP 安装。");
|
|
243
288
|
return [];
|
package/src/menu.js
CHANGED
|
@@ -48,35 +48,36 @@ export function buildDailyMenu() {
|
|
|
48
48
|
title: "请选择功能:",
|
|
49
49
|
sections: [
|
|
50
50
|
{
|
|
51
|
-
title: "
|
|
51
|
+
title: "常用操作",
|
|
52
52
|
items: [
|
|
53
53
|
{ key: "1", label: "启动 Claude Code", value: "launch_claude", desc: "使用 Claude360 接入配置直接启动" },
|
|
54
54
|
{ key: "2", label: "启动 Codex", value: "launch_codex", desc: "使用 Claude360 接入配置直接启动" },
|
|
55
55
|
],
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
|
-
title: "
|
|
58
|
+
title: "账户与充值",
|
|
59
59
|
items: [
|
|
60
60
|
{ key: "3", label: "余额与充值", value: "balance_topup", desc: "查看余额用量,支持微信扫码充值" },
|
|
61
|
-
{ key: "4", label: "
|
|
62
|
-
{ key: "5", label: "
|
|
61
|
+
{ key: "4", label: "切换 Key / 模型", value: "switch_key_model", desc: "切换当前 API Key 或默认模型" },
|
|
62
|
+
{ key: "5", label: "创建新的 API Key", value: "create_key", desc: "在当前账号下新建一个 API Key" },
|
|
63
|
+
{ key: "6", label: "打开 Claude360 控制台", value: "open_console", desc: "在浏览器中打开网页控制台" },
|
|
63
64
|
],
|
|
64
65
|
},
|
|
65
66
|
{
|
|
66
|
-
title: "
|
|
67
|
+
title: "Key / 模型 / MCP 配置",
|
|
67
68
|
items: [
|
|
68
|
-
{ key: "
|
|
69
|
-
{ key: "
|
|
70
|
-
{ key: "
|
|
71
|
-
{ key: "
|
|
69
|
+
{ key: "7", label: "Claude Code 配置", value: "claude_code_menu", desc: "完整初始化、工作流、MCP、模型与记忆配置" },
|
|
70
|
+
{ key: "8", label: "Codex 配置", value: "codex_menu", desc: "完整初始化、Provider、工作流与 MCP 配置" },
|
|
71
|
+
{ key: "9", label: "一键完整初始化", value: "full_init", desc: "Claude Code / Codex 完整初始化向导" },
|
|
72
|
+
{ key: "M", label: "推荐 MCP / Skill", value: "mcp_skill", desc: "安装推荐的 MCP、工作流与 Skill 增强" },
|
|
72
73
|
{ key: "C", label: "生成 cc-switch 配置", value: "cc_switch", desc: "生成可导入 cc-switch 的供应商配置" },
|
|
73
74
|
],
|
|
74
75
|
},
|
|
75
76
|
{
|
|
76
|
-
title: "
|
|
77
|
+
title: "维护与设置",
|
|
77
78
|
items: [
|
|
78
|
-
{ key: "D", label: "诊断与修复", value: "diagnostics_menu", desc: "一键诊断环境与配置问题并尝试修复" },
|
|
79
79
|
{ key: "B", label: "安装或更新工具", value: "install_tools", desc: "安装或升级 Claude Code / Codex / 本 CLI" },
|
|
80
|
+
{ key: "D", label: "诊断与修复", value: "diagnostics_menu", desc: "一键诊断环境与配置问题并尝试修复" },
|
|
80
81
|
{ key: "0", label: "重新登录", value: "relogin", desc: "清除本地登录态后重新浏览器授权" },
|
|
81
82
|
{ key: "Q", label: "退出", value: "exit", desc: "退出 claude360 CLI" },
|
|
82
83
|
],
|
|
@@ -141,7 +142,15 @@ export function resolveMenuSelection(menu, input) {
|
|
|
141
142
|
return byValue ? byValue.value : null;
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
export async function promptMenu({ menu, promptInput, writeLine = console.log } = {}) {
|
|
145
|
+
export async function promptMenu({ menu, promptInput, select, writeLine = console.log } = {}) {
|
|
146
|
+
// 注入 select 时走方向键交互(优化需求一.2):分组转为分隔行
|
|
147
|
+
if (typeof select === "function") {
|
|
148
|
+
const choices = menu.sections.flatMap((section) => [
|
|
149
|
+
...(section.title ? [{ separator: renderSectionRule(section.title, false) }] : []),
|
|
150
|
+
...section.items.map((item) => ({ label: item.label, value: item.value, hint: item.desc || "" })),
|
|
151
|
+
]);
|
|
152
|
+
return select(menu.title || "请选择功能:", choices);
|
|
153
|
+
}
|
|
145
154
|
if (typeof promptInput !== "function") {
|
|
146
155
|
throw new Error("缺少菜单输入");
|
|
147
156
|
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// 交互组件层(优化需求第 4 / 三 / 四 / 五节):
|
|
2
|
+
// TTY 下基于 @inquirer/core 提供方向键单选、空格多选、左右键 YES/NO 确认;
|
|
3
|
+
// 非 TTY / CI 环境降级为编号输入,保证管道输出与测试稳定。
|
|
4
|
+
// 所有页面通过 createPrompts() 拿到统一的 promptSelect / promptInput /
|
|
5
|
+
// confirm / multiSelect,不再各自拼交互逻辑。
|
|
6
|
+
|
|
7
|
+
import readline from "node:readline/promises";
|
|
8
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
9
|
+
import {
|
|
10
|
+
createPrompt,
|
|
11
|
+
isDownKey,
|
|
12
|
+
isEnterKey,
|
|
13
|
+
isSpaceKey,
|
|
14
|
+
isTabKey,
|
|
15
|
+
isUpKey,
|
|
16
|
+
useKeypress,
|
|
17
|
+
usePagination,
|
|
18
|
+
useState,
|
|
19
|
+
} from "@inquirer/core";
|
|
20
|
+
|
|
21
|
+
import { promptMultiSelect } from "./menu.js";
|
|
22
|
+
|
|
23
|
+
const ESC = "[";
|
|
24
|
+
const RESET = `${ESC}0m`;
|
|
25
|
+
const BOLD = `${ESC}1m`;
|
|
26
|
+
const fg = (r, g, b) => `${ESC}38;2;${r};${g};${b}m`;
|
|
27
|
+
const CYAN = fg(34, 211, 238); // 品牌高亮:选中项
|
|
28
|
+
const RED = fg(248, 113, 113); // 危险操作:NO 高亮
|
|
29
|
+
const GRAY = fg(100, 116, 139); // 次要说明 / 未选中项
|
|
30
|
+
const GREEN = fg(74, 222, 128); // 完成态答案
|
|
31
|
+
|
|
32
|
+
const paint = (color, sgr, text) => (color ? `${sgr}${text}${RESET}` : text);
|
|
33
|
+
|
|
34
|
+
export function isInteractive(stream = process.stdout) {
|
|
35
|
+
if (process.env.CI) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
// TERM=dumb 的终端可能是 TTY,但不支持方向键 UI 与 ANSI 光标控制
|
|
39
|
+
if (process.env.TERM === "dumb") {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return Boolean(stream && stream.isTTY && process.stdin.isTTY);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Esc 约定:选项里带 back / skip / exit 时,Esc 等价于选择它(需求二.2)
|
|
46
|
+
export function resolveEscValue(choices = []) {
|
|
47
|
+
const hit = choices.find((choice) => ["back", "skip", "exit"].includes(choice?.value));
|
|
48
|
+
return hit ? hit.value : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 确认行:选中项大写、加粗、高亮并带箭头;未选中项小写灰色(需求第四节)。
|
|
52
|
+
// 终端无法局部放大字体,用大小写 + 颜色 + 箭头模拟视觉差异。
|
|
53
|
+
export function renderConfirmLine(yesActive, { color = true, danger = false } = {}) {
|
|
54
|
+
const yes = yesActive
|
|
55
|
+
? paint(color, `${BOLD}${CYAN}`, "▶ YES ◀")
|
|
56
|
+
: paint(color, GRAY, "yes");
|
|
57
|
+
const no = !yesActive
|
|
58
|
+
? paint(color, `${BOLD}${danger ? RED : CYAN}`, "▶ NO ◀")
|
|
59
|
+
: paint(color, GRAY, "no");
|
|
60
|
+
return ` ${yes} ${no}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ──────────────────────────────────────────────
|
|
64
|
+
// 交互组件(@inquirer/core 自定义 prompt)
|
|
65
|
+
// ──────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const PAGE_SIZE = 14;
|
|
68
|
+
|
|
69
|
+
// 防御:调用方传入空列表或全部为分隔行时,方向键取模运算会得到 NaN 或
|
|
70
|
+
// 进入同步死循环,回车会访问 undefined.value。统一在渲染前抛出可读错误。
|
|
71
|
+
function getSelectableIndexes(choices = []) {
|
|
72
|
+
return choices
|
|
73
|
+
.map((choice, index) => (choice && !choice.separator ? index : -1))
|
|
74
|
+
.filter((index) => index >= 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertSelectableChoices(message, choices) {
|
|
78
|
+
const indexes = getSelectableIndexes(choices);
|
|
79
|
+
if (indexes.length === 0) {
|
|
80
|
+
throw new Error(`${message || "选择列表"}缺少可选择项`);
|
|
81
|
+
}
|
|
82
|
+
return indexes;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 单选:方向键移动(跳过分隔行)、回车确认、Esc 返回 escValue
|
|
86
|
+
export const interactiveSelect = createPrompt((config, done) => {
|
|
87
|
+
const { message, choices = [], escValue, color = true } = config;
|
|
88
|
+
const selectable = assertSelectableChoices(message, choices);
|
|
89
|
+
const [status, setStatus] = useState("idle");
|
|
90
|
+
const [active, setActive] = useState(selectable[0]);
|
|
91
|
+
|
|
92
|
+
useKeypress((key) => {
|
|
93
|
+
if (status === "done") {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (isEnterKey(key)) {
|
|
97
|
+
setStatus("done");
|
|
98
|
+
done(choices[active].value);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (isUpKey(key) || isDownKey(key)) {
|
|
102
|
+
const delta = isUpKey(key) ? -1 : 1;
|
|
103
|
+
const pos = selectable.indexOf(active);
|
|
104
|
+
setActive(selectable[(pos + delta + selectable.length) % selectable.length]);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (key.name === "escape" && escValue !== undefined) {
|
|
108
|
+
setStatus("done");
|
|
109
|
+
done(escValue);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (status === "done") {
|
|
114
|
+
return `${paint(color, BOLD, message)} ${paint(color, GREEN, choices[active]?.separator ? "" : String(choices[active]?.label ?? ""))}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const page = usePagination({
|
|
118
|
+
items: choices,
|
|
119
|
+
active,
|
|
120
|
+
pageSize: PAGE_SIZE,
|
|
121
|
+
loop: false,
|
|
122
|
+
renderItem({ item, isActive }) {
|
|
123
|
+
if (item.separator) {
|
|
124
|
+
return paint(color, GRAY, item.separator);
|
|
125
|
+
}
|
|
126
|
+
const hint = item.hint ? ` ${paint(color, GRAY, item.hint)}` : "";
|
|
127
|
+
if (isActive) {
|
|
128
|
+
return `${paint(color, `${BOLD}${CYAN}`, `❯ ${item.label}`)}${hint}`;
|
|
129
|
+
}
|
|
130
|
+
return ` ${item.label}${hint}`;
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
const help = paint(color, GRAY, `↑↓ 移动 · Enter 确认${escValue !== undefined ? " · Esc 返回" : ""}`);
|
|
134
|
+
return `${paint(color, BOLD, message)}\n${page}\n${help}`;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// 多选:空格选择/取消、a 全选、i 反选、回车确认、Esc 取消(需求第 4 节)
|
|
138
|
+
export const interactiveMultiSelect = createPrompt((config, done) => {
|
|
139
|
+
const { message, choices = [], preselected = [], color = true } = config;
|
|
140
|
+
if (choices.length === 0) {
|
|
141
|
+
throw new Error(`${message || "多选列表"}缺少可选择项`);
|
|
142
|
+
}
|
|
143
|
+
const [status, setStatus] = useState("idle");
|
|
144
|
+
const [active, setActive] = useState(0);
|
|
145
|
+
const [selected, setSelected] = useState(
|
|
146
|
+
() => new Set(preselected.filter((value) => choices.some((choice) => choice.value === value))),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const finish = (values) => {
|
|
150
|
+
setStatus("done");
|
|
151
|
+
done(values);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
useKeypress((key) => {
|
|
155
|
+
if (status === "done") {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (isEnterKey(key)) {
|
|
159
|
+
finish(choices.filter((choice) => selected.has(choice.value)).map((choice) => choice.value));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (isUpKey(key) || isDownKey(key)) {
|
|
163
|
+
const direction = isUpKey(key) ? -1 : 1;
|
|
164
|
+
setActive((active + direction + choices.length) % choices.length);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (isSpaceKey(key)) {
|
|
168
|
+
const next = new Set(selected);
|
|
169
|
+
const value = choices[active].value;
|
|
170
|
+
if (next.has(value)) {
|
|
171
|
+
next.delete(value);
|
|
172
|
+
} else {
|
|
173
|
+
next.add(value);
|
|
174
|
+
}
|
|
175
|
+
setSelected(next);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (key.name === "a") {
|
|
179
|
+
setSelected(new Set(choices.map((choice) => choice.value)));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (key.name === "i") {
|
|
183
|
+
setSelected(new Set(choices.filter((choice) => !selected.has(choice.value)).map((choice) => choice.value)));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (key.name === "escape") {
|
|
187
|
+
finish([]);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (status === "done") {
|
|
192
|
+
return `${paint(color, BOLD, message)} ${paint(color, GREEN, `已选 ${selected.size} 项`)}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const page = usePagination({
|
|
196
|
+
items: choices,
|
|
197
|
+
active,
|
|
198
|
+
pageSize: PAGE_SIZE,
|
|
199
|
+
loop: false,
|
|
200
|
+
renderItem({ item, isActive }) {
|
|
201
|
+
const mark = selected.has(item.value) ? paint(color, GREEN, "[x]") : "[ ]";
|
|
202
|
+
const hint = item.hint ? ` ${paint(color, GRAY, item.hint)}` : "";
|
|
203
|
+
const label = isActive ? paint(color, `${BOLD}${CYAN}`, `❯ ${mark} ${item.label}`) : ` ${mark} ${item.label}`;
|
|
204
|
+
return `${label}${hint}`;
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const help = paint(color, GRAY, "↑↓ 移动 · 空格 选择 · a 全选 · i 反选 · Enter 确认 · Esc 取消");
|
|
208
|
+
return `${paint(color, BOLD, message)}\n${page}\n${help}`;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// 确认:左右方向键 / Tab 切换 YES、NO,回车确认,y / n 快捷,Esc 取消。
|
|
212
|
+
// 默认值由调用方按风险设置:普通继续默认 YES,覆盖 / 删除 / 重置默认 NO。
|
|
213
|
+
export const interactiveConfirm = createPrompt((config, done) => {
|
|
214
|
+
const { message, defaultYes = false, danger = false, color = true } = config;
|
|
215
|
+
const [status, setStatus] = useState("idle");
|
|
216
|
+
const [yes, setYes] = useState(Boolean(defaultYes));
|
|
217
|
+
|
|
218
|
+
const finish = (value) => {
|
|
219
|
+
setStatus("done");
|
|
220
|
+
setYes(value);
|
|
221
|
+
done(value);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
useKeypress((key) => {
|
|
225
|
+
if (status === "done") {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (isEnterKey(key)) {
|
|
229
|
+
finish(yes);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (key.name === "left" || key.name === "right" || isTabKey(key)) {
|
|
233
|
+
setYes(!yes);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (key.name === "y") {
|
|
237
|
+
finish(true);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (key.name === "n" || key.name === "escape") {
|
|
241
|
+
finish(false);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (status === "done") {
|
|
246
|
+
return `${paint(color, BOLD, message)} ${paint(color, GREEN, yes ? "YES" : "NO")}`;
|
|
247
|
+
}
|
|
248
|
+
const help = paint(color, GRAY, "←→ 切换 · Enter 确认 · Esc 取消");
|
|
249
|
+
return `${paint(color, BOLD, message)}\n\n${renderConfirmLine(yes, { color, danger })}\n\n${help}`;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ──────────────────────────────────────────────
|
|
253
|
+
// 降级实现:编号输入(非 TTY / CI 环境)
|
|
254
|
+
// ──────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
async function defaultLineInput(message, defaultValue = "") {
|
|
257
|
+
const rl = readline.createInterface({ input, output });
|
|
258
|
+
try {
|
|
259
|
+
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
260
|
+
const answer = await rl.question(`${message}${suffix}: `);
|
|
261
|
+
return answer.trim() || defaultValue;
|
|
262
|
+
} finally {
|
|
263
|
+
rl.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function createFallbackPrompts({ promptInput, writeLine }) {
|
|
268
|
+
const ask = promptInput || defaultLineInput;
|
|
269
|
+
const print = writeLine || console.log;
|
|
270
|
+
return {
|
|
271
|
+
promptInput: ask,
|
|
272
|
+
async promptSelect(message, choices) {
|
|
273
|
+
while (true) {
|
|
274
|
+
print(message);
|
|
275
|
+
choices.forEach((choice, index) => {
|
|
276
|
+
print(`${index + 1}. ${choice.label}`);
|
|
277
|
+
});
|
|
278
|
+
const answer = String(await ask("请输入编号") ?? "").trim();
|
|
279
|
+
const index = Number(answer) - 1;
|
|
280
|
+
if (Number.isInteger(index) && choices[index]) {
|
|
281
|
+
return choices[index].value;
|
|
282
|
+
}
|
|
283
|
+
const matched = choices.find((choice) => choice.value === answer);
|
|
284
|
+
if (matched) {
|
|
285
|
+
return matched.value;
|
|
286
|
+
}
|
|
287
|
+
print("选择无效,请重新输入。");
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
async confirm(message, { defaultYes = false } = {}) {
|
|
291
|
+
const suffix = defaultYes ? "(Y/n)" : "(y/N)";
|
|
292
|
+
const answer = String(await ask(`${message} ${suffix}`) ?? "").trim().toLowerCase();
|
|
293
|
+
if (answer === "") {
|
|
294
|
+
return defaultYes;
|
|
295
|
+
}
|
|
296
|
+
return ["yes", "y", "确认", "是", "继续"].includes(answer);
|
|
297
|
+
},
|
|
298
|
+
multiSelect(options) {
|
|
299
|
+
return promptMultiSelect({ ...options, promptInput: ask, writeLine: print });
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ──────────────────────────────────────────────
|
|
305
|
+
// 工厂:根据环境返回统一交互接口
|
|
306
|
+
// ──────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
// Ctrl+C 在 @inquirer 中抛 ExitPromptError,统一转为优雅退出
|
|
309
|
+
async function guardExit(run) {
|
|
310
|
+
try {
|
|
311
|
+
return await run();
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (error?.name === "ExitPromptError") {
|
|
314
|
+
process.exit(130);
|
|
315
|
+
}
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function createPrompts({
|
|
321
|
+
interactive = isInteractive(),
|
|
322
|
+
color = true,
|
|
323
|
+
promptInput,
|
|
324
|
+
writeLine,
|
|
325
|
+
} = {}) {
|
|
326
|
+
if (!interactive) {
|
|
327
|
+
return createFallbackPrompts({ promptInput, writeLine });
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
promptInput: promptInput || defaultLineInput,
|
|
331
|
+
promptSelect: (message, choices) =>
|
|
332
|
+
guardExit(() => interactiveSelect({ message, choices, escValue: resolveEscValue(choices), color })),
|
|
333
|
+
confirm: (message, { defaultYes = false, danger = false } = {}) =>
|
|
334
|
+
guardExit(() => interactiveConfirm({ message, defaultYes, danger, color })),
|
|
335
|
+
multiSelect: ({ message, choices, preselected }) =>
|
|
336
|
+
guardExit(() => interactiveMultiSelect({ message, choices, preselected, color })),
|
|
337
|
+
};
|
|
338
|
+
}
|
package/src/topup.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { renderKeyValueTable, renderSectionTitle } from "./ui.js";
|
|
2
|
+
|
|
1
3
|
export async function loadTopUpOptions(api) {
|
|
2
4
|
if (!api) {
|
|
3
5
|
throw new Error("缺少 API client");
|
|
@@ -5,12 +7,22 @@ export async function loadTopUpOptions(api) {
|
|
|
5
7
|
return api.get("/api/cli/topup/options");
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
// 轮询状态默认走单行刷新(TTY 用 \r 覆盖整行),避免重复刷屏(优化需求第 6 节);
|
|
11
|
+
// 空文本表示轮询结束,清行收尾
|
|
12
|
+
function defaultWriteStatus(text) {
|
|
13
|
+
if (!process.stdout.isTTY) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
process.stdout.write(`\r[2K${text || ""}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
export async function runWechatTopUp({
|
|
9
20
|
api,
|
|
10
21
|
promptSelect,
|
|
11
22
|
promptInput,
|
|
12
23
|
renderQr = renderTerminalQr,
|
|
13
24
|
writeLine = console.log,
|
|
25
|
+
writeStatus = defaultWriteStatus,
|
|
14
26
|
sleep = defaultSleep,
|
|
15
27
|
pollIntervalMs = 3000,
|
|
16
28
|
maxPolls = 60,
|
|
@@ -30,16 +42,22 @@ export async function runWechatTopUp({
|
|
|
30
42
|
throw new Error("创建微信充值订单失败");
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
// 订单信息边框包裹,二维码区域带标题与支付状态
|
|
46
|
+
writeLine(renderSectionTitle("微信扫码支付"));
|
|
47
|
+
writeLine(renderKeyValueTable([
|
|
48
|
+
["订单号", String(order.order_id)],
|
|
49
|
+
["金额", String(order.money_display || order.money || amount)],
|
|
50
|
+
["支付方式", "微信扫码"],
|
|
51
|
+
]));
|
|
52
|
+
writeLine("请使用微信扫码支付:");
|
|
34
53
|
await printQrOrCodeUrl({ codeUrl: order.code_url, renderQr, writeLine });
|
|
35
|
-
writeLine(`订单金额:${order.money_display || order.money || amount}`);
|
|
36
|
-
writeLine("订单状态:等待支付...");
|
|
37
54
|
const status = await waitTopUpPaid({
|
|
38
55
|
api,
|
|
39
56
|
orderId: order.order_id,
|
|
40
57
|
sleep,
|
|
41
58
|
pollIntervalMs,
|
|
42
59
|
maxPolls,
|
|
60
|
+
writeStatus,
|
|
43
61
|
});
|
|
44
62
|
const balance = await api.get("/api/cli/me");
|
|
45
63
|
return { order, status, balance };
|
|
@@ -54,7 +72,7 @@ async function chooseTopUpAmount({ options, promptSelect, promptInput }) {
|
|
|
54
72
|
throw new Error("缺少充值金额选择输入");
|
|
55
73
|
}
|
|
56
74
|
const selected = await promptSelect("选择充值金额", amountOptions.map((amount) => ({
|
|
57
|
-
label:
|
|
75
|
+
label: `¥${amount}`,
|
|
58
76
|
value: amount,
|
|
59
77
|
})));
|
|
60
78
|
if (!amountOptions.includes(selected)) {
|
|
@@ -88,19 +106,24 @@ async function printQrOrCodeUrl({ codeUrl, renderQr, writeLine }) {
|
|
|
88
106
|
}
|
|
89
107
|
}
|
|
90
108
|
|
|
91
|
-
async function waitTopUpPaid({ api, orderId, sleep, pollIntervalMs, maxPolls }) {
|
|
109
|
+
async function waitTopUpPaid({ api, orderId, sleep, pollIntervalMs, maxPolls, writeStatus = () => {} }) {
|
|
92
110
|
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
|
|
93
111
|
const status = await api.get(`/api/cli/topup/order?order_id=${encodeURIComponent(orderId)}`);
|
|
94
112
|
if (status?.status === "success") {
|
|
113
|
+
writeStatus("");
|
|
95
114
|
return status;
|
|
96
115
|
}
|
|
97
116
|
if (status?.status && status.status !== "pending") {
|
|
117
|
+
writeStatus("");
|
|
98
118
|
throw new Error(`充值订单状态异常:${status.status}`);
|
|
99
119
|
}
|
|
120
|
+
// 单行状态刷新,不向常规输出重复打印等待文本
|
|
121
|
+
writeStatus(`等待支付中… 已等待 ${Math.round(((attempt + 1) * pollIntervalMs) / 1000)}s`);
|
|
100
122
|
if (attempt < maxPolls - 1) {
|
|
101
123
|
await sleep(pollIntervalMs);
|
|
102
124
|
}
|
|
103
125
|
}
|
|
126
|
+
writeStatus("");
|
|
104
127
|
throw new Error("等待微信支付超时");
|
|
105
128
|
}
|
|
106
129
|
|
package/src/ui.js
CHANGED
|
@@ -135,3 +135,74 @@ export function renderSectionTitle(title, { color = false, width = 46 } = {}) {
|
|
|
135
135
|
}
|
|
136
136
|
return `${BORDER_COLOR}${lead}${RESET}${BOLD}${HEAD_COLOR}${label}${RESET}${BORDER_COLOR}${tail}${RESET}`;
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
// ──────────────────────────────────────────────
|
|
140
|
+
// 统一页面组件(优化需求第 5 / 一.1 节)
|
|
141
|
+
// ──────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
// 页面标题区:品牌名 + 当前页面名,统一边框样式
|
|
144
|
+
export function renderHeader(title, { subtitle = "", color = false } = {}) {
|
|
145
|
+
const label = `Claude360 CLI · ${title}`;
|
|
146
|
+
const inner = Math.max(cellWidth(label), cellWidth(subtitle)) + 2;
|
|
147
|
+
const line = (text) => {
|
|
148
|
+
const body = ` ${padCell(text, inner - 2)} `;
|
|
149
|
+
if (!color) {
|
|
150
|
+
return `│${body}│`;
|
|
151
|
+
}
|
|
152
|
+
return `${BORDER_COLOR}│${RESET}${BOLD}${HEAD_COLOR}${body}${RESET}${BORDER_COLOR}│${RESET}`;
|
|
153
|
+
};
|
|
154
|
+
const edge = (left, right) => {
|
|
155
|
+
const text = `${left}${"─".repeat(inner)}${right}`;
|
|
156
|
+
return color ? `${BORDER_COLOR}${text}${RESET}` : text;
|
|
157
|
+
};
|
|
158
|
+
const lines = [edge("┌", "┐"), line(label)];
|
|
159
|
+
if (subtitle) {
|
|
160
|
+
lines.push(line(subtitle));
|
|
161
|
+
}
|
|
162
|
+
lines.push(edge("└", "┘"));
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 信息盒子:info / warn / error / success 四种状态,
|
|
167
|
+
// 颜色只增强层级,状态始终有文字前缀(需求二「不依赖颜色表达唯一信息」)
|
|
168
|
+
const BOX_KINDS = {
|
|
169
|
+
info: { mark: "i", color: fg(125, 211, 252) },
|
|
170
|
+
warn: { mark: "!", color: fg(250, 204, 21) },
|
|
171
|
+
error: { mark: "×", color: fg(248, 113, 113) },
|
|
172
|
+
success: { mark: "✓", color: fg(74, 222, 128) },
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export function renderBox(message, { kind = "info", color = false, width = 0 } = {}) {
|
|
176
|
+
const { mark, color: kindColor } = BOX_KINDS[kind] || BOX_KINDS.info;
|
|
177
|
+
const rawLines = String(message ?? "").split("\n");
|
|
178
|
+
const textLines = rawLines.map((line, index) => (index === 0 ? `${mark} ${line}` : ` ${line}`));
|
|
179
|
+
const maxAvailable = width > 4 ? width - 4 : 0;
|
|
180
|
+
const fitted = textLines.map((line) => (maxAvailable ? truncateDisplay(line, maxAvailable) : line));
|
|
181
|
+
const inner = Math.max(...fitted.map((line) => cellWidth(line))) + 2;
|
|
182
|
+
const edge = (left, right) => {
|
|
183
|
+
const text = `${left}${"─".repeat(inner)}${right}`;
|
|
184
|
+
return color ? `${kindColor}${text}${RESET}` : text;
|
|
185
|
+
};
|
|
186
|
+
const lines = [edge("┌", "┐")];
|
|
187
|
+
for (const line of fitted) {
|
|
188
|
+
const body = ` ${padCell(line, inner - 2)} `;
|
|
189
|
+
lines.push(color ? `${kindColor}│${RESET}${body}${kindColor}│${RESET}` : `│${body}│`);
|
|
190
|
+
}
|
|
191
|
+
lines.push(edge("└", "┘"));
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 模型表(优化需求第 3 节):展示后端返回的真实模型信息。
|
|
196
|
+
// 字段契约与 /api/cli/models 对齐:id / display_name / tags / description;
|
|
197
|
+
// 后端暂无上下文长度数据源,不渲染“上下文”列,名称列回退模型 ID。
|
|
198
|
+
export function renderModelTable(models = [], { color = false, width = 0 } = {}) {
|
|
199
|
+
return renderTable({
|
|
200
|
+
head: ["模型 ID", "名称", "标签", "说明"],
|
|
201
|
+
rows: models.map((model) => [
|
|
202
|
+
model.id ?? "-",
|
|
203
|
+
model.display_name || model.id || "-",
|
|
204
|
+
Array.isArray(model.tags) && model.tags.length > 0 ? model.tags.join(",") : "-",
|
|
205
|
+
model.description || "-",
|
|
206
|
+
]),
|
|
207
|
+
}, { color, width });
|
|
208
|
+
}
|