@laivc/laicode 0.1.0 → 0.2.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 CHANGED
@@ -9,6 +9,7 @@ LaiCode is the official CLI testbench for the Lai.vc API gateway.
9
9
 
10
10
  The interface defaults to Chinese and uses terminal panels/tables that handle CJK alignment correctly.
11
11
  Its terminal cockpit follows the Lai.vc brand palette: plum, coral, persimmon, ivory, and gold.
12
+ Network and model calls use TTY-aware loading animations. JSON output and non-interactive shell usage stay clean.
12
13
 
13
14
  ## Commands
14
15
 
@@ -20,6 +21,10 @@ laicode status # live cockpit snapshot
20
21
  laicode status --refresh # bypass the short dashboard cache
21
22
  laicode commands # command catalog
22
23
  laicode completion bash # print shell completion script
24
+ laicode tools # inspect local AI tools
25
+ laicode init --tool codex # preview a one-click Codex profile
26
+ laicode init --tool codex --apply
27
+ laicode rollback # undo the latest Laicode config write
23
28
  laicode login
24
29
  laicode models --online
25
30
  laicode chat --model gpt-5.5 --message "Say hello"
@@ -42,6 +47,8 @@ laicode status
42
47
  laicode login
43
48
  laicode models --online
44
49
  laicode doctor
50
+ laicode tools
51
+ laicode init --tool codex
45
52
  ```
46
53
 
47
54
  `status` shows the next recommended actions when the local state is empty.
@@ -56,6 +63,24 @@ Use `laicode bench --json` for script-friendly health checks. The JSON output in
56
63
 
57
64
  Use `laicode doctor --json` for CI-friendly diagnostics across local state, catalog reachability, npm version status, CLI session, API key, and a real chat completion check.
58
65
 
66
+ ## Local Tool Onboarding
67
+
68
+ Phase 1 focuses on checking local AI tools and safely connecting them to Lai.vc.
69
+
70
+ ```bash
71
+ laicode tools
72
+ laicode init --tool codex
73
+ laicode init --tool codex --apply
74
+ codex --profile laicode
75
+ laicode rollback
76
+ ```
77
+
78
+ `init` previews the files it would write by default. `--apply` creates a backup under `~/.laicode/backups` before writing, and `rollback` restores the latest Laicode-managed write.
79
+
80
+ The first automatic adapter creates `~/.codex/laicode.config.toml` instead of modifying the main Codex config. Continue can create a fresh `~/.continue/config.yaml` when no existing Continue config is present. Cursor and Cline are currently inspected with guided setup to avoid unsafe writes into app-managed secure storage.
81
+
82
+ The Codex profile uses Codex's command-backed auth to call `laicode credential api-key`, so the Lai.vc key stays in `~/.laicode` instead of being copied into Codex config.
83
+
59
84
  完整 API Key 默认不会显示。若确实需要查看,使用:
60
85
 
61
86
  ```bash
@@ -101,6 +126,7 @@ LAICODE_HOME=/path/to/state # default: ~/.laicode
101
126
  LAICODE_SITE_URL=https://lai.vc
102
127
  LAICODE_PLAIN=1 # disable styling
103
128
  LAICODE_COLOR=1 # force color preview/output
