claude360 0.2.8 → 0.3.0

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/notices.js ADDED
@@ -0,0 +1,33 @@
1
+ // 许可证与更新日志(优化需求 V2.0 §八):license / changelog 命令展示真实内容,
2
+ // 随 src/ 一起发布,避免占位提示。changelog 命令侧 slice 前 3 条(需求「最多 3 条」)。
3
+
4
+ export const LICENSE_TEXT = [
5
+ "MIT License",
6
+ "",
7
+ "Copyright (c) 2026 Claude360",
8
+ "",
9
+ "Permission is hereby granted, free of charge, to any person obtaining a copy",
10
+ "of this software and associated documentation files (the \"Software\"), to deal",
11
+ "in the Software without restriction, including without limitation the rights",
12
+ "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell",
13
+ "copies of the Software, and to permit persons to whom the Software is",
14
+ "furnished to do so, subject to the following conditions:",
15
+ "",
16
+ "The above copyright notice and this permission notice shall be included in all",
17
+ "copies or substantial portions of the Software.",
18
+ "",
19
+ "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR",
20
+ "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,",
21
+ "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE",
22
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER",
23
+ "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,",
24
+ "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE",
25
+ "SOFTWARE.",
26
+ ].join("\n");
27
+
28
+ // 更新日志(最近在前);changelog 命令展示前 3 条(需求 §八「最多 3 条」)。
29
+ export const CHANGELOG_ENTRIES = [
30
+ "v0.2.9 执行流程视觉重构:任务页 / 步骤编号 / 分割线 / 表格化 / 窄屏卡片降级 / verbose 详细模式",
31
+ "v0.2.0 充值、诊断、Codex Provider 配置接入统一执行页组件",
32
+ "v0.1.x 初始版本:浏览器授权登录、API Key 管理、Claude Code · Codex 接入配置",
33
+ ];
package/src/onboarding.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import { commandVersion, defaultCheckPathWritable, defaultExecCommand } from "./diagnostics.js";
2
2
  import { installOrUpdateTools as defaultInstallOrUpdateTools } from "./tool-installer.js";
3
+ import { colorLevel } from "./colors.js";
4
+ import { createMessenger } from "./messages.js";
5
+
6
+ // 语义消息器工厂:真实终端着色,测试/管道(writeLine 被替换)下无色。
7
+ const mk = (writeLine) => createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
3
8
 
4
9
  const MIN_NODE_MAJOR = 18;
5
10
 
