claude360 0.2.2 → 0.2.4

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/src/mcp-skill.js CHANGED
@@ -3,82 +3,277 @@
3
3
 
4
4
  import { sanitizeText } from "./sanitize.js";
5
5
  import { spawn } from "node:child_process";
6
- import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { copyFile, cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
9
 
10
+ import { createBackup } from "./backup.js";
11
+ import { upsertTomlTable, validateBasicToml } from "./tool-launcher.js";
12
+
10
13
  // ──────────────────────────────────────────────
11
- // 推荐 MCP(通过官方 `claude mcp add --scope user` 安装,修改 ~/.claude.json)
14
+ // 推荐 MCP(PRD 6.6):Claude Code 通过官方 `claude mcp add --scope user`
15
+ // 安装(修改 ~/.claude.json);Codex 写入 ~/.codex/config.toml [mcp_servers.*]。
16
+ // 默认推荐 Context7 / Open Web Search / DeepWiki;Playwright、Serena 依赖较重,
17
+ // 安装前单独确认。全部可跳过、失败不阻塞主流程、不重复安装同名 MCP。
12
18
  // ──────────────────────────────────────────────
13
19
 
14
20
  export const RECOMMENDED_MCPS = [
15
21
  {
16
22
  id: "context7",
17
- label: "Context7 官方文档查询,推荐",
18
- args: ["mcp", "add", "--scope", "user", "context7", "--", "npx", "-y", "@upstash/context7-mcp"],
23
+ label: "Context7 文档查询",
24
+ desc: "查询最新库文档和代码示例",
25
+ recommended: true,
26
+ claudeArgs: ["mcp", "add", "--scope", "user", "context7", "--", "npx", "-y", "@upstash/context7-mcp"],
27
+ codex: { command: "npx", args: ["-y", "@upstash/context7-mcp"] },
19
28
  },
20
29
  {
21
- id: "playwright",
22
- label: "Playwright 浏览器自动化,推荐",
23
- args: ["mcp", "add", "--scope", "user", "playwright", "--", "npx", "-y", "@playwright/mcp@latest"],
30
+ id: "open-websearch",
31
+ label: "Open Web Search 网页搜索",
32
+ desc: "使用搜索引擎进行网页搜索",
33
+ recommended: true,
34
+ claudeArgs: ["mcp", "add", "--scope", "user", "open-websearch", "--", "npx", "-y", "open-websearch@latest"],
35
+ codex: { command: "npx", args: ["-y", "open-websearch@latest"] },
36
+ },
37
+ {
38
+ id: "spec-workflow",
39
+ label: "Spec 工作流",
40
+ desc: "规范化特性开发流程",
41
+ claudeArgs: ["mcp", "add", "--scope", "user", "spec-workflow", "--", "npx", "-y", "@pimzino/spec-workflow-mcp@latest"],
42
+ codex: { command: "npx", args: ["-y", "@pimzino/spec-workflow-mcp@latest"] },
24
43
  },
25
44
  {
26
45
  id: "deepwiki",
27
- label: "DeepWiki GitHub 仓库文档查询",
28
- args: ["mcp", "add", "--scope", "user", "--transport", "http", "deepwiki", "https://mcp.deepwiki.com/mcp"],
46
+ label: "DeepWiki 仓库文档",
47
+ desc: "查询 GitHub 仓库文档",
48
+ recommended: true,
49
+ claudeArgs: ["mcp", "add", "--scope", "user", "--transport", "http", "deepwiki", "https://mcp.deepwiki.com/mcp"],
50
+ codex: { command: "npx", args: ["-y", "mcp-deepwiki@latest"] },
29
51
  },
30
52
  {
31
- id: "open-websearch",
32
- label: "Open Web Search 网络搜索",
33
- args: ["mcp", "add", "--scope", "user", "open-websearch", "--", "npx", "-y", "open-websearch@latest"],
53
+ id: "playwright",
54
+ label: "Playwright 浏览器控制",
55
+ desc: "浏览器自动化操作",
56
+ heavy: "需要下载浏览器内核,占用磁盘较大",
57
+ claudeArgs: ["mcp", "add", "--scope", "user", "playwright", "--", "npx", "-y", "@playwright/mcp@latest"],
58
+ codex: { command: "npx", args: ["-y", "@playwright/mcp@latest"] },
59
+ },
60
+ {
61
+ id: "exa",
62
+ label: "Exa AI 搜索",
63
+ desc: "AI 搜索能力(需自备 EXA_API_KEY)",
64
+ claudeArgs: ["mcp", "add", "--scope", "user", "exa", "--", "npx", "-y", "exa-mcp-server"],
65
+ codex: { command: "npx", args: ["-y", "exa-mcp-server"] },
66
+ },
67
+ {
68
+ id: "serena",
69
+ label: "Serena 助手",
70
+ desc: "语义代码检索与编辑助手",
71
+ heavy: "依赖 Python uv 工具链",
72
+ claudeArgs: ["mcp", "add", "--scope", "user", "serena", "--", "uvx", "--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"],
73
+ codex: { command: "uvx", args: ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"] },
34
74
  },
35
75
  ];
36
76
 
77
+ export function resolveClaudeJsonPath({ homedir = os.homedir } = {}) {
78
+ return path.join(homedir(), ".claude.json");
79
+ }
80
+
81
+ // 已安装的 Claude Code 用户级 MCP(读取 ~/.claude.json 的 mcpServers 键)
82
+ export async function readInstalledClaudeMcps({
83
+ claudeJsonPath = resolveClaudeJsonPath(),
84
+ fs = { readFile },
85
+ } = {}) {
86
+ const content = await readFileIfExists(fs.readFile, claudeJsonPath);
87
+ if (content.trim() === "") {
88
+ return [];
89
+ }
90
+ try {
91
+ const json = JSON.parse(content);
92
+ return Object.keys(json?.mcpServers || {});
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
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) ? "(已安装)" : ""}`,
102
+ value: mcp.id,
103
+ hint: mcp.desc,
104
+ }));
105
+ const preselected = RECOMMENDED_MCPS
106
+ .filter((mcp) => mcp.recommended && !installedIds.includes(mcp.id))
107
+ .map((mcp) => mcp.id);
108
+ const selected = await multiSelect({
109
+ message: "请选择要安装的 MCP 服务(默认推荐 Context7 / Open Web Search / DeepWiki):",
110
+ choices,
111
+ preselected,
112
+ });
113
+ const fresh = [];
114
+ for (const id of selected) {
115
+ if (installedIds.includes(id)) {
116
+ writeLine(`已安装,跳过:${id}`);
117
+ continue;
118
+ }
119
+ fresh.push(RECOMMENDED_MCPS.find((mcp) => mcp.id === id));
120
+ }
121
+ return fresh;
122
+ }
123
+
124
+ // 重依赖(Playwright / Serena 等)逐项单独确认
125
+ async function confirmHeavyMcps(mcps, confirm, writeLine) {
126
+ const approved = [];
127
+ for (const mcp of mcps) {
128
+ if (!mcp.heavy) {
129
+ approved.push(mcp);
130
+ continue;
131
+ }
132
+ const ok = await confirm(`${mcp.label} 依赖较重(${mcp.heavy}),确认安装?`);
133
+ if (ok) {
134
+ approved.push(mcp);
135
+ } else {
136
+ writeLine(`已跳过 ${mcp.id}。`);
137
+ }
138
+ }
139
+ return approved;
140
+ }
141
+
37
142
  export async function installRecommendedMcps({
38
- promptSelect,
143
+ multiSelect,
39
144
  confirm,
40
145
  execCommand = defaultExec,
41
146
  writeLine = console.log,
147
+ claudeJsonPath = resolveClaudeJsonPath(),
148
+ fs = { readFile },
42
149
  } = {}) {
43
- if (typeof promptSelect !== "function" || typeof confirm !== "function") {
150
+ if (typeof multiSelect !== "function" || typeof confirm !== "function") {
44
151
  throw new Error("缺少交互输入");
45
152
  }
153
+ const installedIds = await readInstalledClaudeMcps({ claudeJsonPath, fs });
154
+ const fresh = await selectMcps({ multiSelect, installedIds, writeLine });
155
+ if (fresh.length === 0) {
156
+ writeLine("已跳过 MCP 安装。");
157
+ return [];
158
+ }
159
+ const approved = await confirmHeavyMcps(fresh, confirm, writeLine);
160
+ if (approved.length === 0) {
161
+ writeLine("已跳过 MCP 安装。");
162
+ return [];
163
+ }
164
+
165
+ writeLine([
166
+ "即将通过 Claude Code 官方命令安装以下 MCP(修改 ~/.claude.json 用户级配置):",
167
+ ...approved.map((mcp) => ` claude ${mcp.claudeArgs.join(" ")}`),
168
+ ].join("\n"));
169
+ if (!(await confirm("是否继续?"))) {
170
+ writeLine("已取消 MCP 安装。");
171
+ return [];
172
+ }
173
+
46
174
  const installed = [];
47
- while (true) {
48
- const choices = [
49
- ...RECOMMENDED_MCPS.map((mcp) => ({
50
- label: installed.includes(mcp.id) ? `${mcp.label}(本次已安装)` : mcp.label,
51
- value: mcp.id,
52
- })),
53
- { label: "完成 / 跳过", value: "done" },
54
- ];
55
- const selected = await promptSelect("推荐 MCP(安装会修改 ~/.claude.json 的用户级 MCP 配置)", choices);
56
- if (selected === "done") {
57
- break;
58
- }
59
- const mcp = RECOMMENDED_MCPS.find((item) => item.id === selected);
60
- if (!mcp) {
61
- continue;
62
- }
63
- const commandText = `claude ${mcp.args.join(" ")}`;
64
- const approved = await confirm(
65
- `即将执行:${commandText}\n影响范围:在 Claude Code 用户级配置(~/.claude.json)中添加 MCP ${mcp.id}。\n是否继续?`,
66
- );
67
- if (!approved) {
68
- writeLine(`已跳过 ${mcp.id}。`);
69
- continue;
70
- }
71
- const result = await execCommand("claude", mcp.args);
175
+ for (const mcp of approved) {
176
+ writeLine(`正在安装 MCP:${mcp.label}...`);
177
+ const result = await execCommand("claude", mcp.claudeArgs);
72
178
  if (result.ok) {
73
179
  installed.push(mcp.id);
74
180
  writeLine(`✓ 已安装 MCP:${mcp.id}`);
75
181
  } else {
76
- writeLine(`× 安装 ${mcp.id} 失败:${sanitizeText(result.stderr || result.error || "未知错误")}\n安装失败不影响 Claude360 API 调用,可稍后重试。`);
182
+ writeLine([
183
+ `× 安装 ${mcp.id} 失败`,
184
+ "",
185
+ `原因:${sanitizeText(result.stderr || result.error || "未知错误")}`,
186
+ "",
187
+ "建议:",
188
+ "1. 检查网络",
189
+ "2. 使用国内 npm 镜像",
190
+ "3. 稍后重试(安装失败不影响 Claude360 API 调用)",
191
+ ].join("\n"));
77
192
  }
78
193
  }
79
194
  return installed;
80
195
  }
81
196
 
197
+ // ──────────────────────────────────────────────
198
+ // Codex 推荐 MCP:写入 ~/.codex/config.toml 的 [mcp_servers.*] 表,
199
+ // 写入前备份并做 TOML 校验,校验失败不落盘(PRD 6.6 / 8 章)
200
+ // ──────────────────────────────────────────────
201
+
202
+ export async function readInstalledCodexMcps({
203
+ codexDir = resolveCodexUserDir(),
204
+ fs = { readFile },
205
+ } = {}) {
206
+ const content = await readFileIfExists(fs.readFile, path.join(codexDir, "config.toml"));
207
+ const ids = [];
208
+ for (const match of content.matchAll(/^\s*\[mcp_servers\.([A-Za-z0-9_-]+)\]\s*$/gm)) {
209
+ ids.push(match[1]);
210
+ }
211
+ return ids;
212
+ }
213
+
214
+ function escapeTomlString(value) {
215
+ return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
216
+ }
217
+
218
+ export function buildCodexMcpTable(mcp) {
219
+ const args = mcp.codex.args.map((arg) => `"${escapeTomlString(arg)}"`).join(", ");
220
+ return [
221
+ `[mcp_servers.${mcp.id}]`,
222
+ `command = "${escapeTomlString(mcp.codex.command)}"`,
223
+ `args = [${args}]`,
224
+ ];
225
+ }
226
+
227
+ export async function installCodexMcps({
228
+ multiSelect,
229
+ confirm,
230
+ writeLine = console.log,
231
+ codexDir = resolveCodexUserDir(),
232
+ fs = { readFile, writeFile, mkdir, copyFile, cp, stat },
233
+ now,
234
+ } = {}) {
235
+ if (typeof multiSelect !== "function" || typeof confirm !== "function") {
236
+ throw new Error("缺少交互输入");
237
+ }
238
+ const configPath = path.join(codexDir, "config.toml");
239
+ const installedIds = await readInstalledCodexMcps({ codexDir, fs });
240
+ const fresh = await selectMcps({ multiSelect, installedIds, writeLine });
241
+ if (fresh.length === 0) {
242
+ writeLine("已跳过 MCP 安装。");
243
+ return [];
244
+ }
245
+ const approved = await confirmHeavyMcps(fresh, confirm, writeLine);
246
+ if (approved.length === 0) {
247
+ writeLine("已跳过 MCP 安装。");
248
+ return [];
249
+ }
250
+ if (!(await confirm(`即将在 ${configPath} 中写入 ${approved.map((mcp) => mcp.id).join("、")} 的 MCP 配置(写入前备份)。\n是否继续?`))) {
251
+ writeLine("已取消 MCP 安装。");
252
+ return [];
253
+ }
254
+
255
+ let content = await readFileIfExists(fs.readFile, configPath);
256
+ for (const mcp of approved) {
257
+ content = upsertTomlTable(content, `mcp_servers.${mcp.id}`, buildCodexMcpTable(mcp));
258
+ }
259
+ content = `${content.trimEnd()}\n`;
260
+ const tomlError = validateBasicToml(content);
261
+ if (tomlError) {
262
+ writeLine(`× 生成的 Codex 配置 TOML 校验失败:${tomlError}\n已放弃写入,原配置保持不变。`);
263
+ return [];
264
+ }
265
+ const { backupDir } = await createBackup({ baseDir: codexDir, paths: [configPath], fs, now });
266
+ if (backupDir) {
267
+ writeLine(`✓ 已创建备份:${backupDir}`);
268
+ }
269
+ await fs.mkdir(codexDir, { recursive: true });
270
+ await fs.writeFile(configPath, content, "utf8");
271
+ for (const mcp of approved) {
272
+ writeLine(`✓ 已安装 MCP:${mcp.id}`);
273
+ }
274
+ return approved.map((mcp) => mcp.id);
275
+ }
276
+
82
277
  // ──────────────────────────────────────────────
83
278
  // 标记区块:幂等写入公共 Markdown 文件
84
279
  // ──────────────────────────────────────────────
@@ -277,17 +472,38 @@ export async function installCodexAgents({
277
472
  export async function listInstalledEnhancements({
278
473
  claudeDir = resolveClaudeUserDir(),
279
474
  codexDir = resolveCodexUserDir(),
475
+ claudeJsonPath = resolveClaudeJsonPath(),
280
476
  fs = { readFile },
281
477
  } = {}) {
282
- const lines = ["已安装增强配置:"];
478
+ const lines = ["已安装增强配置:", ""];
479
+
480
+ const claudeMcps = await readInstalledClaudeMcps({ claudeJsonPath, fs });
481
+ const codexMcps = await readInstalledCodexMcps({ codexDir, fs });
482
+ lines.push("[MCP]");
483
+ for (const mcp of RECOMMENDED_MCPS) {
484
+ const states = [
485
+ claudeMcps.includes(mcp.id) ? "Claude Code 已安装" : null,
486
+ codexMcps.includes(mcp.id) ? "Codex 已安装" : null,
487
+ ].filter(Boolean);
488
+ lines.push(`- ${mcp.label}:${states.length > 0 ? states.join(",") : "未安装"}`);
489
+ }
490
+
491
+ lines.push("", "[Claude Code 工作流 / Skill]");
283
492
  const memory = await readFileIfExists(fs.readFile, path.join(claudeDir, "CLAUDE.md"));
284
493
  lines.push(`- Claude360 编程助手规则:${memory.includes("claude360:assistant-rules:start") ? "已安装" : "未安装"}`);
494
+ lines.push(`- AI 输出语言偏好:${memory.includes("claude360:output-language:start") ? "已配置" : "未配置"}`);
495
+ lines.push(`- 系统提示词风格:${memory.includes("claude360:prompt-style:start") ? "已配置" : "未配置"}`);
285
496
  for (const skill of RECOMMENDED_SKILLS.filter((item) => item.kind === "command")) {
286
497
  const content = await readFileIfExists(fs.readFile, path.join(claudeDir, "commands", "claude360", skill.file));
287
498
  lines.push(`- ${skill.label}:${content ? "已安装" : "未安装"}`);
288
499
  }
500
+
501
+ lines.push("", "[Codex]");
289
502
  const agents = await readFileIfExists(fs.readFile, path.join(codexDir, "AGENTS.md"));
290
503
  lines.push(`- Codex AGENTS 规范:${agents.includes(`claude360:${CODEX_AGENTS_BLOCK_ID}:start`) ? "已安装" : "未安装"}`);
504
+ lines.push(`- Codex AI 输出语言偏好:${agents.includes("claude360:output-language:start") ? "已配置" : "未配置"}`);
505
+ const prompts = await readFileIfExists(fs.readFile, path.join(codexDir, "prompts", "workflow.md"));
506
+ lines.push(`- Codex 六步工作流 prompts:${prompts ? "已安装" : "未安装"}`);
291
507
  return lines.join("\n");
292
508
  }
293
509
 
package/src/menu.js CHANGED
@@ -65,16 +65,17 @@ export function buildDailyMenu() {
65
65
  {
66
66
  title: "工具配置",
67
67
  items: [
68
- { key: "6", label: "Claude Code 配置", value: "claude_code_menu", desc: "安装、更新、写入或修复接入配置" },
69
- { key: "7", label: "Codex 配置", value: "codex_menu", desc: "安装、更新、写入或修复接入配置" },
70
- { key: "8", label: "推荐 MCP / Skill", value: "mcp_skill", desc: "安装推荐的 MCP、工作流与 Skill 增强" },
71
- { key: "9", label: "生成 cc-switch 配置", value: "cc_switch", desc: "生成可导入 cc-switch 的供应商配置" },
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 增强" },
72
+ { key: "C", label: "生成 cc-switch 配置", value: "cc_switch", desc: "生成可导入 cc-switch 的供应商配置" },
72
73
  ],
73
74
  },
74
75
  {
75
76
  title: "维护",
76
77
  items: [
77
- { key: "A", label: "诊断与修复", value: "diagnostics_menu", desc: "一键诊断环境与配置问题并尝试修复" },
78
+ { key: "D", label: "诊断与修复", value: "diagnostics_menu", desc: "一键诊断环境与配置问题并尝试修复" },
78
79
  { key: "B", label: "安装或更新工具", value: "install_tools", desc: "安装或升级 Claude Code / Codex / 本 CLI" },
79
80
  { key: "0", label: "重新登录", value: "relogin", desc: "清除本地登录态后重新浏览器授权" },
80
81
  { key: "Q", label: "退出", value: "exit", desc: "退出 claude360 CLI" },
@@ -156,3 +157,64 @@ export async function promptMenu({ menu, promptInput, writeLine = console.log }
156
157
  writeLine("选择无效,请重新输入。");
157
158
  }
158
159
  }
160
+
161
+ // 多选交互(补充需求 6.5 / 6.6 / 8.4):数字切换勾选,A 全选,I 反选,
162
+ // 回车确认。返回选中的 value 数组;用户清空全部选项后回车即视为跳过。
163
+ export function renderMultiSelect({ message, choices, selected }) {
164
+ const lines = [message, ""];
165
+ choices.forEach((choice, index) => {
166
+ const mark = selected.has(choice.value) ? "[x]" : "[ ]";
167
+ const hint = choice.hint ? ` ${choice.hint}` : "";
168
+ lines.push(` ${index + 1}. ${mark} ${choice.label}${hint}`);
169
+ });
170
+ lines.push("", " A. 全选 I. 反选 Enter. 确认");
171
+ return lines.join("\n");
172
+ }
173
+
174
+ export async function promptMultiSelect({
175
+ message,
176
+ choices = [],
177
+ preselected = [],
178
+ promptInput,
179
+ writeLine = console.log,
180
+ } = {}) {
181
+ if (typeof promptInput !== "function") {
182
+ throw new Error("缺少多选输入");
183
+ }
184
+ const selected = new Set(preselected.filter((value) => choices.some((choice) => choice.value === value)));
185
+ while (true) {
186
+ writeLine(renderMultiSelect({ message, choices, selected }));
187
+ const answer = String(await promptInput("请输入编号(可多个,回车确认)") ?? "").trim();
188
+ if (answer === "") {
189
+ return choices.filter((choice) => selected.has(choice.value)).map((choice) => choice.value);
190
+ }
191
+ if (answer.toLowerCase() === "a") {
192
+ choices.forEach((choice) => selected.add(choice.value));
193
+ continue;
194
+ }
195
+ if (answer.toLowerCase() === "i") {
196
+ choices.forEach((choice) => {
197
+ if (selected.has(choice.value)) {
198
+ selected.delete(choice.value);
199
+ } else {
200
+ selected.add(choice.value);
201
+ }
202
+ });
203
+ continue;
204
+ }
205
+ const tokens = answer.split(/[\s,,]+/).filter(Boolean);
206
+ const indexes = tokens.map((token) => Number(token) - 1);
207
+ if (indexes.some((index) => !Number.isInteger(index) || !choices[index])) {
208
+ writeLine("输入无效,请输入编号、A、I 或直接回车确认。");
209
+ continue;
210
+ }
211
+ for (const index of indexes) {
212
+ const value = choices[index].value;
213
+ if (selected.has(value)) {
214
+ selected.delete(value);
215
+ } else {
216
+ selected.add(value);
217
+ }
218
+ }
219
+ }
220
+ }
@@ -1,5 +1,45 @@
1
1
  import { loadGroups, selectGroup } from "./group-manager.js";
2
2
  import { maskSecret } from "./config-store.js";
3
+ import { renderTable } from "./ui.js";
4
+
5
+ // Key 列表表格(补充需求第 3 节):多 Key 对比展示,长字段由表格渲染层截断
6
+ export function renderTokenTable(tokens, { width = 0 } = {}) {
7
+ return renderTable({
8
+ head: ["序号", "名称", "Key", "分组", "状态", "额度", "过期时间"],
9
+ rows: tokens.map((token, index) => [
10
+ String(index + 1),
11
+ token.name || `Token #${token.id}`,
12
+ formatTokenKey(token.masked_key || token.key),
13
+ token.group || "-",
14
+ formatStatusLabel(token),
15
+ token.unlimited_quota ? "不限" : String(token.remain_quota ?? 0),
16
+ formatExpiryLabel(token.expired_time),
17
+ ]),
18
+ }, { width });
19
+ }
20
+
21
+ function formatStatusLabel(token) {
22
+ if (token.status !== 1) {
23
+ return "禁用";
24
+ }
25
+ const expired = Number(token.expired_time);
26
+ if (expired > 0 && expired * 1000 < Date.now()) {
27
+ return "过期";
28
+ }
29
+ return "正常";
30
+ }
31
+
32
+ function formatExpiryLabel(expiredTime) {
33
+ if (expiredTime === -1 || expiredTime === 0 || expiredTime === undefined || expiredTime === null) {
34
+ return "永不";
35
+ }
36
+ const date = new Date(Number(expiredTime) * 1000);
37
+ if (Number.isNaN(date.getTime())) {
38
+ return String(expiredTime);
39
+ }
40
+ const pad = (value) => String(value).padStart(2, "0");
41
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
42
+ }
3
43
 
