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/README.md +107 -126
- package/bin/claude360.js +7 -3
- package/package.json +1 -1
- package/src/account-status.js +8 -5
- package/src/auth.js +9 -2
- package/src/banner.js +47 -6
- package/src/cc-switch.js +34 -6
- package/src/colors.js +31 -0
- package/src/diagnostics.js +68 -25
- package/src/glyphs.js +33 -0
- package/src/index.js +248 -105
- package/src/init-config.js +61 -27
- package/src/init-flow.js +25 -6
- package/src/mcp-skill.js +48 -21
- package/src/menu.js +20 -13
- package/src/messages.js +78 -0
- package/src/notices.js +33 -0
- package/src/onboarding.js +12 -5
- package/src/prompts.js +7 -6
- package/src/token-manager.js +90 -20
- package/src/tool-installer.js +25 -9
- package/src/tool-launcher.js +150 -46
- package/src/topup.js +55 -5
- package/src/ui.js +264 -8
- package/src/workflows.js +10 -7
- package/src/zcf-notice.js +15 -1
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
|
-
|
|
35
|
+
msg.warn("npm 全局目录可能不可写,安装工具时可能需要修复目录权限或切换到用户级 npm prefix。");
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
msg.info(`已跳过 ${result.target} 安装。`);
|
|
67
74
|
continue;
|
|
68
75
|
}
|
|
69
76
|
if (result?.ok === false) {
|
|
70
|
-
|
|
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,
|
|
23
|
+
import { BOLD, RESET, colorLevel, theme, toLevel } from "./colors.js";
|
|
24
24
|
|
|
25
|
-
//
|
|
25
|
+
// 交互配色:从统一语义主题取色(见 colors.js theme),不再各自硬编码 RGB。
|
|
26
26
|
function palette(level) {
|
|
27
|
+
const t = theme(level);
|
|
27
28
|
return {
|
|
28
|
-
cyan:
|
|
29
|
-
red:
|
|
30
|
-
gray:
|
|
31
|
-
green:
|
|
29
|
+
cyan: t.heading, // 品牌高亮:选中项
|
|
30
|
+
red: t.error, // 危险操作:NO 高亮
|
|
31
|
+
gray: t.hint, // 次要说明 / 未选中项
|
|
32
|
+
green: t.success, // 完成态答案
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
35
|
|
package/src/token-manager.js
CHANGED
|
@@ -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
|
-
|
|
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: ["
|
|
9
|
-
rows:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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) {
|
package/src/tool-installer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
97
|
+
// 全局包安装可能耗时较久且不设超时,避免被诊断用的短超时执行器
|
|
98
|
+
// (defaultExecCommand)中途 SIGTERM 杀掉。verbose 时 inherit-stdio 实时显示 npm
|
|
99
|
+
// 进度;默认捕获输出,成功只在上层显示摘要,失败时把 stderr 带回供结构化错误展示。
|
|
100
|
+
function defaultInstallExec(command, args, { verbose = false } = {}) {
|
|
95
101
|
return new Promise((resolve) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
}
|
package/src/tool-launcher.js
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
//
|
|
125
|
-
|
|
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(
|
|
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
|
|
143
|
-
if (
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
//
|
|
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 = `[
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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) {
|