@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 +26 -0
- package/bin/laicode.js +453 -61
- package/lib/adapters.js +379 -0
- package/package.json +2 -1
- package/scripts/smoke-test.js +41 -4
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
|
-
|
|
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
|
|
1104
|
-
refresh:
|
|
1105
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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)
|
|
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
|
|
1219
|
-
|
|
1220
|
-
|
|
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
|
-
|
|
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
|
|
1371
|
-
|
|
1372
|
-
|
|
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
|
|
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 }], {
|
|
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
|
-
|
|
1552
|
-
|
|
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
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
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
|
|
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
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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 }], {
|
|
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)
|
package/lib/adapters.js
ADDED
|
@@ -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.
|
|
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
|
],
|
package/scripts/smoke-test.js
CHANGED
|
@@ -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(),
|
|
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(
|
|
79
|
-
assertIncludes(run(["bin/laicode.js", "
|
|
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")
|