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/init-config.js
CHANGED
|
@@ -9,8 +9,13 @@ import path from "node:path";
|
|
|
9
9
|
|
|
10
10
|
import { createBackup } from "./backup.js";
|
|
11
11
|
import { upsertMarkedBlock } from "./mcp-skill.js";
|
|
12
|
-
import {
|
|
12
|
+
import { upsertTopLevelKey, validateBasicToml } from "./tool-launcher.js";
|
|
13
13
|
import { renderModelTable } from "./ui.js";
|
|
14
|
+
import { colorLevel } from "./colors.js";
|
|
15
|
+
import { createMessenger } from "./messages.js";
|
|
16
|
+
|
|
17
|
+
// 语义消息器工厂:真实终端着色,测试/管道(writeLine 被替换)下无色。
|
|
18
|
+
const mk = (writeLine) => createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
|
|
14
19
|
|
|
15
20
|
const defaultFs = { copyFile, cp, mkdir, readFile, stat, writeFile };
|
|
16
21
|
|
|
@@ -36,10 +41,11 @@ async function readFileIfExists(fs, filePath) {
|
|
|
36
41
|
// 写入 Markdown 标记区块:先备份目标文件到 baseDir/backup/backup_<时间戳>/,
|
|
37
42
|
// 再以 claude360 标记区块幂等更新,不触碰用户已有内容。
|
|
38
43
|
async function writeMarkedBlock({ baseDir, filePath, blockId, content, writeLine, fs, now }) {
|
|
44
|
+
const msg = mk(writeLine);
|
|
39
45
|
const current = await readFileIfExists(fs, filePath);
|
|
40
46
|
const { backupDir } = await createBackup({ baseDir, paths: [filePath], fs, now });
|
|
41
47
|
if (backupDir) {
|
|
42
|
-
|
|
48
|
+
msg.success(`已创建备份:${backupDir}`);
|
|
43
49
|
}
|
|
44
50
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
45
51
|
await fs.writeFile(filePath, upsertMarkedBlock(current, blockId, content), "utf8");
|
|
@@ -85,12 +91,13 @@ export async function configureOutputLanguage({
|
|
|
85
91
|
if (typeof promptSelect !== "function") {
|
|
86
92
|
throw new Error("缺少选择输入");
|
|
87
93
|
}
|
|
94
|
+
const msg = mk(writeLine);
|
|
88
95
|
const selected = await promptSelect("请选择 AI 输出语言:", [
|
|
89
96
|
...OUTPUT_LANGUAGES.map((language) => ({ label: language.label, value: language.id })),
|
|
90
97
|
{ label: "跳过", value: "skip" },
|
|
91
98
|
]);
|
|
92
99
|
if (selected === "skip") {
|
|
93
|
-
|
|
100
|
+
msg.info("已跳过 AI 输出语言配置。");
|
|
94
101
|
return { skipped: true };
|
|
95
102
|
}
|
|
96
103
|
const language = OUTPUT_LANGUAGES.find((item) => item.id === selected);
|
|
@@ -103,7 +110,7 @@ export async function configureOutputLanguage({
|
|
|
103
110
|
fs,
|
|
104
111
|
now,
|
|
105
112
|
});
|
|
106
|
-
|
|
113
|
+
msg.success(`AI 输出语言已写入:${filePath}`);
|
|
107
114
|
return { skipped: false, language: language.id };
|
|
108
115
|
}
|
|
109
116
|
|
|
@@ -164,8 +171,9 @@ export async function configurePromptStyle({
|
|
|
164
171
|
if (typeof promptSelect !== "function") {
|
|
165
172
|
throw new Error("缺少选择输入");
|
|
166
173
|
}
|
|
174
|
+
const msg = mk(writeLine);
|
|
167
175
|
for (const style of PROMPT_STYLES) {
|
|
168
|
-
|
|
176
|
+
msg.info(`${style.label.trim()}:${style.desc}`);
|
|
169
177
|
}
|
|
170
178
|
writeLine("");
|
|
171
179
|
const selected = await promptSelect("请选择系统提示词风格:", [
|
|
@@ -173,7 +181,7 @@ export async function configurePromptStyle({
|
|
|
173
181
|
{ label: "跳过", value: "skip" },
|
|
174
182
|
]);
|
|
175
183
|
if (selected === "skip") {
|
|
176
|
-
|
|
184
|
+
msg.info("已跳过系统提示词风格配置。");
|
|
177
185
|
return { skipped: true };
|
|
178
186
|
}
|
|
179
187
|
const style = PROMPT_STYLES.find((item) => item.id === selected);
|
|
@@ -186,7 +194,7 @@ export async function configurePromptStyle({
|
|
|
186
194
|
fs,
|
|
187
195
|
now,
|
|
188
196
|
});
|
|
189
|
-
|
|
197
|
+
msg.success(`系统提示词风格已写入:${filePath}`);
|
|
190
198
|
return { skipped: false, style: style.id };
|
|
191
199
|
}
|
|
192
200
|
|
|
@@ -204,6 +212,25 @@ export const FALLBACK_CLAUDE_ALIASES = [
|
|
|
204
212
|
{ id: "haiku", display_name: "Haiku(官方别名)", tags: ["经济"], description: "适合日常轻量任务" },
|
|
205
213
|
];
|
|
206
214
|
|
|
215
|
+
// 模型按工具归属过滤:Claude Code 仅展示 Claude 系模型,Codex 仅展示非 Claude
|
|
216
|
+
// (gpt 等)模型。后端 /api/cli/models?tool= 暂未按 tool 过滤,故在客户端按模型
|
|
217
|
+
// id 兜底过滤,避免切换 Claude 模型时混入 gpt、切换 Codex 模型时混入 claude。
|
|
218
|
+
export function isClaudeModel(id) {
|
|
219
|
+
const s = String(id || "").toLowerCase();
|
|
220
|
+
return s.startsWith("claude") || s === "sonnet" || s === "opus" || s === "haiku";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function filterModelsForTool(models, tool) {
|
|
224
|
+
const list = Array.isArray(models) ? models : [];
|
|
225
|
+
if (tool === "claude_code") {
|
|
226
|
+
return list.filter((model) => isClaudeModel(model.id));
|
|
227
|
+
}
|
|
228
|
+
if (tool === "codex") {
|
|
229
|
+
return list.filter((model) => !isClaudeModel(model.id));
|
|
230
|
+
}
|
|
231
|
+
return list;
|
|
232
|
+
}
|
|
233
|
+
|
|
207
234
|
// 后端接口 GET /api/cli/models?tool=<claude_code|codex>:
|
|
208
235
|
// { models: [{ id, display_name, description, tags, recommended }] }
|
|
209
236
|
// (后端暂无上下文长度数据源,契约中不含 context_length)
|
|
@@ -214,8 +241,9 @@ export async function loadAvailableModels(api, tool = "") {
|
|
|
214
241
|
const data = await api.get(`/api/cli/models${query}`);
|
|
215
242
|
const models = (Array.isArray(data?.models) ? data.models : [])
|
|
216
243
|
.filter((model) => model && typeof model.id === "string" && model.id !== "");
|
|
217
|
-
|
|
218
|
-
|
|
244
|
+
const scoped = filterModelsForTool(models, tool);
|
|
245
|
+
if (scoped.length > 0) {
|
|
246
|
+
return { models: scoped, source: "backend" };
|
|
219
247
|
}
|
|
220
248
|
} catch {
|
|
221
249
|
// 后端暂未提供模型列表接口或网络失败,由调用方决定回退策略
|
|
@@ -252,9 +280,10 @@ async function readJsonIfExists(fs, filePath) {
|
|
|
252
280
|
}
|
|
253
281
|
|
|
254
282
|
async function writeJsonWithBackup({ baseDir, filePath, json, writeLine, fs, now }) {
|
|
283
|
+
const msg = mk(writeLine);
|
|
255
284
|
const { backupDir } = await createBackup({ baseDir, paths: [filePath], fs, now });
|
|
256
285
|
if (backupDir) {
|
|
257
|
-
|
|
286
|
+
msg.success(`已创建备份:${backupDir}`);
|
|
258
287
|
}
|
|
259
288
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
260
289
|
await fs.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`, "utf8");
|
|
@@ -271,22 +300,23 @@ export async function configureClaudeDefaultModel({
|
|
|
271
300
|
if (typeof promptSelect !== "function") {
|
|
272
301
|
throw new Error("缺少选择输入");
|
|
273
302
|
}
|
|
303
|
+
const msg = mk(writeLine);
|
|
274
304
|
const { models, source } = await loadAvailableModels(api, "claude_code");
|
|
275
305
|
let list = models;
|
|
276
306
|
if (source !== "backend") {
|
|
277
|
-
|
|
307
|
+
msg.info("Claude360 后端暂未提供模型列表,以下为 Claude Code 官方模型别名:");
|
|
278
308
|
list = FALLBACK_CLAUDE_ALIASES;
|
|
279
309
|
}
|
|
280
310
|
const selected = await selectModelFromList({ models: list, promptSelect, writeLine });
|
|
281
311
|
if (selected === "skip") {
|
|
282
|
-
|
|
312
|
+
msg.info("已跳过默认模型配置。");
|
|
283
313
|
return { skipped: true };
|
|
284
314
|
}
|
|
285
315
|
const settingsPath = path.join(claudeDir, "settings.json");
|
|
286
316
|
const settings = await readJsonIfExists(fs, settingsPath);
|
|
287
317
|
settings.model = selected;
|
|
288
318
|
await writeJsonWithBackup({ baseDir: claudeDir, filePath: settingsPath, json: settings, writeLine, fs, now });
|
|
289
|
-
|
|
319
|
+
msg.success(`默认模型已设置为 ${selected}(${settingsPath})`);
|
|
290
320
|
return { skipped: false, model: selected };
|
|
291
321
|
}
|
|
292
322
|
|
|
@@ -328,8 +358,9 @@ export async function importClaudeEnvPermissions({
|
|
|
328
358
|
if (typeof confirm !== "function") {
|
|
329
359
|
throw new Error("缺少确认输入");
|
|
330
360
|
}
|
|
361
|
+
const msg = mk(writeLine);
|
|
331
362
|
const settingsPath = path.join(claudeDir, "settings.json");
|
|
332
|
-
|
|
363
|
+
msg.info([
|
|
333
364
|
"导入推荐环境变量和权限配置",
|
|
334
365
|
"",
|
|
335
366
|
"该操作将为 Claude Code 写入推荐配置:",
|
|
@@ -341,7 +372,7 @@ export async function importClaudeEnvPermissions({
|
|
|
341
372
|
].join("\n"));
|
|
342
373
|
const approved = await confirm("是否继续?");
|
|
343
374
|
if (!approved) {
|
|
344
|
-
|
|
375
|
+
msg.info("已跳过推荐环境变量和权限配置。");
|
|
345
376
|
return { skipped: true };
|
|
346
377
|
}
|
|
347
378
|
const settings = await readJsonIfExists(fs, settingsPath);
|
|
@@ -350,7 +381,7 @@ export async function importClaudeEnvPermissions({
|
|
|
350
381
|
const allow = new Set([...(settings.permissions?.allow || []), ...recommended.permissions.allow]);
|
|
351
382
|
settings.permissions = { ...(settings.permissions || {}), allow: [...allow] };
|
|
352
383
|
await writeJsonWithBackup({ baseDir: claudeDir, filePath: settingsPath, json: settings, writeLine, fs, now });
|
|
353
|
-
|
|
384
|
+
msg.success(`推荐环境变量与权限配置已写入:${settingsPath}`);
|
|
354
385
|
return { skipped: false, path: settingsPath };
|
|
355
386
|
}
|
|
356
387
|
|
|
@@ -371,31 +402,34 @@ export async function configureCodexDefaultModel({
|
|
|
371
402
|
if (typeof promptSelect !== "function") {
|
|
372
403
|
throw new Error("缺少选择输入");
|
|
373
404
|
}
|
|
405
|
+
const msg = mk(writeLine);
|
|
374
406
|
const { models, source } = await loadAvailableModels(api, "codex");
|
|
375
407
|
if (source !== "backend") {
|
|
376
|
-
|
|
377
|
-
|
|
408
|
+
msg.info("Claude360 后端暂未提供 Codex 模型列表,已跳过默认模型写入。");
|
|
409
|
+
msg.hint("可在启动 Codex 后使用 /model 命令选择模型。");
|
|
378
410
|
return { skipped: true };
|
|
379
411
|
}
|
|
380
412
|
const selected = await selectModelFromList({ models, promptSelect, writeLine });
|
|
381
413
|
if (selected === "skip") {
|
|
382
|
-
|
|
414
|
+
msg.info("已跳过默认模型配置。");
|
|
383
415
|
return { skipped: true };
|
|
384
416
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
417
|
+
// Codex profile v2:模型写入 profile 覆盖层 claude360.config.toml 的顶层 model 键,
|
|
418
|
+
// 不再写主 config.toml 的 [profiles.claude360] 表(新版 Codex 拒绝其与 --profile 共存)。
|
|
419
|
+
const profileConfigPath = path.join(codexDir, "claude360.config.toml");
|
|
420
|
+
const current = await readFileIfExists(fs, profileConfigPath);
|
|
421
|
+
const next = `${upsertTopLevelKey(current, "model", selected).trimEnd()}\n`;
|
|
388
422
|
const tomlError = validateBasicToml(next);
|
|
389
423
|
if (tomlError) {
|
|
390
|
-
|
|
424
|
+
msg.error(`生成的 Codex 配置 TOML 校验失败:${tomlError}\n已放弃写入,原配置保持不变。`);
|
|
391
425
|
return { skipped: true };
|
|
392
426
|
}
|
|
393
|
-
const { backupDir } = await createBackup({ baseDir: codexDir, paths: [
|
|
427
|
+
const { backupDir } = await createBackup({ baseDir: codexDir, paths: [profileConfigPath], fs, now });
|
|
394
428
|
if (backupDir) {
|
|
395
|
-
|
|
429
|
+
msg.success(`已创建备份:${backupDir}`);
|
|
396
430
|
}
|
|
397
431
|
await fs.mkdir(codexDir, { recursive: true });
|
|
398
|
-
await fs.writeFile(
|
|
399
|
-
|
|
432
|
+
await fs.writeFile(profileConfigPath, next, "utf8");
|
|
433
|
+
msg.success(`Codex 默认模型已设置为 ${selected}(${profileConfigPath})`);
|
|
400
434
|
return { skipped: false, model: selected };
|
|
401
435
|
}
|
package/src/init-flow.js
CHANGED
|
@@ -3,29 +3,48 @@
|
|
|
3
3
|
// 不阻塞主流程(AC-05)。各步骤动作由 index.js 注入,本模块只负责编排与输出。
|
|
4
4
|
|
|
5
5
|
import { safeErrorMessage } from "./sanitize.js";
|
|
6
|
+
import { colorLevel } from "./colors.js";
|
|
7
|
+
import { createMessenger } from "./messages.js";
|
|
8
|
+
import { renderDivider, renderStructuredError, renderTaskEnd, renderTaskStart, renderTaskStep } from "./ui.js";
|
|
9
|
+
|
|
10
|
+
// 语义消息器工厂:真实终端着色,测试/管道(writeLine 被替换)下无色。
|
|
11
|
+
const mk = (writeLine) => createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
|
|
6
12
|
|
|
7
13
|
export async function runFullInit({ title, steps, writeLine = console.log } = {}) {
|
|
8
|
-
writeLine
|
|
14
|
+
const msg = mk(writeLine);
|
|
15
|
+
writeLine(renderTaskStart(title, {
|
|
16
|
+
intro: steps.map((step) => step.title),
|
|
17
|
+
}));
|
|
18
|
+
writeLine("");
|
|
9
19
|
let index = 0;
|
|
20
|
+
const total = steps.length;
|
|
10
21
|
for (const step of steps) {
|
|
11
22
|
index += 1;
|
|
12
|
-
|
|
23
|
+
if (index > 1) {
|
|
24
|
+
writeLine(renderDivider("section"));
|
|
25
|
+
writeLine("");
|
|
26
|
+
}
|
|
27
|
+
writeLine(renderTaskStep(index, total, step.title));
|
|
13
28
|
try {
|
|
14
29
|
const result = await step.run();
|
|
15
30
|
if (step.required && result === false) {
|
|
16
|
-
|
|
31
|
+
msg.warn(`已在「${step.title}」中止初始化。`);
|
|
17
32
|
return { completed: false, abortedAt: step.title };
|
|
18
33
|
}
|
|
19
34
|
} catch (error) {
|
|
20
35
|
if (step.required) {
|
|
21
|
-
writeLine(
|
|
22
|
-
|
|
36
|
+
writeLine(renderStructuredError(`${step.title}失败`, {
|
|
37
|
+
reason: safeErrorMessage(error),
|
|
38
|
+
suggestions: ["初始化已中止,可稍后在菜单中重试。"],
|
|
39
|
+
detailCommand: "claude360 doctor --verbose",
|
|
40
|
+
}));
|
|
23
41
|
return { completed: false, abortedAt: step.title };
|
|
24
42
|
}
|
|
25
|
-
|
|
43
|
+
msg.warn(`${step.title}失败(已跳过,不影响后续步骤):${safeErrorMessage(error)}`);
|
|
26
44
|
}
|
|
27
45
|
writeLine("");
|
|
28
46
|
}
|
|
47
|
+
writeLine(renderTaskEnd(`${title}完成`));
|
|
29
48
|
return { completed: true };
|
|
30
49
|
}
|
|
31
50
|
|
package/src/mcp-skill.js
CHANGED
|
@@ -9,6 +9,12 @@ import path from "node:path";
|
|
|
9
9
|
|
|
10
10
|
import { createBackup } from "./backup.js";
|
|
11
11
|
import { upsertTomlTable, validateBasicToml } from "./tool-launcher.js";
|
|
12
|
+
import { colorLevel } from "./colors.js";
|
|
13
|
+
import { createMessenger } from "./messages.js";
|
|
14
|
+
import { renderChoiceTable } from "./ui.js";
|
|
15
|
+
|
|
16
|
+
// 语义消息器工厂:真实终端着色,测试/管道(writeLine 被替换)下无色。
|
|
17
|
+
const mk = (writeLine) => createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
|
|
12
18
|
|
|
13
19
|
// ──────────────────────────────────────────────
|
|
14
20
|
// 推荐 MCP(PRD 6.6):Claude Code 通过官方 `claude mcp add --scope user`
|
|
@@ -138,6 +144,7 @@ export async function readInstalledClaudeMcps({
|
|
|
138
144
|
// 多选推荐 MCP;展示推荐标识 ★、说明与平台兼容性,
|
|
139
145
|
// 已安装项展示为「已安装」且不可重复安装(PRD 6.6 / 优化需求第 4 节)
|
|
140
146
|
async function selectMcps({ mcps, multiSelect, installedIds, writeLine }) {
|
|
147
|
+
const msg = mk(writeLine);
|
|
141
148
|
const choices = mcps.map((mcp) => ({
|
|
142
149
|
label: `${mcp.recommended ? "★ " : ""}${mcp.label}${installedIds.includes(mcp.id) ? "(已安装)" : ""}`,
|
|
143
150
|
value: mcp.id,
|
|
@@ -154,16 +161,32 @@ async function selectMcps({ mcps, multiSelect, installedIds, writeLine }) {
|
|
|
154
161
|
const fresh = [];
|
|
155
162
|
for (const id of selected) {
|
|
156
163
|
if (installedIds.includes(id)) {
|
|
157
|
-
|
|
164
|
+
msg.info(`已安装,跳过:${id}`);
|
|
158
165
|
continue;
|
|
159
166
|
}
|
|
160
167
|
fresh.push(mcps.find((mcp) => mcp.id === id));
|
|
161
168
|
}
|
|
169
|
+
if (fresh.length > 0 && typeof writeLine === "function") {
|
|
170
|
+
writeLine(`✅ 已选择 ${fresh.length} 个 MCP 服务`);
|
|
171
|
+
writeLine(renderChoiceTable({
|
|
172
|
+
columns: [
|
|
173
|
+
["index", "序号"],
|
|
174
|
+
["service", "服务"],
|
|
175
|
+
["description", "说明"],
|
|
176
|
+
],
|
|
177
|
+
rows: fresh.map((mcp, index) => ({
|
|
178
|
+
index: String(index + 1),
|
|
179
|
+
service: mcp.id,
|
|
180
|
+
description: mcp.desc || "-",
|
|
181
|
+
})),
|
|
182
|
+
}, { width: process.stdout.columns || 0 }));
|
|
183
|
+
}
|
|
162
184
|
return fresh;
|
|
163
185
|
}
|
|
164
186
|
|
|
165
187
|
// 重依赖(Playwright / Serena 等)逐项单独确认
|
|
166
188
|
async function confirmHeavyMcps(mcps, confirm, writeLine) {
|
|
189
|
+
const msg = mk(writeLine);
|
|
167
190
|
const approved = [];
|
|
168
191
|
for (const mcp of mcps) {
|
|
169
192
|
if (!mcp.heavy) {
|
|
@@ -174,7 +197,7 @@ async function confirmHeavyMcps(mcps, confirm, writeLine) {
|
|
|
174
197
|
if (ok) {
|
|
175
198
|
approved.push(mcp);
|
|
176
199
|
} else {
|
|
177
|
-
|
|
200
|
+
msg.info(`已跳过 ${mcp.id}。`);
|
|
178
201
|
}
|
|
179
202
|
}
|
|
180
203
|
return approved;
|
|
@@ -193,37 +216,38 @@ export async function installRecommendedMcps({
|
|
|
193
216
|
throw new Error("缺少交互输入");
|
|
194
217
|
}
|
|
195
218
|
const { mcps } = await loadRecommendedMcps(api);
|
|
219
|
+
const msg = mk(writeLine);
|
|
196
220
|
const installedIds = await readInstalledClaudeMcps({ claudeJsonPath, fs });
|
|
197
221
|
const fresh = await selectMcps({ mcps, multiSelect, installedIds, writeLine });
|
|
198
222
|
if (fresh.length === 0) {
|
|
199
|
-
|
|
223
|
+
msg.info("已跳过 MCP 安装。");
|
|
200
224
|
return [];
|
|
201
225
|
}
|
|
202
226
|
const approved = await confirmHeavyMcps(fresh, confirm, writeLine);
|
|
203
227
|
if (approved.length === 0) {
|
|
204
|
-
|
|
228
|
+
msg.info("已跳过 MCP 安装。");
|
|
205
229
|
return [];
|
|
206
230
|
}
|
|
207
231
|
|
|
208
|
-
|
|
232
|
+
msg.info([
|
|
209
233
|
"即将通过 Claude Code 官方命令安装以下 MCP(修改 ~/.claude.json 用户级配置):",
|
|
210
234
|
...approved.map((mcp) => ` claude ${mcp.claudeArgs.join(" ")}`),
|
|
211
235
|
].join("\n"));
|
|
212
236
|
if (!(await confirm("是否继续?"))) {
|
|
213
|
-
|
|
237
|
+
msg.info("已取消 MCP 安装。");
|
|
214
238
|
return [];
|
|
215
239
|
}
|
|
216
240
|
|
|
217
241
|
const installed = [];
|
|
218
242
|
for (const mcp of approved) {
|
|
219
|
-
|
|
243
|
+
msg.step(`正在安装 MCP:${mcp.label}...`);
|
|
220
244
|
const result = await execCommand("claude", mcp.claudeArgs);
|
|
221
245
|
if (result.ok) {
|
|
222
246
|
installed.push(mcp.id);
|
|
223
|
-
|
|
247
|
+
msg.success(`已安装 MCP:${mcp.id}`);
|
|
224
248
|
} else {
|
|
225
|
-
|
|
226
|
-
|
|
249
|
+
msg.error([
|
|
250
|
+
`安装 ${mcp.id} 失败`,
|
|
227
251
|
"",
|
|
228
252
|
`原因:${sanitizeText(result.stderr || result.error || "未知错误")}`,
|
|
229
253
|
"",
|
|
@@ -279,21 +303,22 @@ export async function installCodexMcps({
|
|
|
279
303
|
if (typeof multiSelect !== "function" || typeof confirm !== "function") {
|
|
280
304
|
throw new Error("缺少交互输入");
|
|
281
305
|
}
|
|
306
|
+
const msg = mk(writeLine);
|
|
282
307
|
const configPath = path.join(codexDir, "config.toml");
|
|
283
308
|
const { mcps } = await loadRecommendedMcps(api);
|
|
284
309
|
const installedIds = await readInstalledCodexMcps({ codexDir, fs });
|
|
285
310
|
const fresh = await selectMcps({ mcps, multiSelect, installedIds, writeLine });
|
|
286
311
|
if (fresh.length === 0) {
|
|
287
|
-
|
|
312
|
+
msg.info("已跳过 MCP 安装。");
|
|
288
313
|
return [];
|
|
289
314
|
}
|
|
290
315
|
const approved = await confirmHeavyMcps(fresh, confirm, writeLine);
|
|
291
316
|
if (approved.length === 0) {
|
|
292
|
-
|
|
317
|
+
msg.info("已跳过 MCP 安装。");
|
|
293
318
|
return [];
|
|
294
319
|
}
|
|
295
320
|
if (!(await confirm(`即将在 ${configPath} 中写入 ${approved.map((mcp) => mcp.id).join("、")} 的 MCP 配置(写入前备份)。\n是否继续?`))) {
|
|
296
|
-
|
|
321
|
+
msg.info("已取消 MCP 安装。");
|
|
297
322
|
return [];
|
|
298
323
|
}
|
|
299
324
|
|
|
@@ -304,17 +329,17 @@ export async function installCodexMcps({
|
|
|
304
329
|
content = `${content.trimEnd()}\n`;
|
|
305
330
|
const tomlError = validateBasicToml(content);
|
|
306
331
|
if (tomlError) {
|
|
307
|
-
|
|
332
|
+
msg.error(`生成的 Codex 配置 TOML 校验失败:${tomlError}\n已放弃写入,原配置保持不变。`);
|
|
308
333
|
return [];
|
|
309
334
|
}
|
|
310
335
|
const { backupDir } = await createBackup({ baseDir: codexDir, paths: [configPath], fs, now });
|
|
311
336
|
if (backupDir) {
|
|
312
|
-
|
|
337
|
+
msg.success(`已创建备份:${backupDir}`);
|
|
313
338
|
}
|
|
314
339
|
await fs.mkdir(codexDir, { recursive: true });
|
|
315
340
|
await fs.writeFile(configPath, content, "utf8");
|
|
316
341
|
for (const mcp of approved) {
|
|
317
|
-
|
|
342
|
+
msg.success(`已安装 MCP:${mcp.id}`);
|
|
318
343
|
}
|
|
319
344
|
return approved.map((mcp) => mcp.id);
|
|
320
345
|
}
|
|
@@ -407,6 +432,7 @@ export async function installRecommendedSkills({
|
|
|
407
432
|
claudeDir = resolveClaudeUserDir(),
|
|
408
433
|
fs = { readFile, writeFile, mkdir, copyFile },
|
|
409
434
|
} = {}) {
|
|
435
|
+
const msg = mk(writeLine);
|
|
410
436
|
if (typeof promptSelect !== "function" || typeof confirm !== "function") {
|
|
411
437
|
throw new Error("缺少交互输入");
|
|
412
438
|
}
|
|
@@ -434,7 +460,7 @@ export async function installRecommendedSkills({
|
|
|
434
460
|
`即将更新 ${memoryPath}(以标记区块追加/替换“${skill.label}”,不影响文件其他内容,写入前备份)。\n是否继续?`,
|
|
435
461
|
);
|
|
436
462
|
if (!approved) {
|
|
437
|
-
|
|
463
|
+
msg.info(`已跳过 ${skill.label}。`);
|
|
438
464
|
continue;
|
|
439
465
|
}
|
|
440
466
|
const current = await readFileIfExists(fs.readFile, memoryPath);
|
|
@@ -444,7 +470,7 @@ export async function installRecommendedSkills({
|
|
|
444
470
|
}
|
|
445
471
|
await fs.writeFile(memoryPath, upsertMarkedBlock(current, skill.id, skill.content), "utf8");
|
|
446
472
|
installed.push(skill.id);
|
|
447
|
-
|
|
473
|
+
msg.success(`已安装:${skill.label}`);
|
|
448
474
|
continue;
|
|
449
475
|
}
|
|
450
476
|
|
|
@@ -460,7 +486,7 @@ export async function installRecommendedSkills({
|
|
|
460
486
|
await fs.mkdir(commandsDir, { recursive: true });
|
|
461
487
|
await fs.writeFile(filePath, `${skill.content}\n`, "utf8");
|
|
462
488
|
installed.push(skill.id);
|
|
463
|
-
|
|
489
|
+
msg.success(`已安装:${skill.label}(使用 /claude360:${skill.file.replace(/\.md$/, "")} 调用)`);
|
|
464
490
|
}
|
|
465
491
|
return installed;
|
|
466
492
|
}
|
|
@@ -489,6 +515,7 @@ export async function installCodexAgents({
|
|
|
489
515
|
codexDir = resolveCodexUserDir(),
|
|
490
516
|
fs = { readFile, writeFile, mkdir, copyFile },
|
|
491
517
|
} = {}) {
|
|
518
|
+
const msg = mk(writeLine);
|
|
492
519
|
if (typeof confirm !== "function") {
|
|
493
520
|
throw new Error("缺少确认输入");
|
|
494
521
|
}
|
|
@@ -497,7 +524,7 @@ export async function installCodexAgents({
|
|
|
497
524
|
`即将更新 ${agentsPath}(以标记区块追加/替换 Claude360 协作规范,不影响文件其他内容,写入前备份)。\n是否继续?`,
|
|
498
525
|
);
|
|
499
526
|
if (!approved) {
|
|
500
|
-
|
|
527
|
+
msg.info("已跳过 Codex AGENTS 配置。");
|
|
501
528
|
return { installed: false };
|
|
502
529
|
}
|
|
503
530
|
const current = await readFileIfExists(fs.readFile, agentsPath);
|
|
@@ -506,7 +533,7 @@ export async function installCodexAgents({
|
|
|
506
533
|
await fs.copyFile(agentsPath, `${agentsPath}.claude360.bak`);
|
|
507
534
|
}
|
|
508
535
|
await fs.writeFile(agentsPath, upsertMarkedBlock(current, CODEX_AGENTS_BLOCK_ID, CODEX_AGENTS_CONTENT), "utf8");
|
|
509
|
-
|
|
536
|
+
msg.success(`已更新:${agentsPath}`);
|
|
510
537
|
return { installed: true, path: agentsPath };
|
|
511
538
|
}
|
|
512
539
|
|
package/src/menu.js
CHANGED
|
@@ -5,19 +5,21 @@
|
|
|
5
5
|
// 真实终端下由 promptMenu 自动开启彩色分区样式。
|
|
6
6
|
|
|
7
7
|
import { displayWidth } from "./banner.js";
|
|
8
|
-
import { BOLD, RESET, colorLevel,
|
|
8
|
+
import { BOLD, RESET, colorLevel, theme, toLevel } from "./colors.js";
|
|
9
9
|
|
|
10
|
-
//
|
|
10
|
+
// 分区/选择键配色:从统一语义主题取色(见 colors.js theme),不再各自硬编码 RGB。
|
|
11
11
|
function palette(level) {
|
|
12
|
+
const t = theme(level);
|
|
12
13
|
return {
|
|
13
|
-
rule:
|
|
14
|
-
section:
|
|
15
|
-
key:
|
|
16
|
-
exit:
|
|
17
|
-
desc:
|
|
14
|
+
rule: t.border, // 分隔线:青灰
|
|
15
|
+
section: t.heading, // 分区标题:亮青
|
|
16
|
+
key: t.info, // 选择键:天蓝
|
|
17
|
+
exit: t.error, // 退出键:红
|
|
18
|
+
desc: t.hint, // 功能说明:暗灰
|
|
18
19
|
};
|
|
19
20
|
}
|
|
20
21
|
const MENU_RULE_WIDTH = 46;
|
|
22
|
+
const SECTION_DIVIDER = "----------------------------------------";
|
|
21
23
|
|
|
22
24
|
export function buildFirstRunMenu() {
|
|
23
25
|
return {
|
|
@@ -176,13 +178,18 @@ export async function promptMenu({ menu, promptInput, select, writeLine = consol
|
|
|
176
178
|
// 多选交互(补充需求 6.5 / 6.6 / 8.4):数字切换勾选,A 全选,I 反选,
|
|
177
179
|
// 回车确认。返回选中的 value 数组;用户清空全部选项后回车即视为跳过。
|
|
178
180
|
export function renderMultiSelect({ message, choices, selected }) {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
const count = choices.filter((choice) => selected.has(choice.value)).length;
|
|
182
|
+
const lines = [`⚙️ ${message}`, "", `已选择:${count} 个`, ""];
|
|
183
|
+
// hint 列按 label 最大显示宽度对齐(需求示例 4),不再用固定空格
|
|
184
|
+
const labelWidth = choices.reduce((max, choice) => Math.max(max, displayWidth(choice.label)), 0);
|
|
185
|
+
choices.forEach((choice) => {
|
|
186
|
+
const mark = selected.has(choice.value) ? "●" : "○";
|
|
187
|
+
const hint = choice.hint
|
|
188
|
+
? `${" ".repeat(Math.max(2, labelWidth - displayWidth(choice.label) + 2))}${choice.hint}`
|
|
189
|
+
: "";
|
|
190
|
+
lines.push(`${mark} ${choice.label}${hint}`);
|
|
184
191
|
});
|
|
185
|
-
lines.push("", "
|
|
192
|
+
lines.push("", SECTION_DIVIDER, "", "操作:", "输入编号切换 A 全选 I 反选 Enter 确认");
|
|
186
193
|
return lines.join("\n");
|
|
187
194
|
}
|
|
188
195
|
|
package/src/messages.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// 语义消息层:把行内交互文字(成功 / 失败 / 警告 / info / 步骤 / 键值回显 /
|
|
2
|
+
// 路径 / 次要说明 / 标题)统一为「符号前缀 + 专属颜色」,让终端输出有清晰层级,
|
|
3
|
+
// 不再千篇一律。颜色全部取自 colors.js 的统一语义主题 theme()。
|
|
4
|
+
//
|
|
5
|
+
// 设计要点(与既有约定对齐):
|
|
6
|
+
// - 符号沿用全 CLI 既有标准:成功 ✓ / 失败 × / 警告 !(与 banner.formatCheckLine、
|
|
7
|
+
// ui.renderBox、diagnostics 一致),不依赖颜色表达唯一信息(无色档仍有符号)。
|
|
8
|
+
// - info / path / hint 为中性或次要文本:不加符号,仅靠颜色区分;无色档与原始
|
|
9
|
+
// 未加前缀的行字节一致,保证管道 / 测试零回归。
|
|
10
|
+
// - formatMessage 为纯函数(镜像 banner.formatCheckLine 的 (text,{color}) 签名),
|
|
11
|
+
// 便于单测;createMessenger 在其上包一层 writeLine,供各页面直接调用。
|
|
12
|
+
|
|
13
|
+
import { BOLD, RESET, theme, toLevel } from "./colors.js";
|
|
14
|
+
|
|
15
|
+
// 角色定义:符号前缀 + 主题色名(见 colors.js PALETTE/theme)+ 是否加粗。
|
|
16
|
+
// mark 为空串表示该角色不加符号(中性 / 次要文本,仅靠颜色与文字表达)。
|
|
17
|
+
const ROLES = {
|
|
18
|
+
success: { mark: "✓", color: "success", bold: false }, // 操作成功
|
|
19
|
+
error: { mark: "×", color: "error", bold: false }, // 失败 / 异常
|
|
20
|
+
warn: { mark: "!", color: "warn", bold: false }, // 警告 / 降级
|
|
21
|
+
step: { mark: "→", color: "step", bold: false }, // 进行中 / 步骤
|
|
22
|
+
info: { mark: "", color: "info", bold: false }, // 中性信息 / 说明
|
|
23
|
+
hint: { mark: "", color: "hint", bold: false }, // 次要建议 / 帮助
|
|
24
|
+
path: { mark: "", color: "path", bold: false }, // 路径 / 命令 / URL 回显
|
|
25
|
+
title: { mark: "", color: "title", bold: true }, // 页面 / 品牌标题
|
|
26
|
+
heading: { mark: "", color: "heading", bold: true }, // 章节标题
|
|
27
|
+
prompt: { mark: "?", color: "info", bold: true }, // 提问标题
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// 单行语义消息。未知 kind 回退为 info。无色档(level=0)仅返回「符号 + 文字」,
|
|
31
|
+
// 与改造前的纯文本前缀字节一致。
|
|
32
|
+
export function formatMessage(kind, text, { color = false } = {}) {
|
|
33
|
+
const role = ROLES[kind] || ROLES.info;
|
|
34
|
+
const body = String(text ?? "");
|
|
35
|
+
const prefixed = role.mark ? `${role.mark} ${body}` : body;
|
|
36
|
+
const level = toLevel(color);
|
|
37
|
+
if (!level) {
|
|
38
|
+
return prefixed;
|
|
39
|
+
}
|
|
40
|
+
const t = theme(level);
|
|
41
|
+
return `${role.bold ? BOLD : ""}${t[role.color]}${prefixed}${RESET}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 键值回显:标签弱化(灰蓝)、值强调(白色加粗),形成「次要 → 主要」的视觉落差,
|
|
45
|
+
// 取代过去用 ✓ 表示中性回显的混用。无色档退化为 `标签:值`(与原始回显行一致)。
|
|
46
|
+
export function formatResult(label, value, { color = false } = {}) {
|
|
47
|
+
const lbl = String(label ?? "");
|
|
48
|
+
const val = String(value ?? "");
|
|
49
|
+
const level = toLevel(color);
|
|
50
|
+
if (!level) {
|
|
51
|
+
return `${lbl}:${val}`;
|
|
52
|
+
}
|
|
53
|
+
const t = theme(level);
|
|
54
|
+
return `${t.path}${lbl}:${RESET}${BOLD}${t.title}${val}${RESET}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 工厂:绑定 writeLine 与色深,返回各语义方法。
|
|
58
|
+
// color 通常来自 index.js 的 fancyOutput(真实终端为 colorLevel(),测试/管道为 0),
|
|
59
|
+
// 因此测试替换 writeLine 后输出无 ANSI,断言可直接匹配纯文本前缀。
|
|
60
|
+
export function createMessenger({ writeLine = console.log, color = false } = {}) {
|
|
61
|
+
const emit = (kind) => (text) => writeLine(formatMessage(kind, text, { color }));
|
|
62
|
+
return {
|
|
63
|
+
success: emit("success"),
|
|
64
|
+
error: emit("error"),
|
|
65
|
+
warn: emit("warn"),
|
|
66
|
+
info: emit("info"),
|
|
67
|
+
step: emit("step"),
|
|
68
|
+
hint: emit("hint"),
|
|
69
|
+
path: emit("path"),
|
|
70
|
+
title: emit("title"),
|
|
71
|
+
heading: emit("heading"),
|
|
72
|
+
prompt: emit("prompt"),
|
|
73
|
+
// 键值回显:messenger.result("当前 Key", name)
|
|
74
|
+
result: (label, value) => writeLine(formatResult(label, value, { color })),
|
|
75
|
+
// 原样输出:空行、或已自带格式的多行块(renderTable / renderBox / renderHeader 结果)
|
|
76
|
+
raw: (text = "") => writeLine(text),
|
|
77
|
+
};
|
|
78
|
+
}
|