claude360 0.2.3 → 0.2.5

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、当前分组与倍率(来自后端) |
@@ -173,6 +175,7 @@ CLI 仅依赖一组隔离的 `/api/cli` 接口,由 `newapi/controller/cli.go`
173
175
  | POST | `/api/cli/tokens/:id/reveal` | 校验归属并返回明文 Key,被限频且禁用缓存 |
174
176
  | POST | `/api/cli/tokens` | 按分组创建新 Key(受 `operation_setting.GetMaxUserTokens()` 约束) |
175
177
  | GET | `/api/cli/groups` | 用户可用分组(含 `display_name`、`ratio`、`description`) |
178
+ | GET | `/api/cli/models` | 当前用户可用模型列表(含 `display_name`、`tags`、`description`、`recommended`;`tool` 参数预留) |
176
179
  | GET | `/api/cli/topup/options` | 后端配置的充值金额选项与最低额,包含 WeChat 启用状态 |
177
180
  | POST | `/api/cli/topup/wechat` | 创建微信 Native 订单,返回 `code_url`、`order_id`、`pay_money` |
178
181
  | 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",
3
+ "version": "0.2.5",
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,19 @@ 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;
117
130
 
118
131
  if (showBanner) {
119
132
  if (fancyOutput) {
@@ -121,7 +134,10 @@ export async function runCli({
121
134
  } else {
122
135
  writeLine(renderBanner({ version, baseUrl }));
123
136
  }
124
- await showEnvironmentChecks();
137
+ // 首页保持低高度(优化需求 1):环境检查仅在未登录 / 强制配置时展示
138
+ if (!config.cliToken || forceSetup) {
139
+ await showEnvironmentChecks();
140
+ }
125
141
  }
126
142
 
127
143
  // ──────────────────────────────────────────────
@@ -373,7 +389,7 @@ export async function runCli({
373
389
  // Claude360 Provider 菜单(PRD 8.5)
374
390
  async function runCodexProviderMenu() {
375
391
  while (true) {
376
- writeLine("配置 Claude360 Provider\n");
392
+ writeLine(renderHeader("Codex Provider 配置", { color: fancyOutput }));
377
393
  writeLine("当前 Provider:claude360");
378
394
  writeLine("当前 Profile:claude360\n");
379
395
  const action = await promptSelect("请选择:", [
@@ -452,12 +468,12 @@ export async function runCli({
452
468
  configureLanguage: () => configureLanguage({ baseDir: claudeDir, filePath: claudeMemoryPath, promptSelect, writeLine }),
453
469
  configureStyle: () => configureStyle({ baseDir: claudeDir, filePath: claudeMemoryPath, promptSelect, writeLine }),
454
470
  installWorkflows: () => installClaudeWfs({ claudeDir, multiSelect, confirm, writeLine }),
455
- installMcps: () => installMcps({ multiSelect, confirm, writeLine }),
471
+ installMcps: () => installMcps({ api, multiSelect, confirm, writeLine }),
456
472
  configureModel: () => configureClaudeModel({ api, claudeDir, promptSelect, writeLine }),
457
473
  importEnvPermissions: () => importEnvPermissions({ claudeDir, confirm, writeLine }),
458
474
  testConnection: testConnectionStep,
459
475
  askLaunch: async () => {
460
- if (await confirm("初始化完成。是否立即启动 Claude Code?")) {
476
+ if (await confirm("初始化完成。是否立即启动 Claude Code?", { defaultYes: true })) {
461
477
  await doLaunchClaude();
462
478
  }
463
479
  },
@@ -486,10 +502,10 @@ export async function runCli({
486
502
  writeLine("✓ 已写入 Codex claude360 provider/profile(~/.codex/config.toml)");
487
503
  },
488
504
  checkCompat: checkCodexCompatStep,
489
- installMcps: () => installCodexMcpServers({ codexDir, multiSelect, confirm, writeLine }),
505
+ installMcps: () => installCodexMcpServers({ api, codexDir, multiSelect, confirm, writeLine }),
490
506
  testConnection: testConnectionStep,
491
507
  askLaunch: async () => {
492
- if (await confirm("初始化完成。是否立即启动 Codex?")) {
508
+ if (await confirm("初始化完成。是否立即启动 Codex?", { defaultYes: true })) {
493
509
  await doLaunchCodex();
494
510
  }
495
511
  },
@@ -700,6 +716,7 @@ export async function runCli({
700
716
  }
701
717
  const approved = await confirm(
702
718
  "将退出登录并清除本地保存的 CLI Token 与 API Key。\n不会删除 Claude360 网站上的账号或 API Key。\n是否继续?",
719
+ { danger: true },
703
720
  );
704
721
  if (!approved) {
705
722
  writeLine("已取消。");
@@ -713,7 +730,7 @@ export async function runCli({
713
730
  }
714
731
 
715
732
  async function doRelogin() {
716
- const approved = await confirm("将重新进行浏览器授权并覆盖本地登录状态。是否继续?");
733
+ const approved = await confirm("将重新进行浏览器授权并覆盖本地登录状态。是否继续?", { danger: true });
717
734
  if (!approved) {
718
735
  return false;
719
736
  }
@@ -725,6 +742,7 @@ export async function runCli({
725
742
  async function doClearConfig() {
726
743
  const approved = await confirm(
727
744
  "该操作会清除本地 Claude360 CLI 登录状态和已保存 Key。\n不会删除 Claude360 网站上的账号或 API Key。\n是否继续?",
745
+ { danger: true },
728
746
  );
729
747
  if (!approved) {
730
748
  writeLine("已取消。");
@@ -743,7 +761,7 @@ export async function runCli({
743
761
 
744
762
  async function runBalanceTopUpMenu() {
745
763
  while (true) {
746
- writeLine("余额与充值\n");
764
+ writeLine(renderHeader("余额与充值", { color: fancyOutput }));
747
765
  const me = await showBalanceAndUsage();
748
766
  if (me?.low_balance) {
749
767
  writeLine("状态:余额偏低,建议充值");
@@ -767,7 +785,7 @@ export async function runCli({
767
785
 
768
786
  async function doCreateKey() {
769
787
  const token = await createToken({ api, promptSelect, promptInput });
770
- const useNow = await confirm(`已创建 API Key:${token.tokenName}。是否将其设为当前使用的 Key?`);
788
+ const useNow = await confirm(`已创建 API Key:${token.tokenName}。是否将其设为当前使用的 Key?`, { defaultYes: true });
771
789
  if (useNow) {
772
790
  await saveConfig({
773
791
  apiKey: token.apiKey,
@@ -809,7 +827,7 @@ export async function runCli({
809
827
  break;
810
828
  case "mcp":
811
829
  if (await confirmNotice()) {
812
- await installMcps({ multiSelect, confirm, writeLine });
830
+ await installMcps({ api, multiSelect, confirm, writeLine });
813
831
  }
814
832
  break;
815
833
  case "model":
@@ -863,7 +881,7 @@ export async function runCli({
863
881
  break;
864
882
  case "mcp":
865
883
  if (await confirmNotice()) {
866
- await installCodexMcpServers({ codexDir, multiSelect, confirm, writeLine });
884
+ await installCodexMcpServers({ api, codexDir, multiSelect, confirm, writeLine });
867
885
  }
868
886
  break;
869
887
  case "model":
@@ -902,7 +920,7 @@ export async function runCli({
902
920
  ]);
903
921
  switch (action) {
904
922
  case "claude_mcp":
905
- await installMcps({ multiSelect, confirm, writeLine });
923
+ await installMcps({ api, multiSelect, confirm, writeLine });
906
924
  break;
907
925
  case "claude_skill": {
908
926
  const kind = await promptSelect("请选择要安装的内容:", [
@@ -918,7 +936,7 @@ export async function runCli({
918
936
  break;
919
937
  }
920
938
  case "codex_mcp":
921
- await installCodexMcpServers({ codexDir, multiSelect, confirm, writeLine });
939
+ await installCodexMcpServers({ api, codexDir, multiSelect, confirm, writeLine });
922
940
  break;
923
941
  case "codex_agents":
924
942
  await installAgents({ confirm, writeLine });
@@ -1019,7 +1037,7 @@ export async function runCli({
1019
1037
  { label: "跳过", value: "skip" },
1020
1038
  ]);
1021
1039
  if (wantMcp === "mcp") {
1022
- await installMcps({ promptSelect, confirm, writeLine });
1040
+ await installMcps({ api, multiSelect, confirm, writeLine });
1023
1041
  } else if (wantMcp === "skill") {
1024
1042
  await installSkills({ promptSelect, confirm, writeLine });
1025
1043
  }
@@ -1042,7 +1060,7 @@ export async function runCli({
1042
1060
  if (targets.length === 1) {
1043
1061
  const tool = targets[0];
1044
1062
  const label = tool === "claude" ? "Claude Code" : "Codex";
1045
- if (await confirm(`配置完成。是否立即启动 ${label}?`)) {
1063
+ if (await confirm(`配置完成。是否立即启动 ${label}?`, { defaultYes: true })) {
1046
1064
  return tool === "claude" ? doLaunchClaude() : doLaunchCodex();
1047
1065
  }
1048
1066
  return true;
@@ -1071,7 +1089,12 @@ export async function runCli({
1071
1089
  }
1072
1090
  }
1073
1091
  while (true) {
1074
- const action = await promptMenu({ menu: buildFirstRunMenu(), promptInput, writeLine });
1092
+ const action = await promptMenu({
1093
+ menu: buildFirstRunMenu(),
1094
+ promptInput,
1095
+ writeLine,
1096
+ select: interactiveUi ? promptSelect : undefined,
1097
+ });
1075
1098
  switch (action) {
1076
1099
  case "setup_claude":
1077
1100
  if (await runFirstSetup(["claude"])) {
@@ -1121,12 +1144,53 @@ export async function runCli({
1121
1144
  // 日常菜单循环(PRD 第 11 章)
1122
1145
  // ──────────────────────────────────────────────
1123
1146
 
1147
+ // 切换 Key / 模型(优化需求 1:常用操作直达入口)
1148
+ async function runSwitchKeyModelMenu() {
1149
+ while (true) {
1150
+ const action = await promptSelect("切换 Key / 模型", [
1151
+ { label: `切换 API Key(当前:${config.tokenName || "未选择"})`, value: "key" },
1152
+ { label: "Claude Code 默认模型", value: "claude_model" },
1153
+ { label: "Codex 默认模型", value: "codex_model" },
1154
+ { label: "返回", value: "back" },
1155
+ ]);
1156
+ switch (action) {
1157
+ case "key": {
1158
+ const token = await chooseToken({ api, promptSelect, promptInput, writeLine });
1159
+ await saveConfig({
1160
+ apiKey: token.apiKey,
1161
+ selectedTokenId: token.tokenId,
1162
+ tokenName: token.tokenName,
1163
+ group: token.group,
1164
+ });
1165
+ writeLine(`✓ 当前 Key 已切换为 ${token.tokenName}`);
1166
+ break;
1167
+ }
1168
+ case "claude_model":
1169
+ await configureClaudeModel({ api, claudeDir, promptSelect, writeLine });
1170
+ break;
1171
+ case "codex_model":
1172
+ await configureCodexModel({ api, codexDir, promptSelect, writeLine });
1173
+ break;
1174
+ default:
1175
+ return;
1176
+ }
1177
+ }
1178
+ }
1179
+
1124
1180
  async function runDailyFlow() {
1125
1181
  while (true) {
1182
+ if (interactiveUi) {
1183
+ writeLine(renderHeader("主菜单", { color: fancyOutput }));
1184
+ }
1126
1185
  const status = await loadAccountStatus({ api, config });
1127
1186
  writeLine(formatAccountStatus({ ...status, width: outputWidth() }));
1128
1187
  writeLine("");
1129
- const action = await promptMenu({ menu: buildDailyMenu(), promptInput, writeLine });
1188
+ const action = await promptMenu({
1189
+ menu: buildDailyMenu(),
1190
+ promptInput,
1191
+ writeLine,
1192
+ select: interactiveUi ? promptSelect : undefined,
1193
+ });
1130
1194
  switch (action) {
1131
1195
  case "launch_claude":
1132
1196
  if (await doLaunchClaude()) {
@@ -1141,6 +1205,9 @@ export async function runCli({
1141
1205
  case "balance_topup":
1142
1206
  await runBalanceTopUpMenu();
1143
1207
  break;
1208
+ case "switch_key_model":
1209
+ await runSwitchKeyModelMenu();
1210
+ break;
1144
1211
  case "create_key":
1145
1212
  await doCreateKey();
1146
1213
  break;
@@ -1251,47 +1318,6 @@ function formatCliUsage() {
1251
1318
  ].join("\n");
1252
1319
  }
1253
1320
 
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
1321
 
1296
1322
  function defaultOpenBrowser(url) {
1297
1323
  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,51 @@ 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, context_length }] }
209
+ export async function loadAvailableModels(api, tool = "") {
207
210
  if (api && typeof api.get === "function") {
208
211
  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" };
212
+ const query = tool ? `?tool=${encodeURIComponent(tool)}` : "";
213
+ const data = await api.get(`/api/cli/models${query}`);
214
+ const models = (Array.isArray(data?.models) ? data.models : [])
215
+ .filter((model) => model && typeof model.id === "string" && model.id !== "");
216
+ if (models.length > 0) {
217
+ return { models, source: "backend" };
213
218
  }
214
219
  } catch {
215
- // 后端暂未提供模型列表接口,使用内置映射
220
+ // 后端暂未提供模型列表接口或网络失败,由调用方决定回退策略
216
221
  }
217
222
  }
218
- return { tiers: DEFAULT_MODEL_TIERS, source: "builtin" };
223
+ return { models: [], source: "unavailable" };
224
+ }
225
+
226
+ // 选项标签:真实模型 id 优先,display_name 与标签只作补充说明
227
+ function formatModelChoiceLabel(model) {
228
+ const name = model.display_name && model.display_name !== model.id ? `(${model.display_name})` : "";
229
+ const tags = Array.isArray(model.tags) && model.tags.length > 0 ? ` [${model.tags.join("/")}]` : "";
230
+ return `${model.id}${name}${tags}`;
231
+ }
232
+
233
+ async function selectModelFromList({ models, promptSelect, writeLine }) {
234
+ writeLine(renderModelTable(models, { width: process.stdout.columns || 0 }));
235
+ return promptSelect("请选择默认模型:", [
236
+ ...models.map((model) => ({ label: formatModelChoiceLabel(model), value: model.id, hint: model.description || "" })),
237
+ { label: "跳过", value: "skip" },
238
+ ]);
219
239
  }
220
240
 
221
241
  async function readJsonIfExists(fs, filePath) {
@@ -250,22 +270,23 @@ export async function configureClaudeDefaultModel({
250
270
  if (typeof promptSelect !== "function") {
251
271
  throw new Error("缺少选择输入");
252
272
  }
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
- ]);
273
+ const { models, source } = await loadAvailableModels(api, "claude_code");
274
+ let list = models;
275
+ if (source !== "backend") {
276
+ writeLine("Claude360 后端暂未提供模型列表,以下为 Claude Code 官方模型别名:");
277
+ list = FALLBACK_CLAUDE_ALIASES;
278
+ }
279
+ const selected = await selectModelFromList({ models: list, promptSelect, writeLine });
258
280
  if (selected === "skip") {
259
281
  writeLine("已跳过默认模型配置。");
260
282
  return { skipped: true };
261
283
  }
262
- const tier = tiers.find((item) => item.id === selected);
263
284
  const settingsPath = path.join(claudeDir, "settings.json");
264
285
  const settings = await readJsonIfExists(fs, settingsPath);
265
- settings.model = tier.model;
286
+ settings.model = selected;
266
287
  await writeJsonWithBackup({ baseDir: claudeDir, filePath: settingsPath, json: settings, writeLine, fs, now });
267
- writeLine(`✓ 默认模型已设置为 ${tier.model}(${settingsPath})`);
268
- return { skipped: false, model: tier.model };
288
+ writeLine(`✓ 默认模型已设置为 ${selected}(${settingsPath})`);
289
+ return { skipped: false, model: selected };
269
290
  }
270
291
 
271
292
  // ──────────────────────────────────────────────
@@ -349,24 +370,20 @@ export async function configureCodexDefaultModel({
349
370
  if (typeof promptSelect !== "function") {
350
371
  throw new Error("缺少选择输入");
351
372
  }
352
- const { tiers, source } = await loadModelTiers(api);
373
+ const { models, source } = await loadAvailableModels(api, "codex");
353
374
  if (source !== "backend") {
354
375
  writeLine("Claude360 后端暂未提供 Codex 模型列表,已跳过默认模型写入。");
355
376
  writeLine("可在启动 Codex 后使用 /model 命令选择模型。");
356
377
  return { skipped: true };
357
378
  }
358
- const selected = await promptSelect("请选择默认模型:", [
359
- ...tiers.map((tier) => ({ label: tier.label, value: tier.id })),
360
- { label: "跳过", value: "skip" },
361
- ]);
379
+ const selected = await selectModelFromList({ models, promptSelect, writeLine });
362
380
  if (selected === "skip") {
363
381
  writeLine("已跳过默认模型配置。");
364
382
  return { skipped: true };
365
383
  }
366
- const tier = tiers.find((item) => item.id === selected);
367
384
  const configPath = path.join(codexDir, "config.toml");
368
385
  const current = await readFileIfExists(fs, configPath);
369
- const next = `${upsertProfileKey(current, "claude360", "model", tier.model).trimEnd()}\n`;
386
+ const next = `${upsertProfileKey(current, "claude360", "model", selected).trimEnd()}\n`;
370
387
  const tomlError = validateBasicToml(next);
371
388
  if (tomlError) {
372
389
  writeLine(`× 生成的 Codex 配置 TOML 校验失败:${tomlError}\n已放弃写入,原配置保持不变。`);
@@ -378,6 +395,6 @@ export async function configureCodexDefaultModel({
378
395
  }
379
396
  await fs.mkdir(codexDir, { recursive: true });
380
397
  await fs.writeFile(configPath, next, "utf8");
381
- writeLine(`✓ Codex 默认模型已设置为 ${tier.model}(${configPath})`);
382
- return { skipped: false, model: tier.model };
398
+ writeLine(`✓ Codex 默认模型已设置为 ${selected}(${configPath})`);
399
+ return { skipped: false, model: selected };
383
400
  }
package/src/mcp-skill.js CHANGED
@@ -78,6 +78,36 @@ export function resolveClaudeJsonPath({ homedir = os.homedir } = {}) {
78
78
  return path.join(homedir(), ".claude.json");
79
79
  }
80
80
 
81
+ // 远程 MCP 项必须带完整安装信息才可用,避免脏数据进入安装流程
82
+ function isValidRemoteMcp(mcp) {
83
+ return Boolean(
84
+ mcp
85
+ && typeof mcp.id === "string" && mcp.id !== ""
86
+ && typeof mcp.label === "string" && mcp.label !== ""
87
+ && Array.isArray(mcp.claudeArgs) && mcp.claudeArgs.length > 0
88
+ && mcp.codex && typeof mcp.codex.command === "string"
89
+ && Array.isArray(mcp.codex.args),
90
+ );
91
+ }
92
+
93
+ // MCP 列表远程优先(优化需求第 4 节):GET /api/cli/mcps 返回
94
+ // { mcps: [...] };接口不可用或数据不合法时回退内置 RECOMMENDED_MCPS,
95
+ // 避免列表长期写死在 CLI。
96
+ export async function loadRecommendedMcps(api) {
97
+ if (api && typeof api.get === "function") {
98
+ try {
99
+ const data = await api.get("/api/cli/mcps");
100
+ const mcps = (Array.isArray(data?.mcps) ? data.mcps : []).filter(isValidRemoteMcp);
101
+ if (mcps.length > 0) {
102
+ return { mcps, source: "remote" };
103
+ }
104
+ } catch {
105
+ // 后端暂未提供 MCP 列表接口,回退内置推荐
106
+ }
107
+ }
108
+ return { mcps: RECOMMENDED_MCPS, source: "builtin" };
109
+ }
110
+
81
111
  // 已安装的 Claude Code 用户级 MCP(读取 ~/.claude.json 的 mcpServers 键)
82
112
  export async function readInstalledClaudeMcps({
83
113
  claudeJsonPath = resolveClaudeJsonPath(),
@@ -95,18 +125,19 @@ export async function readInstalledClaudeMcps({
95
125
  }
96
126
  }
97
127
 
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) ? "(已安装)" : ""}`,
128
+ // 多选推荐 MCP;展示推荐标识 ★、说明与平台兼容性,
129
+ // 已安装项展示为「已安装」且不可重复安装(PRD 6.6 / 优化需求第 4 节)
130
+ async function selectMcps({ mcps, multiSelect, installedIds, writeLine }) {
131
+ const choices = mcps.map((mcp) => ({
132
+ label: `${mcp.recommended ? "★ " : ""}${mcp.label}${installedIds.includes(mcp.id) ? "(已安装)" : ""}`,
102
133
  value: mcp.id,
103
- hint: mcp.desc,
134
+ hint: `${mcp.desc || ""} · ${mcp.platforms || "全平台"}`,
104
135
  }));
105
- const preselected = RECOMMENDED_MCPS
136
+ const preselected = mcps
106
137
  .filter((mcp) => mcp.recommended && !installedIds.includes(mcp.id))
107
138
  .map((mcp) => mcp.id);
108
139
  const selected = await multiSelect({
109
- message: "请选择要安装的 MCP 服务(默认推荐 Context7 / Open Web Search / DeepWiki):",
140
+ message: "请选择要安装的 MCP 服务(★ 为推荐项,已预选):",
110
141
  choices,
111
142
  preselected,
112
143
  });
@@ -116,7 +147,7 @@ async function selectMcps({ multiSelect, installedIds, writeLine }) {
116
147
  writeLine(`已安装,跳过:${id}`);
117
148
  continue;
118
149
  }
119
- fresh.push(RECOMMENDED_MCPS.find((mcp) => mcp.id === id));
150
+ fresh.push(mcps.find((mcp) => mcp.id === id));
120
151
  }
121
152
  return fresh;
122
153
  }
@@ -140,6 +171,7 @@ async function confirmHeavyMcps(mcps, confirm, writeLine) {
140
171
  }
141
172
 
142
173
  export async function installRecommendedMcps({
174
+ api,
143
175
  multiSelect,
144
176
  confirm,
145
177
  execCommand = defaultExec,
@@ -150,8 +182,9 @@ export async function installRecommendedMcps({
150
182
  if (typeof multiSelect !== "function" || typeof confirm !== "function") {
151
183
  throw new Error("缺少交互输入");
152
184
  }
185
+ const { mcps } = await loadRecommendedMcps(api);
153
186
  const installedIds = await readInstalledClaudeMcps({ claudeJsonPath, fs });
154
- const fresh = await selectMcps({ multiSelect, installedIds, writeLine });
187
+ const fresh = await selectMcps({ mcps, multiSelect, installedIds, writeLine });
155
188
  if (fresh.length === 0) {
156
189
  writeLine("已跳过 MCP 安装。");
157
190
  return [];
@@ -225,6 +258,7 @@ export function buildCodexMcpTable(mcp) {
225
258
  }
226
259
 
227
260
  export async function installCodexMcps({
261
+ api,
228
262
  multiSelect,
229
263
  confirm,
230
264
  writeLine = console.log,
@@ -236,8 +270,9 @@ export async function installCodexMcps({
236
270
  throw new Error("缺少交互输入");
237
271
  }
238
272
  const configPath = path.join(codexDir, "config.toml");
273
+ const { mcps } = await loadRecommendedMcps(api);
239
274
  const installedIds = await readInstalledCodexMcps({ codexDir, fs });
240
- const fresh = await selectMcps({ multiSelect, installedIds, writeLine });
275
+ const fresh = await selectMcps({ mcps, multiSelect, installedIds, writeLine });
241
276
  if (fresh.length === 0) {
242
277
  writeLine("已跳过 MCP 安装。");
243
278
  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,318 @@
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
+ return Boolean(stream && stream.isTTY && process.stdin.isTTY);
39
+ }
40
+
41
+ // Esc 约定:选项里带 back / skip / exit 时,Esc 等价于选择它(需求二.2)
42
+ export function resolveEscValue(choices = []) {
43
+ const hit = choices.find((choice) => ["back", "skip", "exit"].includes(choice?.value));
44
+ return hit ? hit.value : undefined;
45
+ }
46
+
47
+ // 确认行:选中项大写、加粗、高亮并带箭头;未选中项小写灰色(需求第四节)。
48
+ // 终端无法局部放大字体,用大小写 + 颜色 + 箭头模拟视觉差异。
49
+ export function renderConfirmLine(yesActive, { color = true, danger = false } = {}) {
50
+ const yes = yesActive
51
+ ? paint(color, `${BOLD}${CYAN}`, "▶ YES ◀")
52
+ : paint(color, GRAY, "yes");
53
+ const no = !yesActive
54
+ ? paint(color, `${BOLD}${danger ? RED : CYAN}`, "▶ NO ◀")
55
+ : paint(color, GRAY, "no");
56
+ return ` ${yes} ${no}`;
57
+ }
58
+
59
+ // ──────────────────────────────────────────────
60
+ // 交互组件(@inquirer/core 自定义 prompt)
61
+ // ──────────────────────────────────────────────
62
+
63
+ const PAGE_SIZE = 14;
64
+
65
+ // 单选:方向键移动(跳过分隔行)、回车确认、Esc 返回 escValue
66
+ export const interactiveSelect = createPrompt((config, done) => {
67
+ const { message, choices, escValue, color = true } = config;
68
+ const [status, setStatus] = useState("idle");
69
+ const firstIndex = choices.findIndex((choice) => !choice.separator);
70
+ const [active, setActive] = useState(Math.max(firstIndex, 0));
71
+
72
+ useKeypress((key) => {
73
+ if (status === "done") {
74
+ return;
75
+ }
76
+ if (isEnterKey(key)) {
77
+ setStatus("done");
78
+ done(choices[active].value);
79
+ return;
80
+ }
81
+ if (isUpKey(key) || isDownKey(key)) {
82
+ const direction = isUpKey(key) ? -1 : 1;
83
+ let next = active;
84
+ do {
85
+ next = (next + direction + choices.length) % choices.length;
86
+ } while (choices[next].separator);
87
+ setActive(next);
88
+ return;
89
+ }
90
+ if (key.name === "escape" && escValue !== undefined) {
91
+ setStatus("done");
92
+ done(escValue);
93
+ }
94
+ });
95
+
96
+ if (status === "done") {
97
+ return `${paint(color, BOLD, message)} ${paint(color, GREEN, choices[active]?.separator ? "" : String(choices[active]?.label ?? ""))}`;
98
+ }
99
+
100
+ const page = usePagination({
101
+ items: choices,
102
+ active,
103
+ pageSize: PAGE_SIZE,
104
+ loop: false,
105
+ renderItem({ item, isActive }) {
106
+ if (item.separator) {
107
+ return paint(color, GRAY, item.separator);
108
+ }
109
+ const hint = item.hint ? ` ${paint(color, GRAY, item.hint)}` : "";
110
+ if (isActive) {
111
+ return `${paint(color, `${BOLD}${CYAN}`, `❯ ${item.label}`)}${hint}`;
112
+ }
113
+ return ` ${item.label}${hint}`;
114
+ },
115
+ });
116
+ const help = paint(color, GRAY, `↑↓ 移动 · Enter 确认${escValue !== undefined ? " · Esc 返回" : ""}`);
117
+ return `${paint(color, BOLD, message)}\n${page}\n${help}`;
118
+ });
119
+
120
+ // 多选:空格选择/取消、a 全选、i 反选、回车确认、Esc 取消(需求第 4 节)
121
+ export const interactiveMultiSelect = createPrompt((config, done) => {
122
+ const { message, choices, preselected = [], color = true } = config;
123
+ const [status, setStatus] = useState("idle");
124
+ const [active, setActive] = useState(0);
125
+ const [selected, setSelected] = useState(
126
+ () => new Set(preselected.filter((value) => choices.some((choice) => choice.value === value))),
127
+ );
128
+
129
+ const finish = (values) => {
130
+ setStatus("done");
131
+ done(values);
132
+ };
133
+
134
+ useKeypress((key) => {
135
+ if (status === "done") {
136
+ return;
137
+ }
138
+ if (isEnterKey(key)) {
139
+ finish(choices.filter((choice) => selected.has(choice.value)).map((choice) => choice.value));
140
+ return;
141
+ }
142
+ if (isUpKey(key) || isDownKey(key)) {
143
+ const direction = isUpKey(key) ? -1 : 1;
144
+ setActive((active + direction + choices.length) % choices.length);
145
+ return;
146
+ }
147
+ if (isSpaceKey(key)) {
148
+ const next = new Set(selected);
149
+ const value = choices[active].value;
150
+ if (next.has(value)) {
151
+ next.delete(value);
152
+ } else {
153
+ next.add(value);
154
+ }
155
+ setSelected(next);
156
+ return;
157
+ }
158
+ if (key.name === "a") {
159
+ setSelected(new Set(choices.map((choice) => choice.value)));
160
+ return;
161
+ }
162
+ if (key.name === "i") {
163
+ setSelected(new Set(choices.filter((choice) => !selected.has(choice.value)).map((choice) => choice.value)));
164
+ return;
165
+ }
166
+ if (key.name === "escape") {
167
+ finish([]);
168
+ }
169
+ });
170
+
171
+ if (status === "done") {
172
+ return `${paint(color, BOLD, message)} ${paint(color, GREEN, `已选 ${selected.size} 项`)}`;
173
+ }
174
+
175
+ const page = usePagination({
176
+ items: choices,
177
+ active,
178
+ pageSize: PAGE_SIZE,
179
+ loop: false,
180
+ renderItem({ item, isActive }) {
181
+ const mark = selected.has(item.value) ? paint(color, GREEN, "[x]") : "[ ]";
182
+ const hint = item.hint ? ` ${paint(color, GRAY, item.hint)}` : "";
183
+ const label = isActive ? paint(color, `${BOLD}${CYAN}`, `❯ ${mark} ${item.label}`) : ` ${mark} ${item.label}`;
184
+ return `${label}${hint}`;
185
+ },
186
+ });
187
+ const help = paint(color, GRAY, "↑↓ 移动 · 空格 选择 · a 全选 · i 反选 · Enter 确认 · Esc 取消");
188
+ return `${paint(color, BOLD, message)}\n${page}\n${help}`;
189
+ });
190
+
191
+ // 确认:左右方向键 / Tab 切换 YES、NO,回车确认,y / n 快捷,Esc 取消。
192
+ // 默认值由调用方按风险设置:普通继续默认 YES,覆盖 / 删除 / 重置默认 NO。
193
+ export const interactiveConfirm = createPrompt((config, done) => {
194
+ const { message, defaultYes = false, danger = false, color = true } = config;
195
+ const [status, setStatus] = useState("idle");
196
+ const [yes, setYes] = useState(Boolean(defaultYes));
197
+
198
+ const finish = (value) => {
199
+ setStatus("done");
200
+ setYes(value);
201
+ done(value);
202
+ };
203
+
204
+ useKeypress((key) => {
205
+ if (status === "done") {
206
+ return;
207
+ }
208
+ if (isEnterKey(key)) {
209
+ finish(yes);
210
+ return;
211
+ }
212
+ if (key.name === "left" || key.name === "right" || isTabKey(key)) {
213
+ setYes(!yes);
214
+ return;
215
+ }
216
+ if (key.name === "y") {
217
+ finish(true);
218
+ return;
219
+ }
220
+ if (key.name === "n" || key.name === "escape") {
221
+ finish(false);
222
+ }
223
+ });
224
+
225
+ if (status === "done") {
226
+ return `${paint(color, BOLD, message)} ${paint(color, GREEN, yes ? "YES" : "NO")}`;
227
+ }
228
+ const help = paint(color, GRAY, "←→ 切换 · Enter 确认 · Esc 取消");
229
+ return `${paint(color, BOLD, message)}\n\n${renderConfirmLine(yes, { color, danger })}\n\n${help}`;
230
+ });
231
+
232
+ // ──────────────────────────────────────────────
233
+ // 降级实现:编号输入(非 TTY / CI 环境)
234
+ // ──────────────────────────────────────────────
235
+
236
+ async function defaultLineInput(message, defaultValue = "") {
237
+ const rl = readline.createInterface({ input, output });
238
+ try {
239
+ const suffix = defaultValue ? ` (${defaultValue})` : "";
240
+ const answer = await rl.question(`${message}${suffix}: `);
241
+ return answer.trim() || defaultValue;
242
+ } finally {
243
+ rl.close();
244
+ }
245
+ }
246
+
247
+ function createFallbackPrompts({ promptInput, writeLine }) {
248
+ const ask = promptInput || defaultLineInput;
249
+ const print = writeLine || console.log;
250
+ return {
251
+ promptInput: ask,
252
+ async promptSelect(message, choices) {
253
+ while (true) {
254
+ print(message);
255
+ choices.forEach((choice, index) => {
256
+ print(`${index + 1}. ${choice.label}`);
257
+ });
258
+ const answer = String(await ask("请输入编号") ?? "").trim();
259
+ const index = Number(answer) - 1;
260
+ if (Number.isInteger(index) && choices[index]) {
261
+ return choices[index].value;
262
+ }
263
+ const matched = choices.find((choice) => choice.value === answer);
264
+ if (matched) {
265
+ return matched.value;
266
+ }
267
+ print("选择无效,请重新输入。");
268
+ }
269
+ },
270
+ async confirm(message, { defaultYes = false } = {}) {
271
+ const suffix = defaultYes ? "(Y/n)" : "(y/N)";
272
+ const answer = String(await ask(`${message} ${suffix}`) ?? "").trim().toLowerCase();
273
+ if (answer === "") {
274
+ return defaultYes;
275
+ }
276
+ return ["yes", "y", "确认", "是", "继续"].includes(answer);
277
+ },
278
+ multiSelect(options) {
279
+ return promptMultiSelect({ ...options, promptInput: ask, writeLine: print });
280
+ },
281
+ };
282
+ }
283
+
284
+ // ──────────────────────────────────────────────
285
+ // 工厂:根据环境返回统一交互接口
286
+ // ──────────────────────────────────────────────
287
+
288
+ // Ctrl+C 在 @inquirer 中抛 ExitPromptError,统一转为优雅退出
289
+ async function guardExit(run) {
290
+ try {
291
+ return await run();
292
+ } catch (error) {
293
+ if (error?.name === "ExitPromptError") {
294
+ process.exit(130);
295
+ }
296
+ throw error;
297
+ }
298
+ }
299
+
300
+ export function createPrompts({
301
+ interactive = isInteractive(),
302
+ color = true,
303
+ promptInput,
304
+ writeLine,
305
+ } = {}) {
306
+ if (!interactive) {
307
+ return createFallbackPrompts({ promptInput, writeLine });
308
+ }
309
+ return {
310
+ promptInput: promptInput || defaultLineInput,
311
+ promptSelect: (message, choices) =>
312
+ guardExit(() => interactiveSelect({ message, choices, escValue: resolveEscValue(choices), color })),
313
+ confirm: (message, { defaultYes = false, danger = false } = {}) =>
314
+ guardExit(() => interactiveConfirm({ message, defaultYes, danger, color })),
315
+ multiSelect: ({ message, choices, preselected }) =>
316
+ guardExit(() => interactiveMultiSelect({ message, choices, preselected, color })),
317
+ };
318
+ }
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,73 @@ 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
+ export function renderModelTable(models = [], { color = false, width = 0 } = {}) {
197
+ return renderTable({
198
+ head: ["模型 ID", "名称", "标签", "上下文", "说明"],
199
+ rows: models.map((model) => [
200
+ model.id ?? "-",
201
+ model.display_name || "-",
202
+ Array.isArray(model.tags) && model.tags.length > 0 ? model.tags.join(",") : "-",
203
+ model.context_length ? String(model.context_length) : "-",
204
+ model.description || "-",
205
+ ]),
206
+ }, { color, width });
207
+ }