@@ -8,6 +13,7 @@ export async function ensureEnvironment({
8
13
  checkPathWritable = defaultCheckPathWritable,
9
14
  writeLine = console.log,
10
15
  } = {}) {
16
+ const msg = mk(writeLine);
11
17
  const node = await commandVersion(execCommand, "node", ["--version"]);
12
18
  if (!node.ok) {
13
19
  throw new Error("未检测到 Node.js。Claude360 CLI 需要 Node.js 18+,请先安装 Node.js LTS 后重新运行。");
@@ -26,10 +32,10 @@ export async function ensureEnvironment({
26
32
  const globalDir = prefix.ok ? prefix.stdout.trim() : "";
27
33
  const globalNpmWritable = globalDir ? await checkPathWritable(globalDir) : false;
28
34
  if (!globalNpmWritable) {
29
- writeLine("警告:npm 全局目录可能不可写,安装工具时可能需要修复目录权限或切换到用户级 npm prefix。");
35
+ msg.warn("npm 全局目录可能不可写,安装工具时可能需要修复目录权限或切换到用户级 npm prefix。");
30
36
  }
31
37
 
32
- writeLine(`环境检查通过:Node ${node.version},npm ${npm.version}。`);
38
+ msg.success(`环境检查通过:Node ${node.version},npm ${npm.version}。`);
33
39
  return { node: node.version, npm: npm.version, globalNpmWritable };
34
40
  }
35
41
 
@@ -44,6 +50,7 @@ export async function guideToolInstall({
44
50
  throw new Error("缺少工具选择输入");
45
51
  }
46
52
 
53
+ const msg = mk(writeLine);
47
54
  const claudeInstalled = (await commandVersion(execCommand, "claude", ["--version"])).ok;
48
55
  const codexInstalled = (await commandVersion(execCommand, "codex", ["--version"])).ok;
49
56
 
@@ -55,7 +62,7 @@ export async function guideToolInstall({
55
62
  ]);
56
63
 
57
64
  if (selected === "skip") {
58
- writeLine("已跳过工具安装,可稍后在主菜单选择“安装或更新工具”。");
65
+ msg.info("已跳过工具安装,可稍后在主菜单选择“安装或更新工具”。");
59
66
  return { skipped: true, targets: [] };
60
67
  }
61
68
 
@@ -63,11 +70,11 @@ export async function guideToolInstall({
63
70
  const results = await installOrUpdateTools({ targets, confirm });
64
71
  for (const result of results || []) {
65
72
  if (result?.skipped) {
66
- writeLine(`已跳过 ${result.target} 安装。`);
73
+ msg.info(`已跳过 ${result.target} 安装。`);
67
74
  continue;
68
75
  }
69
76
  if (result?.ok === false) {
70
- writeLine(`安装 ${result.target} 失败:${result.error || ""}${result.remediation ? `\n建议:${result.remediation}` : ""}`);
77
+ msg.error(`安装 ${result.target} 失败:${result.error || ""}${result.remediation ? `\n建议:${result.remediation}` : ""}`);
71
78
  }
72
79
  }
73
80
  return { skipped: false, targets, results };
package/src/prompts.js CHANGED
@@ -20,15 +20,16 @@ import {
20
20
 
21
21
  import { promptMultiSelect } from "./menu.js";
22
22
 
23
- import { BOLD, RESET, colorLevel, fg, toLevel } from "./colors.js";
23
+ import { BOLD, RESET, colorLevel, theme, toLevel } from "./colors.js";
24
24
 
25
- // 交互配色:按色彩深度 level 现算(真彩色保留 RGB,256 色量化到调色板)。
25
+ // 交互配色:从统一语义主题取色(见 colors.js theme),不再各自硬编码 RGB
26
26
  function palette(level) {
27
+ const t = theme(level);
27
28
  return {
28
- cyan: fg(34, 211, 238, level), // 品牌高亮:选中项
29
- red: fg(248, 113, 113, level), // 危险操作:NO 高亮
30
- gray: fg(100, 116, 139, level), // 次要说明 / 未选中项
31
- green: fg(74, 222, 128, level), // 完成态答案
29
+ cyan: t.heading, // 品牌高亮:选中项
30
+ red: t.error, // 危险操作:NO 高亮
31
+ gray: t.hint, // 次要说明 / 未选中项
32
+ green: t.success, // 完成态答案
32
33
  };
33
34
  }
34
35
 
@@ -1,32 +1,76 @@
1
1
  import { loadGroups, selectGroup } from "./group-manager.js";
2
2
  import { maskSecret } from "./config-store.js";
3
- import { renderTable } from "./ui.js";
3
+ import { renderChoiceTable, renderKeyValueTable, renderTable } from "./ui.js";
4
4
 
5
- // Key 列表表格(补充需求第 3 节):多 Key 对比展示,长字段由表格渲染层截断
6
- export function renderTokenTable(tokens, { width = 0 } = {}) {
5
+ // Key 列表表格(补充需求第 3 节):多 Key 对比展示,长字段由表格渲染层截断。
6
+ // 倍率列从 groups(/api/cli/groups)按 token.group 映射,便于直观对比各 Key 的计费倍率。
7
+ export function renderTokenTable(tokens, { width = 0, groups = [] } = {}) {
8
+ // 列精简到需求第七节建议的核心 6 列(序号/名称/分组/额度/状态/标记);
9
+ // 倍率并入分组列,过期由状态列体现(formatStatusLabel 已含「过期」),
10
+ // 完整脱敏 Key 在选择项标签与选后摘要展示,不在主表占列。
11
+ return renderChoiceTable({
12
+ columns: [
13
+ ["index", "序号"],
14
+ ["name", "名称"],
15
+ ["group", "分组"],
16
+ ["quota", "额度"],
17
+ ["status", "状态"],
18
+ ["mark", "标记"],
19
+ ],
20
+ rows: tokens.map((token, index) => ({
21
+ index: String(index + 1),
22
+ name: token.name || `Token #${token.id}`,
23
+ group: appendRatio(token.group, formatGroupRatioLabel(groups, token.group)),
24
+ quota: token.unlimited_quota ? "不限" : String(token.remain_quota ?? 0),
25
+ status: formatStatusLabel(token),
26
+ mark: token.current ? "当前" : "",
27
+ })),
28
+ }, { width });
29
+ }
30
+
31
+ // 分组列追加倍率:default · 1.0x;无倍率("-")时仅展示分组名
32
+ function appendRatio(group, ratioLabel) {
33
+ const name = group || "-";
34
+ return ratioLabel && ratioLabel !== "-" ? `${name} · ${ratioLabel}` : name;
35
+ }
36
+
37
+ // 分组倍率展示:以后端 /api/cli/groups 的 ratio 为准;ratio 为 null(如 auto 分组)
38
+ // 或分组未找到时显示 "-",不杜撰 0.0x。
39
+ function formatGroupRatioLabel(groups, groupName) {
40
+ const group = (Array.isArray(groups) ? groups : []).find((item) => item?.name === groupName);
41
+ const ratio = group?.ratio == null ? NaN : Number(group.ratio);
42
+ if (!Number.isFinite(ratio)) {
43
+ return "-";
44
+ }
45
+ return `${Number.isInteger(ratio) ? ratio.toFixed(1) : String(ratio)}x`;
46
+ }
47
+
48
+ // 分组表格:创建 Key 时展示各分组的倍率与说明,便于选择前对比。
49
+ export function renderGroupTable(groups, { width = 0 } = {}) {
7
50
  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
- ]),
51
+ head: ["分组", "倍率", "说明"],
52
+ rows: (Array.isArray(groups) ? groups : []).map((group) => {
53
+ const name = group.display_name || group.name || "-";
54
+ const ratio = group?.ratio == null ? NaN : Number(group.ratio);
55
+ const ratioText = Number.isFinite(ratio) ? `${Number.isInteger(ratio) ? ratio.toFixed(1) : String(ratio)}x` : "-";
56
+ const desc = [
57
+ group.desc && group.desc !== name ? group.desc : "",
58
+ group.recommended ? "推荐" : "",
59
+ ].filter(Boolean).join(" ") || "-";
60
+ return [name, ratioText, desc];
61
+ }),
18
62
  }, { width });
19
63
  }
20
64
 
21
65
  function formatStatusLabel(token) {
22
66
  if (token.status !== 1) {
23
- return "禁用";
67
+ return "禁用";
24
68
  }
25
69
  const expired = Number(token.expired_time);
26
70
  if (expired > 0 && expired * 1000 < Date.now()) {
27
- return "过期";
71
+ return "⚠️ 过期";
28
72
  }
29
- return "正常";
73
+ return "正常";
30
74
  }
31
75
 
32
76
  function formatExpiryLabel(expiredTime) {
@@ -71,7 +115,7 @@ export const TOKEN_PURPOSES = [
71
115
  ];
72
116
 
73
117
  // 创建新 API Key:选择用途(决定默认名)→ 选择分组(来自后端)→ 创建并返回明文
74
- export async function createNewToken({ api, promptSelect, promptInput = async (_m, d) => d } = {}) {
118
+ export async function createNewToken({ api, promptSelect, promptInput = async (_m, d) => d, writeLine } = {}) {
75
119
  if (!api) {
76
120
  throw new Error("缺少 API client");
77
121
  }
@@ -80,6 +124,9 @@ export async function createNewToken({ api, promptSelect, promptInput = async (_
80
124
  }
81
125
  const purpose = await promptSelect("请选择 Key 用途:", TOKEN_PURPOSES);
82
126
  const groups = await loadGroups(api);
127
+ if (typeof writeLine === "function") {
128
+ writeLine(renderGroupTable(groups, { width: process.stdout.columns || 0 }));
129
+ }
83
130
  const group = await selectGroup({ groups, promptSelect });
84
131
  const name = await promptInput("新 API Key 名称", purpose);
85
132
  const created = await api.post("/api/cli/tokens", {
@@ -116,8 +163,14 @@ export async function chooseOrCreateToken({
116
163
  }
117
164
  // 提供 writeLine 时先用表格展示 Key 详情,选项标签保持简短
118
165
  const useTable = typeof writeLine === "function";
166
+ let groups = [];
119
167
  if (useTable) {
120
- writeLine(renderTokenTable(tokens, { width: process.stdout.columns || 0 }));
168
+ try {
169
+ groups = await loadGroups(api);
170
+ } catch {
171
+ groups = [];
172
+ }
173
+ writeLine(renderTokenTable(tokens, { width: process.stdout.columns || 0, groups }));
121
174
  }
122
175
  const choices = [
123
176
  ...tokens.map((token) => ({
@@ -130,12 +183,18 @@ export async function chooseOrCreateToken({
130
183
  ];
131
184
  const tokenId = await promptSelect("选择 API Key", choices);
132
185
  if (tokenId === "__create__") {
133
- return createNewToken({ api, promptSelect, promptInput });
186
+ return createNewToken({ api, promptSelect, promptInput, writeLine });
134
187
  }
135
188
  const selected = tokens.find((token) => token.id === tokenId);
136
189
  if (!selected) {
137
190
  throw new Error("选择的 API Key 无效");
138
191
  }
192
+ if (typeof writeLine === "function") {
193
+ writeLine("");
194
+ writeLine("✅ 已选择 API Key");
195
+ writeLine("");
196
+ writeLine(renderSelectedTokenSummary(selected, { groups, width: process.stdout.columns || 0 }));
197
+ }
139
198
  const revealed = await api.post(`/api/cli/tokens/${selected.id}/reveal`);
140
199
  if (!revealed?.key) {
141
200
  throw new Error("未返回 API Key");
@@ -149,7 +208,18 @@ export async function chooseOrCreateToken({
149
208
  };
150
209
  }
151
210
 
152
- return createNewToken({ api, promptSelect, promptInput });
211
+ return createNewToken({ api, promptSelect, promptInput, writeLine });
212
+ }
213
+
214
+ function renderSelectedTokenSummary(token, { groups = [], width = 0 } = {}) {
215
+ return renderKeyValueTable([
216
+ ["名称", token.name || `Token #${token.id}`],
217
+ ["Key", formatTokenKey(token.masked_key || token.key)],
218
+ ["分组", token.group || "-"],
219
+ ["倍率", formatGroupRatioLabel(groups, token.group)],
220
+ ["状态", formatStatusLabel(token)],
221
+ ["额度", token.unlimited_quota ? "不限" : String(token.remain_quota ?? 0)],
222
+ ], { width });
153
223
  }
154
224
 
155
225
  function formatStatus(status) {
@@ -1,5 +1,6 @@
1
1
  import { sanitizeText } from "./sanitize.js";
2
2
  import { spawn } from "node:child_process";
3
+ import { warningBlock } from "./ui.js";
3
4
 
4
5
  const TOOL_COMMANDS = {
5
6
  claude360: {
@@ -38,6 +39,7 @@ export async function installOrUpdateTools({
38
39
  targets,
39
40
  confirm,
40
41
  execCommand = defaultInstallExec,
42
+ verbose = false,
41
43
  } = {}) {
42
44
  if (!Array.isArray(targets) || targets.length === 0) {
43
45
  throw new Error("缺少安装或更新目标");
@@ -51,14 +53,17 @@ export async function installOrUpdateTools({
51
53
  const install = buildInstallCommand(target);
52
54
  const commandText = `${install.command} ${install.args.join(" ")}`;
53
55
  const approved = await confirm(
54
- `将执行全局 npm 操作:${commandText}\n影响范围:安装或更新 ${install.toolName} 到当前用户 npm 全局目录。\n是否继续?`,
56
+ warningBlock("即将执行全局 npm 操作", {
57
+ action: commandText,
58
+ impact: `当前用户的 npm 全局目录(安装或更新 ${install.toolName})`,
59
+ }),
55
60
  );
56
61
  if (!approved) {
57
62
  results.push({ target, skipped: true });
58
63
  continue;
59
64
  }
60
65
 
61
- let result = await execCommand(install.command, install.args);
66
+ let result = await execCommand(install.command, install.args, { verbose });
62
67
  let usedMirror = false;
63
68
  // 官方源失败时支持国内镜像回退:一次性 --registry 参数重试,不修改用户全局 npm 配置
64
69
  if (!result.ok) {
@@ -70,7 +75,7 @@ export async function installOrUpdateTools({
70
75
  result = await execCommand(install.command, [
71
76
  ...install.args,
72
77
  `--registry=${NPM_MIRROR_REGISTRY}`,
73
- ]);
78
+ ], { verbose });
74
79
  }
75
80
  }
76
81
 
@@ -89,12 +94,23 @@ export async function installOrUpdateTools({
89
94
  return results;
90
95
  }
91
96
 
92
- // 全局包安装可能耗时较久,使用 inherit-stdio 实时显示 npm 进度,且不设超时,
93
- // 避免被诊断用的短超时执行器(defaultExecCommand)中途 SIGTERM 杀掉。
94
- function defaultInstallExec(command, args) {
97
+ // 全局包安装可能耗时较久且不设超时,避免被诊断用的短超时执行器
98
+ // defaultExecCommand)中途 SIGTERM 杀掉。verbose 时 inherit-stdio 实时显示 npm
99
+ // 进度;默认捕获输出,成功只在上层显示摘要,失败时把 stderr 带回供结构化错误展示。
100
+ function defaultInstallExec(command, args, { verbose = false } = {}) {
95
101
  return new Promise((resolve) => {
96
- const child = spawn(command, args, { stdio: "inherit" });
97
- child.on("error", (error) => resolve({ ok: false, error: error.message }));
98
- child.on("close", (code) => resolve({ ok: code === 0 }));
102
+ if (verbose) {
103
+ const child = spawn(command, args, { stdio: "inherit" });
104
+ child.on("error", (error) => resolve({ ok: false, error: error.message }));
105
+ child.on("close", (code) => resolve({ ok: code === 0 }));
106
+ return;
107
+ }
108
+ let stdout = "";
109
+ let stderr = "";
110
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
111
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
112
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
113
+ child.on("error", (error) => resolve({ ok: false, error: error.message, stdout, stderr }));
114
+ child.on("close", (code) => resolve({ ok: code === 0, stdout, stderr }));
99
115
  });
100
116
  }
@@ -6,6 +6,12 @@ import { safeErrorMessage } from "./sanitize.js";
6
6
  import { copyFile as fsCopyFile, mkdir as fsMkdir, readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
+ import { colorLevel } from "./colors.js";
10
+ import { createMessenger } from "./messages.js";
11
+ import { renderDivider, renderKeyValueTable, renderTaskEnd, renderTaskStart, renderTaskStep, warningBlock } from "./ui.js";
12
+
13
+ // 语义消息器工厂:真实终端着色,测试/管道(writeLine 被替换)下无色。
14
+ const mk = (writeLine) => createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
9
15
 
10
16
  // Codex 协议兼容性检测:以后端上报为准,CLI 不内置协议判断逻辑
11
17
  export async function checkCodexCompat(api) {
@@ -44,9 +50,17 @@ export function resolveCodexConfigPath({ homedir = os.homedir } = {}) {
44
50
  return path.join(homedir(), ".codex", "config.toml");
45
51
  }
46
52
 
53
+ // Codex 0.134.0+ 的 profile(CONFIG_PROFILE_V2)机制:`--profile claude360` 会在
54
+ // 基础配置 config.toml 之上叠加同目录的 claude360.config.toml 覆盖层。profile 专属
55
+ // 键写在这里(顶层键),而不是主 config.toml 的 [profiles.claude360] 表。
56
+ export function resolveCodexProfileConfigPath({ configPath = resolveCodexConfigPath(), profileId = "claude360" } = {}) {
57
+ return path.join(path.dirname(configPath), `${profileId}.config.toml`);
58
+ }
59
+
47
60
  export async function launchCodex({
48
61
  config,
49
62
  configPath = resolveCodexConfigPath(),
63
+ profileConfigPath = resolveCodexProfileConfigPath({ configPath }),
50
64
  readFile = fsReadFile,
51
65
  writeFile = fsWriteFile,
52
66
  mkdir = fsMkdir,
@@ -57,7 +71,7 @@ export async function launchCodex({
57
71
  writeLine = () => {},
58
72
  } = {}) {
59
73
  assertApiKey(config);
60
- await configureCodexProvider({ config, configPath, readFile, writeFile, mkdir, copyFile, confirmConflict, writeLine });
74
+ await configureCodexProvider({ config, configPath, profileConfigPath, readFile, writeFile, mkdir, copyFile, confirmConflict, writeLine });
61
75
  return spawnCommand("codex", ["--profile", "claude360"], {
62
76
  stdio: "inherit",
63
77
  env: {
@@ -70,6 +84,7 @@ export async function launchCodex({
70
84
  export async function configureCodexProvider({
71
85
  config,
72
86
  configPath = resolveCodexConfigPath(),
87
+ profileConfigPath = resolveCodexProfileConfigPath({ configPath }),
73
88
  readFile = fsReadFile,
74
89
  writeFile = fsWriteFile,
75
90
  mkdir = fsMkdir,
@@ -77,33 +92,88 @@ export async function configureCodexProvider({
77
92
  confirmConflict,
78
93
  writeLine = () => {},
79
94
  } = {}) {
95
+ writeLine(renderTaskStart("配置 Codex Provider", {
96
+ intro: ["检查现有配置", "备份配置", "写入 Claude360 Provider", "验证配置"],
97
+ }));
98
+ writeLine("");
99
+ writeLine(renderTaskStep(1, 4, "检查现有配置"));
80
100
  const current = await readConfigIfExists(readFile, configPath);
101
+ const currentProfile = await readConfigIfExists(readFile, profileConfigPath);
81
102
  const desired = {
82
103
  providerId: "claude360",
83
104
  baseUrl: `${normalizeBaseUrl(config.baseUrl)}/v1`,
84
105
  envKey: "CLAUDE360_API_KEY",
85
106
  wireApi: "responses",
86
107
  };
87
- await confirmCodexConfigConflict(current, desired, confirmConflict);
108
+ await confirmCodexConfigConflict({ current, currentProfile, desired, confirmConflict });
109
+ writeLine(renderKeyValueTable([
110
+ ["配置文件", configPath],
111
+ ["Profile 覆盖层", profileConfigPath],
112
+ ["目标 Provider", desired.providerId],
113
+ ], { width: process.stdout.columns || 0 }));
114
+
115
+ // 基础层(config.toml):写入 provider 定义,并迁移移除 legacy 的
116
+ // [profiles.claude360] 表与顶层 profile 选择器(v2 禁止其与 --profile 共存)。
88
117
  const content = buildCodexConfig(current, {
89
118
  providerId: desired.providerId,
90
119
  baseUrl: desired.baseUrl,
91
120
  envKey: desired.envKey,
92
121
  });
93
- const tomlError = validateBasicToml(content);
94
- if (tomlError) {
95
- throw new Error(`生成的 Codex 配置 TOML 校验失败:${tomlError}`);
122
+ // 覆盖层(claude360.config.toml):profile 专属顶层键 model_provider。
123
+ const profileContent = buildCodexProfileConfig(currentProfile, { providerId: desired.providerId });
124
+
125
+ for (const [label, value] of [["config.toml", content], ["claude360.config.toml", profileContent]]) {
126
+ const tomlError = validateBasicToml(value);
127
+ if (tomlError) {
128
+ throw new Error(`生成的 Codex ${label} TOML 校验失败:${tomlError}`);
129
+ }
96
130
  }
131
+
97
132
  await mkdir(path.dirname(configPath), { recursive: true });
98
- // 写入前备份原配置(PRD 安全要求)
99
- let backupPath = null;
100
- if (current !== "") {
101
- backupPath = `${configPath}.claude360.bak`;
102
- await copyFile(configPath, backupPath);
103
- writeLine(`已备份原 Codex 配置到:${backupPath}`);
133
+ writeLine("");
134
+ writeLine(renderDivider("section"));
135
+ writeLine("");
136
+ writeLine(renderTaskStep(2, 4, "备份配置"));
137
+ // 写入前备份原配置(PRD 安全要求):两个文件各自备份
138
+ const backupPath = await backupIfExists({ copyFile, filePath: configPath, exists: current !== "", writeLine, label: "原 Codex 配置" });
139
+ const profileBackupPath = await backupIfExists({ copyFile, filePath: profileConfigPath, exists: currentProfile !== "", writeLine, label: "原 Codex profile 配置" });
140
+ if (!backupPath && !profileBackupPath) {
141
+ mk(writeLine).info("未发现现有配置,跳过备份。");
104
142
  }
143
+ writeLine("");
144
+ writeLine(renderDivider("section"));
145
+ writeLine("");
146
+ writeLine(renderTaskStep(3, 4, "写入 Claude360 Provider"));
105
147
  await writeFile(configPath, content, "utf8");
106
- return { content, backupPath };
148
+ await writeFile(profileConfigPath, profileContent, "utf8");
149
+ mk(writeLine).success("已写入 model_providers.claude360 与 claude360 profile 覆盖层。");
150
+ writeLine("");
151
+ writeLine(renderDivider("section"));
152
+ writeLine("");
153
+ writeLine(renderTaskStep(4, 4, "验证配置"));
154
+ mk(writeLine).success("Codex Provider 配置已通过 TOML 校验。");
155
+ writeLine("");
156
+ writeLine(renderTaskEnd("Codex Provider 配置完成", {
157
+ summary: [
158
+ ["启动命令", "codex --profile claude360"],
159
+ ["配置文件", configPath],
160
+ ["Profile 覆盖层", profileConfigPath],
161
+ ["Provider", desired.providerId],
162
+ ["下一步", "\n1. 立即启动 Codex\n2. 返回主菜单"],
163
+ ],
164
+ }));
165
+ return { content, profileContent, backupPath, profileBackupPath };
166
+ }
167
+
168
+ async function backupIfExists({ copyFile, filePath, exists, writeLine, label }) {
169
+ const msg = mk(writeLine);
170
+ if (!exists) {
171
+ return null;
172
+ }
173
+ const backupPath = `${filePath}.claude360.bak`;
174
+ await copyFile(filePath, backupPath);
175
+ msg.success(`已备份${label}到:${backupPath}`);
176
+ return backupPath;
107
177
  }
108
178
 
109
179
  // 真实 TOML 校验(验收 P0-2):写入 ~/.codex/config.toml 前对合并产物完整 parse,
@@ -120,12 +190,13 @@ export function validateBasicToml(content) {
120
190
  }
121
191
  }
122
192
 
123
- // 验收 P0-3:provider profile 两类 claude360 块的冲突都必须二次确认,
124
- // 拒绝时在 mkdir / 备份 / 写入之前抛错,磁盘零改动
125
- async function confirmCodexConfigConflict(content, desired, confirmConflict) {
193
+ // 验收 P0-3:provider 块(base_url/env_key/wire_api)与 profile 覆盖层的 model_provider
194
+ // 冲突都必须二次确认,拒绝时在 mkdir / 备份 / 写入之前抛错,磁盘零改动。legacy 的
195
+ // [profiles.claude360] 表由迁移逻辑直接移除,不在此处阻塞。
196
+ async function confirmCodexConfigConflict({ current, currentProfile, desired, confirmConflict }) {
126
197
  const conflicts = [];
127
198
 
128
- const providerBlock = extractTableBlock(content, `model_providers.${desired.providerId}`);
199
+ const providerBlock = extractTableBlock(current, `model_providers.${desired.providerId}`);
129
200
  if (providerBlock) {
130
201
  const values = parseTomlStringAssignments(providerBlock);
131
202
  if (values.base_url && values.base_url !== desired.baseUrl) {
@@ -139,12 +210,9 @@ async function confirmCodexConfigConflict(content, desired, confirmConflict) {
139
210
  }
140
211
  }
141
212
 
142
- const profileBlock = extractTableBlock(content, `profiles.${desired.providerId}`);
143
- if (profileBlock) {
144
- const values = parseTomlStringAssignments(profileBlock);
145
- if (values.model_provider && values.model_provider !== desired.providerId) {
146
- conflicts.push(`profiles.${desired.providerId}.model_provider: ${values.model_provider} -> ${desired.providerId}`);
147
- }
213
+ const profileValues = parseTomlStringAssignments(topLevelRegion(currentProfile));
214
+ if (profileValues.model_provider && profileValues.model_provider !== desired.providerId) {
215
+ conflicts.push(`model_provider: ${profileValues.model_provider} -> ${desired.providerId}`);
148
216
  }
149
217
 
150
218
  if (conflicts.length === 0) {
@@ -154,7 +222,10 @@ async function confirmCodexConfigConflict(content, desired, confirmConflict) {
154
222
  throw new Error(`Codex Claude360 配置存在冲突:${conflicts.join("; ")}`);
155
223
  }
156
224
  const approved = await confirmConflict(
157
- `Codex Claude360 配置存在冲突,将覆盖以下字段:\n${conflicts.join("\n")}\n是否继续?`,
225
+ warningBlock("即将覆盖 Codex Claude360 Provider 配置", {
226
+ action: `覆盖冲突字段:\n${conflicts.join("\n")}`,
227
+ impact: "~/.codex/config.toml 与 claude360 profile 覆盖层",
228
+ }),
158
229
  );
159
230
  if (!approved) {
160
231
  throw new Error(`Codex Claude360 配置存在冲突:${conflicts.join("; ")}`);
@@ -202,7 +273,17 @@ export function buildCodexConfig(content = "", {
202
273
  `env_key = "${escapeTomlString(envKey)}"`,
203
274
  'wire_api = "responses"',
204
275
  ]);
205
- nextContent = upsertProfileModelProvider(nextContent, providerId);
276
+ // 迁移:移除 legacy 的 [profiles.claude360] 表与顶层 profile 选择器。
277
+ // 新版 Codex 拒绝 `--profile claude360` 与基础配置中的 legacy profile 共存。
278
+ nextContent = removeTomlTable(nextContent, `profiles.${providerId}`);
279
+ nextContent = removeTopLevelKeyLine(nextContent, "profile", providerId);
280
+ return `${nextContent.trimEnd()}\n`;
281
+ }
282
+
283
+ // 生成 profile 覆盖层(claude360.config.toml):profile 专属配置以顶层键写入,
284
+ // 由 `codex --profile claude360` 叠加到基础 config.toml 之上。
285
+ export function buildCodexProfileConfig(content = "", { providerId = "claude360" } = {}) {
286
+ const nextContent = upsertTopLevelKey(content, "model_provider", providerId);
206
287
  return `${nextContent.trimEnd()}\n`;
207
288
  }
208
289
 
@@ -232,22 +313,37 @@ export function upsertTomlTable(content, tableName, tableLines) {
232
313
  ].join("\n");
233
314
  }
234
315
 
235
- function upsertProfileModelProvider(content, providerId) {
236
- return upsertProfileKey(content, providerId, "model_provider", providerId);
316
+ // 在文件顶层区域(首个 [table] 之前)幂等 upsert 单个字符串键:已存在则替换,
317
+ // 不存在则插入到顶层区域末尾,不影响任何 table 块。
318
+ export function upsertTopLevelKey(content, key, value) {
319
+ const keyLine = `${key} = "${escapeTomlString(value)}"`;
320
+ const lines = content.split(/\r?\n/);
321
+ let tableStart = lines.findIndex((line) => /^\s*\[/.test(line));
322
+ if (tableStart === -1) {
323
+ tableStart = lines.length;
324
+ }
325
+ const keyPattern = new RegExp(`^\\s*${key}\\s*=`);
326
+ const keyIndex = lines.slice(0, tableStart).findIndex((line) => keyPattern.test(line));
327
+ if (keyIndex !== -1) {
328
+ lines[keyIndex] = keyLine;
329
+ return lines.join("\n");
330
+ }
331
+ let insertAt = tableStart;
332
+ while (insertAt > 0 && lines[insertAt - 1].trim() === "") {
333
+ insertAt -= 1;
334
+ }
335
+ lines.splice(insertAt, 0, keyLine);
336
+ return lines.join("\n");
237
337
  }
238
338
 
239
- // [profiles.<profileId>] 表内行级 upsert 单个字符串键,不影响表内其他键;
240
- // 表不存在时在文件末尾追加。
241
- export function upsertProfileKey(content, profileId, key, value) {
339
+ // 移除整个 [tableName] 表块(表头到下一表头 / EOF)。表不存在时原样返回。
340
+ export function removeTomlTable(content, tableName) {
242
341
  const lines = content.split(/\r?\n/);
243
- const header = `[profiles.${profileId}]`;
244
- const keyLine = `${key} = "${escapeTomlString(value)}"`;
342
+ const header = `[${tableName}]`;
245
343
  const start = lines.findIndex((line) => line.trim() === header);
246
344
  if (start === -1) {
247
- const trimmed = content.trimEnd();
248
- return `${trimmed}${trimmed ? "\n\n" : ""}${header}\n${keyLine}\n`;
345
+ return content;
249
346
  }
250
-
251
347
  let end = lines.length;
252
348
  for (let index = start + 1; index < lines.length; index += 1) {
253
349
  if (/^\s*\[/.test(lines[index])) {
@@ -255,21 +351,29 @@ export function upsertProfileKey(content, profileId, key, value) {
255
351
  break;
256
352
  }
257
353
  }
354
+ return [...lines.slice(0, start), ...lines.slice(end)].join("\n");
355
+ }
258
356
 
259
- const profileLines = lines.slice(start, end);
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);
264
- } else {
265
- profileLines[keyIndex] = keyLine;
357
+ // 移除顶层区域(首个 [table] 之前)中精确匹配 `key = "value"` 的选择器行。
358
+ function removeTopLevelKeyLine(content, key, value) {
359
+ const lines = content.split(/\r?\n/);
360
+ let tableStart = lines.findIndex((line) => /^\s*\[/.test(line));
361
+ if (tableStart === -1) {
362
+ tableStart = lines.length;
266
363
  }
364
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
365
+ const pattern = new RegExp(`^\\s*${key}\\s*=\\s*"${escaped}"\\s*$`);
366
+ return lines.filter((line, index) => !(index < tableStart && pattern.test(line))).join("\n");
367
+ }
267
368
 
268
- return [
269
- ...lines.slice(0, start),
270
- ...profileLines,
271
- ...lines.slice(end),
272
- ].join("\n");
369
+ // 返回首个 [table] 之前的顶层区域文本,供 parseTomlStringAssignments 复用。
370
+ function topLevelRegion(content) {
371
+ const lines = content.split(/\r?\n/);
372
+ let end = lines.findIndex((line) => /^\s*\[/.test(line));
373
+ if (end === -1) {
374
+ end = lines.length;
375
+ }
376
+ return lines.slice(0, end).join("\n");
273
377
  }
274
378
 
275
379
  async function readConfigIfExists(readFile, configPath) {