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.
@@ -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 { upsertProfileKey, validateBasicToml } from "./tool-launcher.js";
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
- writeLine(`✓ 已创建备份:${backupDir}`);
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
- writeLine("已跳过 AI 输出语言配置。");
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
- writeLine(`✓ AI 输出语言已写入:${filePath}`);
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
- writeLine(`${style.label.trim()}:${style.desc}`);
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
- writeLine("已跳过系统提示词风格配置。");
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
- writeLine(`✓ 系统提示词风格已写入:${filePath}`);
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
- if (models.length > 0) {
218
- return { models, source: "backend" };
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
- writeLine(`✓ 已创建备份:${backupDir}`);
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
- writeLine("Claude360 后端暂未提供模型列表,以下为 Claude Code 官方模型别名:");
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
- writeLine("已跳过默认模型配置。");
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
- writeLine(`✓ 默认模型已设置为 ${selected}(${settingsPath})`);
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
- writeLine([
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
- writeLine("已跳过推荐环境变量和权限配置。");
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
- writeLine(`✓ 推荐环境变量与权限配置已写入:${settingsPath}`);
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
- writeLine("Claude360 后端暂未提供 Codex 模型列表,已跳过默认模型写入。");
377
- writeLine("可在启动 Codex 后使用 /model 命令选择模型。");
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
- writeLine("已跳过默认模型配置。");
414
+ msg.info("已跳过默认模型配置。");
383
415
  return { skipped: true };
384
416
  }
385
- const configPath = path.join(codexDir, "config.toml");
386
- const current = await readFileIfExists(fs, configPath);
387
- const next = `${upsertProfileKey(current, "claude360", "model", selected).trimEnd()}\n`;
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
- writeLine( 生成的 Codex 配置 TOML 校验失败:${tomlError}\n已放弃写入,原配置保持不变。`);
424
+ msg.error(`生成的 Codex 配置 TOML 校验失败:${tomlError}\n已放弃写入,原配置保持不变。`);
391
425
  return { skipped: true };
392
426
  }
393
- const { backupDir } = await createBackup({ baseDir: codexDir, paths: [configPath], fs, now });
427
+ const { backupDir } = await createBackup({ baseDir: codexDir, paths: [profileConfigPath], fs, now });
394
428
  if (backupDir) {
395
- writeLine(`✓ 已创建备份:${backupDir}`);
429
+ msg.success(`已创建备份:${backupDir}`);
396
430
  }
397
431
  await fs.mkdir(codexDir, { recursive: true });
398
- await fs.writeFile(configPath, next, "utf8");
399
- writeLine(`✓ Codex 默认模型已设置为 ${selected}(${configPath})`);
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(`${title}\n`);
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
- writeLine(`第 ${index} 步:${step.title}`);
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
- writeLine(`已在「${step.title}」中止初始化。`);
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(`× ${step.title}失败:${safeErrorMessage(error)}`);
22
- writeLine("初始化已中止,可稍后在菜单中重试。");
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
- writeLine(`! ${step.title}失败(已跳过,不影响后续步骤):${safeErrorMessage(error)}`);
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
- writeLine(`已安装,跳过:${id}`);
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
- writeLine(`已跳过 ${mcp.id}。`);
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
- writeLine("已跳过 MCP 安装。");
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
- writeLine("已跳过 MCP 安装。");
228
+ msg.info("已跳过 MCP 安装。");
205
229
  return [];
206
230
  }
207
231
 
208
- writeLine([
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
- writeLine("已取消 MCP 安装。");
237
+ msg.info("已取消 MCP 安装。");
214
238
  return [];
215
239
  }
216
240
 
217
241
  const installed = [];
218
242
  for (const mcp of approved) {
219
- writeLine(`正在安装 MCP:${mcp.label}...`);
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
- writeLine(`✓ 已安装 MCP:${mcp.id}`);
247
+ msg.success(`已安装 MCP:${mcp.id}`);
224
248
  } else {
225
- writeLine([
226
- 安装 ${mcp.id} 失败`,
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
- writeLine("已跳过 MCP 安装。");
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
- writeLine("已跳过 MCP 安装。");
317
+ msg.info("已跳过 MCP 安装。");
293
318
  return [];
294
319
  }
295
320
  if (!(await confirm(`即将在 ${configPath} 中写入 ${approved.map((mcp) => mcp.id).join("、")} 的 MCP 配置(写入前备份)。\n是否继续?`))) {
296
- writeLine("已取消 MCP 安装。");
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
- writeLine( 生成的 Codex 配置 TOML 校验失败:${tomlError}\n已放弃写入,原配置保持不变。`);
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
- writeLine(`✓ 已创建备份:${backupDir}`);
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
- writeLine(`✓ 已安装 MCP:${mcp.id}`);
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
- writeLine(`已跳过 ${skill.label}。`);
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
- writeLine(`✓ 已安装:${skill.label}`);
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
- writeLine(`✓ 已安装:${skill.label}(使用 /claude360:${skill.file.replace(/\.md$/, "")} 调用)`);
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
- writeLine("已跳过 Codex AGENTS 配置。");
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
- writeLine(`✓ 已更新:${agentsPath}`);
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, fg, toLevel } from "./colors.js";
8
+ import { BOLD, RESET, colorLevel, 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
- rule: fg(71, 85, 105, level), // 分隔线:青灰
14
- section: fg(34, 211, 238, level), // 分区标题:亮青
15
- key: fg(125, 211, 252, level), // 选择键:天蓝
16
- exit: fg(248, 113, 113, level), // 退出键:红
17
- desc: fg(100, 116, 139, level), // 功能说明:暗灰
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 lines = [message, ""];
180
- choices.forEach((choice, index) => {
181
- const mark = selected.has(choice.value) ? "[x]" : "[ ]";
182
- const hint = choice.hint ? ` ${choice.hint}` : "";
183
- lines.push(` ${index + 1}. ${mark} ${choice.label}${hint}`);
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("", " A. 全选 I. 反选 Enter. 确认");
192
+ lines.push("", SECTION_DIVIDER, "", "操作:", "输入编号切换 A 全选 I 反选 Enter 确认");
186
193
  return lines.join("\n");
187
194
  }
188
195
 
@@ -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
+ }