@laivc/laicode 0.0.1 → 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/bin/laicode.js CHANGED
@@ -1,41 +1,2455 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const version = "0.0.1"
3
+ const fs = require("fs")
4
+ const fsp = require("fs/promises")
5
+ const os = require("os")
6
+ const path = require("path")
7
+ const readline = require("readline")
8
+ const { spawn } = require("child_process")
9
+ const {
10
+ applyInitPlan,
11
+ buildInitPlan,
12
+ detectTools,
13
+ rollbackLatest,
14
+ safePlan,
15
+ } = require("../lib/adapters")
4
16
 
5
- const help = `LaiCode ${version}
17
+ const VERSION = "0.2.0"
18
+ const DEFAULT_SITE_BASE = "https://lai.vc"
19
+ const DEFAULT_API_BASE = "https://api.lai.vc/v1"
20
+ const STATE_DIR = process.env.LAICODE_HOME || path.join(os.homedir(), ".laicode")
21
+ const CONFIG_FILE = path.join(STATE_DIR, "config.json")
22
+ const DASHBOARD_CACHE_TTL_MS = 60_000
6
23
 
7
- Official CLI for configuring local developer tools to use Lai.vc.
24
+ const COMMAND_SPECS = [
25
+ { name: "menu", usage: "laicode menu", summary: "打开交互控制台", flags: ["--fancy"] },
26
+ { name: "brand", usage: "laicode brand", summary: "预览终端字标和品牌色板", flags: ["--color", "--plain"] },
27
+ { name: "status", usage: "laicode status [--refresh] [--json]", summary: "查看实时驾驶舱状态", flags: ["--refresh", "--json"] },
28
+ { name: "commands", usage: "laicode commands [--json]", summary: "查看命令目录和 flag 清单", flags: ["--json"] },
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"] },
33
+ { name: "login", usage: "laicode login [--tier std] [--no-open]", summary: "通过浏览器设备授权登录", flags: ["--tier", "--no-open", "--no-key"] },
34
+ { name: "doctor", usage: "laicode doctor [--model gpt-5.5] [--json]", summary: "运行本机、会话、网关诊断", flags: ["--model", "--json"] },
35
+ { name: "models", usage: "laicode models [--json] [--online]", summary: "查看模型雷达和价格档位", flags: ["--json", "--online", "--category", "--vendor"] },
36
+ { name: "chat", usage: 'laicode chat [--model gpt-5.5] [--message "hello"]', summary: "流式测试单个模型", flags: ["--model", "--message", "-m"] },
37
+ { name: "bench", usage: "laicode bench [--model gpt-5.5] [--count 3] [--json]", summary: "压测网关延迟和吞吐", flags: ["--model", "--count", "-n", "--prompt", "--json"] },
38
+ { name: "keys", usage: "laicode keys [list|create|delete] [--show] [--yes]", summary: "管理 Lai.vc 测试 API Key", flags: ["list", "create", "delete", "--show", "--yes", "--json", "--name", "--tier", "--id"] },
39
+ { name: "config", usage: "laicode config [list|get|set|unset]", summary: "查看或修改本地配置", flags: ["list", "get", "set", "unset", "--json"] },
40
+ { name: "logout", usage: "laicode logout", summary: "撤销会话并清理本地缓存", flags: [] },
41
+ { name: "help", usage: "laicode help", summary: "显示帮助", flags: [] },
42
+ ]
8
43
 
9
- Usage:
10
- laicode --help
11
- laicode --version
12
- laicode doctor
44
+ const GLOBAL_FLAGS = ["--help", "--version", "--plain", "--color", "--fancy"]
45
+ const COMMANDS = COMMAND_SPECS.map((cmd) => cmd.name)
46
+ const COMMAND_FLAGS = Object.fromEntries(COMMAND_SPECS.map((cmd) => [cmd.name, cmd.flags || []]))
47
+ const ENV_VARS = [
48
+ ["LAICODE_HOME", "覆盖本地状态目录,默认 ~/.laicode"],
49
+ ["LAICODE_SITE_URL", "覆盖主站地址,默认 https://lai.vc"],
50
+ ["LAICODE_PLAIN", "设为 1 时禁用终端样式"],
51
+ ["LAICODE_COLOR", "设为 1 时忽略 NO_COLOR,强制彩色输出"],
52
+ ["LAICODE_NO_SPINNER", "设为 1 时禁用 loading 动画"],
53
+ ]
13
54
 
14
- The full cross-platform configurator is coming soon.
15
- Website: https://lai.vc
16
- API base URL: https://api.lai.vc/v1
55
+ function buildHelp() {
56
+ const usageLines = ["laicode", "laicode --fancy", "laicode --color", "laicode --plain", ...COMMAND_SPECS.map((cmd) => cmd.usage)]
57
+ return `LaiCode ${VERSION}
58
+
59
+ Lai.vc 中转测试控制台。
60
+
61
+ 用法:
62
+ ${usageLines.map((line) => ` ${line}`).join("\n")}
63
+
64
+ 命令:
65
+ ${COMMAND_SPECS.map((cmd) => ` ${padDisplay(cmd.name, 11)} ${cmd.summary}`).join("\n")}
66
+
67
+ 环境变量:
68
+ ${ENV_VARS.map(([name, detail]) => ` ${padDisplay(name, 18)} ${detail}`).join("\n")}
17
69
  `
70
+ }
71
+
72
+ const ANSI = {
73
+ reset: "\x1b[0m",
74
+ bold: "\x1b[1m",
75
+ dim: "\x1b[2m",
76
+ red: "\x1b[31m",
77
+ green: "\x1b[32m",
78
+ yellow: "\x1b[33m",
79
+ blue: "\x1b[34m",
80
+ magenta: "\x1b[35m",
81
+ cyan: "\x1b[36m",
82
+ gray: "\x1b[90m",
83
+ }
84
+
85
+ const BRAND = {
86
+ plum: "#3a1e3f",
87
+ plumDeep: "#2a1430",
88
+ coral: "#ff6f7d",
89
+ persimmon: "#ff8a4c",
90
+ ivory: "#fff7ef",
91
+ gold: "#d9c6a1",
92
+ ink: "#1c1c1f",
93
+ }
94
+
95
+ function plainMode() {
96
+ if (process.env.LAICODE_PLAIN === "1") return true
97
+ if (process.env.LAICODE_COLOR === "1") return false
98
+ return process.env.NO_COLOR
99
+ }
100
+
101
+ function color(code, text) {
102
+ if (!process.stdout.isTTY || plainMode()) return String(text)
103
+ return `${code}${text}${ANSI.reset}`
104
+ }
105
+
106
+ function hexColor(hex, text) {
107
+ if (!process.stdout.isTTY || plainMode()) return String(text)
108
+ const clean = hex.replace("#", "")
109
+ const r = parseInt(clean.slice(0, 2), 16)
110
+ const g = parseInt(clean.slice(2, 4), 16)
111
+ const b = parseInt(clean.slice(4, 6), 16)
112
+ return `\x1b[38;2;${r};${g};${b}m${text}${ANSI.reset}`
113
+ }
114
+
115
+ const ui = {
116
+ bold: (s) => color(ANSI.bold, s),
117
+ dim: (s) => color(ANSI.dim, s),
118
+ ok: (s) => color(ANSI.green, s),
119
+ warn: (s) => color(ANSI.yellow, s),
120
+ err: (s) => color(ANSI.red, s),
121
+ accent: (s) => hexColor(BRAND.coral, s),
122
+ hot: (s) => hexColor(BRAND.persimmon, s),
123
+ gold: (s) => hexColor(BRAND.gold, s),
124
+ brand: (s) => hexColor(BRAND.coral, s),
125
+ plum: (s) => hexColor(BRAND.plum, s),
126
+ muted: (s) => color(ANSI.gray, s),
127
+ }
128
+
129
+ const VALUE_FLAGS = new Set([
130
+ "tier",
131
+ "model",
132
+ "message",
133
+ "m",
134
+ "count",
135
+ "n",
136
+ "prompt",
137
+ "category",
138
+ "vendor",
139
+ "name",
140
+ "id",
141
+ "shell",
142
+ "tool",
143
+ ])
144
+
145
+ function parseArgs(argv) {
146
+ const out = { _: [] }
147
+ for (let i = 0; i < argv.length; i++) {
148
+ const a = argv[i]
149
+ if (!a.startsWith("-")) {
150
+ out._.push(a)
151
+ continue
152
+ }
153
+ if (a === "--") {
154
+ out._.push(...argv.slice(i + 1))
155
+ break
156
+ }
157
+ const eq = a.indexOf("=")
158
+ if (eq !== -1) {
159
+ out[a.slice(2, eq)] = a.slice(eq + 1)
160
+ continue
161
+ }
162
+ const key = a.replace(/^-+/, "")
163
+ const next = argv[i + 1]
164
+ if (VALUE_FLAGS.has(key) && next && !next.startsWith("-")) {
165
+ out[key] = next
166
+ i++
167
+ } else {
168
+ out[key] = true
169
+ }
170
+ }
171
+ return out
172
+ }
173
+
174
+ function nowIso() {
175
+ return new Date().toISOString()
176
+ }
177
+
178
+ function ageMs(iso) {
179
+ const ts = Date.parse(iso || "")
180
+ if (!Number.isFinite(ts)) return Infinity
181
+ return Date.now() - ts
182
+ }
183
+
184
+ function formatAge(iso) {
185
+ const age = ageMs(iso)
186
+ if (!Number.isFinite(age)) return "未知"
187
+ if (age < 10_000) return "刚刚"
188
+ if (age < 60_000) return `${Math.round(age / 1000)}s前`
189
+ if (age < 3_600_000) return `${Math.round(age / 60_000)}m前`
190
+ return `${Math.round(age / 3_600_000)}h前`
191
+ }
192
+
193
+ function formatMoney(value) {
194
+ if (value == null || Number.isNaN(Number(value))) return "-"
195
+ return `$${Number(value).toFixed(2)}`
196
+ }
197
+
198
+ function formatMs(value) {
199
+ if (value == null || Number.isNaN(Number(value))) return "-"
200
+ return `${Math.round(Number(value))}ms`
201
+ }
202
+
203
+ function latencyBar(value, maxValue, width = 14) {
204
+ if (!value || !maxValue) return "-".repeat(width)
205
+ const ratio = Math.max(0.05, Math.min(1, Number(value) / Number(maxValue)))
206
+ const filled = Math.max(1, Math.round(ratio * width))
207
+ return `${ui.accent("█".repeat(filled))}${ui.dim("░".repeat(Math.max(0, width - filled)))}`
208
+ }
209
+
210
+ function percentile(values, pct) {
211
+ const sorted = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b)
212
+ if (!sorted.length) return null
213
+ const index = Math.ceil((pct / 100) * sorted.length) - 1
214
+ return sorted[Math.max(0, Math.min(sorted.length - 1, index))]
215
+ }
216
+
217
+ function sleep(ms) {
218
+ return new Promise((resolve) => setTimeout(resolve, ms))
219
+ }
220
+
221
+ function maskSecret(value) {
222
+ if (!value || typeof value !== "string") return value
223
+ if (value.length <= 16) return "***"
224
+ return `${value.slice(0, 8)}...${value.slice(-4)}`
225
+ }
226
+
227
+ function compareVersions(a, b) {
228
+ const pa = String(a || "0").split(".").map((n) => Number(n) || 0)
229
+ const pb = String(b || "0").split(".").map((n) => Number(n) || 0)
230
+ const len = Math.max(pa.length, pb.length)
231
+ for (let i = 0; i < len; i++) {
232
+ const da = pa[i] || 0
233
+ const db = pb[i] || 0
234
+ if (da > db) return 1
235
+ if (da < db) return -1
236
+ }
237
+ return 0
238
+ }
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
+
343
+ function die(message, code = 1) {
344
+ if (activeSpinnerCleanup) activeSpinnerCleanup()
345
+ console.error(message)
346
+ process.exit(code)
347
+ }
348
+
349
+ function terminalWidth() {
350
+ return Math.min(process.stdout.columns || 88, 110)
351
+ }
352
+
353
+ function stripAnsi(text) {
354
+ return String(text).replace(/\x1b\[[0-9;]*m/g, "")
355
+ }
356
+
357
+ function charWidth(ch) {
358
+ const code = ch.codePointAt(0)
359
+ if (code === 0) return 0
360
+ if (code < 32 || (code >= 0x7f && code < 0xa0)) return 0
361
+ if (
362
+ code >= 0x1100 &&
363
+ (code <= 0x115f ||
364
+ code === 0x2329 ||
365
+ code === 0x232a ||
366
+ (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
367
+ (code >= 0xac00 && code <= 0xd7a3) ||
368
+ (code >= 0xf900 && code <= 0xfaff) ||
369
+ (code >= 0xfe10 && code <= 0xfe19) ||
370
+ (code >= 0xfe30 && code <= 0xfe6f) ||
371
+ (code >= 0xff00 && code <= 0xff60) ||
372
+ (code >= 0xffe0 && code <= 0xffe6) ||
373
+ (code >= 0x1f300 && code <= 0x1f64f) ||
374
+ (code >= 0x1f900 && code <= 0x1f9ff))
375
+ ) {
376
+ return 2
377
+ }
378
+ return 1
379
+ }
380
+
381
+ function displayWidth(value) {
382
+ let width = 0
383
+ for (const ch of stripAnsi(value)) width += charWidth(ch)
384
+ return width
385
+ }
386
+
387
+ function padDisplay(value, width, align = "left") {
388
+ const text = String(value ?? "")
389
+ const missing = Math.max(0, width - displayWidth(text))
390
+ if (align === "right") return `${" ".repeat(missing)}${text}`
391
+ if (align === "center") {
392
+ const left = Math.floor(missing / 2)
393
+ return `${" ".repeat(left)}${text}${" ".repeat(missing - left)}`
394
+ }
395
+ return `${text}${" ".repeat(missing)}`
396
+ }
397
+
398
+ function line(char = "-") {
399
+ return char.repeat(terminalWidth())
400
+ }
401
+
402
+ function brandGlyph() {
403
+ if (plainMode()) return "LAI"
404
+ return `${ui.plum("◢")}${ui.accent("◣")}${ui.hot("◤")}${ui.gold("◥")}`
405
+ }
406
+
407
+ function printBrandMark() {
408
+ const width = Math.min(terminalWidth(), 96)
409
+ const title = `${brandGlyph()} ${ui.bold("LAICODE")} ${ui.dim("·")} ${ui.gold("来AI")}`
410
+ const tagline = "生产级 AI 聚合中转测试控制台"
411
+ console.log(`╭${"─".repeat(width - 2)}╮`)
412
+ console.log(`│ ${padDisplay(title, width - 4)} │`)
413
+ console.log(`│ ${padDisplay(ui.dim(tagline), width - 4)} │`)
414
+ console.log(`╰${"─".repeat(width - 2)}╯`)
415
+ }
416
+
417
+ function printBrandPalette() {
418
+ const rows = [
419
+ `${ui.plum("■")} Plum ${BRAND.plum} 主品牌 / 控制台底色`,
420
+ `${ui.accent("■")} Coral ${BRAND.coral} 操作 / 高亮 / 路由热区`,
421
+ `${ui.hot("■")} Persimmon ${BRAND.persimmon} 流量 / 压测 / 动态`,
422
+ `${ui.gold("■")} Gold ${BRAND.gold} 高级 / 完成 / 品牌光泽`,
423
+ `${ui.dim("■")} Ivory ${BRAND.ivory} 背景 / 文档 / 对比`,
424
+ ]
425
+ printPanel("品牌色板", rows)
426
+ }
427
+
428
+ function cmdBrand() {
429
+ printHero("品牌预览")
430
+ printPanel("Lai.vc 终端字标", [
431
+ `${brandGlyph()} LAICODE · 来AI`,
432
+ "生产级 AI 聚合中转测试控制台",
433
+ "一个接口,来连万象",
434
+ ])
435
+ printBrandPalette()
436
+ console.log("")
437
+ console.log(ui.dim("提示: 当前环境若设置 NO_COLOR=1,可使用 `laicode --color brand` 强制预览彩色。"))
438
+ }
439
+
440
+ function shellWords(words) {
441
+ return words.join(" ")
442
+ }
443
+
444
+ function completionShell(args = {}) {
445
+ const requested = args._[1] || args.shell
446
+ if (requested) return String(requested).toLowerCase()
447
+ const shell = path.basename(process.env.SHELL || "").toLowerCase()
448
+ if (shell.includes("zsh")) return "zsh"
449
+ if (shell.includes("fish")) return "fish"
450
+ if (process.platform === "win32") return "powershell"
451
+ return "bash"
452
+ }
453
+
454
+ function cmdCompletion(args = {}) {
455
+ const shell = completionShell(args)
456
+ const commands = shellWords(COMMANDS)
457
+ const globalFlags = shellWords(GLOBAL_FLAGS)
458
+ const allFlags = shellWords([...new Set(Object.values(COMMAND_FLAGS).flat())].filter((item) => item.startsWith("-")))
459
+ const commandFlagsCase = Object.entries(COMMAND_FLAGS)
460
+ .map(([cmd, flags]) => ` ${cmd}) opts="${shellWords(flags)}" ;;`)
461
+ .join("\n")
462
+
463
+ if (shell === "bash") {
464
+ console.log(`# LaiCode bash completion
465
+ _laicode_completion() {
466
+ local cur prev cmd opts
467
+ COMPREPLY=()
468
+ cur="\${COMP_WORDS[COMP_CWORD]}"
469
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
470
+ cmd="\${COMP_WORDS[1]}"
471
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
472
+ COMPREPLY=( $(compgen -W "${commands} ${globalFlags}" -- "$cur") )
473
+ return 0
474
+ fi
475
+ case "$cmd" in
476
+ ${commandFlagsCase}
477
+ *) opts="${globalFlags} ${allFlags}" ;;
478
+ esac
479
+ COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
480
+ }
481
+ complete -F _laicode_completion laicode`)
482
+ return
483
+ }
484
+
485
+ if (shell === "zsh") {
486
+ const zshCommands = COMMANDS.map((cmd) => ` '${cmd}:${cmd}'`).join(" \\\n")
487
+ console.log(`#compdef laicode
488
+ # LaiCode zsh completion
489
+ _laicode() {
490
+ local -a commands
491
+ commands=(
492
+ ${zshCommands}
493
+ )
494
+ if (( CURRENT == 2 )); then
495
+ _describe 'command' commands
496
+ return
497
+ fi
498
+ local cmd="$words[2]"
499
+ case "$cmd" in
500
+ ${Object.entries(COMMAND_FLAGS)
501
+ .map(([cmd, flags]) => ` ${cmd}) _values 'options' ${flags.map((flag) => `'${flag}'`).join(" ")} ;;`)
502
+ .join("\n")}
503
+ *) _values 'options' ${GLOBAL_FLAGS.map((flag) => `'${flag}'`).join(" ")} ;;
504
+ esac
505
+ }
506
+ _laicode "$@"`)
507
+ return
508
+ }
509
+
510
+ if (shell === "fish") {
511
+ const lines = [
512
+ "# LaiCode fish completion",
513
+ "complete -c laicode -f",
514
+ `complete -c laicode -n "__fish_use_subcommand" -a "${commands}"`,
515
+ ...GLOBAL_FLAGS.map((flag) => `complete -c laicode -l ${flag.replace(/^--/, "")}`),
516
+ ]
517
+ for (const [cmd, flags] of Object.entries(COMMAND_FLAGS)) {
518
+ for (const flag of flags) {
519
+ if (flag.startsWith("--")) lines.push(`complete -c laicode -n "__fish_seen_subcommand_from ${cmd}" -l ${flag.replace(/^--/, "")}`)
520
+ else if (flag.startsWith("-")) lines.push(`complete -c laicode -n "__fish_seen_subcommand_from ${cmd}" -s ${flag.replace(/^-/, "")}`)
521
+ else lines.push(`complete -c laicode -n "__fish_seen_subcommand_from ${cmd}" -a "${flag}"`)
522
+ }
523
+ }
524
+ console.log(lines.join("\n"))
525
+ return
526
+ }
527
+
528
+ if (shell === "powershell" || shell === "pwsh") {
529
+ console.log(`# LaiCode PowerShell completion
530
+ Register-ArgumentCompleter -Native -CommandName laicode -ScriptBlock {
531
+ param($wordToComplete, $commandAst, $cursorPosition)
532
+ $commands = '${commands}'.Split(' ')
533
+ $globalFlags = '${globalFlags}'.Split(' ')
534
+ $map = @{
535
+ ${Object.entries(COMMAND_FLAGS)
536
+ .map(([cmd, flags]) => ` '${cmd}' = '${shellWords(flags)}'.Split(' ')`)
537
+ .join("\n")}
538
+ }
539
+ $words = $commandAst.CommandElements | ForEach-Object { $_.Extent.Text }
540
+ if ($words.Count -le 2) { $candidates = $commands + $globalFlags }
541
+ else {
542
+ $cmd = $words[1]
543
+ $candidates = if ($map.ContainsKey($cmd)) { $map[$cmd] } else { $globalFlags }
544
+ }
545
+ $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
546
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
547
+ }
548
+ }`)
549
+ return
550
+ }
551
+
552
+ die("Usage: laicode completion [bash|zsh|fish|powershell]")
553
+ }
554
+
555
+ function commandCatalog() {
556
+ return COMMAND_SPECS.map((cmd) => ({
557
+ name: cmd.name,
558
+ usage: cmd.usage,
559
+ summary: cmd.summary,
560
+ flags: cmd.flags || [],
561
+ }))
562
+ }
563
+
564
+ function cmdCommands(args = {}) {
565
+ const commands = commandCatalog()
566
+ if (args.json) {
567
+ console.log(JSON.stringify({ version: VERSION, commands, globalFlags: GLOBAL_FLAGS }, null, 2))
568
+ return
569
+ }
570
+
571
+ printTable(
572
+ "命令目录",
573
+ [
574
+ { key: "name", label: "命令", width: 10, max: 16 },
575
+ { key: "summary", label: "说明", width: 28, max: 42 },
576
+ { key: "flags", label: "参数 / 子命令", width: 34, max: 52 },
577
+ ],
578
+ commands.map((cmd) => ({
579
+ name: cmd.name,
580
+ summary: cmd.summary,
581
+ flags: cmd.flags.length ? cmd.flags.join(" ") : "-",
582
+ }))
583
+ )
584
+ }
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
+
736
+ function printHero(subtitle = "Lai.vc 中转控制台") {
737
+ console.clear()
738
+ printBrandMark()
739
+ console.log(ui.bold(subtitle) + ui.dim(` v${VERSION}`))
740
+ console.log(ui.muted(line("─")))
741
+ }
742
+
743
+ function printPanel(title, rows) {
744
+ const width = Math.min(terminalWidth(), 96)
745
+ console.log(`╭${"─".repeat(width - 2)}╮`)
746
+ console.log(`│ ${padDisplay(title, width - 4)} │`)
747
+ console.log(`├${"─".repeat(width - 2)}┤`)
748
+ for (const row of rows) {
749
+ const text = String(row)
750
+ console.log(`│ ${padDisplay(text, width - 4)} │`)
751
+ }
752
+ console.log(`╰${"─".repeat(width - 2)}╯`)
753
+ }
754
+
755
+ function truncateDisplay(value, width) {
756
+ const text = String(value ?? "")
757
+ let out = ""
758
+ let used = 0
759
+ for (const ch of text) {
760
+ const w = charWidth(ch)
761
+ if (used + w > width - 1) return `${out}…`
762
+ out += ch
763
+ used += w
764
+ }
765
+ return out
766
+ }
767
+
768
+ function printTable(title, columns, rows) {
769
+ const widths = columns.map((col) => {
770
+ const min = displayWidth(col.label)
771
+ const content = rows.map((row) => displayWidth(row[col.key] ?? ""))
772
+ return Math.min(col.max || 32, Math.max(col.width || 0, min, ...content))
773
+ })
774
+ const total = widths.reduce((sum, w) => sum + w, 0) + columns.length * 3 + 1
775
+ const boxWidth = Math.min(Math.max(total, displayWidth(title) + 4), terminalWidth())
776
+
777
+ const border = (left, fill, sep, right) => {
778
+ let s = left
779
+ for (let i = 0; i < widths.length; i++) {
780
+ s += fill.repeat(widths[i] + 2)
781
+ s += i === widths.length - 1 ? right : sep
782
+ }
783
+ return s
784
+ }
785
+
786
+ console.log(`╭${"─".repeat(Math.max(2, boxWidth - 2))}╮`)
787
+ console.log(`│ ${padDisplay(title, Math.max(0, boxWidth - 4))} │`)
788
+ console.log(border("├", "─", "┬", "┤"))
789
+ console.log(
790
+ "│" +
791
+ columns
792
+ .map((col, i) => ` ${padDisplay(col.label, widths[i], col.align)} `)
793
+ .join("│") +
794
+ "│"
795
+ )
796
+ console.log(border("├", "─", "┼", "┤"))
797
+ for (const row of rows) {
798
+ console.log(
799
+ "│" +
800
+ columns
801
+ .map((col, i) => {
802
+ const raw = row[col.key] ?? ""
803
+ const text = displayWidth(raw) > widths[i] ? truncateDisplay(raw, widths[i]) : raw
804
+ return ` ${padDisplay(text, widths[i], col.align)} `
805
+ })
806
+ .join("│") +
807
+ "│"
808
+ )
809
+ }
810
+ console.log(border("╰", "─", "┴", "╯"))
811
+ }
812
+
813
+ function statusWord(ok, good = "就绪", bad = "缺失") {
814
+ return ok ? ui.ok(good) : ui.warn(bad)
815
+ }
816
+
817
+ function statusDot(ok) {
818
+ return ok ? ui.ok("●") : ui.warn("●")
819
+ }
820
+
821
+ function printDashboard(cfg, extra = {}) {
822
+ const rows = [
823
+ `${statusDot(Boolean(cfg.accessToken))} 账号 ${cfg.user?.email || cfg.user?.name || "未登录"}`,
824
+ `${statusDot(Boolean(cfg.accessToken))} 会话 ${cfg.accessToken ? "已授权" : "需要登录"}`,
825
+ `${statusDot(Boolean(cfg.apiKey))} 密钥 ${cfg.apiKey ? `${maskSecret(cfg.apiKey)} ${ui.dim(cfg.apiKeyName || "")}` : "未配置"}`,
826
+ `${statusDot(Boolean(cfg.defaultModel))} 模型 ${cfg.defaultModel || "未选择"}`,
827
+ `${ui.accent("●")} 网关 ${apiBase(cfg)}`,
828
+ ]
829
+ if (extra.onlineModels != null) rows.push(`${ui.gold("●")} 雷达 在线模型 ${extra.onlineModels} 个`)
830
+ if (extra.balance != null) rows.push(`${ui.gold("●")} 余额 $${Number(extra.balance || 0).toFixed(4)}`)
831
+ if (extra.requests != null) rows.push(`${ui.hot("●")} 请求 ${extra.requests}`)
832
+ if (extra.version) rows.push(`${ui.accent("●")} 版本 ${VERSION} · ${extra.version}`)
833
+ if (extra.checkedAt) {
834
+ const source = extra.stale ? "旧缓存" : extra.cached ? "缓存" : "实时"
835
+ rows.push(`${ui.muted("●")} 快照 ${source} · ${formatAge(extra.checkedAt)}`)
836
+ }
837
+ printPanel("驾驶舱状态", rows)
838
+ }
839
+
840
+ function nextActions(cfg) {
841
+ const actions = []
842
+ if (!cfg.accessToken) {
843
+ actions.push({ command: "laicode login", reason: "浏览器授权,自动准备测试 API Key" })
844
+ } else if (!cfg.apiKey) {
845
+ actions.push({ command: "laicode keys create", reason: "创建或缓存 Lai.vc 测试 API Key" })
846
+ }
847
+ if (!cfg.defaultModel) {
848
+ actions.push({ command: "laicode models --online", reason: "查看在线模型并选择默认模型" })
849
+ }
850
+ if (cfg.accessToken && cfg.apiKey && cfg.defaultModel) {
851
+ actions.push({ command: "laicode chat", reason: `使用 ${cfg.defaultModel} 做流式对话测试` })
852
+ actions.push({ command: "laicode bench", reason: `压测 ${cfg.defaultModel} 的首 token 和总耗时` })
853
+ actions.push({ command: "laicode doctor", reason: "验证会话、密钥和真实模型调用" })
854
+ }
855
+ return actions
856
+ }
857
+
858
+ function printNextActions(cfg) {
859
+ const actions = nextActions(cfg)
860
+ if (!actions.length) return
861
+ printPanel(
862
+ "下一步",
863
+ actions.map((action) => `${padDisplay(action.command, 26)} ${action.reason}`)
864
+ )
865
+ }
866
+
867
+ async function question(prompt, fallback = "") {
868
+ if (!process.stdin.isTTY) return fallback
869
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
870
+ const value = await new Promise((resolve) => rl.question(prompt, resolve))
871
+ rl.close()
872
+ return value || fallback
873
+ }
874
+
875
+ async function pause(message = "Press Enter to continue...") {
876
+ if (!process.stdin.isTTY) return
877
+ await question(ui.dim(message))
878
+ }
879
+
880
+ async function selectMenu(title, items, opts = {}) {
881
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
882
+ return items[0]?.value
883
+ }
884
+
885
+ if (!opts.fancy) {
886
+ if (!opts.noHero) printHero(title)
887
+ else console.log(ui.bold(title))
888
+ if (opts.subtitle) console.log(`${opts.subtitle}\n`)
889
+ for (let i = 0; i < items.length; i++) {
890
+ const item = items[i]
891
+ const n = String(i + 1).padStart(2, " ")
892
+ const label = item.value === opts.cancelValue ? ui.dim(item.label) : ui.bold(item.label)
893
+ const desc = item.description ? ui.dim(` ${item.description}`) : ""
894
+ console.log(`${ui.accent(n)} ${label}${desc}`)
895
+ }
896
+ console.log("")
897
+ const answer = await question(ui.dim("输入编号,或 q 退出: "), "")
898
+ if (!answer || answer.toLowerCase() === "q" || answer === "0") return opts.cancelValue
899
+ const picked = Number(answer)
900
+ if (!Number.isInteger(picked) || picked < 1 || picked > items.length) return opts.cancelValue
901
+ return items[picked - 1].value
902
+ }
903
+
904
+ let index = Math.max(0, items.findIndex((item) => item.value === opts.defaultValue))
905
+ if (index < 0) index = 0
906
+
907
+ const render = () => {
908
+ if (!opts.noHero) printHero(title)
909
+ else console.log(ui.bold(title))
910
+ if (opts.subtitle) console.log(`${opts.subtitle}\n`)
911
+ for (let i = 0; i < items.length; i++) {
912
+ const item = items[i]
913
+ const selected = i === index
914
+ const cursor = selected ? ui.accent(">") : " "
915
+ const label = selected ? ui.bold(item.label) : item.label
916
+ const desc = item.description ? ui.dim(` ${item.description}`) : ""
917
+ console.log(`${cursor} ${label}${desc}`)
918
+ }
919
+ console.log("")
920
+ console.log(ui.muted("Use Up/Down, Enter to select, q/0/Esc to quit."))
921
+ }
922
+
923
+ return new Promise((resolve) => {
924
+ const input = process.stdin
925
+ readline.emitKeypressEvents(input)
926
+ const wasRaw = input.isRaw
927
+ input.setRawMode(true)
928
+ input.resume()
929
+ render()
930
+
931
+ const cleanup = (value) => {
932
+ input.setRawMode(wasRaw || false)
933
+ input.off("keypress", onKey)
934
+ input.off("data", onData)
935
+ console.log("")
936
+ resolve(value)
937
+ }
938
+
939
+ const onData = (chunk) => {
940
+ const s = chunk.toString("utf8")
941
+ if (s === "q" || s === "0" || s === "x" || s === "\u0003") {
942
+ cleanup(opts.cancelValue)
943
+ }
944
+ }
945
+
946
+ const onKey = (_str, key) => {
947
+ if (!key) return
948
+ if (key.name === "up") {
949
+ index = (index - 1 + items.length) % items.length
950
+ render()
951
+ } else if (key.name === "down") {
952
+ index = (index + 1) % items.length
953
+ render()
954
+ } else if (key.name === "return") {
955
+ cleanup(items[index].value)
956
+ } else if (key.name === "q" || key.name === "0" || key.name === "x" || (key.ctrl && key.name === "c") || key.name === "escape") {
957
+ cleanup(opts.cancelValue)
958
+ }
959
+ }
960
+
961
+ input.on("data", onData)
962
+ input.on("keypress", onKey)
963
+ })
964
+ }
965
+
966
+ async function ensureStateDir() {
967
+ await fsp.mkdir(STATE_DIR, { recursive: true, mode: 0o700 })
968
+ try {
969
+ await fsp.chmod(STATE_DIR, 0o700)
970
+ } catch {
971
+ // Windows and some mounted filesystems may not support POSIX mode bits.
972
+ }
973
+ }
974
+
975
+ async function readConfig() {
976
+ try {
977
+ const raw = await fsp.readFile(CONFIG_FILE, "utf8")
978
+ return JSON.parse(raw)
979
+ } catch (err) {
980
+ if (err && err.code === "ENOENT") return {}
981
+ throw err
982
+ }
983
+ }
984
+
985
+ async function writeConfig(next) {
986
+ await ensureStateDir()
987
+ const tmp = `${CONFIG_FILE}.${process.pid}.tmp`
988
+ await fsp.writeFile(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 })
989
+ try {
990
+ await fsp.chmod(tmp, 0o600)
991
+ } catch {
992
+ // Best effort on non-POSIX platforms.
993
+ }
994
+ await fsp.rename(tmp, CONFIG_FILE)
995
+ }
996
+
997
+ async function patchConfig(patch) {
998
+ const cfg = await readConfig()
999
+ const next = { ...cfg, ...patch, updatedAt: nowIso() }
1000
+ await writeConfig(next)
1001
+ return next
1002
+ }
1003
+
1004
+ function redactedConfig(cfg) {
1005
+ return {
1006
+ ...cfg,
1007
+ accessToken: cfg.accessToken ? maskSecret(cfg.accessToken) : undefined,
1008
+ apiKey: cfg.apiKey ? maskSecret(cfg.apiKey) : undefined,
1009
+ }
1010
+ }
1011
+
1012
+ function safeConfig(cfg) {
1013
+ const { accessToken, apiKey, ...rest } = cfg || {}
1014
+ return {
1015
+ ...rest,
1016
+ signedIn: Boolean(accessToken),
1017
+ hasApiKey: Boolean(apiKey),
1018
+ accessToken: accessToken ? "[configured]" : undefined,
1019
+ apiKey: apiKey ? "[configured]" : undefined,
1020
+ }
1021
+ }
1022
+
1023
+ function isSecretConfigKey(name) {
1024
+ return name === "apiKey" || name === "accessToken"
1025
+ }
1026
+
1027
+ function safeKeyRecord(key, reveal = false) {
1028
+ if (reveal) return key
1029
+ return {
1030
+ id: key.id,
1031
+ name: key.name || null,
1032
+ tier: key.tier || null,
1033
+ apiBaseUrl: key.apiBaseUrl || null,
1034
+ createdTime: key.createdTime || null,
1035
+ keyConfigured: Boolean(key.key),
1036
+ }
1037
+ }
1038
+
1039
+ function siteBase(cfg = {}) {
1040
+ return (process.env.LAICODE_SITE_URL || cfg.siteBase || DEFAULT_SITE_BASE).replace(/\/$/, "")
1041
+ }
1042
+
1043
+ function apiBase(cfg = {}) {
1044
+ return (cfg.apiBaseUrl || DEFAULT_API_BASE).replace(/\/$/, "")
1045
+ }
1046
+
1047
+ async function readJsonResponse(res) {
1048
+ const text = await res.text()
1049
+ if (!text) return {}
1050
+ try {
1051
+ return JSON.parse(text)
1052
+ } catch {
1053
+ return { error: text }
1054
+ }
1055
+ }
1056
+
1057
+ async function requestJson(url, init = {}) {
1058
+ const ctrl = new AbortController()
1059
+ const timeout = setTimeout(() => ctrl.abort(), Number(init.timeoutMs || 30000))
1060
+ try {
1061
+ const res = await fetch(url, { ...init, signal: ctrl.signal })
1062
+ const data = await readJsonResponse(res)
1063
+ if (!res.ok) {
1064
+ const msg = data.error || data.message || `${res.status} ${res.statusText}`
1065
+ const err = new Error(msg)
1066
+ err.status = res.status
1067
+ err.data = data
1068
+ throw err
1069
+ }
1070
+ return data
1071
+ } finally {
1072
+ clearTimeout(timeout)
1073
+ }
1074
+ }
1075
+
1076
+ async function siteJson(cfg, pathName, init = {}) {
1077
+ const headers = { "Content-Type": "application/json", ...(init.headers || {}) }
1078
+ return requestJson(`${siteBase(cfg)}${pathName}`, { ...init, headers })
1079
+ }
1080
+
1081
+ async function authJson(cfg, pathName, init = {}) {
1082
+ if (!cfg.accessToken) {
1083
+ die("Not logged in. Run `laicode login` first.")
1084
+ }
1085
+ const headers = {
1086
+ "Content-Type": "application/json",
1087
+ Authorization: `Bearer ${cfg.accessToken}`,
1088
+ ...(init.headers || {}),
1089
+ }
1090
+ try {
1091
+ return await requestJson(`${siteBase(cfg)}${pathName}`, { ...init, headers })
1092
+ } catch (err) {
1093
+ if (err.status === 401) {
1094
+ die("LaiCode session expired or revoked. Run `laicode login` again.")
1095
+ }
1096
+ throw err
1097
+ }
1098
+ }
1099
+
1100
+ function openBrowser(url) {
1101
+ const platform = process.platform
1102
+ let cmd
1103
+ let args
1104
+ if (platform === "darwin") {
1105
+ cmd = "open"
1106
+ args = [url]
1107
+ } else if (platform === "win32") {
1108
+ cmd = "cmd"
1109
+ args = ["/c", "start", "", url]
1110
+ } else {
1111
+ cmd = "xdg-open"
1112
+ args = [url]
1113
+ }
1114
+
1115
+ if (!commandExists(cmd)) return false
1116
+
1117
+ try {
1118
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" })
1119
+ child.on("error", () => {})
1120
+ child.unref()
1121
+ return true
1122
+ } catch {
1123
+ return false
1124
+ }
1125
+ }
1126
+
1127
+ function commandExists(cmd) {
1128
+ if (cmd.includes(path.sep)) return fs.existsSync(cmd)
1129
+ const dirs = String(process.env.PATH || "").split(path.delimiter).filter(Boolean)
1130
+ const exts =
1131
+ process.platform === "win32"
1132
+ ? String(process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
1133
+ : [""]
1134
+ for (const dir of dirs) {
1135
+ for (const ext of exts) {
1136
+ if (fs.existsSync(path.join(dir, cmd + ext))) return true
1137
+ }
1138
+ }
1139
+ return false
1140
+ }
1141
+
1142
+ function platformName() {
1143
+ const p = os.platform()
1144
+ if (p === "darwin") return "macos"
1145
+ if (p === "win32") return "windows"
1146
+ return p
1147
+ }
1148
+
1149
+ async function bootstrap(cfg, quiet = false) {
1150
+ const data = await authJson(cfg, "/api/laicode/bootstrap", { method: "GET" })
1151
+ await patchConfig({
1152
+ siteBase: siteBase(cfg),
1153
+ apiBaseUrl: data.apiBaseUrl || apiBase(cfg),
1154
+ user: data.user || cfg.user,
1155
+ lastBootstrapAt: nowIso(),
1156
+ })
1157
+ if (!quiet) {
1158
+ const user = data.user?.email || data.user?.name || "unknown"
1159
+ console.log(`Logged in as ${user}`)
1160
+ if (data.account) {
1161
+ console.log(`Balance: $${Number(data.account.balance || 0).toFixed(4)} · Requests: ${data.account.requests || 0}`)
1162
+ }
1163
+ }
1164
+ return data
1165
+ }
1166
+
1167
+ async function listKeys(cfg) {
1168
+ const data = await authJson(cfg, "/api/laicode/keys", { method: "GET" })
1169
+ return data.keys || []
1170
+ }
1171
+
1172
+ function defaultKeyName() {
1173
+ return `Laicode ${os.hostname()}`.slice(0, 40)
1174
+ }
1175
+
1176
+ async function ensureApiKey(cfg, opts = {}) {
1177
+ if (cfg.apiKey && /^sk-/.test(cfg.apiKey)) return cfg.apiKey
1178
+ if (!cfg.accessToken) return null
1179
+
1180
+ const keys = await listKeys(cfg)
1181
+ const wanted = opts.name || defaultKeyName()
1182
+ const found =
1183
+ keys.find((k) => k.name === wanted && k.key) ||
1184
+ keys.find((k) => String(k.name || "").startsWith("Laicode ") && k.key) ||
1185
+ keys.find((k) => k.key)
1186
+
1187
+ if (found) {
1188
+ await patchConfig({
1189
+ apiKey: found.key,
1190
+ apiKeyId: found.id,
1191
+ apiKeyName: found.name,
1192
+ apiBaseUrl: found.apiBaseUrl || cfg.apiBaseUrl || DEFAULT_API_BASE,
1193
+ defaultTier: found.tier || cfg.defaultTier,
1194
+ })
1195
+ return found.key
1196
+ }
1197
+
1198
+ if (opts.create === false) return null
1199
+
1200
+ const body = { name: wanted }
1201
+ if (opts.tier || cfg.defaultTier) body.tier = opts.tier || cfg.defaultTier
1202
+ const data = await authJson(cfg, "/api/laicode/keys", {
1203
+ method: "POST",
1204
+ body: JSON.stringify(body),
1205
+ })
1206
+ const key = data.key
1207
+ if (!key?.key) throw new Error("Key creation succeeded but no key was returned")
1208
+ await patchConfig({
1209
+ apiKey: key.key,
1210
+ apiKeyId: key.id,
1211
+ apiKeyName: key.name,
1212
+ apiBaseUrl: key.apiBaseUrl || cfg.apiBaseUrl || DEFAULT_API_BASE,
1213
+ defaultTier: key.tier || cfg.defaultTier,
1214
+ })
1215
+ return key.key
1216
+ }
1217
+
1218
+ function pickDefaultModel(models = []) {
1219
+ const preferred = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]
1220
+ for (const id of preferred) {
1221
+ const m = models.find((x) => x.modelId === id && modelOnline(x))
1222
+ if (m) return m.modelId
1223
+ }
1224
+ const first = models.find(modelOnline) || models[0]
1225
+ return first?.modelId
1226
+ }
1227
+
1228
+ function modelOnline(model) {
1229
+ return Boolean((model.tiers || []).some((t) => t.status === "online"))
1230
+ }
1231
+
1232
+ function printModelRadarSummary(models, rows) {
1233
+ const onlineModels = models.filter(modelOnline)
1234
+ const vendors = new Set(models.map((m) => m.vendor).filter(Boolean))
1235
+ const onlineRows = rows.filter((row) => row.online)
1236
+ const cheapest = onlineRows
1237
+ .filter((row) => row.rawInput != null)
1238
+ .sort((a, b) => a.rawInput - b.rawInput)[0]
1239
+ const fastest = onlineModels
1240
+ .filter((m) => Number.isFinite(Number(m.performance?.latencyMs)))
1241
+ .sort((a, b) => Number(a.performance.latencyMs) - Number(b.performance.latencyMs))[0]
1242
+ const maxLatency = Math.max(1, ...onlineModels.map((m) => Number(m.performance?.latencyMs) || 0))
1243
+ const recommended = pickDefaultModel(models) || "-"
1244
+
1245
+ printPanel("雷达摘要", [
1246
+ `在线模型 ${onlineModels.length}/${models.length}`,
1247
+ `厂商覆盖 ${vendors.size || 0} 个`,
1248
+ `在线档位 ${onlineRows.length} 个`,
1249
+ `推荐默认 ${recommended}`,
1250
+ `最低输入价 ${cheapest ? `${cheapest.model} · ${cheapest.tier} · ${formatMoney(cheapest.rawInput)}/1M` : "-"}`,
1251
+ `最快延迟 ${
1252
+ fastest
1253
+ ? `${fastest.modelId} · ${formatMs(fastest.performance.latencyMs)} ${latencyBar(fastest.performance.latencyMs, maxLatency, 12)}`
1254
+ : "-"
1255
+ }`,
1256
+ ])
1257
+ console.log("")
1258
+ }
1259
+
1260
+ async function getModels(cfg) {
1261
+ if (cfg.accessToken) {
1262
+ const data = await bootstrap(cfg, true)
1263
+ return { models: data.models || [], tiers: data.tiers || [], account: data.account, user: data.user }
1264
+ }
1265
+ const data = await siteJson(cfg, "/api/catalog", { method: "GET" })
1266
+ return { models: data.models || [], tiers: [] }
1267
+ }
1268
+
1269
+ async function getNpmLatestVersion() {
1270
+ const data = await requestJson("https://registry.npmjs.org/@laivc%2flaicode", {
1271
+ method: "GET",
1272
+ timeoutMs: 8000,
1273
+ })
1274
+ return data["dist-tags"]?.latest || data.version || null
1275
+ }
1276
+
1277
+ async function getVersionSummary() {
1278
+ try {
1279
+ const latest = await getNpmLatestVersion()
1280
+ if (!latest) return "latest 未知"
1281
+ const cmp = compareVersions(VERSION, latest)
1282
+ if (cmp < 0) return `npm ${latest},可更新`
1283
+ if (cmp > 0) return `npm ${latest},待发布`
1284
+ return `npm ${latest},已同步`
1285
+ } catch {
1286
+ return "版本未知"
1287
+ }
1288
+ }
1289
+
1290
+ function normalizeDashboardCache(cache, extra = {}) {
1291
+ if (!cache || typeof cache !== "object") return null
1292
+ return {
1293
+ onlineModels: cache.onlineModels ?? null,
1294
+ balance: cache.balance ?? null,
1295
+ requests: cache.requests ?? null,
1296
+ version: cache.version ?? null,
1297
+ checkedAt: cache.checkedAt || null,
1298
+ ...extra,
1299
+ }
1300
+ }
1301
+
1302
+ async function dashboardSnapshot(cfg, opts = {}) {
1303
+ const ttlMs = Number(opts.ttlMs ?? DASHBOARD_CACHE_TTL_MS)
1304
+ const cached = normalizeDashboardCache(cfg.dashboardCache, { cached: true })
1305
+ if (!opts.refresh && cached?.checkedAt && ageMs(cached.checkedAt) < ttlMs) return cached
1306
+
1307
+ const snap = { onlineModels: null, balance: null, requests: null, version: null, checkedAt: nowIso(), cached: false }
1308
+ const patch = {}
1309
+ try {
1310
+ if (cfg.accessToken) {
1311
+ const data = await requestJson(`${siteBase(cfg)}/api/laicode/bootstrap`, {
1312
+ method: "GET",
1313
+ timeoutMs: 8000,
1314
+ headers: {
1315
+ "Content-Type": "application/json",
1316
+ Authorization: `Bearer ${cfg.accessToken}`,
1317
+ },
1318
+ })
1319
+ patch.siteBase = siteBase(cfg)
1320
+ patch.apiBaseUrl = data.apiBaseUrl || apiBase(cfg)
1321
+ patch.user = data.user || cfg.user
1322
+ patch.lastBootstrapAt = snap.checkedAt
1323
+ snap.balance = data.account?.balance
1324
+ snap.requests = data.account?.requests
1325
+ snap.onlineModels = (data.models || []).filter(modelOnline).length
1326
+ } else {
1327
+ const data = await siteJson(cfg, "/api/catalog", { method: "GET", timeoutMs: 8000 })
1328
+ snap.onlineModels = (data.models || []).filter(modelOnline).length
1329
+ }
1330
+ } catch {
1331
+ // Dashboard snapshot is decorative; never block the main menu.
1332
+ if (cached) return { ...cached, stale: true }
1333
+ }
1334
+ snap.version = await getVersionSummary()
1335
+ await patchConfig({
1336
+ ...patch,
1337
+ dashboardCache: {
1338
+ onlineModels: snap.onlineModels,
1339
+ balance: snap.balance,
1340
+ requests: snap.requests,
1341
+ version: snap.version,
1342
+ checkedAt: snap.checkedAt,
1343
+ },
1344
+ })
1345
+ return snap
1346
+ }
1347
+
1348
+ function statusPayload(cfg, snapshot) {
1349
+ return {
1350
+ version: VERSION,
1351
+ stateDir: STATE_DIR,
1352
+ configFile: CONFIG_FILE,
1353
+ siteBase: siteBase(cfg),
1354
+ apiBaseUrl: apiBase(cfg),
1355
+ signedIn: Boolean(cfg.accessToken),
1356
+ hasApiKey: Boolean(cfg.apiKey),
1357
+ apiKeyConfigured: Boolean(cfg.apiKey),
1358
+ apiKeyId: cfg.apiKeyId || null,
1359
+ apiKeyName: cfg.apiKeyName || null,
1360
+ defaultModel: cfg.defaultModel || null,
1361
+ user: cfg.user || null,
1362
+ nextActions: nextActions(cfg),
1363
+ dashboard: snapshot,
1364
+ }
1365
+ }
1366
+
1367
+ async function cmdStatus(args = {}) {
1368
+ const cfg = await readConfig()
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
+ )
1378
+ const next = await readConfig()
1379
+
1380
+ if (args.json) {
1381
+ console.log(JSON.stringify(statusPayload(next, snapshot), null, 2))
1382
+ return
1383
+ }
1384
+
1385
+ printHero(args.refresh ? "状态总览 · 已刷新" : "状态总览")
1386
+ printDashboard(next, snapshot)
1387
+ console.log("")
1388
+ printPanel("本地状态", localStatusRows(next))
1389
+ console.log("")
1390
+ printNextActions(next)
1391
+ }
1392
+
1393
+ async function confirmRevealKey(args) {
1394
+ if (!args.show) return false
1395
+ if (args.yes || args.force) return true
1396
+ if (!process.stdin.isTTY) {
1397
+ die("拒绝在非交互环境显示完整 key。若确认需要,请显式添加 `--yes`。")
1398
+ }
1399
+ console.log(ui.warn("即将显示完整 API Key。请确认当前终端不会被录屏、共享或写入日志。"))
1400
+ const answer = await question("输入 SHOW 确认显示完整 key: ", "")
1401
+ if (answer !== "SHOW") die("已取消显示完整 key。")
1402
+ return true
1403
+ }
1404
+
1405
+ async function chatCompletion(cfg, messages, opts = {}) {
1406
+ const key = opts.apiKey || cfg.apiKey || (await ensureApiKey(cfg))
1407
+ if (!key) die("No API key available. Run `laicode login` or `laicode config set api-key sk-...`.")
1408
+
1409
+ const model = opts.model || cfg.defaultModel
1410
+ if (!model) die("未选择模型。请传入 `--model <model>` 或先运行 `laicode models`。")
1411
+
1412
+ const started = Date.now()
1413
+ let firstTokenAt = 0
1414
+ let content = ""
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
+ }
1440
+
1441
+ if (!res.ok) {
1442
+ if (loading) loading.stop()
1443
+ const data = await readJsonResponse(res)
1444
+ const msg = data.error?.message || data.error || data.message || `${res.status} ${res.statusText}`
1445
+ const err = new Error(msg)
1446
+ err.status = res.status
1447
+ err.data = data
1448
+ throw err
1449
+ }
1450
+
1451
+ if (opts.stream === false) {
1452
+ const data = await readJsonResponse(res)
1453
+ if (loading) loading.stop()
1454
+ content = data.choices?.[0]?.message?.content || ""
1455
+ return { content, firstTokenMs: Date.now() - started, totalMs: Date.now() - started, status: res.status }
1456
+ }
1457
+
1458
+ const decoder = new TextDecoder()
1459
+ let buffer = ""
1460
+ for await (const chunk of res.body) {
1461
+ buffer += decoder.decode(chunk, { stream: true })
1462
+ const lines = buffer.split(/\r?\n/)
1463
+ buffer = lines.pop() || ""
1464
+ for (const line of lines) {
1465
+ const trimmed = line.trim()
1466
+ if (!trimmed || !trimmed.startsWith("data:")) continue
1467
+ const payload = trimmed.slice(5).trim()
1468
+ if (payload === "[DONE]") continue
1469
+ let data
1470
+ try {
1471
+ data = JSON.parse(payload)
1472
+ } catch {
1473
+ continue
1474
+ }
1475
+ const delta = data.choices?.[0]?.delta?.content || data.choices?.[0]?.message?.content || ""
1476
+ if (delta) {
1477
+ if (!firstTokenAt) firstTokenAt = Date.now()
1478
+ content += 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
+ }
1487
+ }
1488
+ }
1489
+ }
1490
+
1491
+ if (loading) loading.stop()
1492
+ if (opts.print !== false) process.stdout.write("\n")
1493
+ return {
1494
+ content,
1495
+ firstTokenMs: firstTokenAt ? firstTokenAt - started : Date.now() - started,
1496
+ totalMs: Date.now() - started,
1497
+ status: res.status,
1498
+ }
1499
+ }
18
1500
 
19
- const arg = process.argv[2]
1501
+ async function cmdLogin(args) {
1502
+ const cfg = await readConfig()
1503
+ const base = siteBase(cfg)
1504
+ const body = {
1505
+ client: "laicode-cli",
1506
+ version: VERSION,
1507
+ hostname: os.hostname(),
1508
+ platform: platformName(),
1509
+ scopes: ["account:read", "keys:read", "keys:create"],
1510
+ }
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
+ )
20
1520
 
21
- if (!arg || arg === "--help" || arg === "-h") {
22
- console.log(help)
23
- process.exit(0)
1521
+ console.log("打开以下链接授权 LaiCode:")
1522
+ console.log(data.verificationUriComplete || data.verificationUri)
1523
+ if (data.userCode) console.log(`授权码: ${data.userCode}`)
1524
+ console.log(`有效期: ${data.expiresIn || 600}s`)
1525
+
1526
+ if (!args["no-open"]) {
1527
+ const opened = openBrowser(data.verificationUriComplete || data.verificationUri)
1528
+ if (opened) console.log("已尝试打开浏览器。若未打开,请手动复制链接。")
1529
+ else console.log("当前机器没有可用浏览器打开器,请手动复制链接。")
1530
+ }
1531
+
1532
+ let interval = Number(data.interval || 2)
1533
+ const deadline = Date.now() + Number(data.expiresIn || 600) * 1000
1534
+ const pollSpinner = createSpinner("等待浏览器授权", { delayMs: 0 }).start()
1535
+ const fallbackDots = !pollSpinner.enabled
1536
+ while (Date.now() < deadline) {
1537
+ await sleep(interval * 1000)
1538
+ const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000))
1539
+ pollSpinner.update(`等待浏览器授权 · 剩余 ${remaining}s`)
1540
+ const poll = await siteJson({ siteBase: base }, "/api/laicode/device/poll", {
1541
+ method: "POST",
1542
+ body: JSON.stringify({ deviceCode: data.deviceCode }),
1543
+ })
1544
+ if (poll.status === "pending") {
1545
+ if (fallbackDots) process.stdout.write(".")
1546
+ interval = Number(poll.interval || interval)
1547
+ continue
1548
+ }
1549
+ if (poll.status === "slow_down") {
1550
+ interval = Math.max(interval + 1, Number(poll.interval || interval))
1551
+ pollSpinner.update(`等待浏览器授权 · 降低轮询频率 ${interval}s`)
1552
+ if (fallbackDots) process.stdout.write(" slow_down ")
1553
+ continue
1554
+ }
1555
+ if (fallbackDots) process.stdout.write("\n")
1556
+ else pollSpinner.stop()
1557
+ if (poll.status === "denied") die("授权已被拒绝。")
1558
+ if (poll.status === "expired") die("授权已过期,请重新运行 `laicode login`。")
1559
+ if (poll.status === "authorized") {
1560
+ const expiresIn = Number(poll.expiresIn || 2592000)
1561
+ let next = await patchConfig({
1562
+ siteBase: base,
1563
+ apiBaseUrl: poll.apiBaseUrl || DEFAULT_API_BASE,
1564
+ accessToken: poll.accessToken,
1565
+ accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
1566
+ user: poll.user,
1567
+ loggedInAt: nowIso(),
1568
+ })
1569
+ console.log(`已授权: ${poll.user?.email || poll.user?.name || "unknown"}`)
1570
+ if (!args["no-key"]) {
1571
+ const key = await withSpinner("准备 API Key", () => ensureApiKey(next, { tier: args.tier }), { delayMs: 80 })
1572
+ next = await readConfig()
1573
+ console.log(`API Key 就绪: ${maskSecret(key)} (${next.apiKeyName || "Laicode key"})`)
1574
+ }
1575
+ return
1576
+ }
1577
+ die(`Unexpected authorization status: ${poll.status}`)
1578
+ }
1579
+ pollSpinner.stop()
1580
+ if (fallbackDots) process.stdout.write("\n")
1581
+ die("授权等待超时,请重新运行 `laicode login`。")
1582
+ }
1583
+
1584
+ async function cmdModels(args) {
1585
+ const cfg = await readConfig()
1586
+ const data = await withSpinner("拉取模型雷达", () => getModels(cfg), { enabled: !args.json, delayMs: 80 })
1587
+ let models = data.models || []
1588
+ if (args.online) models = models.filter(modelOnline)
1589
+ if (args.category) models = models.filter((m) => m.category === args.category)
1590
+ if (args.vendor) models = models.filter((m) => m.vendor === args.vendor)
1591
+
1592
+ if (args.json) {
1593
+ console.log(JSON.stringify({ models }, null, 2))
1594
+ return
1595
+ }
1596
+
1597
+ if (!models.length) {
1598
+ console.log("未找到模型。")
1599
+ return
1600
+ }
1601
+
1602
+ const rows = []
1603
+ for (const m of models) {
1604
+ for (const t of m.tiers || []) {
1605
+ if (args.online && t.status !== "online") continue
1606
+ rows.push({
1607
+ model: m.modelId,
1608
+ vendor: m.vendor || "-",
1609
+ category: m.category || "-",
1610
+ tier: t.name || t.id,
1611
+ input: formatMoney(t.input),
1612
+ output: formatMoney(t.output),
1613
+ sources: String(t.sources ?? "-"),
1614
+ status: t.status === "online" ? ui.ok("在线") : ui.warn("离线"),
1615
+ latency: formatMs(m.performance?.latencyMs),
1616
+ rawInput: t.input == null ? null : Number(t.input),
1617
+ online: t.status === "online",
1618
+ })
1619
+ }
1620
+ }
1621
+ printModelRadarSummary(models, rows)
1622
+ printTable(
1623
+ "模型雷达 · Lai.vc",
1624
+ [
1625
+ { key: "model", label: "模型", width: 16, max: 24 },
1626
+ { key: "vendor", label: "厂商", width: 8, max: 14 },
1627
+ { key: "category", label: "类别", width: 6, max: 10 },
1628
+ { key: "tier", label: "档位", width: 6, max: 10 },
1629
+ { key: "input", label: "输入/1M", width: 8, align: "right" },
1630
+ { key: "output", label: "输出/1M", width: 8, align: "right" },
1631
+ { key: "sources", label: "源", width: 3, align: "right" },
1632
+ { key: "status", label: "状态", width: 4 },
1633
+ { key: "latency", label: "延迟", width: 8, align: "right" },
1634
+ ],
1635
+ rows
1636
+ )
1637
+ }
1638
+
1639
+ async function cmdKeys(args) {
1640
+ const action = args._[1] || "list"
1641
+ const cfg = await readConfig()
1642
+ if (!cfg.accessToken) die("Not logged in. Run `laicode login` first.")
1643
+ if (action === "list") {
1644
+ const reveal = await confirmRevealKey(args)
1645
+ const keys = await withSpinner("读取 API Key 列表", () => listKeys(cfg), { enabled: !args.json, delayMs: 80 })
1646
+ if (args.json) {
1647
+ console.log(JSON.stringify({ keys: keys.map((k) => safeKeyRecord(k, reveal)), reveal }, null, 2))
1648
+ return
1649
+ }
1650
+ if (!keys.length) {
1651
+ console.log("未找到 API Key。")
1652
+ return
1653
+ }
1654
+ printTable(
1655
+ "API 密钥",
1656
+ [
1657
+ { key: "id", label: "ID", width: 5, align: "right" },
1658
+ { key: "name", label: "名称", width: 24, max: 34 },
1659
+ { key: "key", label: "密钥", width: 18, max: 32 },
1660
+ { key: "created", label: "创建时间", width: 16, max: 22 },
1661
+ ],
1662
+ keys.map((k) => ({
1663
+ id: String(k.id),
1664
+ name: k.name || "-",
1665
+ key: reveal ? k.key : maskSecret(k.key),
1666
+ created: k.createdTime ? new Date(k.createdTime * 1000).toLocaleString("zh-CN") : "-",
1667
+ }))
1668
+ )
1669
+ return
1670
+ }
1671
+
1672
+ if (action === "create") {
1673
+ const name = args.name || defaultKeyName()
1674
+ const body = { name }
1675
+ if (args.tier) body.tier = args.tier
1676
+ const reveal = await confirmRevealKey(args)
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
+ )
1686
+ const key = data.key
1687
+ await patchConfig({
1688
+ apiKey: key.key,
1689
+ apiKeyId: key.id,
1690
+ apiKeyName: key.name,
1691
+ apiBaseUrl: key.apiBaseUrl || cfg.apiBaseUrl || DEFAULT_API_BASE,
1692
+ defaultTier: key.tier || cfg.defaultTier,
1693
+ })
1694
+ if (args.json) {
1695
+ console.log(JSON.stringify({ ok: true, key: safeKeyRecord(key, reveal), reveal }, null, 2))
1696
+ return
1697
+ }
1698
+ console.log(`已创建 API Key ${key.id}: ${key.name}`)
1699
+ console.log(reveal ? key.key : maskSecret(key.key))
1700
+ return
1701
+ }
1702
+
1703
+ if (action === "delete") {
1704
+ const id = args._[2] || args.id
1705
+ if (!id) die("Usage: laicode keys delete <id>")
1706
+ await withSpinner(
1707
+ `删除 API Key ${id}`,
1708
+ () => authJson(cfg, `/api/laicode/keys/${encodeURIComponent(id)}`, { method: "DELETE" }),
1709
+ { enabled: !args.json, delayMs: 80 }
1710
+ )
1711
+ const patch = {}
1712
+ if (String(cfg.apiKeyId) === String(id)) {
1713
+ patch.apiKey = undefined
1714
+ patch.apiKeyId = undefined
1715
+ patch.apiKeyName = undefined
1716
+ }
1717
+ await patchConfig({ ...cfg, ...patch })
1718
+ if (args.json) {
1719
+ console.log(JSON.stringify({ ok: true, deletedId: id }, null, 2))
1720
+ return
1721
+ }
1722
+ console.log(`已删除 API Key ${id}`)
1723
+ return
1724
+ }
1725
+
1726
+ die("Usage: laicode keys [list|create|delete]")
1727
+ }
1728
+
1729
+ async function cmdConfig(args) {
1730
+ const action = args._[1] || "list"
1731
+ const cfg = await readConfig()
1732
+ const keyMap = {
1733
+ "api-key": "apiKey",
1734
+ "base-url": "apiBaseUrl",
1735
+ "site-url": "siteBase",
1736
+ model: "defaultModel",
1737
+ tier: "defaultTier",
1738
+ }
1739
+
1740
+ if (action === "list" || action === undefined) {
1741
+ const out = args.json ? safeConfig(cfg) : redactedConfig(cfg)
1742
+ if (args.json) console.log(JSON.stringify(out, null, 2))
1743
+ else {
1744
+ console.log(`State dir: ${STATE_DIR}`)
1745
+ console.log(JSON.stringify(out, null, 2))
1746
+ }
1747
+ return
1748
+ }
1749
+
1750
+ if (action === "get") {
1751
+ const raw = args._[2]
1752
+ if (!raw) die("Usage: laicode config get <key>")
1753
+ const name = keyMap[raw] || raw
1754
+ const value = cfg[name]
1755
+ if (args.json) {
1756
+ console.log(
1757
+ JSON.stringify(
1758
+ {
1759
+ key: raw,
1760
+ mappedKey: name,
1761
+ secret: isSecretConfigKey(name),
1762
+ configured: isSecretConfigKey(name) ? Boolean(value) : undefined,
1763
+ value: isSecretConfigKey(name) ? undefined : value ?? null,
1764
+ },
1765
+ null,
1766
+ 2
1767
+ )
1768
+ )
1769
+ return
1770
+ }
1771
+ console.log(isSecretConfigKey(name) ? maskSecret(value) : value ?? "")
1772
+ return
1773
+ }
1774
+
1775
+ if (action === "set") {
1776
+ const raw = args._[2]
1777
+ const value = args._[3]
1778
+ if (!raw || value == null) die("Usage: laicode config set <key> <value>")
1779
+ const name = keyMap[raw] || raw
1780
+ const next = await patchConfig({ [name]: value })
1781
+ const warnings = []
1782
+ if (name === "apiKey" && !String(value).startsWith("sk-")) warnings.push("API key does not start with sk-")
1783
+ if (args.json) {
1784
+ console.log(
1785
+ JSON.stringify(
1786
+ {
1787
+ ok: true,
1788
+ action: "set",
1789
+ key: raw,
1790
+ mappedKey: name,
1791
+ secret: isSecretConfigKey(name),
1792
+ warnings,
1793
+ config: safeConfig(next),
1794
+ },
1795
+ null,
1796
+ 2
1797
+ )
1798
+ )
1799
+ return
1800
+ }
1801
+ console.log(`Set ${raw}`)
1802
+ for (const warning of warnings) console.log(`Warning: ${warning}`)
1803
+ return
1804
+ }
1805
+
1806
+ if (action === "unset") {
1807
+ const raw = args._[2]
1808
+ if (!raw) die("Usage: laicode config unset <key>")
1809
+ const name = keyMap[raw] || raw
1810
+ const next = { ...cfg }
1811
+ delete next[name]
1812
+ next.updatedAt = nowIso()
1813
+ await writeConfig(next)
1814
+ if (args.json) {
1815
+ console.log(
1816
+ JSON.stringify(
1817
+ {
1818
+ ok: true,
1819
+ action: "unset",
1820
+ key: raw,
1821
+ mappedKey: name,
1822
+ secret: isSecretConfigKey(name),
1823
+ config: safeConfig(next),
1824
+ },
1825
+ null,
1826
+ 2
1827
+ )
1828
+ )
1829
+ return
1830
+ }
1831
+ console.log(`Unset ${raw}`)
1832
+ return
1833
+ }
1834
+
1835
+ die("Usage: laicode config [list|get|set|unset]")
1836
+ }
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
+
1851
+ async function cmdChat(args) {
1852
+ let cfg = await readConfig()
1853
+ if (!cfg.apiKey && cfg.accessToken) {
1854
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { delayMs: 80 })
1855
+ cfg = await readConfig()
1856
+ }
1857
+
1858
+ let model = args.model || cfg.defaultModel
1859
+ if (!model && cfg.accessToken) {
1860
+ const data = await withSpinner("选择默认在线模型", () => getModels(cfg), { delayMs: 80 })
1861
+ model = pickDefaultModel(data.models)
1862
+ }
1863
+ if (!model) die("未选择模型。请使用 `laicode chat --model <model>`。")
1864
+
1865
+ const message = args.message || args.m || args._.slice(1).join(" ")
1866
+ if (message) {
1867
+ console.log(`model: ${model}`)
1868
+ await chatCompletion(cfg, [{ role: "user", content: message }], {
1869
+ model,
1870
+ stream: true,
1871
+ loadingText: "等待模型响应",
1872
+ })
1873
+ await patchConfig({ defaultModel: model })
1874
+ return
1875
+ }
1876
+
1877
+ console.log(`进入交互对话。model=${model}。输入 /exit 退出。`)
1878
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
1879
+ const messages = []
1880
+ for (;;) {
1881
+ const input = await new Promise((resolve) => rl.question("> ", resolve))
1882
+ if (!input || input.trim() === "/exit" || input.trim() === "/quit") break
1883
+ messages.push({ role: "user", content: input })
1884
+ const res = await chatCompletion(cfg, messages, {
1885
+ model,
1886
+ stream: true,
1887
+ loadingText: "assistant 思考中",
1888
+ prefixOnFirstToken: "assistant: ",
1889
+ })
1890
+ messages.push({ role: "assistant", content: res.content })
1891
+ }
1892
+ rl.close()
1893
+ await patchConfig({ defaultModel: model })
1894
+ }
1895
+
1896
+ async function cmdBench(args) {
1897
+ let cfg = await readConfig()
1898
+ if (!cfg.apiKey && cfg.accessToken) {
1899
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { enabled: !args.json, delayMs: 80 })
1900
+ cfg = await readConfig()
1901
+ }
1902
+ let model = args.model || cfg.defaultModel
1903
+ if (!model && cfg.accessToken) {
1904
+ const data = await withSpinner("选择默认在线模型", () => getModels(cfg), { enabled: !args.json, delayMs: 80 })
1905
+ model = pickDefaultModel(data.models)
1906
+ }
1907
+ if (!model) die("未选择模型。请使用 `laicode bench --model <model>`。")
1908
+
1909
+ const count = Math.max(1, Number(args.count || args.n || 3))
1910
+ const prompt = args.prompt || "Say OK and one short sentence about API gateway health."
1911
+ const rows = []
1912
+ const startedAt = nowIso()
1913
+ if (!args.json) console.log(ui.bold(`正在压测 ${model}`) + ui.dim(` · ${count} 次流式请求`))
1914
+ for (let i = 0; i < count; i++) {
1915
+ const label = `${i + 1}/${count}`
1916
+ try {
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 }
1926
+ )
1927
+ rows.push({ index: i + 1, ok: true, firstTokenMs: res.firstTokenMs, totalMs: res.totalMs })
1928
+ if (!args.json) console.log(`${ui.ok("●")} ${label} 成功 首 token=${res.firstTokenMs}ms 总耗时=${res.totalMs}ms`)
1929
+ } catch (err) {
1930
+ rows.push({ index: i + 1, ok: false, error: err.message })
1931
+ if (!args.json) console.log(`${ui.err("●")} ${label} 失败 ${err.message}`)
1932
+ }
1933
+ }
1934
+ const ok = rows.filter((r) => r.ok)
1935
+ const avg = (field) => Math.round(ok.reduce((sum, r) => sum + r[field], 0) / Math.max(1, ok.length))
1936
+ const totals = ok.map((r) => r.totalMs)
1937
+ const firstTokens = ok.map((r) => r.firstTokenMs)
1938
+ const maxTotal = Math.max(1, ...totals)
1939
+ const avgTotal = ok.length ? avg("totalMs") : null
1940
+ const avgFirst = ok.length ? avg("firstTokenMs") : null
1941
+ const fastest = totals.length ? Math.min(...totals) : null
1942
+ const slowest = totals.length ? Math.max(...totals) : null
1943
+ const summary = {
1944
+ total: rows.length,
1945
+ success: ok.length,
1946
+ failed: rows.length - ok.length,
1947
+ successRate: rows.length ? ok.length / rows.length : 0,
1948
+ avgFirstTokenMs: avgFirst,
1949
+ avgTotalMs: avgTotal,
1950
+ p50TotalMs: percentile(totals, 50),
1951
+ p95TotalMs: percentile(totals, 95),
1952
+ fastestTotalMs: fastest,
1953
+ slowestTotalMs: slowest,
1954
+ totalJitterMs: totals.length ? slowest - fastest : null,
1955
+ firstTokenJitterMs: firstTokens.length ? Math.max(...firstTokens) - Math.min(...firstTokens) : null,
1956
+ throughputRps: avgTotal ? Number((1000 / avgTotal).toFixed(2)) : null,
1957
+ }
1958
+
1959
+ await patchConfig({ defaultModel: model })
1960
+
1961
+ if (args.json) {
1962
+ console.log(
1963
+ JSON.stringify(
1964
+ {
1965
+ model,
1966
+ count,
1967
+ startedAt,
1968
+ endedAt: nowIso(),
1969
+ promptLength: prompt.length,
1970
+ summary,
1971
+ results: rows,
1972
+ },
1973
+ null,
1974
+ 2
1975
+ )
1976
+ )
1977
+ if (summary.failed) process.exitCode = 1
1978
+ return
1979
+ }
1980
+
1981
+ printTable(
1982
+ `${model} 单次结果`,
1983
+ [
1984
+ { key: "index", label: "#", width: 3, align: "right" },
1985
+ { key: "status", label: "状态", width: 6 },
1986
+ { key: "first", label: "首 token", width: 10, align: "right" },
1987
+ { key: "total", label: "总耗时", width: 10, align: "right" },
1988
+ { key: "note", label: "耗时条 / 备注", width: 28, max: 48 },
1989
+ ],
1990
+ rows.map((r) => ({
1991
+ index: String(r.index),
1992
+ status: r.ok ? ui.ok("成功") : ui.err("失败"),
1993
+ first: r.ok ? `${r.firstTokenMs}ms` : "-",
1994
+ total: r.ok ? `${r.totalMs}ms` : "-",
1995
+ note: r.ok ? latencyBar(r.totalMs, maxTotal, 18) : r.error || "请求失败",
1996
+ }))
1997
+ )
1998
+ printPanel(`${model} 压测结果`, [
1999
+ `成功率 ${summary.success}/${summary.total} (${Math.round(summary.successRate * 100)}%)`,
2000
+ `平均首 token ${formatMs(avgFirst)}`,
2001
+ `平均总耗时 ${formatMs(avgTotal)}`,
2002
+ `P50 / P95 ${formatMs(summary.p50TotalMs)} / ${formatMs(summary.p95TotalMs)}`,
2003
+ `最快 / 最慢 ${formatMs(fastest)} / ${formatMs(slowest)}`,
2004
+ `抖动范围 ${formatMs(summary.totalJitterMs)}`,
2005
+ `首 token 抖动 ${formatMs(summary.firstTokenJitterMs)}`,
2006
+ `吞吐估算 ${summary.throughputRps ? `${summary.throughputRps.toFixed(2)} req/s` : "-"}`,
2007
+ ])
2008
+ if (summary.failed) process.exitCode = 1
2009
+ }
2010
+
2011
+ async function cmdDoctor(args) {
2012
+ const checks = []
2013
+ const startedAt = nowIso()
2014
+ const record = (id, name, ok, detail = "", extra = {}) => {
2015
+ checks.push({ id, name, ok: Boolean(ok), status: ok ? "ok" : "failed", detail, ...extra })
2016
+ }
2017
+
2018
+ await ensureStateDir()
2019
+ record("state-dir", "本地状态目录", true, STATE_DIR)
2020
+
2021
+ let cfg = await readConfig()
2022
+ record("config-file", "配置文件", true, fs.existsSync(CONFIG_FILE) ? CONFIG_FILE : "尚未创建")
2023
+
2024
+ try {
2025
+ const publicCatalog = await withSpinner(
2026
+ "检查公开模型目录",
2027
+ () => siteJson(cfg, "/api/catalog", { method: "GET", timeoutMs: 10000 }),
2028
+ { enabled: !args.json, delayMs: 80 }
2029
+ )
2030
+ const count = publicCatalog.models?.length || 0
2031
+ record("catalog", "公开模型目录", Boolean(count), `${count} 个模型`, { count })
2032
+ } catch (err) {
2033
+ record("catalog", "公开模型目录", false, err.message)
2034
+ }
2035
+
2036
+ try {
2037
+ const latest = await withSpinner("检查 npm 版本", () => getNpmLatestVersion(), { enabled: !args.json, delayMs: 80 })
2038
+ if (!latest) {
2039
+ record("version", "版本检查", true, `当前 ${VERSION},未取到 latest`, { current: VERSION, latest: null })
2040
+ } else {
2041
+ const cmp = compareVersions(VERSION, latest)
2042
+ if (cmp < 0) {
2043
+ record("version", "版本检查", true, `当前 ${VERSION},npm latest ${latest},可更新`, {
2044
+ current: VERSION,
2045
+ latest,
2046
+ state: "upgrade-available",
2047
+ })
2048
+ } else if (cmp > 0) {
2049
+ record("version", "版本检查", true, `当前 ${VERSION},npm latest ${latest},待发布`, {
2050
+ current: VERSION,
2051
+ latest,
2052
+ state: "local-ahead",
2053
+ })
2054
+ } else {
2055
+ record("version", "版本检查", true, `当前 ${VERSION},已是最新`, {
2056
+ current: VERSION,
2057
+ latest,
2058
+ state: "synced",
2059
+ })
2060
+ }
2061
+ }
2062
+ } catch (err) {
2063
+ record("version", "版本检查", true, `跳过: ${err.message}`, { skipped: true })
2064
+ }
2065
+
2066
+ if (cfg.accessToken) {
2067
+ try {
2068
+ const data = await withSpinner("验证 CLI 会话", () => bootstrap(cfg, true), { enabled: !args.json, delayMs: 80 })
2069
+ record("session", "CLI 会话", true, data.user?.email || data.user?.name || "已授权", {
2070
+ user: data.user || cfg.user || null,
2071
+ })
2072
+ } catch (err) {
2073
+ record("session", "CLI 会话", false, err.message)
2074
+ }
2075
+ } else {
2076
+ record("session", "CLI 会话", false, "未登录,请运行 `laicode login`")
2077
+ }
2078
+
2079
+ if (!cfg.apiKey && cfg.accessToken) {
2080
+ try {
2081
+ await withSpinner("检查 API Key", () => ensureApiKey(cfg), { enabled: !args.json, delayMs: 80 })
2082
+ cfg = await readConfig()
2083
+ record("api-key", "API Key", true, cfg.apiKeyName || "已配置", {
2084
+ configured: Boolean(cfg.apiKey),
2085
+ apiKeyName: cfg.apiKeyName || null,
2086
+ })
2087
+ } catch (err) {
2088
+ record("api-key", "API Key", false, err.message)
2089
+ }
2090
+ } else if (cfg.apiKey) {
2091
+ record("api-key", "API Key", true, cfg.apiKeyName || "已配置", {
2092
+ configured: true,
2093
+ apiKeyName: cfg.apiKeyName || null,
2094
+ })
2095
+ } else {
2096
+ record("api-key", "API Key", false, "未配置")
2097
+ }
2098
+
2099
+ if (cfg.apiKey) {
2100
+ try {
2101
+ let model = args.model || cfg.defaultModel
2102
+ if (!model && cfg.accessToken) {
2103
+ const data = await withSpinner("选择诊断模型", () => getModels(cfg), { enabled: !args.json, delayMs: 80 })
2104
+ model = pickDefaultModel(data.models)
2105
+ }
2106
+ if (!model) throw new Error("no model selected")
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 }
2116
+ )
2117
+ record("chat-completion", "真实模型调用", true, `${model} 首 token=${res.firstTokenMs}ms 总耗时=${res.totalMs}ms`, {
2118
+ model,
2119
+ firstTokenMs: res.firstTokenMs,
2120
+ totalMs: res.totalMs,
2121
+ })
2122
+ } catch (err) {
2123
+ record("chat-completion", "真实模型调用", false, err.message)
2124
+ }
2125
+ }
2126
+
2127
+ const failed = checks.filter((c) => !c.ok).length
2128
+ const payload = {
2129
+ ok: failed === 0,
2130
+ version: VERSION,
2131
+ startedAt,
2132
+ endedAt: nowIso(),
2133
+ stateDir: STATE_DIR,
2134
+ configFile: CONFIG_FILE,
2135
+ siteBase: siteBase(cfg),
2136
+ apiBaseUrl: apiBase(cfg),
2137
+ defaultModel: cfg.defaultModel || null,
2138
+ signedIn: Boolean(cfg.accessToken),
2139
+ hasApiKey: Boolean(cfg.apiKey),
2140
+ checks,
2141
+ summary: {
2142
+ total: checks.length,
2143
+ passed: checks.length - failed,
2144
+ failed,
2145
+ },
2146
+ }
2147
+
2148
+ if (args.json) {
2149
+ console.log(JSON.stringify(payload, null, 2))
2150
+ if (failed) process.exitCode = 1
2151
+ return
2152
+ }
2153
+
2154
+ printTable(
2155
+ "系统诊断",
2156
+ [
2157
+ { key: "status", label: "状态", width: 6 },
2158
+ { key: "name", label: "检查项", width: 16, max: 24 },
2159
+ { key: "detail", label: "详情", width: 42, max: 58 },
2160
+ ],
2161
+ checks.map((c) => ({
2162
+ status: c.ok ? ui.ok("通过") : ui.err("失败"),
2163
+ name: c.name,
2164
+ detail: c.detail || "-",
2165
+ }))
2166
+ )
2167
+ if (failed) process.exitCode = 1
2168
+ }
2169
+
2170
+ async function cmdLogout() {
2171
+ const cfg = await readConfig()
2172
+ if (cfg.accessToken) {
2173
+ try {
2174
+ await withSpinner("撤销远程会话", () => authJson(cfg, "/api/laicode/session/revoke", { method: "POST" }), {
2175
+ delayMs: 80,
2176
+ })
2177
+ console.log("Remote session revoked.")
2178
+ } catch (err) {
2179
+ if (err.status !== 401) console.log(`Remote revoke failed: ${err.message}`)
2180
+ }
2181
+ }
2182
+ const next = { ...cfg }
2183
+ delete next.accessToken
2184
+ delete next.accessTokenExpiresAt
2185
+ delete next.user
2186
+ delete next.apiKey
2187
+ delete next.apiKeyId
2188
+ delete next.apiKeyName
2189
+ next.updatedAt = nowIso()
2190
+ await writeConfig(next)
2191
+ console.log("Local LaiCode session cleared.")
2192
+ }
2193
+
2194
+ function localStatusRows(cfg) {
2195
+ return [
2196
+ `账号 ${cfg.user?.email || cfg.user?.name || "未登录"}`,
2197
+ `会话 ${cfg.accessToken ? statusWord(true, "已授权") : statusWord(false, "需要登录")}`,
2198
+ `密钥 ${cfg.apiKey ? maskSecret(cfg.apiKey) : "未配置"}`,
2199
+ `模型 ${cfg.defaultModel || "未选择"}`,
2200
+ `网关 ${apiBase(cfg)}`,
2201
+ `状态 ${STATE_DIR}`,
2202
+ ]
2203
+ }
2204
+
2205
+ async function chooseModel(cfg, title = "Choose Model") {
2206
+ const data = await withSpinner("拉取在线模型", () => getModels(cfg), { delayMs: 80 })
2207
+ const online = (data.models || []).filter(modelOnline)
2208
+ if (!online.length) {
2209
+ console.log(ui.warn("没有找到在线模型。"))
2210
+ return null
2211
+ }
2212
+
2213
+ const items = online.map((m) => {
2214
+ const prices = (m.tiers || [])
2215
+ .filter((t) => t.status === "online")
2216
+ .map((t) => `${t.id}:$${t.input}/1M`)
2217
+ .join(" ")
2218
+ return {
2219
+ label: `${m.modelId}`,
2220
+ description: `${m.vendor || ""} ${m.category || ""} ${prices}`.trim(),
2221
+ value: m.modelId,
2222
+ }
2223
+ })
2224
+ items.push({ label: "Cancel", description: "", value: null })
2225
+ return selectMenu(title, items, { defaultValue: cfg.defaultModel || pickDefaultModel(online), cancelValue: null })
2226
+ }
2227
+
2228
+ async function menuLogin() {
2229
+ await cmdLogin({ _: ["login"] })
2230
+ await pause()
2231
+ }
2232
+
2233
+ async function menuModels() {
2234
+ await cmdModels({ _: ["models"], online: true })
2235
+ await pause()
2236
+ }
2237
+
2238
+ async function menuTools() {
2239
+ await cmdTools({ _: ["tools"] })
2240
+ await pause()
24
2241
  }
25
2242
 
26
- if (arg === "--version" || arg === "-v") {
27
- console.log(version)
28
- process.exit(0)
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()
29
2253
  }
30
2254
 
31
- if (arg === "doctor") {
32
- console.log("LaiCode doctor")
33
- console.log("API base URL: https://api.lai.vc/v1")
34
- console.log("Status: CLI package installed. Full configurator coming soon.")
35
- process.exit(0)
2255
+ async function menuChat() {
2256
+ let cfg = await readConfig()
2257
+ if (!cfg.accessToken && !cfg.apiKey) {
2258
+ console.log(ui.warn("请先登录或设置 API Key 后再开始对话。"))
2259
+ await pause()
2260
+ return
2261
+ }
2262
+ if (!cfg.apiKey && cfg.accessToken) {
2263
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { delayMs: 80 })
2264
+ cfg = await readConfig()
2265
+ }
2266
+ const model = await chooseModel(cfg, "Chat Test")
2267
+ if (!model) return
2268
+ const prompt = await question("Prompt: ", "Say hello in one sentence.")
2269
+ printHero("Streaming Response")
2270
+ console.log(`${ui.dim("model")} ${model}\n`)
2271
+ await chatCompletion(cfg, [{ role: "user", content: prompt }], {
2272
+ model,
2273
+ stream: true,
2274
+ loadingText: "等待模型响应",
2275
+ })
2276
+ await patchConfig({ defaultModel: model })
2277
+ await pause()
36
2278
  }
37
2279
 
38
- console.error(`Unknown command: ${arg}`)
39
- console.error("Run `laicode --help` for usage.")
40
- process.exit(1)
2280
+ async function menuBench() {
2281
+ let cfg = await readConfig()
2282
+ if (!cfg.accessToken && !cfg.apiKey) {
2283
+ console.log(ui.warn("请先登录或设置 API Key 后再进行压测。"))
2284
+ await pause()
2285
+ return
2286
+ }
2287
+ if (!cfg.apiKey && cfg.accessToken) {
2288
+ await withSpinner("准备 API Key", () => ensureApiKey(cfg), { delayMs: 80 })
2289
+ cfg = await readConfig()
2290
+ }
2291
+ const model = await chooseModel(cfg, "Benchmark")
2292
+ if (!model) return
2293
+ const count = await question("Requests [3]: ", "3")
2294
+ printHero("Benchmark")
2295
+ await cmdBench({ _: ["bench"], model, count })
2296
+ await pause()
2297
+ }
2298
+
2299
+ async function menuKeys() {
2300
+ const action = await selectMenu(
2301
+ "API Keys",
2302
+ [
2303
+ { label: "列出密钥", description: "默认掩码展示", value: "list" },
2304
+ { label: "创建密钥", description: "创建 Laicode 测试 key", value: "create" },
2305
+ { label: "删除密钥", description: "按 ID 删除", value: "delete" },
2306
+ { label: "返回", description: "", value: null },
2307
+ ],
2308
+ { cancelValue: null }
2309
+ )
2310
+ if (!action) return
2311
+ if (action === "list") {
2312
+ await cmdKeys({ _: ["keys", "list"] })
2313
+ await pause()
2314
+ return
2315
+ }
2316
+ if (action === "create") {
2317
+ const name = await question(`名称 [${defaultKeyName()}]: `, defaultKeyName())
2318
+ const tier = await question("档位 [默认]: ", "")
2319
+ await cmdKeys({ _: ["keys", "create"], name, tier: tier || undefined })
2320
+ await pause()
2321
+ return
2322
+ }
2323
+ if (action === "delete") {
2324
+ await cmdKeys({ _: ["keys", "list"] })
2325
+ const id = await question("要删除的 Key ID: ", "")
2326
+ if (id) await cmdKeys({ _: ["keys", "delete", id] })
2327
+ await pause()
2328
+ }
2329
+ }
2330
+
2331
+ async function menuConfig() {
2332
+ const cfg = await readConfig()
2333
+ printHero("本地配置")
2334
+ printPanel("本地状态", localStatusRows(cfg).map((row) => stripAnsi(row)))
2335
+ console.log("")
2336
+ console.log(JSON.stringify(redactedConfig(cfg), null, 2))
2337
+ await pause()
2338
+ }
2339
+
2340
+ async function menuDoctor() {
2341
+ printHero("系统诊断")
2342
+ await cmdDoctor({ _: ["doctor"] })
2343
+ await pause()
2344
+ }
2345
+
2346
+ async function menuLogout() {
2347
+ const answer = await question("退出登录并清理本地 key 缓存?[y/N]: ", "n")
2348
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
2349
+ await cmdLogout()
2350
+ }
2351
+ await pause()
2352
+ }
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
+
2362
+ async function cmdMenu(args = {}) {
2363
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2364
+ console.log(buildHelp())
2365
+ return
2366
+ }
2367
+
2368
+ for (;;) {
2369
+ const cfg = await readConfig()
2370
+ const snapshot = await withSpinner("同步驾驶舱", () => dashboardSnapshot(cfg), { delayMs: 160 })
2371
+ printHero("Lai.vc 中转控制台")
2372
+ printDashboard(cfg, snapshot)
2373
+ console.log("")
2374
+ const action = await selectMenu(
2375
+ "选择任务",
2376
+ [
2377
+ { label: "开始对话", description: "流式测试单个模型", value: "chat" },
2378
+ { label: "AI 工具体检", description: "检查 Codex、Cursor、Continue、Cline", value: "tools" },
2379
+ { label: "一键接入", description: "预览 diff、备份、写入、可回滚", value: "init" },
2380
+ { label: "模型雷达", description: "在线模型、价格、多源、延迟", value: "models" },
2381
+ { label: "网关压测", description: "首 token、总耗时、成功率", value: "bench" },
2382
+ { label: "系统诊断", description: "会话、密钥、目录、真实调用", value: "doctor" },
2383
+ { label: "密钥管理", description: "列出、创建、删除测试 key", value: "keys" },
2384
+ { label: "本地配置", description: "查看 ~/.laicode 状态", value: "config" },
2385
+ { label: "品牌预览", description: "终端字标和品牌色板", value: "brand" },
2386
+ { label: "重新登录", description: "浏览器设备授权", value: "login" },
2387
+ { label: "回滚接入", description: "撤销最近一次配置写入", value: "rollback" },
2388
+ { label: "退出登录", description: "撤销会话并清理缓存", value: "logout" },
2389
+ { label: "退出", description: "", value: "exit" },
2390
+ ],
2391
+ { cancelValue: "exit", fancy: args.fancy, noHero: !args.fancy }
2392
+ )
2393
+
2394
+ if (!action || action === "exit") return
2395
+ if (action === "login") await menuLogin()
2396
+ else if (action === "tools") await menuTools()
2397
+ else if (action === "init") await menuInit()
2398
+ else if (action === "models") await menuModels()
2399
+ else if (action === "chat") await menuChat()
2400
+ else if (action === "bench") await menuBench()
2401
+ else if (action === "doctor") await menuDoctor()
2402
+ else if (action === "keys") await menuKeys()
2403
+ else if (action === "config") await menuConfig()
2404
+ else if (action === "brand") {
2405
+ cmdBrand()
2406
+ await pause()
2407
+ }
2408
+ else if (action === "rollback") await menuRollback()
2409
+ else if (action === "logout") await menuLogout()
2410
+ }
2411
+ }
2412
+
2413
+ async function main() {
2414
+ const args = parseArgs(process.argv.slice(2))
2415
+ if (args.color) process.env.LAICODE_COLOR = "1"
2416
+ if (args.plain) process.env.LAICODE_PLAIN = "1"
2417
+ const cmd = args._[0]
2418
+
2419
+ if (cmd === "--version" || cmd === "-v" || args.version || args.v) {
2420
+ console.log(VERSION)
2421
+ return
2422
+ }
2423
+ if (!cmd) {
2424
+ return await cmdMenu(args)
2425
+ }
2426
+ if (!cmd || cmd === "help" || args.help || args.h) {
2427
+ console.log(buildHelp())
2428
+ return
2429
+ }
2430
+
2431
+ try {
2432
+ if (cmd === "login") return await cmdLogin(args)
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)
2437
+ if (cmd === "keys") return await cmdKeys(args)
2438
+ if (cmd === "config") return await cmdConfig(args)
2439
+ if (cmd === "credential") return await cmdCredential(args)
2440
+ if (cmd === "chat") return await cmdChat(args)
2441
+ if (cmd === "bench") return await cmdBench(args)
2442
+ if (cmd === "doctor") return await cmdDoctor(args)
2443
+ if (cmd === "logout") return await cmdLogout(args)
2444
+ if (cmd === "menu") return await cmdMenu(args)
2445
+ if (cmd === "brand") return cmdBrand(args)
2446
+ if (cmd === "status") return await cmdStatus(args)
2447
+ if (cmd === "completion") return cmdCompletion(args)
2448
+ if (cmd === "commands") return cmdCommands(args)
2449
+ die(`Unknown command: ${cmd}\nRun \`laicode --help\` for usage.`)
2450
+ } catch (err) {
2451
+ die(err.message || String(err))
2452
+ }
2453
+ }
41
2454
 
2455
+ main()