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 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
- Claude360
118
- 1. 查看余额
119
- 2. 微信扫码充值
120
- 3. 启动 Claude Code
121
- 4. 启动 Codex
122
- 5. 安装或更新工具
123
- 6. 切换 API Key / 分组
124
- 7. 诊断
125
- 8. 退出
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
- - **查看余额**:调用 `GET /api/cli/me`,展示账号、余额、已用、当前 Key 名称与分组。
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
- - **切换 API Key / 分组**:重新进入 Token 向导。
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.4",
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
  }
@@ -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 = defaultPromptSelect,
76
- promptInput = defaultPromptInput,
77
- confirm = defaultConfirm,
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 = promptMultiSelect,
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
- await showEnvironmentChecks();
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("配置 Claude360 Provider\n");
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: confirm, writeLine });
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: confirm, writeLine });
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: confirm, writeLine });
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("余额与充值\n");
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({ promptSelect, confirm, writeLine });
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({ menu: buildFirstRunMenu(), promptInput, writeLine });
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({ menu: buildDailyMenu(), promptInput, writeLine });
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 = {
@@ -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
- // 后端未提供时回退到 Claude Code 官方别名(sonnet/opus/haiku),
195
- // CLI 不硬编码具体模型 ID、倍率与计费规则。
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 DEFAULT_MODEL_TIERS = [
199
- { id: "recommended", label: "推荐模型 适合大多数代码任务", model: "sonnet" },
200
- { id: "performance", label: "高性能模型 适合复杂架构和长上下文任务", model: "opus" },
201
- { id: "economy", label: "经济模型 适合日常轻量任务", model: "haiku" },
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
- // 后端可选接口 GET /api/cli/models:{ tiers: [{ id, label, model }] };
205
- // 不可用时回退内置映射。
206
- export async function loadModelTiers(api) {
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 data = await api.get("/api/cli/models");
210
- const tiers = Array.isArray(data?.tiers) ? data.tiers : [];
211
- if (tiers.length > 0 && tiers.every((tier) => tier.id && tier.label && tier.model)) {
212
- return { tiers, source: "backend" };
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 { tiers: DEFAULT_MODEL_TIERS, source: "builtin" };
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 { tiers } = await loadModelTiers(api);
254
- const selected = await promptSelect("请选择默认模型:", [
255
- ...tiers.map((tier) => ({ label: tier.label, value: tier.id })),
256
- { label: "跳过", value: "skip" },
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 = tier.model;
287
+ settings.model = selected;
266
288
  await writeJsonWithBackup({ baseDir: claudeDir, filePath: settingsPath, json: settings, writeLine, fs, now });
267
- writeLine(`✓ 默认模型已设置为 ${tier.model}(${settingsPath})`);
268
- return { skipped: false, model: tier.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 { tiers, source } = await loadModelTiers(api);
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 promptSelect("请选择默认模型:", [
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", tier.model).trimEnd()}\n`;
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 默认模型已设置为 ${tier.model}(${configPath})`);
382
- return { skipped: false, model: tier.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;已安装项展示为「已安装」且不可重复安装(PRD 6.6)
99
- async function selectMcps({ multiSelect, installedIds, writeLine }) {
100
- const choices = RECOMMENDED_MCPS.map((mcp) => ({
101
- label: `${mcp.label}${installedIds.includes(mcp.id) ? "(已安装)" : ""}`,
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 = RECOMMENDED_MCPS
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 服务(默认推荐 Context7 / Open Web Search / DeepWiki):",
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(RECOMMENDED_MCPS.find((mcp) => mcp.id === id));
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: "Claude360",
58
+ title: "账户与充值",
59
59
  items: [
60
60
  { key: "3", label: "余额与充值", value: "balance_topup", desc: "查看余额用量,支持微信扫码充值" },
61
- { key: "4", label: "创建新的 API Key", value: "create_key", desc: "在当前账号下新建一个 API Key" },
62
- { key: "5", label: "打开 Claude360 控制台", value: "open_console", desc: "在浏览器中打开网页控制台" },
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: "6", label: "Claude Code 配置", value: "claude_code_menu", desc: "完整初始化、工作流、MCP、模型与记忆配置" },
69
- { key: "7", label: "Codex 配置", value: "codex_menu", desc: "完整初始化、Provider、工作流与 MCP 配置" },
70
- { key: "8", label: "一键完整初始化", value: "full_init", desc: "Claude Code / Codex 完整初始化向导" },
71
- { key: "9", label: "推荐 MCP / Skill", value: "mcp_skill", desc: "安装推荐的 MCP、工作流与 Skill 增强" },
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${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
- writeLine("请使用微信扫码支付");
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: `${amount}`,
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
+ }