129
+ LAICODE_NO_SPINNER=1 # disable loading animations
104
130
  ```
105
131
 
106
132
  ## Manual API Key Mode
package/bin/laicode.js CHANGED
@@ -6,8 +6,15 @@ 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
+ buildInitPlan,
12
+ detectTools,
13
+ rollbackLatest,
14
+ safePlan,
15
+ } = require("../lib/adapters")
16
+
17
+ const VERSION = "0.2.0"
11
18
  const DEFAULT_SITE_BASE = "https://lai.vc"
12
19
  const DEFAULT_API_BASE = "https://api.lai.vc/v1"
13
20
  const STATE_DIR = process.env.LAICODE_HOME || path.join(os.homedir(), ".laicode")
@@ -20,6 +27,9 @@ const COMMAND_SPECS = [
20
27
  { name: "status", usage: "laicode status [--refresh] [--json]", summary: "查看实时驾驶舱状态", flags: ["--refresh", "--json"] },
21
28
  { name: "commands", usage: "laicode commands [--json]", summary: "查看命令目录和 flag 清单", flags: ["--json"] },
22
29
  { name: "completion", usage: "laicode completion [bash|zsh|fish|powershell]", summary: "输出 shell 补全脚本", flags: ["bash", "zsh", "fish", "powershell"] },
30
+ { name: "tools", usage: "laicode tools [--json]", summary: "检查本机 AI 工具接入状态", flags: ["--json"] },
31
+ { name: "init", usage: "laicode init [--tool codex] [--apply] [--json]", summary: "预览或应用一键接入计划", flags: ["--tool", "--model", "--apply", "--json"] },
32
+ { name: "rollback", usage: "laicode rollback [--json]", summary: "回滚最近一次接入写入", flags: ["--json"] },
23
33
  { name: "login", usage: "laicode login [--tier std] [--no-open]", summary: "通过浏览器设备授权登录", flags: ["--tier", "--no-open", "--no-key"] },
24
34
  { name: "doctor", usage: "laicode doctor [--model gpt-5.5] [--json]", summary: "运行本机、会话、网关诊断", flags: ["--model", "--json"] },
25
35
  { name: "models", usage: "laicode models [--json] [--online]", summary: "查看模型雷达和价格档位", flags: ["--json", "--online", "--category", "--vendor"] },
@@ -39,6 +49,7 @@ const ENV_VARS = [
39
49
  ["LAICODE_SITE_URL", "覆盖主站地址,默认 https://lai.vc"],
40
50
  ["LAICODE_PLAIN", "设为 1 时禁用终端样式"],
41
51
  ["LAICODE_COLOR", "设为 1 时忽略 NO_COLOR,强制彩色输出"],
52
+ ["LAICODE_NO_SPINNER", "设为 1 时禁用 loading 动画"],
42
53
  ]
43
54
 
44
55
  function buildHelp() {
@@ -128,6 +139,7 @@ const VALUE_FLAGS = new Set([
128
139
  "name",
129
140
  "id",
130
141
  "shell",
142
+ "tool",
131
143
  ])
132
144
 
133
145
  function parseArgs(argv) {
@@ -225,7 +237,111 @@ function compareVersions(a, b) {
225
237
  return 0
226
238
  }
227
239
 
240
+ let activeSpinnerCleanup = null
241
+
242
+ function spinnerEnabled(opts = {}) {
243
+ if (opts.enabled === false) return false
244
+ if (!process.stdout.isTTY) return false
245
+ if (process.env.LAICODE_NO_SPINNER === "1") return false
246
+ if (process.env.CI === "1" || process.env.CI === "true") return false
247
+ if (process.env.TERM === "dumb") return false
248
+ return true
249
+ }
250
+
251
+ function createSpinner(label, opts = {}) {
252
+ const enabled = spinnerEnabled(opts)
253
+ const frames = plainMode() ? ["-", "\\", "|", "/"] : ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
254
+ const intervalMs = Number(opts.intervalMs || 80)
255
+ const delayMs = Number(opts.delayMs ?? 120)
256
+ let text = label
257
+ let frame = 0
258
+ let active = false
259
+ let visible = false
260
+ let interval = null
261
+ let delay = null
262
+
263
+ const clear = () => {
264
+ readline.clearLine(process.stdout, 0)
265
+ readline.cursorTo(process.stdout, 0)
266
+ }
267
+
268
+ const paint = () => {
269
+ if (!active || !enabled) return
270
+ clear()
271
+ const glyph = frames[frame % frames.length]
272
+ frame += 1
273
+ process.stdout.write(`${ui.accent(glyph)} ${ui.dim(text)}`)
274
+ }
275
+
276
+ const show = () => {
277
+ if (!active || visible || !enabled) return
278
+ visible = true
279
+ activeSpinnerCleanup = () => spinner.stop()
280
+ paint()
281
+ interval = setInterval(paint, intervalMs)
282
+ if (interval.unref) interval.unref()
283
+ }
284
+
285
+ const spinner = {
286
+ get enabled() {
287
+ return enabled
288
+ },
289
+ start() {
290
+ if (!enabled || active) return this
291
+ active = true
292
+ if (delayMs > 0) {
293
+ delay = setTimeout(show, delayMs)
294
+ if (delay.unref) delay.unref()
295
+ } else {
296
+ show()
297
+ }
298
+ return this
299
+ },
300
+ update(nextText) {
301
+ if (nextText) text = nextText
302
+ if (visible) paint()
303
+ return this
304
+ },
305
+ stop(finalText) {
306
+ if (!enabled || !active) return this
307
+ active = false
308
+ if (delay) clearTimeout(delay)
309
+ if (interval) clearInterval(interval)
310
+ delay = null
311
+ interval = null
312
+ if (activeSpinnerCleanup) activeSpinnerCleanup = null
313
+ if (visible) clear()
314
+ visible = false
315
+ if (finalText) process.stdout.write(`${finalText}\n`)
316
+ return this
317
+ },
318
+ succeed(finalText) {
319
+ return this.stop(finalText ? `${ui.ok("✓")} ${finalText}` : "")
320
+ },
321
+ fail(finalText) {
322
+ return this.stop(finalText ? `${ui.err("×")} ${finalText}` : "")
323
+ },
324
+ }
325
+
326
+ return spinner
327
+ }
328
+
329
+ async function withSpinner(label, work, opts = {}) {
330
+ const spinner = createSpinner(label, opts).start()
331
+ try {
332
+ const result = await work(spinner)
333
+ if (opts.successText) spinner.succeed(opts.successText)
334
+ else spinner.stop()
335
+ return result
336
+ } catch (err) {
337
+ if (opts.failureText) spinner.fail(opts.failureText)
338
+ else spinner.stop()
339
+ throw err
340
+ }
341
+ }
342
+
228
343
  function die(message, code = 1) {
344
+ if (activeSpinnerCleanup) activeSpinnerCleanup()
229
345
  console.error(message)
230
346
  process.exit(code)
231
347
  }
@@ -467,6 +583,156 @@ function cmdCommands(args = {}) {
467
583
  )
468
584
  }
469
585
 
586
+ function supportLabel(value) {
587
+ if (value === "auto") return ui.ok("可自动接入")
588
+ if (value === "auto-new") return ui.ok("可创建配置")
589
+ if (value === "manual-merge") return ui.warn("需合并")
590
+ return ui.dim("指引")
591
+ }
592
+
593
+ async function cmdTools(args = {}) {
594
+ const tools = await withSpinner("扫描本机 AI 工具", () => Promise.resolve(detectTools()), {
595
+ enabled: !args.json,
596
+ delayMs: 80,
597
+ })
598
+
599
+ if (args.json) {
600
+ console.log(JSON.stringify({ tools }, null, 2))
601
+ return
602
+ }
603
+
604
+ printHero("AI 工具体检")
605
+ printTable(
606
+ "接入状态",
607
+ [
608
+ { key: "name", label: "工具", width: 12, max: 18 },
609
+ { key: "detected", label: "检测", width: 6 },
610
+ { key: "support", label: "能力", width: 12, max: 18 },
611
+ { key: "status", label: "状态", width: 22, max: 36 },
612
+ { key: "next", label: "下一步", width: 28, max: 44 },
613
+ ],
614
+ tools.map((tool) => ({
615
+ name: tool.name,
616
+ detected: tool.detected ? ui.ok("发现") : ui.dim("未见"),
617
+ support: supportLabel(tool.support),
618
+ status: tool.status,
619
+ next: tool.next,
620
+ }))
621
+ )
622
+ }
623
+
624
+ function printInitPlan(plan, safe) {
625
+ printHero(plan.ok ? "接入计划" : "接入指引")
626
+ printPanel("目标", [
627
+ `工具 ${plan.tool}`,
628
+ `模式 ${plan.mode}`,
629
+ `模型 ${plan.model || "-"}`,
630
+ `网关 ${plan.apiBaseUrl || apiBase({})}`,
631
+ `说明 ${plan.summary || plan.reason || "-"}`,
632
+ ])
633
+
634
+ if (safe.operations?.length) {
635
+ console.log("")
636
+ printTable(
637
+ "将写入的文件",
638
+ [
639
+ { key: "action", label: "动作", width: 8 },
640
+ { key: "path", label: "路径", width: 42, max: 72 },
641
+ { key: "exists", label: "现状", width: 8 },
642
+ ],
643
+ safe.operations.map((op) => ({
644
+ action: op.action,
645
+ path: op.path,
646
+ exists: op.exists ? ui.warn("覆盖") : ui.ok("新建"),
647
+ }))
648
+ )
649
+ console.log("")
650
+ for (const op of safe.operations) {
651
+ console.log(ui.bold(op.description || op.path))
652
+ console.log(ui.dim(op.path))
653
+ console.log("```")
654
+ console.log(op.preview.trimEnd())
655
+ console.log("```")
656
+ }
657
+ console.log("")
658
+ console.log(ui.dim("应用命令: laicode init --tool " + plan.tool + " --apply"))
659
+ if (plan.runCommand) console.log(ui.dim("使用命令: " + plan.runCommand))
660
+ return
661
+ }
662
+
663
+ if (plan.instructions?.length) {
664
+ printPanel("手动接入步骤", plan.instructions.map((item, index) => `${index + 1}. ${item}`))
665
+ }
666
+ }
667
+
668
+ async function cmdInit(args = {}) {
669
+ let cfg = await readConfig()
670
+ const tool = args.tool || args._[1] || "codex"
671
+ const apply = Boolean(args.apply)
672
+ const model = args.model || cfg.defaultModel || "gpt-5.5"
673
+
674
+ let key = cfg.apiKey
675
+ if (apply && !key && cfg.accessToken) {
676
+ key = await withSpinner("准备 API Key", () => ensureApiKey(cfg), { enabled: !args.json, delayMs: 80 })
677
+ cfg = await readConfig()
678
+ }
679
+ if (apply && !key) die("未找到 API Key。请先运行 `laicode login` 或 `laicode config set api-key sk-...`。")
680
+
681
+ const plan = buildInitPlan(tool, {
682
+ apiBaseUrl: apiBase(cfg),
683
+ apiKey: apply ? key : key || "sk-preview-only",
684
+ commandName: "laicode",
685
+ env: process.env,
686
+ model,
687
+ })
688
+ const safe = safePlan(plan, [key, cfg.apiKey].filter(Boolean))
689
+
690
+ if (args.json && !apply) {
691
+ console.log(JSON.stringify({ apply, plan: safe }, null, 2))
692
+ return
693
+ }
694
+
695
+ if (!args.json) printInitPlan(plan, safe)
696
+ if (!apply) return
697
+ if (!plan.ok) die(plan.reason || "当前工具暂不能自动接入。")
698
+
699
+ const manifest = await withSpinner("写入接入配置并创建备份", () => applyInitPlan(plan, { stateDir: STATE_DIR }), {
700
+ enabled: !args.json,
701
+ delayMs: 80,
702
+ })
703
+ if (args.json) {
704
+ console.log(JSON.stringify({ apply, plan: safe, result: manifest }, null, 2))
705
+ return
706
+ }
707
+ console.log("")
708
+ printPanel("已应用", [
709
+ `工具 ${manifest.tool}`,
710
+ `备份 ${manifest.id}`,
711
+ `文件 ${manifest.operations.map((op) => op.path).join(", ")}`,
712
+ plan.runCommand ? `使用 ${plan.runCommand}` : "使用 打开目标工具验证",
713
+ ])
714
+ }
715
+
716
+ async function cmdRollback(args = {}) {
717
+ const manifest = await withSpinner("查找最近一次备份", () => rollbackLatest({ stateDir: STATE_DIR }), {
718
+ enabled: !args.json,
719
+ delayMs: 80,
720
+ })
721
+ if (args.json) {
722
+ console.log(JSON.stringify({ ok: Boolean(manifest), rollback: manifest || null }, null, 2))
723
+ return
724
+ }
725
+ if (!manifest) {
726
+ console.log("没有可回滚的 Laicode 接入备份。")
727
+ return
728
+ }
729
+ printPanel("已回滚", [
730
+ `工具 ${manifest.tool}`,
731
+ `备份 ${manifest.id}`,
732
+ `文件 ${manifest.operations.map((op) => op.path).join(", ")}`,
733
+ ])
734
+ }
735
+
470
736
  function printHero(subtitle = "Lai.vc 中转控制台") {
471
737
  console.clear()
472
738
  printBrandMark()
@@ -1100,10 +1366,15 @@ function statusPayload(cfg, snapshot) {
1100
1366
 
1101
1367
  async function cmdStatus(args = {}) {
1102
1368
  const cfg = await readConfig()
1103
- const snapshot = await dashboardSnapshot(cfg, {
1104
- refresh: args.refresh,
1105
- ttlMs: args.refresh ? 0 : DASHBOARD_CACHE_TTL_MS,
1106
- })
1369
+ const snapshot = await withSpinner(
1370
+ args.refresh ? "刷新驾驶舱快照" : "同步驾驶舱快照",
1371
+ () =>
1372
+ dashboardSnapshot(cfg, {
1373
+ refresh: args.refresh,
1374
+ ttlMs: args.refresh ? 0 : DASHBOARD_CACHE_TTL_MS,
1375
+ }),
1376
+ { enabled: !args.json }
1377
+ )
1107
1378
  const next = await readConfig()
1108
1379
 
1109
1380
  if (args.json) {
@@ -1141,21 +1412,34 @@ async function chatCompletion(cfg, messages, opts = {}) {
1141
1412
  const started = Date.now()
1142
1413
  let firstTokenAt = 0
1143
1414
  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
- })
1415
+ let prefixWritten = false
1416
+ const loading =
1417
+ opts.print !== false && opts.loadingText
1418
+ ? createSpinner(opts.loadingText, { delayMs: opts.loadingDelayMs ?? 80 }).start()
1419
+ : null
1420
+
1421
+ let res
1422
+ try {
1423
+ res = await fetch(`${apiBase(cfg)}/chat/completions`, {
1424
+ method: "POST",
1425
+ headers: {
1426
+ "Content-Type": "application/json",
1427
+ Authorization: `Bearer ${key}`,
1428
+ },
1429
+ body: JSON.stringify({
1430
+ model,
1431
+ messages,
1432
+ stream: opts.stream !== false,
1433
+ temperature: opts.temperature == null ? 0.2 : Number(opts.temperature),
1434
+ }),
1435
+ })
1436
+ } catch (err) {
1437
+ if (loading) loading.stop()
1438
+ throw err
1439
+ }
1157
1440
 
1158
1441
  if (!res.ok) {
1442
+ if (loading) loading.stop()
1159
1443
  const data = await readJsonResponse(res)
1160
1444
  const msg = data.error?.message || data.error || data.message || `${res.status} ${res.statusText}`
1161
1445
  const err = new Error(msg)
@@ -1166,6 +1450,7 @@ async function chatCompletion(cfg, messages, opts = {}) {
1166
1450
 
1167
1451
  if (opts.stream === false) {
1168
1452
  const data = await readJsonResponse(res)
1453
+ if (loading) loading.stop()
1169
1454
  content = data.choices?.[0]?.message?.content || ""
1170
1455
  return { content, firstTokenMs: Date.now() - started, totalMs: Date.now() - started, status: res.status }
1171
1456
  }
@@ -1191,11 +1476,19 @@ async function chatCompletion(cfg, messages, opts = {}) {
1191
1476
  if (delta) {
1192
1477
  if (!firstTokenAt) firstTokenAt = Date.now()
1193
1478
  content += delta
1194
- if (opts.print !== false) process.stdout.write(delta)
1479
+ if (opts.print !== false) {
1480
+ if (loading) loading.stop()
1481
+ if (opts.prefixOnFirstToken && !prefixWritten) {
1482
+ process.stdout.write(opts.prefixOnFirstToken)
1483
+ prefixWritten = true
1484
+ }
1485
+ process.stdout.write(delta)
1486
+ }
1195
1487
  }
1196
1488
  }
1197
1489
  }
1198
1490
 
1491
+ if (loading) loading.stop()
1199
1492
  if (opts.print !== false) process.stdout.write("\n")
1200
1493
  return {
1201
1494
  content,
@@ -1215,10 +1508,15 @@ async function cmdLogin(args) {
1215
1508
  platform: platformName(),
1216
1509
  scopes: ["account:read", "keys:read", "keys:create"],
1217
1510
  }
1218
- const data = await siteJson({ siteBase: base }, "/api/laicode/device/start", {
1219
- method: "POST",
1220
- body: JSON.stringify(body),
1221
- })
1511
+ const data = await withSpinner(
1512
+ "创建设备授权码",
1513
+ () =>
1514
+ siteJson({ siteBase: base }, "/api/laicode/device/start", {
1515
+ method: "POST",
1516
+ body: JSON.stringify(body),
1517
+ }),
1518
+ { delayMs: 80 }
1519
+ )
1222
1520
 
1223
1521
  console.log("打开以下链接授权 LaiCode:")
1224
1522
  console.log(data.verificationUriComplete || data.verificationUri)
@@ -1233,23 +1531,29 @@ async function cmdLogin(args) {
1233
1531
 
1234
1532
  let interval = Number(data.interval || 2)
1235
1533
  const deadline = Date.now() + Number(data.expiresIn || 600) * 1000
1534
+ const pollSpinner = createSpinner("等待浏览器授权", { delayMs: 0 }).start()
1535
+ const fallbackDots = !pollSpinner.enabled
1236
1536
  while (Date.now() < deadline) {
1237
1537
  await sleep(interval * 1000)
1538
+ const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000))
1539
+ pollSpinner.update(`等待浏览器授权 · 剩余 ${remaining}s`)
1238
1540
  const poll = await siteJson({ siteBase: base }, "/api/laicode/device/poll", {
1239
1541
  method: "POST",
1240
1542
  body: JSON.stringify({ deviceCode: data.deviceCode }),
1241
1543
  })
1242
1544
  if (poll.status === "pending") {
1243
- process.stdout.write(".")
1545
+ if (fallbackDots) process.stdout.write(".")
1244
1546
  interval = Number(poll.interval || interval)
1245
1547
  continue
1246
1548
  }
1247
1549
  if (poll.status === "slow_down") {
1248
1550
  interval = Math.max(interval + 1, Number(poll.interval || interval))
1249
- process.stdout.write(" slow_down ")
1551
+ pollSpinner.update(`等待浏览器授权 · 降低轮询频率 ${interval}s`)
1552
+ if (fallbackDots) process.stdout.write(" slow_down ")
1250
1553
  continue
1251
1554
  }
1252
- process.stdout.write("\n")
1555
+ if (fallbackDots) process.stdout.write("\n")
1556
+ else pollSpinner.stop()
1253
1557
  if (poll.status === "denied") die("授权已被拒绝。")
1254
1558
  if (poll.status === "expired") die("授权已过期,请重新运行 `laicode login`。")
1255
1559
  if (poll.status === "authorized") {
@@ -1264,7 +1568,7 @@ async function cmdLogin(args) {
1264
1568
  })
1265
1569
  console.log(`已授权: ${poll.user?.email || poll.user?.name || "unknown"}`)
1266
1570
  if (!args["no-key"]) {
1267
- const key = await ensureApiKey(next, { tier: args.tier })
1571
+ const key = await withSpinner("准备 API Key", () => ensureApiKey(next, { tier: args.tier }), { delayMs: 80 })
1268
1572
  next = await readConfig()
1269
1573
  console.log(`API Key 就绪: ${maskSecret(key)} (${next.apiKeyName || "Laicode key"})`)
1270
1574
  }
@@ -1272,12 +1576,14 @@ async function cmdLogin(args) {
1272
1576
  }
1273
1577
  die(`Unexpected authorization status: ${poll.status}`)
1274
1578
  }
1579
+ pollSpinner.stop()
1580
+ if (fallbackDots) process.stdout.write("\n")
1275
1581
  die("授权等待超时,请重新运行 `laicode login`。")
1276
1582
  }
1277
1583
 
1278
1584
  async function cmdModels(args) {
1279
1585
  const cfg = await readConfig()
1280
- const data = await getModels(cfg)
1586
+ const data = await withSpinner("拉取模型雷达", () => getModels(cfg), { enabled: !args.json, delayMs: 80 })
1281
1587
  let models = data.models || []
1282
1588
  if (args.online) models = models.filter(modelOnline)
1283
1589
  if (args.category) models = models.filter((m) => m.category === args.category)
@@ -1333,9 +1639,10 @@ async function cmdModels(args) {
1333
1639
  async function cmdKeys(args) {
1334
1640
  const action = args._[1] || "list"
1335
1641
  const cfg = await readConfig()
1642
+ if (!cfg.accessToken) die("Not logged in. Run `laicode login` first.")
1336
1643
  if (action === "list") {
1337
- const keys = await listKeys(cfg)
1338
1644
  const reveal = await confirmRevealKey(args)
1645
+ const keys = await withSpinner("读取 API Key 列表", () => listKeys(cfg), { enabled: !args.json, delayMs: 80 })
1339
1646
  if (args.json) {
1340
1647
  console.log(JSON.stringify({ keys: keys.map((k) => safeKeyRecord(k, reveal)), reveal }, null, 2))
1341
1648
  return
@@ -1367,10 +1674,15 @@ async function cmdKeys(args) {
1367
1674
  const body = { name }
1368
1675
  if (args.tier) body.tier = args.tier
1369
1676
  const reveal = await confirmRevealKey(args)
1370
- const data = await authJson(cfg, "/api/laicode/keys", {
1371
- method: "POST",
1372
- body: JSON.stringify(body),
1373
- })
1677
+ const data = await withSpinner(
1678
+ "创建 API Key",
1679
+ () =>
1680
+ authJson(cfg, "/api/laicode/keys", {
1681
+ method: "POST",
1682
+ body: JSON.stringify(body),
1683
+ }),
1684
+ { enabled: !args.json, delayMs: 80 }
1685
+ )
1374
1686
  const key = data.key
1375
1687
  await patchConfig({
1376
1688
  apiKey: key.key,
@@ -1391,7 +1703,11 @@ async function cmdKeys(args) {
1391
1703
  if (action === "delete") {
1392
1704
  const id = args._[2] || args.id
1393
1705
  if (!id) die("Usage: laicode keys delete <id>")
1394
- await authJson(cfg, `/api/laicode/keys/${encodeURIComponent(id)}`, { method: "DELETE" })
1706
+ await withSpinner(
1707
+ `删除 API Key ${id}`,
1708
+ () => authJson(cfg, `/api/laicode/keys/${encodeURIComponent(id)}`, { method: "DELETE" }),
1709
+ { enabled: !args.json, delayMs: 80 }
1710
+ )
1395
1711
  const patch = {}
1396
1712
  if (String(cfg.apiKeyId) === String(id)) {
1397
1713
  patch.apiKey = undefined
@@ -1519,16 +1835,29 @@ async function cmdConfig(args) {
1519
1835
  die("Usage: laicode config [list|get|set|unset]")
1520
1836
  }
1521
1837
 
1838
+ async function cmdCredential(args) {
1839
+ const action = args._[1]
1840
+ if (action !== "api-key") die("Usage: laicode credential api-key")
1841
+ let cfg = await readConfig()
1842
+ let key = cfg.apiKey
1843
+ if (!key && cfg.accessToken) {
1844
+ key = await ensureApiKey(cfg)
1845
+ cfg = await readConfig()
1846
+ }
1847
+ if (!key) die("No API key available. Run `laicode login` first.")
1848
+ process.stdout.write(`${key}\n`)
1849
+ }
1850
+
1522
1851
  async function cmdChat(args) {
1523
1852
  let cfg = await readConfig()
1524
1853
  if (!cfg.apiKey && cfg.accessToken) {
1525
- await ensureApiKey(cfg)
1854
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { delayMs: 80 })
1526
1855
  cfg = await readConfig()
1527
1856
  }
1528
1857
 
1529
1858
  let model = args.model || cfg.defaultModel
1530
1859
  if (!model && cfg.accessToken) {
1531
- const data = await getModels(cfg)
1860
+ const data = await withSpinner("选择默认在线模型", () => getModels(cfg), { delayMs: 80 })
1532
1861
  model = pickDefaultModel(data.models)
1533
1862
  }
1534
1863
  if (!model) die("未选择模型。请使用 `laicode chat --model <model>`。")
@@ -1536,7 +1865,11 @@ async function cmdChat(args) {
1536
1865
  const message = args.message || args.m || args._.slice(1).join(" ")
1537
1866
  if (message) {
1538
1867
  console.log(`model: ${model}`)
1539
- await chatCompletion(cfg, [{ role: "user", content: message }], { model, stream: true })
1868
+ await chatCompletion(cfg, [{ role: "user", content: message }], {
1869
+ model,
1870
+ stream: true,
1871
+ loadingText: "等待模型响应",
1872
+ })
1540
1873
  await patchConfig({ defaultModel: model })
1541
1874
  return
1542
1875
  }
@@ -1548,8 +1881,12 @@ async function cmdChat(args) {
1548
1881
  const input = await new Promise((resolve) => rl.question("> ", resolve))
1549
1882
  if (!input || input.trim() === "/exit" || input.trim() === "/quit") break
1550
1883
  messages.push({ role: "user", content: input })
1551
- process.stdout.write("assistant: ")
1552
- const res = await chatCompletion(cfg, messages, { model, stream: true })
1884
+ const res = await chatCompletion(cfg, messages, {
1885
+ model,
1886
+ stream: true,
1887
+ loadingText: "assistant 思考中",
1888
+ prefixOnFirstToken: "assistant: ",
1889
+ })
1553
1890
  messages.push({ role: "assistant", content: res.content })
1554
1891
  }
1555
1892
  rl.close()
@@ -1559,12 +1896,12 @@ async function cmdChat(args) {
1559
1896
  async function cmdBench(args) {
1560
1897
  let cfg = await readConfig()
1561
1898
  if (!cfg.apiKey && cfg.accessToken) {
1562
- await ensureApiKey(cfg)
1899
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { enabled: !args.json, delayMs: 80 })
1563
1900
  cfg = await readConfig()
1564
1901
  }
1565
1902
  let model = args.model || cfg.defaultModel
1566
1903
  if (!model && cfg.accessToken) {
1567
- const data = await getModels(cfg)
1904
+ const data = await withSpinner("选择默认在线模型", () => getModels(cfg), { enabled: !args.json, delayMs: 80 })
1568
1905
  model = pickDefaultModel(data.models)
1569
1906
  }
1570
1907
  if (!model) die("未选择模型。请使用 `laicode bench --model <model>`。")
@@ -1577,10 +1914,15 @@ async function cmdBench(args) {
1577
1914
  for (let i = 0; i < count; i++) {
1578
1915
  const label = `${i + 1}/${count}`
1579
1916
  try {
1580
- const res = await chatCompletion(
1581
- cfg,
1582
- [{ role: "user", content: prompt }],
1583
- { model, stream: true, print: false, temperature: 0 }
1917
+ const res = await withSpinner(
1918
+ `压测 ${label} · 等待首 token`,
1919
+ () =>
1920
+ chatCompletion(
1921
+ cfg,
1922
+ [{ role: "user", content: prompt }],
1923
+ { model, stream: true, print: false, temperature: 0 }
1924
+ ),
1925
+ { enabled: !args.json, delayMs: 80 }
1584
1926
  )
1585
1927
  rows.push({ index: i + 1, ok: true, firstTokenMs: res.firstTokenMs, totalMs: res.totalMs })
1586
1928
  if (!args.json) console.log(`${ui.ok("●")} ${label} 成功 首 token=${res.firstTokenMs}ms 总耗时=${res.totalMs}ms`)
@@ -1680,7 +2022,11 @@ async function cmdDoctor(args) {
1680
2022
  record("config-file", "配置文件", true, fs.existsSync(CONFIG_FILE) ? CONFIG_FILE : "尚未创建")
1681
2023
 
1682
2024
  try {
1683
- const publicCatalog = await siteJson(cfg, "/api/catalog", { method: "GET", timeoutMs: 10000 })
2025
+ const publicCatalog = await withSpinner(
2026
+ "检查公开模型目录",
2027
+ () => siteJson(cfg, "/api/catalog", { method: "GET", timeoutMs: 10000 }),
2028
+ { enabled: !args.json, delayMs: 80 }
2029
+ )
1684
2030
  const count = publicCatalog.models?.length || 0
1685
2031
  record("catalog", "公开模型目录", Boolean(count), `${count} 个模型`, { count })
1686
2032
  } catch (err) {
@@ -1688,7 +2034,7 @@ async function cmdDoctor(args) {
1688
2034
  }
1689
2035
 
1690
2036
  try {
1691
- const latest = await getNpmLatestVersion()
2037
+ const latest = await withSpinner("检查 npm 版本", () => getNpmLatestVersion(), { enabled: !args.json, delayMs: 80 })
1692
2038
  if (!latest) {
1693
2039
  record("version", "版本检查", true, `当前 ${VERSION},未取到 latest`, { current: VERSION, latest: null })
1694
2040
  } else {
@@ -1719,7 +2065,7 @@ async function cmdDoctor(args) {
1719
2065
 
1720
2066
  if (cfg.accessToken) {
1721
2067
  try {
1722
- const data = await bootstrap(cfg, true)
2068
+ const data = await withSpinner("验证 CLI 会话", () => bootstrap(cfg, true), { enabled: !args.json, delayMs: 80 })
1723
2069
  record("session", "CLI 会话", true, data.user?.email || data.user?.name || "已授权", {
1724
2070
  user: data.user || cfg.user || null,
1725
2071
  })
@@ -1732,7 +2078,7 @@ async function cmdDoctor(args) {
1732
2078
 
1733
2079
  if (!cfg.apiKey && cfg.accessToken) {
1734
2080
  try {
1735
- await ensureApiKey(cfg)
2081
+ await withSpinner("检查 API Key", () => ensureApiKey(cfg), { enabled: !args.json, delayMs: 80 })
1736
2082
  cfg = await readConfig()
1737
2083
  record("api-key", "API Key", true, cfg.apiKeyName || "已配置", {
1738
2084
  configured: Boolean(cfg.apiKey),
@@ -1754,14 +2100,19 @@ async function cmdDoctor(args) {
1754
2100
  try {
1755
2101
  let model = args.model || cfg.defaultModel
1756
2102
  if (!model && cfg.accessToken) {
1757
- const data = await getModels(cfg)
2103
+ const data = await withSpinner("选择诊断模型", () => getModels(cfg), { enabled: !args.json, delayMs: 80 })
1758
2104
  model = pickDefaultModel(data.models)
1759
2105
  }
1760
2106
  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 }
2107
+ const res = await withSpinner(
2108
+ "执行真实模型调用",
2109
+ () =>
2110
+ chatCompletion(
2111
+ cfg,
2112
+ [{ role: "user", content: "Reply with OK." }],
2113
+ { model, stream: true, print: false, temperature: 0 }
2114
+ ),
2115
+ { enabled: !args.json, delayMs: 80 }
1765
2116
  )
1766
2117
  record("chat-completion", "真实模型调用", true, `${model} 首 token=${res.firstTokenMs}ms 总耗时=${res.totalMs}ms`, {
1767
2118
  model,
@@ -1820,7 +2171,9 @@ async function cmdLogout() {
1820
2171
  const cfg = await readConfig()
1821
2172
  if (cfg.accessToken) {
1822
2173
  try {
1823
- await authJson(cfg, "/api/laicode/session/revoke", { method: "POST" })
2174
+ await withSpinner("撤销远程会话", () => authJson(cfg, "/api/laicode/session/revoke", { method: "POST" }), {
2175
+ delayMs: 80,
2176
+ })
1824
2177
  console.log("Remote session revoked.")
1825
2178
  } catch (err) {
1826
2179
  if (err.status !== 401) console.log(`Remote revoke failed: ${err.message}`)
@@ -1850,7 +2203,7 @@ function localStatusRows(cfg) {
1850
2203
  }
1851
2204
 
1852
2205
  async function chooseModel(cfg, title = "Choose Model") {
1853
- const data = await getModels(cfg)
2206
+ const data = await withSpinner("拉取在线模型", () => getModels(cfg), { delayMs: 80 })
1854
2207
  const online = (data.models || []).filter(modelOnline)
1855
2208
  if (!online.length) {
1856
2209
  console.log(ui.warn("没有找到在线模型。"))
@@ -1882,6 +2235,23 @@ async function menuModels() {
1882
2235
  await pause()
1883
2236
  }
1884
2237
 
2238
+ async function menuTools() {
2239
+ await cmdTools({ _: ["tools"] })
2240
+ await pause()
2241
+ }
2242
+
2243
+ async function menuInit() {
2244
+ await cmdTools({ _: ["tools"] })
2245
+ console.log("")
2246
+ const tool = await question("接入工具 [codex]: ", "codex")
2247
+ await cmdInit({ _: ["init"], tool: tool || "codex" })
2248
+ const answer = await question("应用这个接入计划?[y/N]: ", "n")
2249
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
2250
+ await cmdInit({ _: ["init"], tool: tool || "codex", apply: true })
2251
+ }
2252
+ await pause()
2253
+ }
2254
+
1885
2255
  async function menuChat() {
1886
2256
  let cfg = await readConfig()
1887
2257
  if (!cfg.accessToken && !cfg.apiKey) {
@@ -1890,7 +2260,7 @@ async function menuChat() {
1890
2260
  return
1891
2261
  }
1892
2262
  if (!cfg.apiKey && cfg.accessToken) {
1893
- await ensureApiKey(cfg)
2263
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { delayMs: 80 })
1894
2264
  cfg = await readConfig()
1895
2265
  }
1896
2266
  const model = await chooseModel(cfg, "Chat Test")
@@ -1898,7 +2268,11 @@ async function menuChat() {
1898
2268
  const prompt = await question("Prompt: ", "Say hello in one sentence.")
1899
2269
  printHero("Streaming Response")
1900
2270
  console.log(`${ui.dim("model")} ${model}\n`)
1901
- await chatCompletion(cfg, [{ role: "user", content: prompt }], { model, stream: true })
2271
+ await chatCompletion(cfg, [{ role: "user", content: prompt }], {
2272
+ model,
2273
+ stream: true,
2274
+ loadingText: "等待模型响应",
2275
+ })
1902
2276
  await patchConfig({ defaultModel: model })
1903
2277
  await pause()
1904
2278
  }
@@ -1911,7 +2285,7 @@ async function menuBench() {
1911
2285
  return
1912
2286
  }
1913
2287
  if (!cfg.apiKey && cfg.accessToken) {
1914
- await ensureApiKey(cfg)
2288
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { delayMs: 80 })
1915
2289
  cfg = await readConfig()
1916
2290
  }
1917
2291
  const model = await chooseModel(cfg, "Benchmark")
@@ -1977,6 +2351,14 @@ async function menuLogout() {
1977
2351
  await pause()
1978
2352
  }
1979
2353
 
2354
+ async function menuRollback() {
2355
+ const answer = await question("回滚最近一次 Laicode 接入写入?[y/N]: ", "n")
2356
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
2357
+ await cmdRollback({ _: ["rollback"] })
2358
+ }
2359
+ await pause()
2360
+ }
2361
+
1980
2362
  async function cmdMenu(args = {}) {
1981
2363
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1982
2364
  console.log(buildHelp())
@@ -1985,7 +2367,7 @@ async function cmdMenu(args = {}) {
1985
2367
 
1986
2368
  for (;;) {
1987
2369
  const cfg = await readConfig()
1988
- const snapshot = await dashboardSnapshot(cfg)
2370
+ const snapshot = await withSpinner("同步驾驶舱", () => dashboardSnapshot(cfg), { delayMs: 160 })
1989
2371
  printHero("Lai.vc 中转控制台")
1990
2372
  printDashboard(cfg, snapshot)
1991
2373
  console.log("")
@@ -1993,6 +2375,8 @@ async function cmdMenu(args = {}) {
1993
2375
  "选择任务",
1994
2376
  [
1995
2377
  { label: "开始对话", description: "流式测试单个模型", value: "chat" },
2378
+ { label: "AI 工具体检", description: "检查 Codex、Cursor、Continue、Cline", value: "tools" },
2379
+ { label: "一键接入", description: "预览 diff、备份、写入、可回滚", value: "init" },
1996
2380
  { label: "模型雷达", description: "在线模型、价格、多源、延迟", value: "models" },
1997
2381
  { label: "网关压测", description: "首 token、总耗时、成功率", value: "bench" },
1998
2382
  { label: "系统诊断", description: "会话、密钥、目录、真实调用", value: "doctor" },
@@ -2000,6 +2384,7 @@ async function cmdMenu(args = {}) {
2000
2384
  { label: "本地配置", description: "查看 ~/.laicode 状态", value: "config" },
2001
2385
  { label: "品牌预览", description: "终端字标和品牌色板", value: "brand" },
2002
2386
  { label: "重新登录", description: "浏览器设备授权", value: "login" },
2387
+ { label: "回滚接入", description: "撤销最近一次配置写入", value: "rollback" },
2003
2388
  { label: "退出登录", description: "撤销会话并清理缓存", value: "logout" },
2004
2389
  { label: "退出", description: "", value: "exit" },
2005
2390
  ],
@@ -2008,6 +2393,8 @@ async function cmdMenu(args = {}) {
2008
2393
 
2009
2394
  if (!action || action === "exit") return
2010
2395
  if (action === "login") await menuLogin()
2396
+ else if (action === "tools") await menuTools()
2397
+ else if (action === "init") await menuInit()
2011
2398
  else if (action === "models") await menuModels()
2012
2399
  else if (action === "chat") await menuChat()
2013
2400
  else if (action === "bench") await menuBench()
@@ -2018,6 +2405,7 @@ async function cmdMenu(args = {}) {
2018
2405
  cmdBrand()
2019
2406
  await pause()
2020
2407
  }
2408
+ else if (action === "rollback") await menuRollback()
2021
2409
  else if (action === "logout") await menuLogout()
2022
2410
  }
2023
2411
  }
@@ -2043,8 +2431,12 @@ async function main() {
2043
2431
  try {
2044
2432
  if (cmd === "login") return await cmdLogin(args)
2045
2433
  if (cmd === "models") return await cmdModels(args)
2434
+ if (cmd === "tools") return await cmdTools(args)
2435
+ if (cmd === "init") return await cmdInit(args)
2436
+ if (cmd === "rollback") return await cmdRollback(args)
2046
2437
  if (cmd === "keys") return await cmdKeys(args)
2047
2438
  if (cmd === "config") return await cmdConfig(args)
2439
+ if (cmd === "credential") return await cmdCredential(args)
2048
2440
  if (cmd === "chat") return await cmdChat(args)
2049
2441
  if (cmd === "bench") return await cmdBench(args)
2050
2442
  if (cmd === "doctor") return await cmdDoctor(args)
@@ -0,0 +1,379 @@
1
+ "use strict"
2
+
3
+ const fs = require("fs")
4
+ const fsp = require("fs/promises")
5
+ const os = require("os")
6
+ const path = require("path")
7
+
8
+ const DEFAULT_API_BASE = "https://api.lai.vc/v1"
9
+ const DEFAULT_MODEL = "gpt-5.5"
10
+
11
+ function homeDir(env = process.env) {
12
+ return env.HOME || env.USERPROFILE || os.homedir()
13
+ }
14
+
15
+ function expandHome(value, env = process.env) {
16
+ if (!value) return value
17
+ if (value === "~") return homeDir(env)
18
+ if (value.startsWith("~/")) return path.join(homeDir(env), value.slice(2))
19
+ return value
20
+ }
21
+
22
+ function exists(file) {
23
+ try {
24
+ fs.accessSync(file)
25
+ return true
26
+ } catch {
27
+ return false
28
+ }
29
+ }
30
+
31
+ function commandExists(cmd, env = process.env) {
32
+ const pathEnv = String(env.PATH || "")
33
+ const dirs = pathEnv.split(path.delimiter).filter(Boolean)
34
+ const exts =
35
+ process.platform === "win32"
36
+ ? String(env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
37
+ : [""]
38
+ for (const dir of dirs) {
39
+ for (const ext of exts) {
40
+ if (exists(path.join(dir, cmd + ext))) return true
41
+ }
42
+ }
43
+ return false
44
+ }
45
+
46
+ function codexHome(env = process.env) {
47
+ return env.CODEX_HOME ? expandHome(env.CODEX_HOME, env) : path.join(homeDir(env), ".codex")
48
+ }
49
+
50
+ function continueHome(env = process.env) {
51
+ return path.join(homeDir(env), ".continue")
52
+ }
53
+
54
+ function platformConfigDir(appName, env = process.env) {
55
+ if (process.platform === "darwin") return path.join(homeDir(env), "Library", "Application Support", appName)
56
+ if (process.platform === "win32") return path.join(env.APPDATA || path.join(homeDir(env), "AppData", "Roaming"), appName)
57
+ return path.join(env.XDG_CONFIG_HOME || path.join(homeDir(env), ".config"), appName)
58
+ }
59
+
60
+ function detectTools(opts = {}) {
61
+ const env = opts.env || process.env
62
+ const codexDir = codexHome(env)
63
+ const continueDir = continueHome(env)
64
+ const cursorDir = platformConfigDir("Cursor", env)
65
+ const codeDir = platformConfigDir("Code", env)
66
+ const vscodeExtensions = path.join(homeDir(env), ".vscode", "extensions")
67
+
68
+ const continueYaml = path.join(continueDir, "config.yaml")
69
+ const continueJson = path.join(continueDir, "config.json")
70
+ const clineDetected =
71
+ exists(path.join(codeDir, "User", "globalStorage", "saoudrizwan.claude-dev")) ||
72
+ exists(path.join(codeDir, "User", "globalStorage", "cline.cline")) ||
73
+ (exists(vscodeExtensions) &&
74
+ fs.readdirSync(vscodeExtensions).some((name) => /(^|\.)(cline|claude-dev)/i.test(name)))
75
+
76
+ return [
77
+ {
78
+ id: "codex",
79
+ name: "Codex CLI",
80
+ detected: commandExists("codex", env) || exists(codexDir),
81
+ support: "auto",
82
+ status: exists(path.join(codexDir, "laicode.config.toml")) ? "已接入 profile" : "可一键接入",
83
+ configPath: path.join(codexDir, "laicode.config.toml"),
84
+ next: "laicode init --tool codex --apply",
85
+ },
86
+ {
87
+ id: "continue",
88
+ name: "Continue",
89
+ detected: exists(continueDir),
90
+ support: exists(continueYaml) || exists(continueJson) ? "manual-merge" : "auto-new",
91
+ status: exists(continueYaml) || exists(continueJson) ? "检测到现有配置,先预览手动合并" : "可创建新配置",
92
+ configPath: exists(continueYaml) ? continueYaml : exists(continueJson) ? continueJson : continueYaml,
93
+ next: "laicode init --tool continue --apply",
94
+ },
95
+ {
96
+ id: "cline",
97
+ name: "Cline",
98
+ detected: clineDetected,
99
+ support: "guide",
100
+ status: clineDetected ? "需在扩展 UI 中选择 OpenAI Compatible" : "未检测到",
101
+ configPath: path.join(codeDir, "User"),
102
+ next: "laicode init --tool cline",
103
+ },
104
+ {
105
+ id: "cursor",
106
+ name: "Cursor",
107
+ detected: exists(cursorDir) || commandExists("cursor", env),
108
+ support: "guide",
109
+ status: exists(cursorDir) || commandExists("cursor", env) ? "密钥在安全存储,暂不脚本写入" : "未检测到",
110
+ configPath: path.join(cursorDir, "User"),
111
+ next: "laicode init --tool cursor",
112
+ },
113
+ ]
114
+ }
115
+
116
+ function tomlString(value) {
117
+ return JSON.stringify(String(value))
118
+ }
119
+
120
+ function yamlString(value) {
121
+ return JSON.stringify(String(value))
122
+ }
123
+
124
+ function codexProfileContent(ctx) {
125
+ const model = ctx.model || DEFAULT_MODEL
126
+ const apiBaseUrl = ctx.apiBaseUrl || DEFAULT_API_BASE
127
+ return [
128
+ "# Created by Laicode. Remove this file or run `laicode rollback` to undo.",
129
+ `model = ${tomlString(model)}`,
130
+ 'model_provider = "laivc"',
131
+ "",
132
+ "[model_providers.laivc]",
133
+ 'name = "Lai.vc"',
134
+ `base_url = ${tomlString(apiBaseUrl)}`,
135
+ "",
136
+ "[model_providers.laivc.auth]",
137
+ `command = ${tomlString(ctx.commandName || "laicode")}`,
138
+ 'args = ["credential", "api-key"]',
139
+ "timeout_ms = 5000",
140
+ "refresh_interval_ms = 300000",
141
+ "",
142
+ ].join("\n")
143
+ }
144
+
145
+ function continueConfigContent(ctx) {
146
+ const model = ctx.model || DEFAULT_MODEL
147
+ const apiBaseUrl = ctx.apiBaseUrl || DEFAULT_API_BASE
148
+ const apiKey = ctx.apiKey || "sk-..."
149
+ return [
150
+ "name: LaiCode",
151
+ "version: 0.0.1",
152
+ "schema: v1",
153
+ "models:",
154
+ ` - name: ${yamlString(`Lai.vc ${model}`)}`,
155
+ " provider: openai",
156
+ ` model: ${yamlString(model)}`,
157
+ ` apiBase: ${yamlString(apiBaseUrl)}`,
158
+ ` apiKey: ${yamlString(apiKey)}`,
159
+ " useResponsesApi: false",
160
+ "",
161
+ ].join("\n")
162
+ }
163
+
164
+ function manualPlan(tool, ctx, reason, instructions) {
165
+ return {
166
+ tool,
167
+ ok: false,
168
+ mode: "manual",
169
+ reason,
170
+ instructions,
171
+ operations: [],
172
+ apiBaseUrl: ctx.apiBaseUrl || DEFAULT_API_BASE,
173
+ model: ctx.model || DEFAULT_MODEL,
174
+ }
175
+ }
176
+
177
+ function buildInitPlan(tool, ctx = {}) {
178
+ const env = ctx.env || process.env
179
+ const normalized = String(tool || "codex").toLowerCase()
180
+ const apiBaseUrl = ctx.apiBaseUrl || DEFAULT_API_BASE
181
+ const model = ctx.model || DEFAULT_MODEL
182
+
183
+ if (normalized === "codex") {
184
+ const target = path.join(codexHome(env), "laicode.config.toml")
185
+ return {
186
+ tool: "codex",
187
+ ok: true,
188
+ mode: "profile",
189
+ summary: "创建 Codex 独立 profile,不改主配置和官方登录态。",
190
+ apiBaseUrl,
191
+ model,
192
+ runCommand: "codex --profile laicode",
193
+ operations: [
194
+ {
195
+ action: "write",
196
+ path: target,
197
+ mode: 0o600,
198
+ description: "Codex Lai.vc profile",
199
+ content: codexProfileContent({ ...ctx, apiBaseUrl, model }),
200
+ },
201
+ ],
202
+ }
203
+ }
204
+
205
+ if (normalized === "continue") {
206
+ const target = path.join(continueHome(env), "config.yaml")
207
+ const existingYaml = exists(target)
208
+ const existingJson = exists(path.join(continueHome(env), "config.json"))
209
+ if ((existingYaml || existingJson) && !ctx.force) {
210
+ return manualPlan("continue", { ...ctx, apiBaseUrl, model }, "检测到 Continue 现有配置,当前版本不自动合并 YAML/JSON。", [
211
+ "打开 Continue 配置文件。",
212
+ `新增 provider=openai, apiBase=${apiBaseUrl}, model=${model}, useResponsesApi=false。`,
213
+ "后续版本会在引入结构化 YAML 合并后支持自动 patch。",
214
+ ])
215
+ }
216
+ return {
217
+ tool: "continue",
218
+ ok: true,
219
+ mode: "new-config",
220
+ summary: "创建 Continue config.yaml,使用 OpenAI compatible provider。",
221
+ apiBaseUrl,
222
+ model,
223
+ operations: [
224
+ {
225
+ action: "write",
226
+ path: target,
227
+ mode: 0o600,
228
+ description: "Continue Lai.vc config",
229
+ content: continueConfigContent({ ...ctx, apiBaseUrl, model }),
230
+ },
231
+ ],
232
+ }
233
+ }
234
+
235
+ if (normalized === "cline") {
236
+ return manualPlan("cline", { ...ctx, apiBaseUrl, model }, "Cline 设置目前由 VS Code 扩展 UI 管理,先提供可靠指引。", [
237
+ "打开 Cline 设置。",
238
+ "API Provider 选择 OpenAI Compatible。",
239
+ `Base URL 填 ${apiBaseUrl}。`,
240
+ "API Key 使用 `laicode keys --show` 显式查看后粘贴。",
241
+ `Model ID 填 ${model}。`,
242
+ ])
243
+ }
244
+
245
+ if (normalized === "cursor") {
246
+ return manualPlan("cursor", { ...ctx, apiBaseUrl, model }, "Cursor API Key 存在安全存储中,当前不支持可靠脚本写入。", [
247
+ "打开 Cursor Settings -> Models。",
248
+ "添加 OpenAI API Key。",
249
+ `启用 Override OpenAI Base URL 并填写 ${apiBaseUrl}。`,
250
+ `添加并启用自定义模型 ${model}。`,
251
+ ])
252
+ }
253
+
254
+ return manualPlan(normalized, { ...ctx, apiBaseUrl, model }, `暂不支持工具: ${normalized}`, ["运行 `laicode tools` 查看当前支持矩阵。"])
255
+ }
256
+
257
+ function redactText(value, secrets = []) {
258
+ let out = String(value || "")
259
+ for (const secret of secrets.filter(Boolean)) {
260
+ out = out.split(secret).join(maskSecret(secret))
261
+ }
262
+ return out
263
+ }
264
+
265
+ function maskSecret(value) {
266
+ if (!value || value.length <= 16) return "***"
267
+ return `${value.slice(0, 8)}...${value.slice(-4)}`
268
+ }
269
+
270
+ function safePlan(plan, secrets = []) {
271
+ return {
272
+ ...plan,
273
+ operations: (plan.operations || []).map((op) => ({
274
+ action: op.action,
275
+ path: op.path,
276
+ mode: op.mode,
277
+ description: op.description,
278
+ preview: redactText(op.content, secrets),
279
+ exists: exists(op.path),
280
+ })),
281
+ }
282
+ }
283
+
284
+ function backupName(file) {
285
+ return Buffer.from(file).toString("base64url")
286
+ }
287
+
288
+ async function writeFileAtomic(file, content, mode = 0o600) {
289
+ await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
290
+ const tmp = `${file}.${process.pid}.tmp`
291
+ await fsp.writeFile(tmp, content, { mode })
292
+ try {
293
+ await fsp.chmod(tmp, mode)
294
+ } catch {
295
+ // Best effort for non-POSIX filesystems.
296
+ }
297
+ await fsp.rename(tmp, file)
298
+ }
299
+
300
+ async function applyInitPlan(plan, opts = {}) {
301
+ if (!plan.ok || !plan.operations?.length) {
302
+ throw new Error(plan.reason || "No operations to apply")
303
+ }
304
+ const stateDir = opts.stateDir || path.join(homeDir(), ".laicode")
305
+ const id = new Date().toISOString().replace(/[:.]/g, "-")
306
+ const backupDir = path.join(stateDir, "backups", id)
307
+ const applied = []
308
+
309
+ await fsp.mkdir(backupDir, { recursive: true, mode: 0o700 })
310
+ for (const op of plan.operations) {
311
+ if (op.action !== "write") throw new Error(`Unsupported operation: ${op.action}`)
312
+ const existed = exists(op.path)
313
+ const backupPath = path.join(backupDir, backupName(op.path))
314
+ if (existed) {
315
+ await fsp.mkdir(path.dirname(backupPath), { recursive: true })
316
+ await fsp.copyFile(op.path, backupPath)
317
+ }
318
+ await writeFileAtomic(op.path, op.content, op.mode || 0o600)
319
+ applied.push({
320
+ action: op.action,
321
+ path: op.path,
322
+ description: op.description,
323
+ existed,
324
+ backupPath: existed ? backupPath : null,
325
+ mode: op.mode || 0o600,
326
+ })
327
+ }
328
+
329
+ const manifest = {
330
+ id,
331
+ createdAt: new Date().toISOString(),
332
+ tool: plan.tool,
333
+ mode: plan.mode,
334
+ operations: applied,
335
+ }
336
+ await writeFileAtomic(path.join(backupDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", 0o600)
337
+ return manifest
338
+ }
339
+
340
+ async function rollbackLatest(opts = {}) {
341
+ const stateDir = opts.stateDir || path.join(homeDir(), ".laicode")
342
+ const backupsDir = path.join(stateDir, "backups")
343
+ let entries
344
+ try {
345
+ entries = await fsp.readdir(backupsDir, { withFileTypes: true })
346
+ } catch (err) {
347
+ if (err.code === "ENOENT") return null
348
+ throw err
349
+ }
350
+ const ids = entries
351
+ .filter((entry) => entry.isDirectory() && !entry.name.endsWith(".rolled-back"))
352
+ .map((entry) => entry.name)
353
+ .sort()
354
+ const id = ids[ids.length - 1]
355
+ if (!id) return null
356
+
357
+ const backupDir = path.join(backupsDir, id)
358
+ const manifest = JSON.parse(await fsp.readFile(path.join(backupDir, "manifest.json"), "utf8"))
359
+ for (const op of [...manifest.operations].reverse()) {
360
+ if (op.existed && op.backupPath) {
361
+ await fsp.mkdir(path.dirname(op.path), { recursive: true, mode: 0o700 })
362
+ await fsp.copyFile(op.backupPath, op.path)
363
+ } else if (!op.existed) {
364
+ await fsp.rm(op.path, { force: true })
365
+ }
366
+ }
367
+ await fsp.rename(backupDir, `${backupDir}.rolled-back`)
368
+ return manifest
369
+ }
370
+
371
+ module.exports = {
372
+ DEFAULT_API_BASE,
373
+ DEFAULT_MODEL,
374
+ applyInitPlan,
375
+ buildInitPlan,
376
+ detectTools,
377
+ rollbackLatest,
378
+ safePlan,
379
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laivc/laicode",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Official Lai.vc CLI for configuring developer tools.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://lai.vc",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "files": [
29
29
  "bin",
30
+ "lib",
30
31
  "README.md",
31
32
  "scripts"
32
33
  ],
@@ -5,14 +5,17 @@ const fs = require("fs")
5
5
  const os = require("os")
6
6
  const path = require("path")
7
7
 
8
- const home = path.join(os.tmpdir(), "laicode-npm-smoke-test")
8
+ const home = path.join(os.tmpdir(), `laicode-npm-smoke-test-${process.pid}`)
9
+ const codexHome = path.join(home, "codex")
9
10
  fs.rmSync(home, { recursive: true, force: true })
10
11
  fs.mkdirSync(home, { recursive: true, mode: 0o700 })
12
+ fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 })
11
13
  fs.writeFileSync(
12
14
  path.join(home, "config.json"),
13
15
  JSON.stringify(
14
16
  {
15
17
  apiBaseUrl: "https://api.lai.vc/v1",
18
+ apiKey: "sk-smoke-test-secret-value",
16
19
  defaultModel: "gpt-5.5",
17
20
  dashboardCache: {
18
21
  onlineModels: 8,
@@ -28,7 +31,7 @@ fs.writeFileSync(
28
31
  { mode: 0o600 }
29
32
  )
30
33
 
31
- const env = { ...process.env, LAICODE_HOME: home, LAICODE_PLAIN: "1" }
34
+ const env = { ...process.env, CODEX_HOME: codexHome, HOME: home, LAICODE_HOME: home, LAICODE_PLAIN: "1" }
32
35
 
33
36
  function run(args) {
34
37
  const result = spawnSync(process.execPath, args, { encoding: "utf8", env })
@@ -73,10 +76,13 @@ const help = run(["bin/laicode.js", "--help"])
73
76
  assertIncludes(help, "laicode status [--refresh] [--json]", "help lists status")
74
77
  assertIncludes(help, "laicode commands [--json]", "help lists commands")
75
78
  assertIncludes(help, "laicode completion [bash|zsh|fish|powershell]", "help lists completion")
79
+ assertIncludes(help, "laicode tools [--json]", "help lists tools")
80
+ assertIncludes(help, "laicode init [--tool codex] [--apply] [--json]", "help lists init")
76
81
  assertIncludes(help, "laicode doctor [--model gpt-5.5] [--json]", "help lists doctor json")
77
82
  assertIncludes(help, "laicode bench [--model gpt-5.5] [--count 3] [--json]", "help lists bench json")
78
- assertIncludes(run(["bin/laicode.js", "--version"]), "0.1.0", "long version")
79
- assertIncludes(run(["bin/laicode.js", "-v"]), "0.1.0", "short version")
83
+ assertIncludes(help, "LAICODE_NO_SPINNER", "help lists spinner toggle")
84
+ assertIncludes(run(["bin/laicode.js", "--version"]), "0.2.0", "long version")
85
+ assertIncludes(run(["bin/laicode.js", "-v"]), "0.2.0", "short version")
80
86
  assertIncludes(run(["bin/laicode.js", "--plain", "brand"]), "Lai.vc 终端字标", "plain brand command")
81
87
  assertIncludes(run(["bin/laicode.js", "--color", "brand"]), "品牌色板", "color brand command")
82
88
  assertIncludes(run(["bin/laicode.js", "status"]), "快照 缓存", "status uses local dashboard cache")
@@ -92,6 +98,37 @@ if (!Array.isArray(status.nextActions) || !status.nextActions.find((action) => a
92
98
  assertIncludes(run(["bin/laicode.js", "commands", "--json"]), '"name": "commands"', "commands json")
93
99
  assertIncludes(run(["bin/laicode.js", "completion", "bash"]), "complete -F _laicode_completion laicode", "bash completion")
94
100
  assertIncludes(run(["bin/laicode.js", "completion", "fish"]), "__fish_seen_subcommand_from bench", "fish completion")
101
+ const toolsJson = run(["bin/laicode.js", "tools", "--json"])
102
+ const tools = parseJson(toolsJson, "tools json parses")
103
+ if (!tools.tools.find((tool) => tool.id === "codex")) {
104
+ console.error("Smoke assertion failed: tools json includes codex")
105
+ process.exit(1)
106
+ }
107
+ const initPreviewJson = run(["bin/laicode.js", "init", "--tool", "codex", "--json"])
108
+ assertExcludes(initPreviewJson, "sk-smoke-test-secret-value", "init preview redacts api key")
109
+ const initPreview = parseJson(initPreviewJson, "init preview json parses")
110
+ if (initPreview.apply !== false || initPreview.plan.tool !== "codex") {
111
+ console.error("Smoke assertion failed: init preview describes codex plan")
112
+ process.exit(1)
113
+ }
114
+ const initApplyJson = run(["bin/laicode.js", "init", "--tool", "codex", "--apply", "--json"])
115
+ const initApply = parseJson(initApplyJson, "init apply json parses")
116
+ if (initApply.apply !== true || initApply.plan.tool !== "codex") {
117
+ console.error("Smoke assertion failed: init apply json describes codex plan")
118
+ process.exit(1)
119
+ }
120
+ const profilePath = path.join(codexHome, "laicode.config.toml")
121
+ if (!fs.existsSync(profilePath)) {
122
+ console.error("Smoke assertion failed: init apply writes codex profile")
123
+ process.exit(1)
124
+ }
125
+ assertIncludes(fs.readFileSync(profilePath, "utf8"), "model_provider = \"laivc\"", "codex profile provider")
126
+ const rollbackJson = run(["bin/laicode.js", "rollback", "--json"])
127
+ const rollback = parseJson(rollbackJson, "rollback json parses")
128
+ if (!rollback.ok || fs.existsSync(profilePath)) {
129
+ console.error("Smoke assertion failed: rollback removes created codex profile")
130
+ process.exit(1)
131
+ }
95
132
  const configJson = run(["bin/laicode.js", "config", "--json"])
96
133
  assertIncludes(configJson, '"defaultModel": "gpt-5.5"', "config json")
97
134
  assertExcludes(configJson, "sk-", "config json hides api keys")