4
44
  export async function loadBalance(api) {
5
45
  return api.get("/api/cli/me");
@@ -62,6 +102,7 @@ export async function chooseOrCreateToken({
62
102
  api,
63
103
  promptSelect,
64
104
  promptInput = async (_message, defaultValue = "Claude360 CLI") => defaultValue,
105
+ writeLine,
65
106
  } = {}) {
66
107
  if (!api) {
67
108
  throw new Error("缺少 API client");
@@ -73,9 +114,16 @@ export async function chooseOrCreateToken({
73
114
  if (typeof promptSelect !== "function") {
74
115
  throw new Error("缺少 Token 选择输入");
75
116
  }
117
+ // 提供 writeLine 时先用表格展示 Key 详情,选项标签保持简短
118
+ const useTable = typeof writeLine === "function";
119
+ if (useTable) {
120
+ writeLine(renderTokenTable(tokens, { width: process.stdout.columns || 0 }));
121
+ }
76
122
  const choices = [
77
123
  ...tokens.map((token) => ({
78
- label: formatTokenOption(token),
124
+ label: useTable
125
+ ? `${token.name || `Token #${token.id}`}(${formatTokenKey(token.masked_key || token.key)})`
126
+ : formatTokenOption(token),
79
127
  value: token.id,
80
128
  })),
81
129
  { label: "创建新的 API Key", value: "__create__" },
@@ -206,7 +206,7 @@ export function buildCodexConfig(content = "", {
206
206
  return `${nextContent.trimEnd()}\n`;
207
207
  }
208
208
 
209
- function upsertTomlTable(content, tableName, tableLines) {
209
+ export function upsertTomlTable(content, tableName, tableLines) {
210
210
  const block = tableLines.join("\n");
211
211
  const header = `[${tableName}]`;
212
212
 
@@ -233,12 +233,19 @@ function upsertTomlTable(content, tableName, tableLines) {
233
233
  }
234
234
 
235
235
  function upsertProfileModelProvider(content, providerId) {
236
+ return upsertProfileKey(content, providerId, "model_provider", providerId);
237
+ }
238
+
239
+ // 在 [profiles.<profileId>] 表内行级 upsert 单个字符串键,不影响表内其他键;
240
+ // 表不存在时在文件末尾追加。
241
+ export function upsertProfileKey(content, profileId, key, value) {
236
242
  const lines = content.split(/\r?\n/);
237
- const header = `[profiles.${providerId}]`;
243
+ const header = `[profiles.${profileId}]`;
244
+ const keyLine = `${key} = "${escapeTomlString(value)}"`;
238
245
  const start = lines.findIndex((line) => line.trim() === header);
239
246
  if (start === -1) {
240
247
  const trimmed = content.trimEnd();
241
- return `${trimmed}${trimmed ? "\n\n" : ""}${header}\nmodel_provider = "${escapeTomlString(providerId)}"\n`;
248
+ return `${trimmed}${trimmed ? "\n\n" : ""}${header}\n${keyLine}\n`;
242
249
  }
243
250
 
244
251
  let end = lines.length;
@@ -250,12 +257,12 @@ function upsertProfileModelProvider(content, providerId) {
250
257
  }
251
258
 
252
259
  const profileLines = lines.slice(start, end);
253
- const providerLine = `model_provider = "${escapeTomlString(providerId)}"`;
254
- const providerIndex = profileLines.findIndex((line) => /^\s*model_provider\s*=/.test(line));
255
- if (providerIndex === -1) {
256
- profileLines.splice(1, 0, providerLine);
260
+ const keyPattern = new RegExp(`^\\s*${key}\\s*=`);
261
+ const keyIndex = profileLines.findIndex((line) => keyPattern.test(line));
262
+ if (keyIndex === -1) {
263
+ profileLines.splice(1, 0, keyLine);
257
264
  } else {
258
- profileLines[providerIndex] = providerLine;
265
+ profileLines[keyIndex] = keyLine;
259
266
  }
260
267
 
261
268
  return [