@laivc/laicode 0.1.0 → 0.2.1

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/bin/laicode.js CHANGED
@@ -6,8 +6,17 @@ const os = require("os")
6
6
  const path = require("path")
7
7
  const readline = require("readline")
8
8
  const { spawn } = require("child_process")
9
-
10
- const VERSION = "0.1.0"
9
+ const {
10
+ applyInitPlan,
11
+ applyToolActionPlan,
12
+ buildInitPlan,
13
+ buildToolActionPlan,
14
+ detectTools,
15
+ rollbackLatest,
16
+ safePlan,
17
+ } = require("../lib/adapters")
18
+
19
+ const VERSION = "0.2.1"
11
20
  const DEFAULT_SITE_BASE = "https://lai.vc"
12
21
  const DEFAULT_API_BASE = "https://api.lai.vc/v1"
13
22
  const STATE_DIR = process.env.LAICODE_HOME || path.join(os.homedir(), ".laicode")
@@ -15,6 +24,9 @@ const CONFIG_FILE = path.join(STATE_DIR, "config.json")
15
24
  const DASHBOARD_CACHE_TTL_MS = 60_000
16
25
 
17
26
  const COMMAND_SPECS = [
27
+ { name: "tools", usage: "laicode tools [scan|install|uninstall|reinstall|configure] [--tool codex] [--apply] [--json]", summary: "AI 工具体检、安装、配置和维护", flags: ["scan", "install", "uninstall", "reinstall", "configure", "--tool", "--apply", "--json"] },
28
+ { name: "init", usage: "laicode init [--tool codex] [--apply] [--json]", summary: "预览或应用一键接入计划", flags: ["--tool", "--model", "--apply", "--json"] },
29
+ { name: "rollback", usage: "laicode rollback [--json]", summary: "回滚最近一次接入写入", flags: ["--json"] },
18
30
  { name: "menu", usage: "laicode menu", summary: "打开交互控制台", flags: ["--fancy"] },
19
31
  { name: "brand", usage: "laicode brand", summary: "预览终端字标和品牌色板", flags: ["--color", "--plain"] },
20
32
  { name: "status", usage: "laicode status [--refresh] [--json]", summary: "查看实时驾驶舱状态", flags: ["--refresh", "--json"] },
@@ -39,6 +51,7 @@ const ENV_VARS = [
39
51
  ["LAICODE_SITE_URL", "覆盖主站地址,默认 https://lai.vc"],
40
52
  ["LAICODE_PLAIN", "设为 1 时禁用终端样式"],
41
53
  ["LAICODE_COLOR", "设为 1 时忽略 NO_COLOR,强制彩色输出"],
54
+ ["LAICODE_NO_SPINNER", "设为 1 时禁用 loading 动画"],
42
55
  ]
43
56
 
44
57
  function buildHelp() {
@@ -128,6 +141,7 @@ const VALUE_FLAGS = new Set([
128
141
  "name",
129
142
  "id",
130
143
  "shell",
144
+ "tool",
131
145
  ])
132
146
 
133
147
  function parseArgs(argv) {
@@ -225,7 +239,111 @@ function compareVersions(a, b) {
225
239
  return 0
226
240
  }
227
241
 
242
+ let activeSpinnerCleanup = null
243
+
244
+ function spinnerEnabled(opts = {}) {
245
+ if (opts.enabled === false) return false
246
+ if (!process.stdout.isTTY) return false
247
+ if (process.env.LAICODE_NO_SPINNER === "1") return false
248
+ if (process.env.CI === "1" || process.env.CI === "true") return false
249
+ if (process.env.TERM === "dumb") return false
250
+ return true
251
+ }
252
+
253
+ function createSpinner(label, opts = {}) {
254
+ const enabled = spinnerEnabled(opts)
255
+ const frames = plainMode() ? ["-", "\\", "|", "/"] : ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
256
+ const intervalMs = Number(opts.intervalMs || 80)
257
+ const delayMs = Number(opts.delayMs ?? 120)
258
+ let text = label
259
+ let frame = 0
260
+ let active = false
261
+ let visible = false
262
+ let interval = null
263
+ let delay = null
264
+
265
+ const clear = () => {
266
+ readline.clearLine(process.stdout, 0)
267
+ readline.cursorTo(process.stdout, 0)
268
+ }
269
+
270
+ const paint = () => {
271
+ if (!active || !enabled) return
272
+ clear()
273
+ const glyph = frames[frame % frames.length]
274
+ frame += 1
275
+ process.stdout.write(`${ui.accent(glyph)} ${ui.dim(text)}`)
276
+ }
277
+
278
+ const show = () => {
279
+ if (!active || visible || !enabled) return
280
+ visible = true
281
+ activeSpinnerCleanup = () => spinner.stop()
282
+ paint()
283
+ interval = setInterval(paint, intervalMs)
284
+ if (interval.unref) interval.unref()
285
+ }
286
+
287
+ const spinner = {
288
+ get enabled() {
289
+ return enabled
290
+ },
291
+ start() {
292
+ if (!enabled || active) return this
293
+ active = true
294
+ if (delayMs > 0) {
295
+ delay = setTimeout(show, delayMs)
296
+ if (delay.unref) delay.unref()
297
+ } else {
298
+ show()
299
+ }
300
+ return this
301
+ },
302
+ update(nextText) {
303
+ if (nextText) text = nextText
304
+ if (visible) paint()
305
+ return this
306
+ },
307
+ stop(finalText) {
308
+ if (!enabled || !active) return this
309
+ active = false
310
+ if (delay) clearTimeout(delay)
311
+ if (interval) clearInterval(interval)
312
+ delay = null
313
+ interval = null
314
+ if (activeSpinnerCleanup) activeSpinnerCleanup = null
315
+ if (visible) clear()
316
+ visible = false
317
+ if (finalText) process.stdout.write(`${finalText}\n`)
318
+ return this
319
+ },
320
+ succeed(finalText) {
321
+ return this.stop(finalText ? `${ui.ok("✓")} ${finalText}` : "")
322
+ },
323
+ fail(finalText) {
324
+ return this.stop(finalText ? `${ui.err("×")} ${finalText}` : "")
325
+ },
326
+ }
327
+
328
+ return spinner
329
+ }
330
+
331
+ async function withSpinner(label, work, opts = {}) {
332
+ const spinner = createSpinner(label, opts).start()
333
+ try {
334
+ const result = await work(spinner)
335
+ if (opts.successText) spinner.succeed(opts.successText)
336
+ else spinner.stop()
337
+ return result
338
+ } catch (err) {
339
+ if (opts.failureText) spinner.fail(opts.failureText)
340
+ else spinner.stop()
341
+ throw err
342
+ }
343
+ }
344
+
228
345
  function die(message, code = 1) {
346
+ if (activeSpinnerCleanup) activeSpinnerCleanup()
229
347
  console.error(message)
230
348
  process.exit(code)
231
349
  }
@@ -467,6 +585,229 @@ function cmdCommands(args = {}) {
467
585
  )
468
586
  }
469
587
 
588
+ function supportLabel(value) {
589
+ if (value === "auto") return ui.ok("可自动接入")
590
+ if (value === "auto-new") return ui.ok("可创建配置")
591
+ if (value === "manual-merge") return ui.warn("需合并")
592
+ if (value === "blocked") return ui.warn("待网关")
593
+ return ui.dim("指引")
594
+ }
595
+
596
+ async function cmdTools(args = {}) {
597
+ const action = args._[1] || "scan"
598
+ if (action === "configure") {
599
+ return await cmdInit({ ...args, _: ["init"], tool: args.tool || args._[2] || "codex" })
600
+ }
601
+ if (["install", "uninstall", "reinstall"].includes(action)) {
602
+ return await cmdToolAction(action, args)
603
+ }
604
+ if (action !== "scan" && action !== "list") {
605
+ die("Usage: laicode tools [scan|install|uninstall|reinstall|configure] [--tool codex]")
606
+ }
607
+
608
+ const tools = await withSpinner("扫描本机 AI 工具", () => Promise.resolve(detectTools()), {
609
+ enabled: !args.json,
610
+ delayMs: 80,
611
+ })
612
+
613
+ if (args.json) {
614
+ console.log(JSON.stringify({ tools }, null, 2))
615
+ return
616
+ }
617
+
618
+ printHero("AI 工具体检")
619
+ printTable(
620
+ "接入状态",
621
+ [
622
+ { key: "name", label: "工具", width: 12, max: 18 },
623
+ { key: "detected", label: "检测", width: 6 },
624
+ { key: "support", label: "能力", width: 12, max: 18 },
625
+ { key: "status", label: "状态", width: 22, max: 36 },
626
+ { key: "next", label: "下一步", width: 28, max: 44 },
627
+ ],
628
+ tools.map((tool) => ({
629
+ name: tool.name,
630
+ detected: tool.detected ? ui.ok("发现") : ui.dim("未见"),
631
+ support: supportLabel(tool.support),
632
+ status: tool.status,
633
+ next: tool.next,
634
+ }))
635
+ )
636
+ }
637
+
638
+ function printToolActionPlan(plan) {
639
+ printHero("AI 工具维护")
640
+ printPanel("动作", [
641
+ `工具 ${plan.tool}`,
642
+ `动作 ${plan.action}`,
643
+ `模式 ${plan.mode}`,
644
+ `状态 ${plan.ok ? "可执行" : "不可执行"}`,
645
+ `说明 ${plan.summary || plan.reason || "-"}`,
646
+ ])
647
+
648
+ if (plan.commands?.length) {
649
+ console.log("")
650
+ printPanel("将执行的命令", plan.commands.map((command) => command.display))
651
+ }
652
+ if (plan.warnings?.length) {
653
+ console.log("")
654
+ printPanel("注意", plan.warnings)
655
+ }
656
+ if (plan.postSteps?.length) {
657
+ console.log("")
658
+ printPanel("后续", plan.postSteps)
659
+ }
660
+ if (!plan.ok && plan.reason) console.log(ui.warn(plan.reason))
661
+ else console.log(ui.dim(`执行命令: laicode tools ${plan.action} --tool ${plan.tool} --apply`))
662
+ }
663
+
664
+ async function cmdToolAction(action, args = {}) {
665
+ const tool = args.tool || args._[2] || "codex"
666
+ const apply = Boolean(args.apply)
667
+ const plan = buildToolActionPlan(tool, action, { env: process.env })
668
+
669
+ if (args.json && !apply) {
670
+ console.log(JSON.stringify({ apply, plan }, null, 2))
671
+ return
672
+ }
673
+
674
+ if (!args.json) printToolActionPlan(plan)
675
+ if (!apply) return
676
+ if (!plan.ok) die(plan.reason || "当前动作不可执行。")
677
+
678
+ const result = await withSpinner(
679
+ `执行 ${plan.tool} ${plan.action}`,
680
+ () => applyToolActionPlan(plan, { stdio: args.json ? "pipe" : "inherit" }),
681
+ {
682
+ enabled: !args.json,
683
+ delayMs: 80,
684
+ }
685
+ )
686
+ if (args.json) {
687
+ console.log(JSON.stringify({ apply, plan, result }, null, 2))
688
+ return
689
+ }
690
+ console.log("")
691
+ printPanel("已完成", [
692
+ `工具 ${result.tool}`,
693
+ `动作 ${result.action}`,
694
+ `命令数 ${result.commands.length}`,
695
+ ...(result.postSteps || []),
696
+ ])
697
+ }
698
+
699
+ function printInitPlan(plan, safe) {
700
+ printHero(plan.ok ? "接入计划" : "接入指引")
701
+ printPanel("目标", [
702
+ `工具 ${plan.tool}`,
703
+ `模式 ${plan.mode}`,
704
+ `模型 ${plan.model || "-"}`,
705
+ `网关 ${plan.apiBaseUrl || apiBase({})}`,
706
+ `说明 ${plan.summary || plan.reason || "-"}`,
707
+ ])
708
+
709
+ if (safe.operations?.length) {
710
+ console.log("")
711
+ printTable(
712
+ "将写入的文件",
713
+ [
714
+ { key: "action", label: "动作", width: 8 },
715
+ { key: "path", label: "路径", width: 42, max: 72 },
716
+ { key: "exists", label: "现状", width: 8 },
717
+ ],
718
+ safe.operations.map((op) => ({
719
+ action: op.action,
720
+ path: op.path,
721
+ exists: op.exists ? ui.warn("覆盖") : ui.ok("新建"),
722
+ }))
723
+ )
724
+ console.log("")
725
+ for (const op of safe.operations) {
726
+ console.log(ui.bold(op.description || op.path))
727
+ console.log(ui.dim(op.path))
728
+ console.log("```")
729
+ console.log(op.preview.trimEnd())
730
+ console.log("```")
731
+ }
732
+ console.log("")
733
+ console.log(ui.dim("应用命令: laicode init --tool " + plan.tool + " --apply"))
734
+ if (plan.runCommand) console.log(ui.dim("使用命令: " + plan.runCommand))
735
+ return
736
+ }
737
+
738
+ if (plan.instructions?.length) {
739
+ printPanel("手动接入步骤", plan.instructions.map((item, index) => `${index + 1}. ${item}`))
740
+ }
741
+ }
742
+
743
+ async function cmdInit(args = {}) {
744
+ let cfg = await readConfig()
745
+ const tool = args.tool || args._[1] || "codex"
746
+ const apply = Boolean(args.apply)
747
+ const model = args.model || cfg.defaultModel || "gpt-5.5"
748
+
749
+ let key = cfg.apiKey
750
+ if (apply && !key && cfg.accessToken) {
751
+ key = await withSpinner("准备 API Key", () => ensureApiKey(cfg), { enabled: !args.json, delayMs: 80 })
752
+ cfg = await readConfig()
753
+ }
754
+ if (apply && !key) die("未找到 API Key。请先运行 `laicode login` 或 `laicode config set api-key sk-...`。")
755
+
756
+ const plan = buildInitPlan(tool, {
757
+ apiBaseUrl: apiBase(cfg),
758
+ apiKey: apply ? key : key || "sk-preview-only",
759
+ commandName: "laicode",
760
+ env: process.env,
761
+ model,
762
+ })
763
+ const safe = safePlan(plan, [key, cfg.apiKey].filter(Boolean))
764
+
765
+ if (args.json && !apply) {
766
+ console.log(JSON.stringify({ apply, plan: safe }, null, 2))
767
+ return
768
+ }
769
+
770
+ if (!args.json) printInitPlan(plan, safe)
771
+ if (!apply) return
772
+ if (!plan.ok) die(plan.reason || "当前工具暂不能自动接入。")
773
+
774
+ const manifest = await withSpinner("写入接入配置并创建备份", () => applyInitPlan(plan, { stateDir: STATE_DIR }), {
775
+ enabled: !args.json,
776
+ delayMs: 80,
777
+ })
778
+ if (args.json) {
779
+ console.log(JSON.stringify({ apply, plan: safe, result: manifest }, null, 2))
780
+ return
781
+ }
782
+ console.log("")
783
+ printPanel("已应用", [
784
+ `工具 ${manifest.tool}`,
785
+ `备份 ${manifest.id}`,
786
+ `文件 ${manifest.operations.map((op) => op.path).join(", ")}`,
787
+ plan.runCommand ? `使用 ${plan.runCommand}` : "使用 打开目标工具验证",
788
+ ])
789
+ }
790
+
791
+ async function cmdRollback(args = {}) {
792
+ const manifest = await withSpinner("查找最近一次备份", () => rollbackLatest({ stateDir: STATE_DIR }), {
793
+ enabled: !args.json,
794
+ delayMs: 80,
795
+ })
796
+ if (args.json) {
797
+ console.log(JSON.stringify({ ok: Boolean(manifest), rollback: manifest || null }, null, 2))
798
+ return
799
+ }
800
+ if (!manifest) {
801
+ console.log("没有可回滚的 Laicode 接入备份。")
802
+ return
803
+ }
804
+ printPanel("已回滚", [
805
+ `工具 ${manifest.tool}`,
806
+ `备份 ${manifest.id}`,
807
+ `文件 ${manifest.operations.map((op) => op.path).join(", ")}`,
808
+ ])
809
+ }
810
+
470
811
  function printHero(subtitle = "Lai.vc 中转控制台") {
471
812
  console.clear()
472
813
  printBrandMark()
@@ -1100,10 +1441,15 @@ function statusPayload(cfg, snapshot) {
1100
1441
 
1101
1442
  async function cmdStatus(args = {}) {
1102
1443
  const cfg = await readConfig()
1103
- const snapshot = await dashboardSnapshot(cfg, {
1104
- refresh: args.refresh,
1105
- ttlMs: args.refresh ? 0 : DASHBOARD_CACHE_TTL_MS,
1106
- })
1444
+ const snapshot = await withSpinner(
1445
+ args.refresh ? "刷新驾驶舱快照" : "同步驾驶舱快照",
1446
+ () =>
1447
+ dashboardSnapshot(cfg, {
1448
+ refresh: args.refresh,
1449
+ ttlMs: args.refresh ? 0 : DASHBOARD_CACHE_TTL_MS,
1450
+ }),
1451
+ { enabled: !args.json }
1452
+ )
1107
1453
  const next = await readConfig()
1108
1454
 
1109
1455
  if (args.json) {
@@ -1141,21 +1487,34 @@ async function chatCompletion(cfg, messages, opts = {}) {
1141
1487
  const started = Date.now()
1142
1488
  let firstTokenAt = 0
1143
1489
  let content = ""
1144
- const res = await fetch(`${apiBase(cfg)}/chat/completions`, {
1145
- method: "POST",
1146
- headers: {
1147
- "Content-Type": "application/json",
1148
- Authorization: `Bearer ${key}`,
1149
- },
1150
- body: JSON.stringify({
1151
- model,
1152
- messages,
1153
- stream: opts.stream !== false,
1154
- temperature: opts.temperature == null ? 0.2 : Number(opts.temperature),
1155
- }),
1156
- })
1490
+ let prefixWritten = false
1491
+ const loading =
1492
+ opts.print !== false && opts.loadingText
1493
+ ? createSpinner(opts.loadingText, { delayMs: opts.loadingDelayMs ?? 80 }).start()
1494
+ : null
1495
+
1496
+ let res
1497
+ try {
1498
+ res = await fetch(`${apiBase(cfg)}/chat/completions`, {
1499
+ method: "POST",
1500
+ headers: {
1501
+ "Content-Type": "application/json",
1502
+ Authorization: `Bearer ${key}`,
1503
+ },
1504
+ body: JSON.stringify({
1505
+ model,
1506
+ messages,
1507
+ stream: opts.stream !== false,
1508
+ temperature: opts.temperature == null ? 0.2 : Number(opts.temperature),
1509
+ }),
1510
+ })
1511
+ } catch (err) {
1512
+ if (loading) loading.stop()
1513
+ throw err
1514
+ }
1157
1515
 
1158
1516
  if (!res.ok) {
1517
+ if (loading) loading.stop()
1159
1518
  const data = await readJsonResponse(res)
1160
1519
  const msg = data.error?.message || data.error || data.message || `${res.status} ${res.statusText}`
1161
1520
  const err = new Error(msg)
@@ -1166,6 +1525,7 @@ async function chatCompletion(cfg, messages, opts = {}) {
1166
1525
 
1167
1526
  if (opts.stream === false) {
1168
1527
  const data = await readJsonResponse(res)
1528
+ if (loading) loading.stop()
1169
1529
  content = data.choices?.[0]?.message?.content || ""
1170
1530
  return { content, firstTokenMs: Date.now() - started, totalMs: Date.now() - started, status: res.status }
1171
1531
  }
@@ -1191,11 +1551,19 @@ async function chatCompletion(cfg, messages, opts = {}) {
1191
1551
  if (delta) {
1192
1552
  if (!firstTokenAt) firstTokenAt = Date.now()
1193
1553
  content += delta
1194
- if (opts.print !== false) process.stdout.write(delta)
1554
+ if (opts.print !== false) {
1555
+ if (loading) loading.stop()
1556
+ if (opts.prefixOnFirstToken && !prefixWritten) {
1557
+ process.stdout.write(opts.prefixOnFirstToken)
1558
+ prefixWritten = true
1559
+ }
1560
+ process.stdout.write(delta)
1561
+ }
1195
1562
  }
1196
1563
  }
1197
1564
  }
1198
1565
 
1566
+ if (loading) loading.stop()
1199
1567
  if (opts.print !== false) process.stdout.write("\n")
1200
1568
  return {
1201
1569
  content,
@@ -1215,10 +1583,15 @@ async function cmdLogin(args) {
1215
1583
  platform: platformName(),
1216
1584
  scopes: ["account:read", "keys:read", "keys:create"],
1217
1585
  }
1218
- const data = await siteJson({ siteBase: base }, "/api/laicode/device/start", {
1219
- method: "POST",
1220
- body: JSON.stringify(body),
1221
- })
1586
+ const data = await withSpinner(
1587
+ "创建设备授权码",
1588
+ () =>
1589
+ siteJson({ siteBase: base }, "/api/laicode/device/start", {
1590
+ method: "POST",
1591
+ body: JSON.stringify(body),
1592
+ }),
1593
+ { delayMs: 80 }
1594
+ )
1222
1595
 
1223
1596
  console.log("打开以下链接授权 LaiCode:")
1224
1597
  console.log(data.verificationUriComplete || data.verificationUri)
@@ -1233,23 +1606,29 @@ async function cmdLogin(args) {
1233
1606
 
1234
1607
  let interval = Number(data.interval || 2)
1235
1608
  const deadline = Date.now() + Number(data.expiresIn || 600) * 1000
1609
+ const pollSpinner = createSpinner("等待浏览器授权", { delayMs: 0 }).start()
1610
+ const fallbackDots = !pollSpinner.enabled
1236
1611
  while (Date.now() < deadline) {
1237
1612
  await sleep(interval * 1000)
1613
+ const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000))
1614
+ pollSpinner.update(`等待浏览器授权 · 剩余 ${remaining}s`)
1238
1615
  const poll = await siteJson({ siteBase: base }, "/api/laicode/device/poll", {
1239
1616
  method: "POST",
1240
1617
  body: JSON.stringify({ deviceCode: data.deviceCode }),
1241
1618
  })
1242
1619
  if (poll.status === "pending") {
1243
- process.stdout.write(".")
1620
+ if (fallbackDots) process.stdout.write(".")
1244
1621
  interval = Number(poll.interval || interval)
1245
1622
  continue
1246
1623
  }
1247
1624
  if (poll.status === "slow_down") {
1248
1625
  interval = Math.max(interval + 1, Number(poll.interval || interval))
1249
- process.stdout.write(" slow_down ")
1626
+ pollSpinner.update(`等待浏览器授权 · 降低轮询频率 ${interval}s`)
1627
+ if (fallbackDots) process.stdout.write(" slow_down ")
1250
1628
  continue
1251
1629
  }
1252
- process.stdout.write("\n")
1630
+ if (fallbackDots) process.stdout.write("\n")
1631
+ else pollSpinner.stop()
1253
1632
  if (poll.status === "denied") die("授权已被拒绝。")
1254
1633
  if (poll.status === "expired") die("授权已过期,请重新运行 `laicode login`。")
1255
1634
  if (poll.status === "authorized") {
@@ -1264,7 +1643,7 @@ async function cmdLogin(args) {
1264
1643
  })
1265
1644
  console.log(`已授权: ${poll.user?.email || poll.user?.name || "unknown"}`)
1266
1645
  if (!args["no-key"]) {
1267
- const key = await ensureApiKey(next, { tier: args.tier })
1646
+ const key = await withSpinner("准备 API Key", () => ensureApiKey(next, { tier: args.tier }), { delayMs: 80 })
1268
1647
  next = await readConfig()
1269
1648
  console.log(`API Key 就绪: ${maskSecret(key)} (${next.apiKeyName || "Laicode key"})`)
1270
1649
  }
@@ -1272,12 +1651,14 @@ async function cmdLogin(args) {
1272
1651
  }
1273
1652
  die(`Unexpected authorization status: ${poll.status}`)
1274
1653
  }
1654
+ pollSpinner.stop()
1655
+ if (fallbackDots) process.stdout.write("\n")
1275
1656
  die("授权等待超时,请重新运行 `laicode login`。")
1276
1657
  }
1277
1658
 
1278
1659
  async function cmdModels(args) {
1279
1660
  const cfg = await readConfig()
1280
- const data = await getModels(cfg)
1661
+ const data = await withSpinner("拉取模型雷达", () => getModels(cfg), { enabled: !args.json, delayMs: 80 })
1281
1662
  let models = data.models || []
1282
1663
  if (args.online) models = models.filter(modelOnline)
1283
1664
  if (args.category) models = models.filter((m) => m.category === args.category)
@@ -1333,9 +1714,10 @@ async function cmdModels(args) {
1333
1714
  async function cmdKeys(args) {
1334
1715
  const action = args._[1] || "list"
1335
1716
  const cfg = await readConfig()
1717
+ if (!cfg.accessToken) die("Not logged in. Run `laicode login` first.")
1336
1718
  if (action === "list") {
1337
- const keys = await listKeys(cfg)
1338
1719
  const reveal = await confirmRevealKey(args)
1720
+ const keys = await withSpinner("读取 API Key 列表", () => listKeys(cfg), { enabled: !args.json, delayMs: 80 })
1339
1721
  if (args.json) {
1340
1722
  console.log(JSON.stringify({ keys: keys.map((k) => safeKeyRecord(k, reveal)), reveal }, null, 2))
1341
1723
  return
@@ -1367,10 +1749,15 @@ async function cmdKeys(args) {
1367
1749
  const body = { name }
1368
1750
  if (args.tier) body.tier = args.tier
1369
1751
  const reveal = await confirmRevealKey(args)
1370
- const data = await authJson(cfg, "/api/laicode/keys", {
1371
- method: "POST",
1372
- body: JSON.stringify(body),
1373
- })
1752
+ const data = await withSpinner(
1753
+ "创建 API Key",
1754
+ () =>
1755
+ authJson(cfg, "/api/laicode/keys", {
1756
+ method: "POST",
1757
+ body: JSON.stringify(body),
1758
+ }),
1759
+ { enabled: !args.json, delayMs: 80 }
1760
+ )
1374
1761
  const key = data.key
1375
1762
  await patchConfig({
1376
1763
  apiKey: key.key,
@@ -1391,7 +1778,11 @@ async function cmdKeys(args) {
1391
1778
  if (action === "delete") {
1392
1779
  const id = args._[2] || args.id
1393
1780
  if (!id) die("Usage: laicode keys delete <id>")
1394
- await authJson(cfg, `/api/laicode/keys/${encodeURIComponent(id)}`, { method: "DELETE" })
1781
+ await withSpinner(
1782
+ `删除 API Key ${id}`,
1783
+ () => authJson(cfg, `/api/laicode/keys/${encodeURIComponent(id)}`, { method: "DELETE" }),
1784
+ { enabled: !args.json, delayMs: 80 }
1785
+ )
1395
1786
  const patch = {}
1396
1787
  if (String(cfg.apiKeyId) === String(id)) {
1397
1788
  patch.apiKey = undefined
@@ -1519,16 +1910,29 @@ async function cmdConfig(args) {
1519
1910
  die("Usage: laicode config [list|get|set|unset]")
1520
1911
  }
1521
1912
 
1913
+ async function cmdCredential(args) {
1914
+ const action = args._[1]
1915
+ if (action !== "api-key") die("Usage: laicode credential api-key")
1916
+ let cfg = await readConfig()
1917
+ let key = cfg.apiKey
1918
+ if (!key && cfg.accessToken) {
1919
+ key = await ensureApiKey(cfg)
1920
+ cfg = await readConfig()
1921
+ }
1922
+ if (!key) die("No API key available. Run `laicode login` first.")
1923
+ process.stdout.write(`${key}\n`)
1924
+ }
1925
+
1522
1926
  async function cmdChat(args) {
1523
1927
  let cfg = await readConfig()
1524
1928
  if (!cfg.apiKey && cfg.accessToken) {
1525
- await ensureApiKey(cfg)
1929
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { delayMs: 80 })
1526
1930
  cfg = await readConfig()
1527
1931
  }
1528
1932
 
1529
1933
  let model = args.model || cfg.defaultModel
1530
1934
  if (!model && cfg.accessToken) {
1531
- const data = await getModels(cfg)
1935
+ const data = await withSpinner("选择默认在线模型", () => getModels(cfg), { delayMs: 80 })
1532
1936
  model = pickDefaultModel(data.models)
1533
1937
  }
1534
1938
  if (!model) die("未选择模型。请使用 `laicode chat --model <model>`。")
@@ -1536,7 +1940,11 @@ async function cmdChat(args) {
1536
1940
  const message = args.message || args.m || args._.slice(1).join(" ")
1537
1941
  if (message) {
1538
1942
  console.log(`model: ${model}`)
1539
- await chatCompletion(cfg, [{ role: "user", content: message }], { model, stream: true })
1943
+ await chatCompletion(cfg, [{ role: "user", content: message }], {
1944
+ model,
1945
+ stream: true,
1946
+ loadingText: "等待模型响应",
1947
+ })
1540
1948
  await patchConfig({ defaultModel: model })
1541
1949
  return
1542
1950
  }
@@ -1548,8 +1956,12 @@ async function cmdChat(args) {
1548
1956
  const input = await new Promise((resolve) => rl.question("> ", resolve))
1549
1957
  if (!input || input.trim() === "/exit" || input.trim() === "/quit") break
1550
1958
  messages.push({ role: "user", content: input })
1551
- process.stdout.write("assistant: ")
1552
- const res = await chatCompletion(cfg, messages, { model, stream: true })
1959
+ const res = await chatCompletion(cfg, messages, {
1960
+ model,
1961
+ stream: true,
1962
+ loadingText: "assistant 思考中",
1963
+ prefixOnFirstToken: "assistant: ",
1964
+ })
1553
1965
  messages.push({ role: "assistant", content: res.content })
1554
1966
  }
1555
1967
  rl.close()
@@ -1559,12 +1971,12 @@ async function cmdChat(args) {
1559
1971
  async function cmdBench(args) {
1560
1972
  let cfg = await readConfig()
1561
1973
  if (!cfg.apiKey && cfg.accessToken) {
1562
- await ensureApiKey(cfg)
1974
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { enabled: !args.json, delayMs: 80 })
1563
1975
  cfg = await readConfig()
1564
1976
  }
1565
1977
  let model = args.model || cfg.defaultModel
1566
1978
  if (!model && cfg.accessToken) {
1567
- const data = await getModels(cfg)
1979
+ const data = await withSpinner("选择默认在线模型", () => getModels(cfg), { enabled: !args.json, delayMs: 80 })
1568
1980
  model = pickDefaultModel(data.models)
1569
1981
  }
1570
1982
  if (!model) die("未选择模型。请使用 `laicode bench --model <model>`。")
@@ -1577,10 +1989,15 @@ async function cmdBench(args) {
1577
1989
  for (let i = 0; i < count; i++) {
1578
1990
  const label = `${i + 1}/${count}`
1579
1991
  try {
1580
- const res = await chatCompletion(
1581
- cfg,
1582
- [{ role: "user", content: prompt }],
1583
- { model, stream: true, print: false, temperature: 0 }
1992
+ const res = await withSpinner(
1993
+ `压测 ${label} · 等待首 token`,
1994
+ () =>
1995
+ chatCompletion(
1996
+ cfg,
1997
+ [{ role: "user", content: prompt }],
1998
+ { model, stream: true, print: false, temperature: 0 }
1999
+ ),
2000
+ { enabled: !args.json, delayMs: 80 }
1584
2001
  )
1585
2002
  rows.push({ index: i + 1, ok: true, firstTokenMs: res.firstTokenMs, totalMs: res.totalMs })
1586
2003
  if (!args.json) console.log(`${ui.ok("●")} ${label} 成功 首 token=${res.firstTokenMs}ms 总耗时=${res.totalMs}ms`)
@@ -1680,7 +2097,11 @@ async function cmdDoctor(args) {
1680
2097
  record("config-file", "配置文件", true, fs.existsSync(CONFIG_FILE) ? CONFIG_FILE : "尚未创建")
1681
2098
 
1682
2099
  try {
1683
- const publicCatalog = await siteJson(cfg, "/api/catalog", { method: "GET", timeoutMs: 10000 })
2100
+ const publicCatalog = await withSpinner(
2101
+ "检查公开模型目录",
2102
+ () => siteJson(cfg, "/api/catalog", { method: "GET", timeoutMs: 10000 }),
2103
+ { enabled: !args.json, delayMs: 80 }
2104
+ )
1684
2105
  const count = publicCatalog.models?.length || 0
1685
2106
  record("catalog", "公开模型目录", Boolean(count), `${count} 个模型`, { count })
1686
2107
  } catch (err) {
@@ -1688,7 +2109,7 @@ async function cmdDoctor(args) {
1688
2109
  }
1689
2110
 
1690
2111
  try {
1691
- const latest = await getNpmLatestVersion()
2112
+ const latest = await withSpinner("检查 npm 版本", () => getNpmLatestVersion(), { enabled: !args.json, delayMs: 80 })
1692
2113
  if (!latest) {
1693
2114
  record("version", "版本检查", true, `当前 ${VERSION},未取到 latest`, { current: VERSION, latest: null })
1694
2115
  } else {
@@ -1719,7 +2140,7 @@ async function cmdDoctor(args) {
1719
2140
 
1720
2141
  if (cfg.accessToken) {
1721
2142
  try {
1722
- const data = await bootstrap(cfg, true)
2143
+ const data = await withSpinner("验证 CLI 会话", () => bootstrap(cfg, true), { enabled: !args.json, delayMs: 80 })
1723
2144
  record("session", "CLI 会话", true, data.user?.email || data.user?.name || "已授权", {
1724
2145
  user: data.user || cfg.user || null,
1725
2146
  })
@@ -1732,7 +2153,7 @@ async function cmdDoctor(args) {
1732
2153
 
1733
2154
  if (!cfg.apiKey && cfg.accessToken) {
1734
2155
  try {
1735
- await ensureApiKey(cfg)
2156
+ await withSpinner("检查 API Key", () => ensureApiKey(cfg), { enabled: !args.json, delayMs: 80 })
1736
2157
  cfg = await readConfig()
1737
2158
  record("api-key", "API Key", true, cfg.apiKeyName || "已配置", {
1738
2159
  configured: Boolean(cfg.apiKey),
@@ -1754,14 +2175,19 @@ async function cmdDoctor(args) {
1754
2175
  try {
1755
2176
  let model = args.model || cfg.defaultModel
1756
2177
  if (!model && cfg.accessToken) {
1757
- const data = await getModels(cfg)
2178
+ const data = await withSpinner("选择诊断模型", () => getModels(cfg), { enabled: !args.json, delayMs: 80 })
1758
2179
  model = pickDefaultModel(data.models)
1759
2180
  }
1760
2181
  if (!model) throw new Error("no model selected")
1761
- const res = await chatCompletion(
1762
- cfg,
1763
- [{ role: "user", content: "Reply with OK." }],
1764
- { model, stream: true, print: false, temperature: 0 }
2182
+ const res = await withSpinner(
2183
+ "执行真实模型调用",
2184
+ () =>
2185
+ chatCompletion(
2186
+ cfg,
2187
+ [{ role: "user", content: "Reply with OK." }],
2188
+ { model, stream: true, print: false, temperature: 0 }
2189
+ ),
2190
+ { enabled: !args.json, delayMs: 80 }
1765
2191
  )
1766
2192
  record("chat-completion", "真实模型调用", true, `${model} 首 token=${res.firstTokenMs}ms 总耗时=${res.totalMs}ms`, {
1767
2193
  model,
@@ -1820,7 +2246,9 @@ async function cmdLogout() {
1820
2246
  const cfg = await readConfig()
1821
2247
  if (cfg.accessToken) {
1822
2248
  try {
1823
- await authJson(cfg, "/api/laicode/session/revoke", { method: "POST" })
2249
+ await withSpinner("撤销远程会话", () => authJson(cfg, "/api/laicode/session/revoke", { method: "POST" }), {
2250
+ delayMs: 80,
2251
+ })
1824
2252
  console.log("Remote session revoked.")
1825
2253
  } catch (err) {
1826
2254
  if (err.status !== 401) console.log(`Remote revoke failed: ${err.message}`)
@@ -1850,7 +2278,7 @@ function localStatusRows(cfg) {
1850
2278
  }
1851
2279
 
1852
2280
  async function chooseModel(cfg, title = "Choose Model") {
1853
- const data = await getModels(cfg)
2281
+ const data = await withSpinner("拉取在线模型", () => getModels(cfg), { delayMs: 80 })
1854
2282
  const online = (data.models || []).filter(modelOnline)
1855
2283
  if (!online.length) {
1856
2284
  console.log(ui.warn("没有找到在线模型。"))
@@ -1882,6 +2310,120 @@ async function menuModels() {
1882
2310
  await pause()
1883
2311
  }
1884
2312
 
2313
+ async function menuTools() {
2314
+ await cmdTools({ _: ["tools"] })
2315
+ await pause()
2316
+ }
2317
+
2318
+ async function menuInit() {
2319
+ await cmdTools({ _: ["tools"] })
2320
+ console.log("")
2321
+ const tool = await question("接入工具 [codex]: ", "codex")
2322
+ await cmdInit({ _: ["init"], tool: tool || "codex" })
2323
+ const answer = await question("应用这个接入计划?[y/N]: ", "n")
2324
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
2325
+ await cmdInit({ _: ["init"], tool: tool || "codex", apply: true })
2326
+ }
2327
+ await pause()
2328
+ }
2329
+
2330
+ async function chooseAiTool(title = "选择 AI 工具") {
2331
+ const tools = detectTools()
2332
+ const items = tools.map((tool) => ({
2333
+ label: tool.name,
2334
+ description: `${tool.detected ? "已发现" : "未检测"} · ${tool.status}`,
2335
+ value: tool.id,
2336
+ }))
2337
+ items.push({ label: "返回", description: "", value: null })
2338
+ return selectMenu(title, items, { cancelValue: null })
2339
+ }
2340
+
2341
+ async function maybeApplyToolCommand(action, tool) {
2342
+ await cmdTools({ _: ["tools", action], tool })
2343
+ const answer = await question(`执行 ${tool} ${action}?[y/N]: `, "n")
2344
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
2345
+ await cmdTools({ _: ["tools", action], tool, apply: true })
2346
+ }
2347
+ await pause()
2348
+ }
2349
+
2350
+ async function maybeApplyToolConfig(tool) {
2351
+ await cmdInit({ _: ["init"], tool })
2352
+ const answer = await question(`应用 ${tool} 接入配置?[y/N]: `, "n")
2353
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
2354
+ await cmdInit({ _: ["init"], tool, apply: true })
2355
+ }
2356
+ await pause()
2357
+ }
2358
+
2359
+ async function menuToolDetail(tool) {
2360
+ for (;;) {
2361
+ const action = await selectMenu(
2362
+ `管理 ${tool}`,
2363
+ [
2364
+ { label: "预览接入配置", description: "只展示 diff/计划,不写入", value: "preview" },
2365
+ { label: "应用接入配置", description: "备份后写入,可 rollback", value: "apply-config" },
2366
+ { label: "安装工具", description: "预览安装命令,可确认执行", value: "install" },
2367
+ { label: "卸载工具", description: "仅管理 Laicode 可控的 npm 全局安装", value: "uninstall" },
2368
+ { label: "重装工具", description: "卸载后重新安装", value: "reinstall" },
2369
+ { label: "查看工具矩阵", description: "回到总览表", value: "matrix" },
2370
+ { label: "返回", description: "", value: null },
2371
+ ],
2372
+ { cancelValue: null }
2373
+ )
2374
+ if (!action) return
2375
+ if (action === "preview") {
2376
+ await cmdInit({ _: ["init"], tool })
2377
+ await pause()
2378
+ } else if (action === "apply-config") {
2379
+ await maybeApplyToolConfig(tool)
2380
+ } else if (action === "install" || action === "uninstall" || action === "reinstall") {
2381
+ await maybeApplyToolCommand(action, tool)
2382
+ } else if (action === "matrix") {
2383
+ await cmdTools({ _: ["tools"] })
2384
+ await pause()
2385
+ }
2386
+ }
2387
+ }
2388
+
2389
+ async function menuToolAction(action) {
2390
+ const tool = await chooseAiTool(action === "configure" ? "选择要配置的工具" : "选择要维护的工具")
2391
+ if (!tool) return
2392
+ if (action === "configure") await maybeApplyToolConfig(tool)
2393
+ else await maybeApplyToolCommand(action, tool)
2394
+ }
2395
+
2396
+ async function menuToolHub() {
2397
+ for (;;) {
2398
+ const action = await selectMenu(
2399
+ "AI 工具一键配置",
2400
+ [
2401
+ { label: "体检全部工具", description: "扫描 Claude、Codex、VS Code、Cursor、OpenCode", value: "scan" },
2402
+ { label: "选择工具管理", description: "进入单个工具的完整操作菜单", value: "manage" },
2403
+ { label: "一键配置工具", description: "预览并应用 Lai.vc 接入配置", value: "configure" },
2404
+ { label: "安装工具", description: "生成安装命令,可确认执行", value: "install" },
2405
+ { label: "卸载工具", description: "卸载 Laicode 可控的安装", value: "uninstall" },
2406
+ { label: "重装工具", description: "卸载并重新安装", value: "reinstall" },
2407
+ { label: "回滚最近接入", description: "撤销最近一次 Laicode 写入", value: "rollback" },
2408
+ { label: "返回主菜单", description: "", value: null },
2409
+ ],
2410
+ { cancelValue: null }
2411
+ )
2412
+ if (!action) return
2413
+ if (action === "scan") {
2414
+ await cmdTools({ _: ["tools"] })
2415
+ await pause()
2416
+ } else if (action === "manage") {
2417
+ const tool = await chooseAiTool()
2418
+ if (tool) await menuToolDetail(tool)
2419
+ } else if (action === "configure" || action === "install" || action === "uninstall" || action === "reinstall") {
2420
+ await menuToolAction(action)
2421
+ } else if (action === "rollback") {
2422
+ await menuRollback()
2423
+ }
2424
+ }
2425
+ }
2426
+
1885
2427
  async function menuChat() {
1886
2428
  let cfg = await readConfig()
1887
2429
  if (!cfg.accessToken && !cfg.apiKey) {
@@ -1890,7 +2432,7 @@ async function menuChat() {
1890
2432
  return
1891
2433
  }
1892
2434
  if (!cfg.apiKey && cfg.accessToken) {
1893
- await ensureApiKey(cfg)
2435
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { delayMs: 80 })
1894
2436
  cfg = await readConfig()
1895
2437
  }
1896
2438
  const model = await chooseModel(cfg, "Chat Test")
@@ -1898,7 +2440,11 @@ async function menuChat() {
1898
2440
  const prompt = await question("Prompt: ", "Say hello in one sentence.")
1899
2441
  printHero("Streaming Response")
1900
2442
  console.log(`${ui.dim("model")} ${model}\n`)
1901
- await chatCompletion(cfg, [{ role: "user", content: prompt }], { model, stream: true })
2443
+ await chatCompletion(cfg, [{ role: "user", content: prompt }], {
2444
+ model,
2445
+ stream: true,
2446
+ loadingText: "等待模型响应",
2447
+ })
1902
2448
  await patchConfig({ defaultModel: model })
1903
2449
  await pause()
1904
2450
  }
@@ -1911,7 +2457,7 @@ async function menuBench() {
1911
2457
  return
1912
2458
  }
1913
2459
  if (!cfg.apiKey && cfg.accessToken) {
1914
- await ensureApiKey(cfg)
2460
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { delayMs: 80 })
1915
2461
  cfg = await readConfig()
1916
2462
  }
1917
2463
  const model = await chooseModel(cfg, "Benchmark")
@@ -1977,6 +2523,14 @@ async function menuLogout() {
1977
2523
  await pause()
1978
2524
  }
1979
2525
 
2526
+ async function menuRollback() {
2527
+ const answer = await question("回滚最近一次 Laicode 接入写入?[y/N]: ", "n")
2528
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
2529
+ await cmdRollback({ _: ["rollback"] })
2530
+ }
2531
+ await pause()
2532
+ }
2533
+
1980
2534
  async function cmdMenu(args = {}) {
1981
2535
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1982
2536
  console.log(buildHelp())
@@ -1985,13 +2539,14 @@ async function cmdMenu(args = {}) {
1985
2539
 
1986
2540
  for (;;) {
1987
2541
  const cfg = await readConfig()
1988
- const snapshot = await dashboardSnapshot(cfg)
2542
+ const snapshot = await withSpinner("同步驾驶舱", () => dashboardSnapshot(cfg), { delayMs: 160 })
1989
2543
  printHero("Lai.vc 中转控制台")
1990
2544
  printDashboard(cfg, snapshot)
1991
2545
  console.log("")
1992
2546
  const action = await selectMenu(
1993
2547
  "选择任务",
1994
2548
  [
2549
+ { label: "AI 工具一键配置", description: "体检、安装、配置、重装、卸载、回滚", value: "tool-hub" },
1995
2550
  { label: "开始对话", description: "流式测试单个模型", value: "chat" },
1996
2551
  { label: "模型雷达", description: "在线模型、价格、多源、延迟", value: "models" },
1997
2552
  { label: "网关压测", description: "首 token、总耗时、成功率", value: "bench" },
@@ -2000,6 +2555,7 @@ async function cmdMenu(args = {}) {
2000
2555
  { label: "本地配置", description: "查看 ~/.laicode 状态", value: "config" },
2001
2556
  { label: "品牌预览", description: "终端字标和品牌色板", value: "brand" },
2002
2557
  { label: "重新登录", description: "浏览器设备授权", value: "login" },
2558
+ { label: "回滚接入", description: "撤销最近一次配置写入", value: "rollback" },
2003
2559
  { label: "退出登录", description: "撤销会话并清理缓存", value: "logout" },
2004
2560
  { label: "退出", description: "", value: "exit" },
2005
2561
  ],
@@ -2007,7 +2563,10 @@ async function cmdMenu(args = {}) {
2007
2563
  )
2008
2564
 
2009
2565
  if (!action || action === "exit") return
2010
- if (action === "login") await menuLogin()
2566
+ if (action === "tool-hub") await menuToolHub()
2567
+ else if (action === "login") await menuLogin()
2568
+ else if (action === "tools") await menuTools()
2569
+ else if (action === "init") await menuInit()
2011
2570
  else if (action === "models") await menuModels()
2012
2571
  else if (action === "chat") await menuChat()
2013
2572
  else if (action === "bench") await menuBench()
@@ -2018,6 +2577,7 @@ async function cmdMenu(args = {}) {
2018
2577
  cmdBrand()
2019
2578
  await pause()
2020
2579
  }
2580
+ else if (action === "rollback") await menuRollback()
2021
2581
  else if (action === "logout") await menuLogout()
2022
2582
  }
2023
2583
  }
@@ -2043,8 +2603,12 @@ async function main() {
2043
2603
  try {
2044
2604
  if (cmd === "login") return await cmdLogin(args)
2045
2605
  if (cmd === "models") return await cmdModels(args)
2606
+ if (cmd === "tools") return await cmdTools(args)
2607
+ if (cmd === "init") return await cmdInit(args)
2608
+ if (cmd === "rollback") return await cmdRollback(args)
2046
2609
  if (cmd === "keys") return await cmdKeys(args)
2047
2610
  if (cmd === "config") return await cmdConfig(args)
2611
+ if (cmd === "credential") return await cmdCredential(args)
2048
2612
  if (cmd === "chat") return await cmdChat(args)
2049
2613
  if (cmd === "bench") return await cmdBench(args)
2050
2614
  if (cmd === "doctor") return await cmdDoctor(args)