claude360 0.2.8 → 0.2.9

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.
@@ -6,6 +6,11 @@ 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
+
12
+ // 语义消息器工厂:真实终端着色,测试/管道(writeLine 被替换)下无色。
13
+ const mk = (writeLine) => createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
9
14
 
10
15
  // Codex 协议兼容性检测:以后端上报为准,CLI 不内置协议判断逻辑
11
16
  export async function checkCodexCompat(api) {
@@ -44,9 +49,17 @@ export function resolveCodexConfigPath({ homedir = os.homedir } = {}) {
44
49
  return path.join(homedir(), ".codex", "config.toml");
45
50
  }
46
51
 
52
+ // Codex 0.134.0+ 的 profile(CONFIG_PROFILE_V2)机制:`--profile claude360` 会在
53
+ // 基础配置 config.toml 之上叠加同目录的 claude360.config.toml 覆盖层。profile 专属
54
+ // 键写在这里(顶层键),而不是主 config.toml 的 [profiles.claude360] 表。
55
+ export function resolveCodexProfileConfigPath({ configPath = resolveCodexConfigPath(), profileId = "claude360" } = {}) {
56
+ return path.join(path.dirname(configPath), `${profileId}.config.toml`);
57
+ }
58
+
47
59
  export async function launchCodex({
48
60
  config,
49
61
  configPath = resolveCodexConfigPath(),
62
+ profileConfigPath = resolveCodexProfileConfigPath({ configPath }),
50
63
  readFile = fsReadFile,
51
64
  writeFile = fsWriteFile,
52
65
  mkdir = fsMkdir,
@@ -57,7 +70,7 @@ export async function launchCodex({
57
70
  writeLine = () => {},
58
71
  } = {}) {
59
72
  assertApiKey(config);
60
- await configureCodexProvider({ config, configPath, readFile, writeFile, mkdir, copyFile, confirmConflict, writeLine });
73
+ await configureCodexProvider({ config, configPath, profileConfigPath, readFile, writeFile, mkdir, copyFile, confirmConflict, writeLine });
61
74
  return spawnCommand("codex", ["--profile", "claude360"], {
62
75
  stdio: "inherit",
63
76
  env: {
@@ -70,6 +83,7 @@ export async function launchCodex({
70
83
  export async function configureCodexProvider({
71
84
  config,
72
85
  configPath = resolveCodexConfigPath(),
86
+ profileConfigPath = resolveCodexProfileConfigPath({ configPath }),
73
87
  readFile = fsReadFile,
74
88
  writeFile = fsWriteFile,
75
89
  mkdir = fsMkdir,
@@ -78,32 +92,50 @@ export async function configureCodexProvider({
78
92
  writeLine = () => {},
79
93
  } = {}) {
80
94
  const current = await readConfigIfExists(readFile, configPath);
95
+ const currentProfile = await readConfigIfExists(readFile, profileConfigPath);
81
96
  const desired = {
82
97
  providerId: "claude360",
83
98
  baseUrl: `${normalizeBaseUrl(config.baseUrl)}/v1`,
84
99
  envKey: "CLAUDE360_API_KEY",
85
100
  wireApi: "responses",
86
101
  };
87
- await confirmCodexConfigConflict(current, desired, confirmConflict);
102
+ await confirmCodexConfigConflict({ current, currentProfile, desired, confirmConflict });
103
+
104
+ // 基础层(config.toml):写入 provider 定义,并迁移移除 legacy 的
105
+ // [profiles.claude360] 表与顶层 profile 选择器(v2 禁止其与 --profile 共存)。
88
106
  const content = buildCodexConfig(current, {
89
107
  providerId: desired.providerId,
90
108
  baseUrl: desired.baseUrl,
91
109
  envKey: desired.envKey,
92
110
  });
93
- const tomlError = validateBasicToml(content);
94
- if (tomlError) {
95
- throw new Error(`生成的 Codex 配置 TOML 校验失败:${tomlError}`);
111
+ // 覆盖层(claude360.config.toml):profile 专属顶层键 model_provider。
112
+ const profileContent = buildCodexProfileConfig(currentProfile, { providerId: desired.providerId });
113
+
114
+ for (const [label, value] of [["config.toml", content], ["claude360.config.toml", profileContent]]) {
115
+ const tomlError = validateBasicToml(value);
116
+ if (tomlError) {
117
+ throw new Error(`生成的 Codex ${label} TOML 校验失败:${tomlError}`);
118
+ }
96
119
  }
120
+
97
121
  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}`);
104
- }
122
+ // 写入前备份原配置(PRD 安全要求):两个文件各自备份
123
+ const backupPath = await backupIfExists({ copyFile, filePath: configPath, exists: current !== "", writeLine, label: "原 Codex 配置" });
105
124
  await writeFile(configPath, content, "utf8");
106
- return { content, backupPath };
125
+ const profileBackupPath = await backupIfExists({ copyFile, filePath: profileConfigPath, exists: currentProfile !== "", writeLine, label: "原 Codex profile 配置" });
126
+ await writeFile(profileConfigPath, profileContent, "utf8");
127
+ return { content, profileContent, backupPath, profileBackupPath };
128
+ }
129
+
130
+ async function backupIfExists({ copyFile, filePath, exists, writeLine, label }) {
131
+ const msg = mk(writeLine);
132
+ if (!exists) {
133
+ return null;
134
+ }
135
+ const backupPath = `${filePath}.claude360.bak`;
136
+ await copyFile(filePath, backupPath);
137
+ msg.success(`已备份${label}到:${backupPath}`);
138
+ return backupPath;
107
139
  }
108
140
 
109
141
  // 真实 TOML 校验(验收 P0-2):写入 ~/.codex/config.toml 前对合并产物完整 parse,
@@ -120,12 +152,13 @@ export function validateBasicToml(content) {
120
152
  }
121
153
  }
122
154
 
123
- // 验收 P0-3:provider profile 两类 claude360 块的冲突都必须二次确认,
124
- // 拒绝时在 mkdir / 备份 / 写入之前抛错,磁盘零改动
125
- async function confirmCodexConfigConflict(content, desired, confirmConflict) {
155
+ // 验收 P0-3:provider 块(base_url/env_key/wire_api)与 profile 覆盖层的 model_provider
156
+ // 冲突都必须二次确认,拒绝时在 mkdir / 备份 / 写入之前抛错,磁盘零改动。legacy 的
157
+ // [profiles.claude360] 表由迁移逻辑直接移除,不在此处阻塞。
158
+ async function confirmCodexConfigConflict({ current, currentProfile, desired, confirmConflict }) {
126
159
  const conflicts = [];
127
160
 
128
- const providerBlock = extractTableBlock(content, `model_providers.${desired.providerId}`);
161
+ const providerBlock = extractTableBlock(current, `model_providers.${desired.providerId}`);
129
162
  if (providerBlock) {
130
163
  const values = parseTomlStringAssignments(providerBlock);
131
164
  if (values.base_url && values.base_url !== desired.baseUrl) {
@@ -139,12 +172,9 @@ async function confirmCodexConfigConflict(content, desired, confirmConflict) {
139
172
  }
140
173
  }
141
174
 
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
- }
175
+ const profileValues = parseTomlStringAssignments(topLevelRegion(currentProfile));
176
+ if (profileValues.model_provider && profileValues.model_provider !== desired.providerId) {
177
+ conflicts.push(`model_provider: ${profileValues.model_provider} -> ${desired.providerId}`);
148
178
  }
149
179
 
150
180
  if (conflicts.length === 0) {
@@ -202,7 +232,17 @@ export function buildCodexConfig(content = "", {
202
232
  `env_key = "${escapeTomlString(envKey)}"`,
203
233
  'wire_api = "responses"',
204
234
  ]);
205
- nextContent = upsertProfileModelProvider(nextContent, providerId);
235
+ // 迁移:移除 legacy 的 [profiles.claude360] 表与顶层 profile 选择器。
236
+ // 新版 Codex 拒绝 `--profile claude360` 与基础配置中的 legacy profile 共存。
237
+ nextContent = removeTomlTable(nextContent, `profiles.${providerId}`);
238
+ nextContent = removeTopLevelKeyLine(nextContent, "profile", providerId);
239
+ return `${nextContent.trimEnd()}\n`;
240
+ }
241
+
242
+ // 生成 profile 覆盖层(claude360.config.toml):profile 专属配置以顶层键写入,
243
+ // 由 `codex --profile claude360` 叠加到基础 config.toml 之上。
244
+ export function buildCodexProfileConfig(content = "", { providerId = "claude360" } = {}) {
245
+ const nextContent = upsertTopLevelKey(content, "model_provider", providerId);
206
246
  return `${nextContent.trimEnd()}\n`;
207
247
  }
208
248
 
@@ -232,22 +272,37 @@ export function upsertTomlTable(content, tableName, tableLines) {
232
272
  ].join("\n");
233
273
  }
234
274
 
235
- function upsertProfileModelProvider(content, providerId) {
236
- return upsertProfileKey(content, providerId, "model_provider", providerId);
275
+ // 在文件顶层区域(首个 [table] 之前)幂等 upsert 单个字符串键:已存在则替换,
276
+ // 不存在则插入到顶层区域末尾,不影响任何 table 块。
277
+ export function upsertTopLevelKey(content, key, value) {
278
+ const keyLine = `${key} = "${escapeTomlString(value)}"`;
279
+ const lines = content.split(/\r?\n/);
280
+ let tableStart = lines.findIndex((line) => /^\s*\[/.test(line));
281
+ if (tableStart === -1) {
282
+ tableStart = lines.length;
283
+ }
284
+ const keyPattern = new RegExp(`^\\s*${key}\\s*=`);
285
+ const keyIndex = lines.slice(0, tableStart).findIndex((line) => keyPattern.test(line));
286
+ if (keyIndex !== -1) {
287
+ lines[keyIndex] = keyLine;
288
+ return lines.join("\n");
289
+ }
290
+ let insertAt = tableStart;
291
+ while (insertAt > 0 && lines[insertAt - 1].trim() === "") {
292
+ insertAt -= 1;
293
+ }
294
+ lines.splice(insertAt, 0, keyLine);
295
+ return lines.join("\n");
237
296
  }
238
297
 
239
- // [profiles.<profileId>] 表内行级 upsert 单个字符串键,不影响表内其他键;
240
- // 表不存在时在文件末尾追加。
241
- export function upsertProfileKey(content, profileId, key, value) {
298
+ // 移除整个 [tableName] 表块(表头到下一表头 / EOF)。表不存在时原样返回。
299
+ export function removeTomlTable(content, tableName) {
242
300
  const lines = content.split(/\r?\n/);
243
- const header = `[profiles.${profileId}]`;
244
- const keyLine = `${key} = "${escapeTomlString(value)}"`;
301
+ const header = `[${tableName}]`;
245
302
  const start = lines.findIndex((line) => line.trim() === header);
246
303
  if (start === -1) {
247
- const trimmed = content.trimEnd();
248
- return `${trimmed}${trimmed ? "\n\n" : ""}${header}\n${keyLine}\n`;
304
+ return content;
249
305
  }
250
-
251
306
  let end = lines.length;
252
307
  for (let index = start + 1; index < lines.length; index += 1) {
253
308
  if (/^\s*\[/.test(lines[index])) {
@@ -255,21 +310,29 @@ export function upsertProfileKey(content, profileId, key, value) {
255
310
  break;
256
311
  }
257
312
  }
313
+ return [...lines.slice(0, start), ...lines.slice(end)].join("\n");
314
+ }
258
315
 
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;
316
+ // 移除顶层区域(首个 [table] 之前)中精确匹配 `key = "value"` 的选择器行。
317
+ function removeTopLevelKeyLine(content, key, value) {
318
+ const lines = content.split(/\r?\n/);
319
+ let tableStart = lines.findIndex((line) => /^\s*\[/.test(line));
320
+ if (tableStart === -1) {
321
+ tableStart = lines.length;
266
322
  }
323
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
324
+ const pattern = new RegExp(`^\\s*${key}\\s*=\\s*"${escaped}"\\s*$`);
325
+ return lines.filter((line, index) => !(index < tableStart && pattern.test(line))).join("\n");
326
+ }
267
327
 
268
- return [
269
- ...lines.slice(0, start),
270
- ...profileLines,
271
- ...lines.slice(end),
272
- ].join("\n");
328
+ // 返回首个 [table] 之前的顶层区域文本,供 parseTomlStringAssignments 复用。
329
+ function topLevelRegion(content) {
330
+ const lines = content.split(/\r?\n/);
331
+ let end = lines.findIndex((line) => /^\s*\[/.test(line));
332
+ if (end === -1) {
333
+ end = lines.length;
334
+ }
335
+ return lines.slice(0, end).join("\n");
273
336
  }
274
337
 
275
338
  async function readConfigIfExists(readFile, configPath) {
package/src/topup.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { colorLevel } from "./colors.js";
2
+ import { createMessenger } from "./messages.js";
1
3
  import { renderKeyValueTable, renderSectionTitle } from "./ui.js";
2
4
 
3
5
  export async function loadTopUpOptions(api) {
@@ -31,6 +33,7 @@ export async function runWechatTopUp({
31
33
  throw new Error("缺少 API client");
32
34
  }
33
35
 
36
+ const msg = createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
34
37
  const options = await loadTopUpOptions(api);
35
38
  // 后端口径前置拦截(验收 P1-2):禁用时不进金额选择、不创建订单
36
39
  if (options?.wechat_enabled === false) {
@@ -49,7 +52,7 @@ export async function runWechatTopUp({
49
52
  ["金额", String(order.money_display || order.money || amount)],
50
53
  ["支付方式", "微信扫码"],
51
54
  ]));
52
- writeLine("请使用微信扫码支付:");
55
+ msg.info("请使用微信扫码支付:");
53
56
  await printQrOrCodeUrl({ codeUrl: order.code_url, renderQr, writeLine });
54
57
  const status = await waitTopUpPaid({
55
58
  api,
@@ -96,13 +99,14 @@ async function chooseTopUpAmount({ options, promptSelect, promptInput }) {
96
99
  }
97
100
 
98
101
  async function printQrOrCodeUrl({ codeUrl, renderQr, writeLine }) {
102
+ const msg = createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
99
103
  try {
100
104
  const qr = await renderQr(codeUrl);
101
105
  if (qr) {
102
106
  writeLine(qr);
103
107
  }
104
108
  } catch {
105
- writeLine(`二维码渲染失败,请复制 code_url 完成支付:${codeUrl}`);
109
+ msg.warn(`二维码渲染失败,请复制 code_url 完成支付:${codeUrl}`);
106
110
  }
107
111
  }
108
112
 
package/src/ui.js CHANGED
@@ -5,13 +5,14 @@
5
5
 
6
6
  import { displayWidth } from "./banner.js";
7
7
 
8
- import { BOLD, RESET, fg, toLevel } from "./colors.js";
8
+ import { BOLD, PALETTE, RESET, fg, theme, toLevel } from "./colors.js";
9
9
 
10
- // 表格/标题配色:按色彩深度 level 现算(真彩色保留 RGB,256 色量化到调色板)。
10
+ // 表格/标题配色:从统一语义主题取色(见 colors.js theme),不再各自硬编码 RGB
11
11
  function palette(level) {
12
+ const t = theme(level);
12
13
  return {
13
- border: fg(71, 85, 105, level), // 边框:青灰
14
- head: fg(125, 211, 252, level), // 表头:天蓝
14
+ border: t.border, // 边框:青灰
15
+ head: t.info, // 表头:天蓝
15
16
  };
16
17
  }
17
18
 
@@ -177,10 +178,10 @@ export function renderHeader(title, { subtitle = "", color = false } = {}) {
177
178
  // 四种状态:文字前缀(mark)始终存在,颜色仅增强层级(需求二「不依赖颜色表达唯一信息」)
178
179
  const BOX_MARKS = { info: "i", warn: "!", error: "×", success: "✓" };
179
180
  const BOX_RGB = {
180
- info: [125, 211, 252],
181
- warn: [250, 204, 21],
182
- error: [248, 113, 113],
183
- success: [74, 222, 128],
181
+ info: PALETTE.info,
182
+ warn: PALETTE.warn,
183
+ error: PALETTE.error,
184
+ success: PALETTE.success,
184
185
  };
185
186
 
186
187
  export function renderBox(message, { kind = "info", color = false, width = 0 } = {}) {
package/src/workflows.js CHANGED
@@ -8,6 +8,8 @@ import path from "node:path";
8
8
 
9
9
  import { createBackup } from "./backup.js";
10
10
  import { ZCF_ATTRIBUTION_COMMENT } from "./zcf-notice.js";
11
+ import { colorLevel } from "./colors.js";
12
+ import { createMessenger } from "./messages.js";
11
13
 
12
14
  const defaultFs = { copyFile, cp, mkdir, readFile, stat, writeFile };
13
15
 
@@ -229,6 +231,7 @@ async function installWorkflowSet({
229
231
  if (typeof multiSelect !== "function" || typeof confirm !== "function") {
230
232
  throw new Error("缺少交互输入");
231
233
  }
234
+ const msg = createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
232
235
  const selected = await multiSelect({
233
236
  message: "请选择要安装的工作流:",
234
237
  choices: workflows.map((workflow) => ({
@@ -238,23 +241,23 @@ async function installWorkflowSet({
238
241
  })),
239
242
  });
240
243
  if (selected.length === 0) {
241
- writeLine("已跳过工作流安装。");
244
+ msg.info("已跳过工作流安装。");
242
245
  return { installed: [], skipped: true };
243
246
  }
244
247
 
245
248
  const picked = workflows.filter((workflow) => selected.includes(workflow.id));
246
- writeLine(`安装将写入目录:${targetDir}`);
249
+ msg.info(`安装将写入目录:${targetDir}`);
247
250
 
248
251
  // 备份已存在的同名文件(一次性集中备份到时间戳目录)
249
252
  const targets = picked.flatMap((workflow) => workflow.files.map((file) => path.join(targetDir, file.name)));
250
253
  const { backupDir } = await createBackup({ baseDir: backupBaseDir, paths: targets, fs, now });
251
254
  if (backupDir) {
252
- writeLine(`✓ 已创建备份:${backupDir}`);
255
+ msg.success(`已创建备份:${backupDir}`);
253
256
  }
254
257
 
255
258
  const installed = [];
256
259
  for (const workflow of picked) {
257
- writeLine(`正在安装工作流:${workflow.label}...`);
260
+ msg.step(`正在安装工作流:${workflow.label}...`);
258
261
  let wroteAny = false;
259
262
  for (const file of workflow.files) {
260
263
  const filePath = path.join(targetDir, file.name);
@@ -263,19 +266,19 @@ async function installWorkflowSet({
263
266
  if (existing !== file.content) {
264
267
  const approved = await confirm(`文件已存在:${filePath}\n是否覆盖?(原文件已备份)`);
265
268
  if (!approved) {
266
- writeLine(`已跳过:${file.name}`);
269
+ msg.info(`已跳过:${file.name}`);
267
270
  continue;
268
271
  }
269
272
  }
270
273
  }
271
274
  await fs.mkdir(targetDir, { recursive: true });
272
275
  await fs.writeFile(filePath, file.content, "utf8");
273
- writeLine(`✓ 已安装命令:${file.name}`);
276
+ msg.success(`已安装命令:${file.name}`);
274
277
  wroteAny = true;
275
278
  }
276
279
  if (wroteAny) {
277
280
  installed.push(workflow.id);
278
- writeLine(`✓ ${workflow.label}安装成功`);
281
+ msg.success(`${workflow.label}安装成功`);
279
282
  }
280
283
  }
281
284
  if (installed.length > 0 && usageHint) {