llm-api-gateway-cli 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # LLM API Gateway CLI 测试工具
1
+ # LLM API Gateway CLI 编程工具
2
2
 
3
3
  验证 `llm-api-gateway`(`http://127.0.0.1:9000`)除了 Web 对话页 / SDK 之外,**CLI 方式同样可用**。多个脚本覆盖多种接入方言,其中**原生 Agent(`cli-agent.js`)是终端里干活的推荐方式** —— 自带工具循环,无需安装 Claude Code。
4
4
 
@@ -37,10 +37,12 @@ npm install # 安装 openai 与 @anthropic-ai/sdk
37
37
 
38
38
  装完就能在**任意目录**直接用 `gateway-agent`,密钥不需要手写 `.env`:起服务后在页面「设置」里填一次(或 `gateway-agent config set key sk-xxx`),存进 `~/.llm-api-gateway-cli/credentials.json`(**不进 `config.json`**);也仍然可以用环境变量 / `.env` / `--key`,那三层优先级更高。
39
39
 
40
- > **⚠️ 装完还差一步:配密钥**(不用手写 `.env`)。`gateway-web` / `gateway-task` **没有密钥也能起**(页面照常打得开,启动横幅写「密钥 未配置」),在页面「设置 → 网关密钥」里填一次即可,保存后立即生效、不用重启;命令行也可 `gateway-agent config set key sk-xxx`。只有一次性命令行 agent(`gateway-agent -p "…"`)没有界面可交互,缺密钥会**直接报错退出**。想用环境变量 / `.env` 也行(优先级更高):`.env` 的加载顺序是**①安装目录 → ②当前工作目录**,npm 全局安装的包目录里只有 `.env.example`,所以要放在**你运行命令的那个目录**(服务在**启动那一刻**读)。网关不在默认地址(`http://127.0.0.1:9000`)或想固定模型,用 `gateway-agent config set baseUrl|model …`(也能在设置面板里改)。完整步骤见手册页第一节「安装与启动 → 首次配置」。
40
+ > **⚠️ 装完还差一步:配密钥**(不用手写 `.env`)。`gateway-web` / `gateway-task` **没有密钥也能起**(页面照常打得开,启动横幅写「密钥 未配置」),在页面「设置 → 网关密钥」里填一次即可,保存后立即生效、不用重启;命令行也可 `gateway-agent config set key sk-xxx`。**四个命令行入口也读这个文件**,所以配一次 Web 与命令行都认。只有一次性命令行 agent(`gateway-agent -p "…"`)没有界面可交互,缺密钥会**直接报错退出**(横幅会先告诉你当前的网关/模型及其来源,以及「那是内置默认、不是配好了」)。想用环境变量 / `.env` 也行(优先级更高):`.env` 的加载顺序是**①安装目录 → ②当前工作目录**,npm 全局安装的包目录里只有 `.env.example`,所以要放在**你运行命令的那个目录**(服务在**启动那一刻**读)。网关不在默认地址(`http://127.0.0.1:9000`)或想固定模型,用 `gateway-agent config set baseUrl|model …`(也能在设置面板里改)。完整步骤见手册页第一节「安装与启动 → 首次配置」。
41
41
 
42
42
  > 不想翻文档也行:起服务后打开 **[操作手册页 `/manual`](http://127.0.0.1:3100/manual)**,第一节就是「安装与启动」—— 三条路线、参数表、换镜像、**首次配置(界面 / 命令行 / 环境变量三种给密钥方式、密钥存哪、网关地址与模型)**、启动自检与卸载,命令都是可复制的。
43
43
 
44
+ > **装完第一步是 `gateway-agent setup`**:它会问一次**网关地址**与**网关密钥**并写进本机(`setup --base-url … --key …` 可一步到位;全新机器首次运行会自动进入这一步)。之后**裸敲 `llm-api-gateway-cli`(或 `gateway-agent`)就是启动器**:先问「① 对话 / ② 任务」,再问「① 命令行窗口 / ② 网页 UI」,选完直接进入对应形态 —— 不替你默认。选了网页 UI 才会起服务,然后**自己**打开 http://127.0.0.1:3100/task(「设置 → 其他配置」里地址与密钥都能改)。想跳过启动器:`-p "问题"` / `-i` / `gateway-task`。卸载用 `npm uninstall -g llm-api-gateway-cli`(`-g` 不能少)。
45
+
44
46
  ### macOS / Linux / WSL
45
47
 
46
48
  ```bash
@@ -761,11 +763,14 @@ npm run test:all # 先离线再联网
761
763
  | `tests/task-session.test.mjs` | 任务会话(方案 C)与历史预算:工具结果跨轮活下来、纯工具轮不被丢、旧任务懒迁移(含**带 `args` 时还原成原生 `tool_calls`**)、同一任务并发 409、请求校验 400、旧格式兼容开关、删任务连带删会话、`/api/store` 汇报会话占用;`history.maxChars` 超预算时压正文留记录、改配置立刻生效、`readFileDigest` 清单注入与「已被外部修改」告警 |
762
764
  | `tests/settings.test.mjs` | 配置落盘:18 个键的默认值与往返、四层优先级与来源列、越界值/未知键(含模糊建议)、`config.json` 拒收密钥(改名夹带也拒)、`unset` 回默认、坏文件降级与拒绝覆盖、UI 状态键与 `config` 子命令(`list/get/set/unset/path`,含「被环境变量盖住」的警告)、`GET/PUT /api/settings` |
763
765
  | `tests/secrets.test.mjs` | 免 `.env` 的密钥配置:密钥文件路径(跟数据根走 / `LLM_GATEWAY_SECRET_FILE` 覆盖)、读写往返与覆盖、POSIX `0600` 与「权限被放松」告警、坏文件降级、优先级(`--key`/env > 文件)、`keyRow` 只回掩码、`config set/get/list/unset/path key` 的落点,以及**没有密钥也能起服务** + 接口回 409 `needsKey` + 界面 PUT 一次即生效/手工改文件刷新即生效 |
766
+ | `tests/config-source.test.mjs` | **配置来源与首次使用引导**:`loadDotEnv` 记下「哪个变量由哪个 `.env` 提供」且真环境变量不算、来源文案四层(内置默认 / 启动参数 / 环境变量 + `.env` 路径 / 配置文件路径)、`summarizeConfigSources` 的「全默认 + 无 config.json = 全新机器」判定、密钥来源说到具体别名、**四个 CLI 也认 `credentials.json`**(本轮修掉的缺陷),以及 CLI/hub 横幅必须带来源、缺密钥时仍退 1 的源码契约 |
764
767
 
765
768
  ## 配置:住在磁盘上,CLI / Web / REPL 同一套
766
769
 
767
770
  **优先级一句话**:`--flag` > 环境变量 > `config.json` > 内置默认;密钥是唯一的例外 —— `--key` / 环境变量 / `.env` > `credentials.json`,而它**永远不进 `config.json`**。
768
771
 
772
+ **启动横幅会告诉你每个值「从哪来」**(`(内置默认)` / `(启动参数 --model)` / `(环境变量 GATEWAY_MODEL(来自 .env:E:\proj\.env))` / `(配置文件 …\config.json)`),命令行 agent 的横幅还多一行脱敏密钥。这条不是装饰:`.env` 的查找顺序是**①安装目录 → ②当前工作目录**,所以**在别的项目目录里跑会读到那个目录的 `.env`** —— 横幅里那句 `来自 .env:<路径>` 就是用来破「我明明没配过,怎么已经有值了 / 怎么已经有密钥了」这个高频误会的。反过来,全新机器第一次跑会看到两个值都标「内置默认」并多一句「这台机器还没有任何配置」+ 三条填法:**有值不等于配过了**。四个命令行入口与两个 Web 入口读同一套配置(含 `credentials.json`)。
773
+
769
774
  配置文件的默认位置是 `~/.llm-api-gateway-cli/config.json`(Windows 也就是 `C:\Users\<你>\.llm-api-gateway-cli\config.json`;与任务、会话同一个数据根,整体想换地方用 `LLM_GATEWAY_DATA_DIR`),也可以用环境变量 `LLM_GATEWAY_CONFIG` 只把配置文件指到别处。文件不存在也没关系,第一次 `config set` 会自动建。
770
775
 
771
776
  ```bash
package/cli-agent.js CHANGED
@@ -23,16 +23,20 @@
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import path from 'node:path';
25
25
  import readline from 'node:readline';
26
+ import { spawn } from 'node:child_process';
26
27
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
27
28
 
28
- import { loadDotEnv, parseArgs, DEFAULT_BASE_URL } from './lib/common.js';
29
+ import { loadDotEnv, parseArgs, DEFAULT_BASE_URL, maskKey } from './lib/common.js';
29
30
  import { resolveConfig, numArg, fail, KEY_HINT } from './lib/config.js';
30
31
  import { checkWorkDir, setBashEnabled, availableTools } from './lib/tools.js';
31
32
  import { createSession, restoreSession, runTurn, resumePendingTurn, countTurns } from './lib/runner.js';
32
33
  import { createSessionStore, resolveSessionDir, SESSION_TTL_MS } from './lib/sessionstore.js';
33
34
  import { COMMANDS, parseSlash, unescapeSlash, suggest, helpText, diffLines } from './lib/commands.js';
34
35
  import { configCommand, configHelp } from './lib/configcmd.js';
35
- import { settingsFilePath } from './lib/settings.js';
36
+ import { runSetup, isFreshMachine, createTerminalAsk, setupHelp } from './lib/setup.js';
37
+ import { runLauncher } from './lib/launcher.js';
38
+ import { settingsFilePath, summarizeConfigSources } from './lib/settings.js';
39
+ import { secretSourceText } from './lib/secrets.js';
36
40
  import { AGENT_LIMITS } from './lib/agent.js';
37
41
  import { MEMORY_FILES, readMemory } from './lib/memory.js';
38
42
  import { refreshMcpTools, mcpStatusText } from './lib/mcp.js';
@@ -43,21 +47,31 @@ loadDotEnv(SCRIPT_DIR);
43
47
  const DEFAULT_MODEL = process.env.GATEWAY_MODEL || 'qwen3:8b';
44
48
  const HISTORY_LIMIT = 200;
45
49
 
46
- const HELP = `用法:node cli-agent.js [选项] [提示词]
47
- node cli-agent.js config <list|get|set|unset|path> [] []
50
+ const HELP = `用法:gateway-agent 启动器:先问「对话 / 任务」,再问「命令行 / 网页」
51
+ gateway-agent [选项] [提示词]
52
+ gateway-agent setup [--base-url <地址>] [--key sk-xxx] 首次配置引导
53
+ gateway-agent config <list|get|set|unset|path> [键] [值]
54
+ (同一入口的别名:llm-api-gateway-cli;源码目录里也可以 node cli-agent.js …)
48
55
 
49
56
  模式六 · 原生 Agent:自带工具循环,直接在终端里读写工作目录内的文件,写入前需确认。
50
57
  不依赖 Claude Code(无需安装 @anthropic-ai/claude-code)。
51
58
 
59
+ 相关命令(本机 Web UI —— 本命令不会替你打开浏览器):
60
+ gateway-task [--port 3100] 起本机 Web UI 与任务页(gateway-web 是同一服务的别名);
61
+ 没有密钥也能起,网关地址与密钥在任务页「设置」里配
62
+ http://127.0.0.1:3100/ 聊天页
63
+ http://127.0.0.1:3100/task 任务页(「设置」里可改网关地址 / 模型 / 密钥)
64
+ http://127.0.0.1:3100/manual 操作手册页(安装、首次配置、指令一览的完整版)
65
+
52
66
  子命令:
67
+ setup 首次配置引导:问一次「网关地址」与「网关密钥」并写进本机配置
68
+ (地址 → config.json,密钥 → credentials.json)。带 --base-url /
69
+ --key 时直接写入、不再提问;全新机器首次运行会自动进入这一步。
53
70
  config list|get|set|unset|path
54
71
  查看/修改持久化配置(存到磁盘,清缓存不丢)。config list 会标出
55
72
  每一项「当前生效值」来自命令行、环境变量、配置文件还是内置默认
56
73
  —— 改了没生效时看这一列就知道被谁盖住了。
57
74
 
58
- 模式六 · 原生 Agent:自带工具循环,直接在终端里读写工作目录内的文件,写入前需确认。
59
- 不依赖 Claude Code(无需安装 @anthropic-ai/claude-code)。
60
-
61
75
  选项:
62
76
  -p, --prompt <文本> 单轮任务(省略则读位置参数 / 标准输入)
63
77
  -i, --interactive 进入多轮交互(REPL)
@@ -336,11 +350,53 @@ function saveHistory(sessionDir, rl) {
336
350
 
337
351
  // ---------- 主流程 ----------
338
352
 
353
+ /**
354
+ * 跑一次设置向导(`setup` 子命令与「首次运行自动引导」共用)。
355
+ * 只负责接线:问答用真终端,正文走 stdout,失败写 stderr。
356
+ */
357
+ async function runSetupFlow(args = {}) {
358
+ const r = await runSetup({
359
+ ask: createTerminalAsk(),
360
+ args,
361
+ env: process.env,
362
+ log: (s) => process.stdout.write(s),
363
+ });
364
+ if (!r.ok) process.stderr.write(`[错误] ${r.error}\n`);
365
+ return r;
366
+ }
367
+
368
+ /** 尽力打开浏览器。失败绝不影响服务本身(URL 已经打印出来了) */
369
+ function openBrowser(url) {
370
+ try {
371
+ const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
372
+ const argv = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
373
+ const child = spawn(cmd, argv, { stdio: 'ignore', detached: true });
374
+ // 不监听 error 的话,spawn 失败会变成未处理事件把进程炸掉 —— 打不开浏览器不值得让服务挂掉
375
+ child.on('error', () => {});
376
+ child.unref();
377
+ return true;
378
+ } catch {
379
+ return false;
380
+ }
381
+ }
382
+
339
383
  async function main() {
340
384
  // `gateway-agent config …`:配置子命令。放在 parseArgs 之前,而且不需要密钥 ——
341
385
  // 「配置还没弄好」恰恰是用户最可能先跑它的场景;交给 parseArgs 还会把
342
386
  // `set system --foo` 这类取值当成选项吃掉。
343
387
  const raw = process.argv.slice(2);
388
+ // `gateway-agent setup`:首次配置引导。和 `config` 一样放在 parseArgs 之前 ——
389
+ // 「还没配」正是最可能先跑它的场景,而它自己收 --base-url / --key(给了就不问)。
390
+ if (raw[0] === 'setup') {
391
+ const rest = raw.slice(1);
392
+ if (rest.includes('-h') || rest.includes('--help')) {
393
+ process.stdout.write(setupHelp());
394
+ return;
395
+ }
396
+ const r = await runSetupFlow(parseArgs(rest, VALUE_FLAGS, BOOL_FLAGS));
397
+ if (r.code) process.exitCode = r.code;
398
+ return;
399
+ }
344
400
  if (raw[0] === 'config') {
345
401
  const rest = raw.slice(1);
346
402
  if (rest.includes('-h') || rest.includes('--help')) {
@@ -364,14 +420,68 @@ async function main() {
364
420
  // bash 默认关闭:命令级权限不该默默打开
365
421
  setBashEnabled(Boolean(args['allow-bash']));
366
422
 
367
- const { key, baseUrl, model, temperature, maxTokens } = resolveConfig(args, { defaultModel: DEFAULT_MODEL });
423
+ let bootCfg = resolveConfig(args, { defaultModel: DEFAULT_MODEL });
424
+
425
+ // 首次配置引导:这台机器一条配置来源都没有 + 交互终端 → 先把「网关地址 / 密钥」问清楚,
426
+ // 配完就地继续启动。不这么做的话,第一次用的人只会收到一句「缺少 sk- 密钥」然后被赶去看文档。
427
+ // 非 TTY(管道 / CI)不触发,保持原来的报错退出语义。
428
+ if (!bootCfg.key && process.stdin.isTTY && isFreshMachine({ env: process.env })) {
429
+ const r = await runSetupFlow(args);
430
+ if (r.ok) bootCfg = resolveConfig(args, { defaultModel: DEFAULT_MODEL });
431
+ }
432
+
433
+ const { key, baseUrl, model, temperature, maxTokens } = bootCfg;
434
+ // 启动横幅必须能回答「这个值从哪来」:用户在新机器上第一次跑,看到 base/model 有值会以为
435
+ // 「已经配好了」,其实那可能只是**内置默认值**,也可能来自**当前目录的 .env**(别的项目的配置)。
436
+ const srcInfo = summarizeConfigSources({ env: process.env, args });
437
+ const srcOf = (k) => (srcInfo.rows[k] ? `(${srcInfo.rows[k].sourceText})` : '');
438
+ const keySrcOf = key ? secretSourceText(bootCfg.secret) : '';
368
439
  // 模型轮次上限:与 Web 侧同名同义;非法值交给 resolveMaxSteps 回落到默认
369
440
  const maxSteps = numArg(args['max-steps']);
370
441
  if (!key) {
442
+ process.stderr.write(
443
+ `[gateway] 还没配密钥 · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n` +
444
+ (srcInfo.allDefault && !srcInfo.configExists
445
+ ? ' 这台机器还没有任何配置(没找到 config.json / credentials.json / .env / 相关环境变量):\n' +
446
+ ' 上面那两个值是**内置默认**,不是「已经配好了」——配一次之后就一直用它。\n'
447
+ : ''),
448
+ );
449
+ // 没密钥时最实用的出口是 Web UI:它没有密钥也能起(KEY_HINT 第①条没说那个页面怎么起起来)
450
+ process.stderr.write(' Web UI(没密钥也能起):gateway-task → http://127.0.0.1:3100/task\n');
371
451
  process.stderr.write(`[错误] ${KEY_HINT}\n`);
372
452
  process.exit(1);
373
453
  }
374
454
 
455
+ // 裸命令(一个参数都没有)+ 交互终端 → 启动器:先问「对话 / 任务」,再问「命令行 / 网页」。
456
+ // 只在 argv 恰好没有参数时触发,所以 -p(单轮)、-i(直接进对话)、管道输入三条老路径一点没变。
457
+ let launcherInteractive = false;
458
+ if (process.argv.length === 2 && process.stdin.isTTY) {
459
+ const r = await runLauncher({
460
+ ask: createTerminalAsk(),
461
+ log: (s) => process.stdout.write(s),
462
+ startCli: async (plan) => {
463
+ launcherInteractive = true;
464
+ // 任务态要指定在哪个目录干活:走 args.cwd,下面 checkWorkDir 会照常校验它
465
+ if (plan.workDir) args.cwd = plan.workDir;
466
+ return { code: 0 };
467
+ },
468
+ startWeb: async (plan) => {
469
+ // 不另起子进程:在本进程内起同一个 hub(聊天页/任务页是同一个服务),Ctrl+C 停止
470
+ const { runHub } = await import('./lib/hub.js');
471
+ runHub([], { entry: plan.entry });
472
+ openBrowser(plan.url);
473
+ return { code: 0 };
474
+ },
475
+ });
476
+ if (!r.ok) {
477
+ process.stderr.write(`[错误] ${r.error}\n`);
478
+ process.exitCode = r.code || 1;
479
+ return;
480
+ }
481
+ // 选了网页:服务已经 listen,本进程就留着当服务用,不再往下走 REPL
482
+ if (r.plan?.kind === 'web') return;
483
+ }
484
+
375
485
  // 会话存储(可关):--no-session 时 store 为 null,行为退回纯内存
376
486
  let store = null;
377
487
  let sessionDir = null;
@@ -453,7 +563,7 @@ async function main() {
453
563
  }
454
564
  };
455
565
 
456
- let interactive = Boolean(args.interactive);
566
+ let interactive = Boolean(args.interactive) || launcherInteractive;
457
567
  let prompt = typeof args.prompt === 'string' ? args.prompt : args._.join(' ').trim();
458
568
 
459
569
  // 没给提示词又不在交互模式:先尝试标准输入;TTY 下(没有输入)则退化为交互模式
@@ -517,9 +627,13 @@ async function main() {
517
627
  : '按上述原因未向网关发出请求'}(CLI 本次不携带 MCP 工具)`
518
628
  : mcpStatusText();
519
629
  process.stderr.write(
520
- `[gateway] 原生 Agent · base=${baseUrl} model=${model}\n` +
630
+ `[gateway] 原生 Agent · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n` +
631
+ // 密钥这一行上轮只做在 Web 横幅里,CLI 的横幅反倒不说密钥从哪来 —— 而「我没配过密钥,
632
+ // 它怎么跑起来了」正是最需要一句解释的情形(多半是当前目录的 .env 给的)
633
+ ` 密钥 ${maskKey(key)}(${keySrcOf})\n` +
521
634
  ` 工作目录 ${workDir}\n` +
522
635
  ` 工具 ${toolNames}${args['allow-bash'] ? ' [bash 已开启]' : ''}\n` +
636
+ ` Web UI gateway-task → http://127.0.0.1:3100/task(本命令不会替你打开浏览器)\n` +
523
637
  ` ${mcpLine}\n` +
524
638
  ` 轮次上限 ${session.maxSteps || AGENT_LIMITS.MAX_STEPS} 轮\n` +
525
639
  (resumedFrom ? ` 已恢复会话 ${resumedFrom}(${countTurns(session)} 条消息)\n` : '') +
@@ -663,4 +777,7 @@ async function main() {
663
777
  }
664
778
  }
665
779
 
666
- main();
780
+ main().catch((e) => {
781
+ process.stderr.write(`[错误] ${e?.message || e}\n`);
782
+ process.exit(1);
783
+ });
package/lib/common.js CHANGED
@@ -11,19 +11,40 @@ import path from 'node:path';
11
11
  export const DEFAULT_BASE_URL = 'http://127.0.0.1:9000';
12
12
 
13
13
  /**
14
- * 读取 .env(先脚本目录、再当前工作目录),不覆盖已存在的环境变量。
15
- * 用函数声明是为了能在模块顶层前置调用,让 GATEWAY_MODEL 等参与默认值计算。
14
+ * 「这个环境变量是哪个 .env 给的」。
15
+ *
16
+ * 只记**真正被 .env 写入**的那些:真环境变量本来就存在时 .env 不参与(下面的
17
+ * `!(m[1] in process.env)` 分支),也就不该被标成「来自 .env」。
18
+ * 有了它,配置来源才能说清「环境变量 GATEWAY_MODEL(来自 .env:E:\proj\.env)」,
19
+ * 而不是笼统一句「环境变量」—— 用户排查「我没配过怎么会有值」时,差别就在这。
16
20
  */
21
+ const dotEnvOrigins = new Map();
22
+
23
+ /** 读取 .env(先脚本目录、再当前工作目录),不覆盖已存在的环境变量。
24
+ * 返回值是**本次调用**真正写入的来源表,便于调用方/测试确认「谁被写进来了」。 */
17
25
  export function loadDotEnv(scriptDir) {
26
+ const files = [];
27
+ const origins = new Map();
18
28
  for (const file of [path.join(scriptDir, '.env'), path.join(process.cwd(), '.env')]) {
19
29
  if (!existsSync(file)) continue;
30
+ files.push(file);
20
31
  for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
21
32
  const m = raw.match(/^\s*([A-Za-z_][\w.-]*)\s*=\s*(.*)\s*$/);
22
33
  if (!m) continue;
23
34
  const value = m[2].replace(/^['"]|['"]$/g, '');
24
- if (!(m[1] in process.env)) process.env[m[1]] = value;
35
+ if (!(m[1] in process.env)) {
36
+ process.env[m[1]] = value;
37
+ dotEnvOrigins.set(m[1], file);
38
+ origins.set(m[1], file);
39
+ }
25
40
  }
26
41
  }
42
+ return { files, origins };
43
+ }
44
+
45
+ /** 某个环境变量的 .env 出处;不是 .env 给的(没设 / 真环境变量)返回 '' */
46
+ export function dotEnvOrigin(name) {
47
+ return name ? dotEnvOrigins.get(name) || '' : '';
27
48
  }
28
49
 
29
50
  /** 只接受真正的字符串参数值:值型选项缺值时会退化成布尔 true,必须挡掉 */
package/lib/config.js CHANGED
@@ -8,12 +8,20 @@
8
8
  * 所有 CLI 都走 resolveConfig,保证问「密钥从哪来」只有一种答案。
9
9
  */
10
10
 
11
- import { resolveKey, resolveBaseUrl, strArg } from './common.js';
11
+ import { resolveBaseUrl, strArg } from './common.js';
12
+ import { resolveSecretKey } from './secrets.js';
12
13
 
13
- /** 缺密钥时的统一提示(各 CLI 文案一致,避免用户按提示改了还是不通) */
14
+ /**
15
+ * 缺密钥时的统一提示(各 CLI 文案一致,避免用户按提示改了还是不通)。
16
+ *
17
+ * 三条路都必须是真的:界面 / `config set key` 写的是 `credentials.json`,而四个 CLI 现在
18
+ * 也读这个文件(下面的 resolveConfig 走 resolveSecretKey)—— 提示与行为对不上,
19
+ * 用户会按着提示配完发现还是报缺密钥。
20
+ */
14
21
  export const KEY_HINT =
15
- '缺少 sk- 密钥:可在 Web 界面「设置」里填,或执行 gateway-agent config set key sk-xxx(存 credentials.json,仅本用户可读);'
16
- + '也可以用 --key sk-xxx、环境变量 SK / GATEWAY_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY,或在 .env 中写 GATEWAY_KEY=sk-xxx';
22
+ '缺少 sk- 密钥:① 执行 gateway-agent setup(首次配置引导:问一次网关地址与密钥,直接可用);'
23
+ + ' gateway-agent config set key sk-xxx / 在任务页·聊天页「设置」里填(都存 credentials.json,仅本用户可读);'
24
+ + '③ 或 --key sk-xxx、环境变量 SK / GATEWAY_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY,或在 .env 中写 GATEWAY_KEY=sk-xxx';
17
25
 
18
26
  /**
19
27
  * 数字参数:非法值(NaN / 布尔 / 空串)一律返回 undefined。
@@ -38,8 +46,17 @@ export function numArg(v) {
38
46
  */
39
47
  export function resolveConfig(args = {}, { defaultModel = '', defaultMaxTokens, env = process.env } = {}) {
40
48
  const maxTokens = numArg(args['max-tokens']) ?? defaultMaxTokens;
49
+ // 密钥走 lib/secrets.js:`--key` > 环境变量 / `.env` > credentials.json。
50
+ // 命令行必须认那个文件,否则「在界面里配一次就能用」对 CLI 入口是句空话
51
+ // (界面与 config set key 写的都是它)。来源字段一并带出来,横幅才能说清「谁给的」。
52
+ const secret = resolveSecretKey({ args, env });
41
53
  return {
42
- key: resolveKey(args, env),
54
+ key: secret.key,
55
+ keySource: secret.source,
56
+ keyFile: secret.file,
57
+ // 整个来源对象一并带出:横幅要 `secretSourceText(cfg.secret)` 才能说清
58
+ // 「环境变量 GATEWAY_KEY(来自 .env:…)」这类细节
59
+ secret,
43
60
  baseUrl: resolveBaseUrl(args, env),
44
61
  // 顺序:--model > GATEWAY_MODEL > 该入口的默认模型
45
62
  model: strArg(args.model) || strArg(env.GATEWAY_MODEL) || defaultModel,
package/lib/hub.js CHANGED
@@ -78,6 +78,8 @@ import {
78
78
  isSecretKey,
79
79
  parseSettingValue,
80
80
  unknownKeyError,
81
+ settingSourceText,
82
+ summarizeConfigSources,
81
83
  } from './settings.js';
82
84
  import { commandRows } from './commands.js';
83
85
  import { estimateCost, formatCost } from './pricing.js';
@@ -1456,11 +1458,17 @@ export function runHub(argv, { entry = 'web' } = {}) {
1456
1458
  console.log('LLM API Gateway · 本地 Web UI');
1457
1459
  console.log(` 聊天 http://${host}:${port}/`);
1458
1460
  console.log(` 任务 http://${host}:${port}/task`);
1459
- console.log(` 网关 ${baseUrl}`);
1460
- console.log(` 模型 ${model}`);
1461
+ // 网关 / 模型也要带来源:新机器上「有值」不等于「配过」——默认值、当前目录的 .env、
1462
+ // config.json 三者长得很像,不说清来源就只能靠猜(与 CLI 横幅同一套文案)
1463
+ console.log(` 网关 ${baseUrl}(${settingSourceText(settings, 'baseUrl')})`);
1464
+ console.log(` 模型 ${model}(${settingSourceText(settings, 'model')})`);
1461
1465
  // 密钥一行要能回答「配没配、从哪来」——来源是环境时,credentials.json 不参与,这一点要说清
1462
- console.log(` 密钥 ${key ? `${maskKey(key)}(${secretSourceText(secret.source)})` : '未配置'}`);
1466
+ console.log(` 密钥 ${key ? `${maskKey(key)}(${secretSourceText(secret)})` : '未配置'}`);
1463
1467
  if (!key) {
1468
+ const srcInfo = summarizeConfigSources({ keys: ['baseUrl', 'model'], env: cfg.env, args });
1469
+ if (srcInfo.allDefault && !srcInfo.configExists) {
1470
+ console.log(' 这台机器还没有任何配置:上面的网关与模型是**内置默认值**,不是「已经配好了」');
1471
+ }
1464
1472
  console.log(' 填法:页面「设置」里填(存 credentials.json,仅本用户可读)');
1465
1473
  console.log(' 或 gateway-agent config set key sk-xxx;或环境变量 GATEWAY_KEY / --key');
1466
1474
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * 裸命令启动器(`llm-api-gateway-cli` 不带参数时)
3
+ *
4
+ * 用户的要求很直接:**每次敲这个命令,应该由它来问「你要对话还是任务、要在命令行还是网页」**,
5
+ * 而不是默默替他选一个(今天的实现是「无参数 + TTY → 直接进 REPL」)。
6
+ *
7
+ * 为什么拆成「纯映射 + 注入动作」两层:
8
+ * · `resolvePlan()` 是纯函数,四条路径的映射可以被测试逐条钉住;
9
+ * · `runLauncher()` 只管问与打印,真去起服务还是进 REPL 由调用方注入 ——
10
+ * 测试里换成假的,就永远不会在测试机上偷偷起一个 3100。
11
+ *
12
+ * 兼容性红线:**只有「无参数 + 交互终端」才进这里**。`-p`(单轮)、`-i`(直接进对话)、
13
+ * 管道输入(`echo … | llm-api-gateway-cli`)三条老路径一律不弹菜单。
14
+ */
15
+
16
+ import { strArg } from './common.js';
17
+
18
+ /** 本机 Web UI 的默认端口。事实来源是 `lib/hub.js` 的 `DEFAULT_PORT`,测试会交叉断言两者一致 */
19
+ export const WEB_PORT = 3100;
20
+
21
+ export const TOPICS = [
22
+ { value: 'chat', label: '对话', desc: '纯聊天,不碰你的文件' },
23
+ { value: 'task', label: '任务', desc: '在指定目录里真读写文件,每次写入前先问你' },
24
+ ];
25
+
26
+ export const SURFACES = [
27
+ { value: 'cli', label: '命令行窗口', desc: '就在这个终端里继续' },
28
+ { value: 'web', label: '网页 UI', desc: `起本机网页 http://127.0.0.1:${WEB_PORT}(聊天页 / 任务页)` },
29
+ ];
30
+
31
+ /**
32
+ * 纯函数:两个选择 → 要执行的动作。
33
+ *
34
+ * | topic | surface | 结果 |
35
+ * | --- | --- | --- |
36
+ * | chat | cli | 命令行对话(REPL) |
37
+ * | task | cli | 先问工作目录,再进任务态(REPL,写入需批准) |
38
+ * | chat | web | 起服务,落到 `/` |
39
+ * | task | web | 起服务,落到 `/task` |
40
+ */
41
+ export function resolvePlan({ topic, surface, port = WEB_PORT } = {}) {
42
+ const t = topic === 'task' ? 'task' : 'chat';
43
+ const s = surface === 'web' ? 'web' : 'cli';
44
+ if (s === 'web') {
45
+ return {
46
+ kind: 'web',
47
+ topic: t,
48
+ entry: t === 'task' ? 'task' : 'web',
49
+ path: t === 'task' ? '/task' : '/',
50
+ url: `http://127.0.0.1:${port}${t === 'task' ? '/task' : '/'}`,
51
+ };
52
+ }
53
+ return { kind: 'cli', topic: t, interactive: true, needWorkDir: t === 'task' };
54
+ }
55
+
56
+ /** 把一行输入解析成选项值:支持序号 `1`/`2`、空回车(取默认)、也支持直接写 `task` / `web` */
57
+ export function pickOption(answer, list, defaultIndex = 0) {
58
+ const s = strArg(answer).toLowerCase();
59
+ if (!s) return list[defaultIndex]?.value ?? '';
60
+ const n = Number(s);
61
+ if (Number.isInteger(n) && n >= 1 && n <= list.length) return list[n - 1].value;
62
+ return list.find((o) => o.value === s)?.value ?? '';
63
+ }
64
+
65
+ /** 菜单正文 */
66
+ export function menuText(title, list) {
67
+ const lines = list.map((o, i) => ` ${i + 1}) ${o.label.padEnd(6, ' ')} ${o.desc}`);
68
+ return `${title}\n${lines.join('\n')}\n`;
69
+ }
70
+
71
+ export function launcherIntro() {
72
+ return (
73
+ 'LLM API Gateway · 启动器\n' +
74
+ '两个问题选完就进入对应形态(直接回车 = 选 1)。\n' +
75
+ '想跳过这个菜单:-p "你的问题"(单轮)· -i(直接进命令行对话)· gateway-task(直接起网页)\n\n'
76
+ );
77
+ }
78
+
79
+ /** 问一次选择题;问不出合法值返回 null(**不会**替用户默认) */
80
+ async function askChoice(ask, title, list, log, attempts) {
81
+ log(menuText(title, list));
82
+ for (let i = 0; i < attempts; i++) {
83
+ const answer = await ask('选择', { default: '1' });
84
+ const value = pickOption(answer, list, 0);
85
+ if (value) return value;
86
+ log(` ✗ 请输入 1-${list.length}${list.map((o) => ` / ${o.value}`).join('')}\n`);
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * 跑一次启动器。
93
+ *
94
+ * @param {object} o
95
+ * @param {(label:string, opts?:{default?:string, mask?:boolean}) => Promise<string>} o.ask
96
+ * @param {(s:string)=>void} [o.log]
97
+ * @param {(plan:object)=>Promise<{code:number}>} [o.startWeb] 真去起服务(注入,便于测试)
98
+ * @param {(plan:object)=>Promise<{code:number}>} [o.startCli] 真去进 REPL
99
+ * @param {string} [o.cwd] 任务工作目录的默认值(默认 process.cwd())
100
+ * @returns {Promise<{ok:boolean, code:number, plan?:object, error?:string}>}
101
+ */
102
+ export async function runLauncher({
103
+ ask = null,
104
+ log = () => {},
105
+ startWeb = null,
106
+ startCli = null,
107
+ cwd = process.cwd(),
108
+ port = WEB_PORT,
109
+ attempts = 3,
110
+ } = {}) {
111
+ if (!ask) return { ok: false, code: 1, error: '当前不是交互终端:请直接用参数表达意图(-p / -i / gateway-task)。' };
112
+
113
+ log(launcherIntro());
114
+
115
+ const topic = await askChoice(ask, '你要做什么?', TOPICS, log, attempts);
116
+ if (!topic) return { ok: false, code: 1, error: '没有选出「做什么」,已退出(没有改动任何配置)。' };
117
+
118
+ const surface = await askChoice(ask, '要在哪里跑?', SURFACES, log, attempts);
119
+ if (!surface) return { ok: false, code: 1, error: '没有选出「在哪里跑」,已退出(没有改动任何配置)。' };
120
+
121
+ const plan = resolvePlan({ topic, surface, port });
122
+
123
+ // 任务 + 命令行:还得知道在哪个目录干活(这是任务态与纯对话唯一的区别)
124
+ if (plan.kind === 'cli' && plan.needWorkDir) {
125
+ const answer = await ask(`任务工作目录 [${cwd}]`, { default: cwd });
126
+ plan.workDir = strArg(answer) || cwd;
127
+ }
128
+
129
+ if (plan.kind === 'web') {
130
+ log(`\n→ 起本机 Web UI:${plan.url}${plan.entry === 'task' ? '(任务页)' : '(聊天页)'}\n 停止:Ctrl+C\n\n`);
131
+ if (!startWeb) return { ok: false, code: 1, plan, error: '内部错误:没有注入 startWeb。' };
132
+ return { ok: true, ...(await startWeb(plan)), plan };
133
+ }
134
+
135
+ log(
136
+ `\n→ 命令行${plan.topic === 'task' ? `任务态(工作目录 ${plan.workDir},写入前会问你)` : '对话'}\n` +
137
+ ' 退出:/exit 或 Ctrl+C\n\n',
138
+ );
139
+ if (!startCli) return { ok: false, code: 1, plan, error: '内部错误:没有注入 startCli。' };
140
+ return { ok: true, ...(await startCli(plan)), plan };
141
+ }
package/lib/secrets.js CHANGED
@@ -22,7 +22,7 @@ import os from 'node:os';
22
22
 
23
23
  import { writeJsonAtomic } from './jsonstore.js';
24
24
  import { defaultStoreRoot } from './taskstore.js';
25
- import { resolveKey, strArg, maskKey } from './common.js';
25
+ import { resolveKey, strArg, maskKey, dotEnvOrigin } from './common.js';
26
26
 
27
27
  /** 想单独把密钥文件放别处时用它(整体换数据根用 LLM_GATEWAY_DATA_DIR) */
28
28
  export const SECRET_ENV = 'LLM_GATEWAY_SECRET_FILE';
@@ -142,11 +142,14 @@ export function clearSecret({ file = secretFilePath() } = {}) {
142
142
  export function resolveSecretKey({ args = null, env = process.env, file = secretFilePath(env) } = {}) {
143
143
  const external = resolveKey(args, env);
144
144
  if (external) {
145
- // 区分 --key 与其它:args.key 存在就是命令行显式给的
146
- return { key: external, source: strArg(args?.key) ? 'flag' : 'env', file, warning: '' };
145
+ // 区分 --key 与其它:args.key 存在就是命令行显式给的。
146
+ // env 层再细分到「哪个别名」以及「是不是某个 .env 给的」——横幅要说清这个。
147
+ if (strArg(args?.key)) return { key: external, source: 'flag', envName: '', dotEnvFile: '', file, warning: '' };
148
+ const envName = KEY_ENV_NAMES.find((n) => strArg(env?.[n])) || '';
149
+ return { key: external, source: 'env', envName, dotEnvFile: envName ? dotEnvOrigin(envName) : '', file, warning: '' };
147
150
  }
148
151
  const read = readSecret({ file, env });
149
- return { key: read.key, source: read.key ? 'file' : '', file, warning: read.warning };
152
+ return { key: read.key, source: read.key ? 'file' : '', envName: '', dotEnvFile: '', file, warning: read.warning };
150
153
  }
151
154
 
152
155
  export const SECRET_SOURCE_LABELS = {
@@ -155,9 +158,22 @@ export const SECRET_SOURCE_LABELS = {
155
158
  file: '密钥文件',
156
159
  };
157
160
 
158
- /** 人话来源:给启动横幅与 config list 用(`.env` 走的是 env 层,见 resolveSecretKey 注释) */
159
- export function secretSourceText(source) {
160
- return SECRET_SOURCE_LABELS[source] || '未配置';
161
+ /** 认的密钥环境变量别名(顺序即优先级,与 lib/common.js resolveKey 一致) */
162
+ const KEY_ENV_NAMES = ['SK', 'GATEWAY_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
163
+
164
+ /**
165
+ * 人话来源:给启动横幅与 config list 用(`.env` 走的是 env 层,见 resolveSecretKey 注释)。
166
+ *
167
+ * 第二参可以传 `{ envName, dotEnvFile }`(或整个 resolveSecretKey 的返回值),
168
+ * 这样「环境变量」能细到「环境变量 GATEWAY_KEY(来自 .env:E:\proj\.env)」——
169
+ * 用户排查「我没配过哪来的密钥」时靠的就是这一句。
170
+ */
171
+ export function secretSourceText(source, detail = {}) {
172
+ const s = typeof source === 'object' && source ? source.source : source;
173
+ const d = typeof source === 'object' && source ? source : detail || {};
174
+ const base = SECRET_SOURCE_LABELS[s] || '未配置';
175
+ if (s !== 'env' || !d.envName) return base;
176
+ return d.dotEnvFile ? `${base} ${d.envName}(来自 .env:${d.dotEnvFile})` : `${base} ${d.envName}`;
161
177
  }
162
178
 
163
179
  /**
@@ -190,8 +206,8 @@ export function keyRow({ env = process.env, file = secretFilePath(env), args = n
190
206
  mask: maskKey(resolved.key),
191
207
  file,
192
208
  fileMode: perms.mode,
193
- // 来源是人话,页面直接显示;命令行同源
194
- sourceText: secretSourceText(resolved.source),
209
+ // 来源是人话,页面直接显示;命令行同源(带别名与 .env 出处,见 secretSourceText)
210
+ sourceText: secretSourceText(resolved),
195
211
  warning: resolved.warning || perms.warning || '',
196
212
  fromFile,
197
213
  };
package/lib/settings.js CHANGED
@@ -20,7 +20,7 @@ import path from 'node:path';
20
20
  import os from 'node:os';
21
21
  import { readFileSync, mkdirSync } from 'node:fs';
22
22
 
23
- import { DEFAULT_BASE_URL } from './common.js';
23
+ import { DEFAULT_BASE_URL, dotEnvOrigin } from './common.js';
24
24
  import { MODES, AGENT_LIMITS } from './agent.js';
25
25
  import { TTL_DAYS, MAX_TASKS, defaultStoreRoot } from './taskstore.js';
26
26
  import { TASK_SESSION_MAX_BYTES, HISTORY_MAX_CHARS, HISTORY_KEEP_RECENT_TURNS } from './tasksession.js';
@@ -399,6 +399,70 @@ export function resolveSettings({ file = settingsFilePath(), env = process.env,
399
399
  return { values, sources, warnings, file: read.file, exists: read.exists };
400
400
  }
401
401
 
402
+ /** 四层优先级的展示名 */
403
+ export const SOURCE_LABELS = {
404
+ flag: '启动参数',
405
+ env: '环境变量',
406
+ file: '配置文件',
407
+ default: '内置默认',
408
+ };
409
+
410
+ /**
411
+ * 把某一项的来源翻成**能直接打印**的一句话。
412
+ *
413
+ * 只写「环境变量」是不够的:用户最常卡住的正是「我没配过,这值哪来的」——
414
+ * 而 `GATEWAY_MODEL` 可能来自真环境变量,也可能来自**某个 .env 文件**
415
+ * (`.env` 的查找顺序是「安装目录 → 当前工作目录」,见 lib/common.js)。
416
+ * 所以这里把变量名与 .env 路径都写出来,一眼能看出是不是别的项目的配置漏进来了。
417
+ *
418
+ * 第三参 `originOf` 是「查 .env 出处」的函数(默认用全局记录的 `dotEnvOrigin`),
419
+ * 与 `resolveSettings({env, file})` 同一个思路:可注入,测试才能给出确定的环境。
420
+ */
421
+ export function settingSourceText(resolved, key, originOf = dotEnvOrigin) {
422
+ const entry = SETTINGS_SCHEMA.find((e) => e.key === key) || {};
423
+ switch (resolved.sources[key]) {
424
+ case 'flag': {
425
+ // flag 里可能已经自带横线(`--base-url` / `-m/--model`),别再补一个变成 `----base-url`
426
+ const flag = entry.flag || key;
427
+ return `${SOURCE_LABELS.flag} ${flag.startsWith('-') ? flag : `--${flag}`}`;
428
+ }
429
+ case 'env': {
430
+ const name = entry.env || '';
431
+ const dot = name ? originOf(name) : '';
432
+ if (!name) return SOURCE_LABELS.env;
433
+ return dot ? `${SOURCE_LABELS.env} ${name}(来自 .env:${dot})` : `${SOURCE_LABELS.env} ${name}`;
434
+ }
435
+ case 'file':
436
+ return `${SOURCE_LABELS.file} ${resolved.file}`;
437
+ default:
438
+ return SOURCE_LABELS.default;
439
+ }
440
+ }
441
+
442
+ /**
443
+ * 启动横幅与「首次使用」判定共用的摘要。
444
+ *
445
+ * `allDefault` 看的是**整个白名单**而不是传进来的几个键:只要任何一项被 flag / 环境变量 /
446
+ * config.json 给过值,这台机器就算「配置过」,不该再唠叨首次使用引导。
447
+ */
448
+ export function summarizeConfigSources({ keys = ['baseUrl', 'model'], env = process.env, args = null, file = settingsFilePath() } = {}) {
449
+ const resolved = resolveSettings({ file, env, args });
450
+ const rows = {};
451
+ for (const key of keys) {
452
+ rows[key] = {
453
+ value: getByPath(resolved.values, key),
454
+ source: resolved.sources[key],
455
+ sourceText: settingSourceText(resolved, key),
456
+ };
457
+ }
458
+ return {
459
+ rows,
460
+ file: resolved.file,
461
+ configExists: resolved.exists,
462
+ allDefault: SETTINGS_SCHEMA.every((entry) => resolved.sources[entry.key] === 'default'),
463
+ };
464
+ }
465
+
402
466
  /**
403
467
  * `config list` 与 Web 设置面板共用的行:键、当前值、来源、默认、说明,
404
468
  * 外加**渲染所需的类型信息**(type / 枚举取值 / 范围)。
package/lib/setup.js ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * 首次配置引导(`gateway-agent setup`)
3
+ *
4
+ * 为什么必须有这一步:在这之前,「第一次用」的唯一出口是**报错** —— 缺密钥就退 1,
5
+ * 让用户自己去文档里拼 `config set` / 环境变量 / `.env`;网关地址更是提都没提。
6
+ * 别的 CLI 工具装完都会问一次「服务地址是什么、凭据是什么」,本项目缺的正是这一步。
7
+ *
8
+ * 三条设计约束:
9
+ * 1. **交互与业务分离**:问题由调用方注入(`ask`),所以测试能喂固定答案,不需要真终端;
10
+ * 2. **非交互可配**:`setup --base-url … --key …` 一条命令配完,CI 里能跑;
11
+ * 3. **不另立一套读写**:地址走 `writeSettings`(→ `config.json`),密钥走 `writeSecret`
12
+ * (→ `credentials.json`),校验口径与网页设置面板、`config set` 完全同一份。
13
+ *
14
+ * 「默认值」的处理是这套东西的关键:默认值**只在提问时以 `[默认]` 形式给出**,
15
+ * 用户回车才采用、也可以直接覆盖 —— 不做「悄悄用默认值跑起来」这件事。
16
+ */
17
+
18
+ import { strArg } from './common.js';
19
+ import { settingsFilePath, writeSettings, resolveSettings } from './settings.js';
20
+ import { secretFilePath, writeSecret, resolveSecretKey, KEY_SHAPE, SECRET_FILE_NAME } from './secrets.js';
21
+
22
+ /** 地址校验:与 settings 白名单同一口径(lib/settings.js:65) */
23
+ export const HTTP_URL_RE = /^https?:\/\/[^\s]+$/i;
24
+
25
+ /** 校验网关地址;通过返回 '',否则返回一句人话 */
26
+ export function validateBaseUrl(v) {
27
+ const s = strArg(v);
28
+ if (!s) return '网关地址不能为空';
29
+ if (!HTTP_URL_RE.test(s)) return '网关地址需要以 http:// 或 https:// 开头';
30
+ return '';
31
+ }
32
+
33
+ /** 校验密钥:与 lib/secrets.js 的 KEY_SHAPE 同一口径 */
34
+ export function validateKey(v) {
35
+ const s = strArg(v);
36
+ if (!s) return '网关密钥不能为空';
37
+ if (!KEY_SHAPE.test(s)) {
38
+ return `密钥形状不对:需要以 sk- 开头且长度足够(收到 ${s.length} 个字符,前缀 ${s.slice(0, 3)})`;
39
+ }
40
+ return '';
41
+ }
42
+
43
+ /**
44
+ * 这台机器是不是「全新、还没配过」。
45
+ *
46
+ * 判据是「四条来源一条都没有」:环境变量 / `.env`(会被 loadDotEnv 灌进 env)、
47
+ * `config.json`、`credentials.json`。任何一条有值就说明用户配过,不该再拿向导烦他。
48
+ * 与启动横幅那句「这台机器还没有任何配置」用的是同一套事实。
49
+ */
50
+ export function isFreshMachine({ env = process.env, file = settingsFilePath(env), secretFile = secretFilePath(env) } = {}) {
51
+ if (resolveSecretKey({ env, file: secretFile }).key) return false;
52
+ if (!strArg(env.GATEWAY_BASE_URL) && !resolveSettings({ file, env }).exists) return true;
53
+ return false;
54
+ }
55
+
56
+ /** 脱敏:只留前 6 后 4,给向导的回执用(`config get key` 也是这个口径) */
57
+ export function maskKeyish(key) {
58
+ const s = strArg(key);
59
+ if (s.length <= 10) return s ? `${s.slice(0, 3)}****` : '';
60
+ return `${s.slice(0, 6)}****${s.slice(-4)}`;
61
+ }
62
+
63
+ /** 向导正文(纯文本,便于测试逐字断言) */
64
+ export function setupIntro({ baseUrlDefault }) {
65
+ return [
66
+ 'LLM API Gateway · 首次配置',
67
+ '',
68
+ '这一步问两件事,都只写在本机(不会上传、不会进 git):',
69
+ ` 1) 网关地址:你部署的 LLM API Gateway 的地址(默认 ${baseUrlDefault})`,
70
+ ' 2) 网关密钥:网关后台发放的 sk- 密钥',
71
+ '',
72
+ ].join('\n');
73
+ }
74
+
75
+ /**
76
+ * 跑一次设置向导。
77
+ *
78
+ * @param {object} o
79
+ * @param {(label:string, opts?:{default?:string, mask?:boolean}) => Promise<string>} o.ask
80
+ * 读一行;**返回原始输入**(可能是空串),默认值由本函数套用 —— 提问方不承担校验
81
+ * @param {(s:string) => void} [o.log] 正文输出(默认丢弃,调用方传 process.stdout.write)
82
+ * @param {object} [o.args] `setup` 的参数(`--base-url` / `--key` 给了就不问)
83
+ * @param {number} [o.attempts] 单个问题的最多重问次数
84
+ * @returns {Promise<{ok:boolean, code:number, baseUrl?:string, key?:string,
85
+ * file?:string, secretFile?:string, error?:string}>}
86
+ */
87
+ export async function runSetup({
88
+ ask = null,
89
+ log = () => {},
90
+ args = {},
91
+ env = process.env,
92
+ file = settingsFilePath(env),
93
+ secretFile = secretFilePath(env),
94
+ attempts = 3,
95
+ } = {}) {
96
+ const resolved = resolveSettings({ file, env });
97
+ const currentBase = strArg(args['base-url']) || strArg(resolved.values.baseUrl) || 'http://127.0.0.1:9000';
98
+ const givenBase = strArg(args['base-url']);
99
+ const givenKey = strArg(args.key);
100
+
101
+ // 参数已经给全 → 纯写盘,一个问题都不问(CI / 脚本路径)
102
+ const needAsk = !(givenBase && givenKey);
103
+ if (needAsk && !ask) {
104
+ return {
105
+ ok: false,
106
+ code: 1,
107
+ error: '当前不是交互终端:请用 `gateway-agent setup --base-url <地址> --key sk-xxx` 一次配完。',
108
+ };
109
+ }
110
+
111
+ log(setupIntro({ baseUrlDefault: currentBase }));
112
+
113
+ /* ---------- 1) 网关地址 ---------- */
114
+ let baseUrl = givenBase;
115
+ if (!baseUrl) {
116
+ for (let i = 0; i < attempts; i++) {
117
+ const answer = await ask('网关地址', { default: currentBase });
118
+ const value = strArg(answer) || currentBase; // 回车 = 采用方括号里的默认值
119
+ const err = validateBaseUrl(value);
120
+ if (!err) {
121
+ baseUrl = value;
122
+ break;
123
+ }
124
+ log(` ✗ ${err}\n`);
125
+ }
126
+ if (!baseUrl) return { ok: false, code: 1, error: '网关地址没填对,已取消(没有写入任何文件)。' };
127
+ }
128
+
129
+ /* ---------- 2) 网关密钥 ---------- */
130
+ let key = givenKey;
131
+ if (!key) {
132
+ for (let i = 0; i < attempts; i++) {
133
+ const answer = await ask('网关密钥', { mask: true });
134
+ const err = validateKey(answer);
135
+ if (!err) {
136
+ key = strArg(answer);
137
+ break;
138
+ }
139
+ log(` ✗ ${err}\n`);
140
+ }
141
+ if (!key) return { ok: false, code: 1, error: '网关密钥没填对,已取消(没有写入任何文件)。' };
142
+ }
143
+
144
+ /* ---------- 3) 落盘:复用既有实现,原子写与权限都在里面 ---------- */
145
+ let wrote;
146
+ try {
147
+ // 地址去掉尾部斜杠,与 resolveBaseUrl / Web 侧同一口径
148
+ const res = writeSettings({ baseUrl: baseUrl.replace(/\/+$/, '') }, { file });
149
+ wrote = writeSecret(key, { file: secretFile });
150
+ log(
151
+ '\n已写入:\n' +
152
+ ` 网关地址 ${baseUrl.replace(/\/+$/, '')}\n → ${res.file}\n` +
153
+ ` 网关密钥 ${maskKeyish(key)}\n → ${wrote.file}${wrote.mode ? `(权限 ${wrote.mode.toString(8)})` : '(Windows:靠用户目录 ACL)'}\n` +
154
+ (wrote.warning ? `${wrote.warning}\n` : ''),
155
+ );
156
+ } catch (e) {
157
+ return { ok: false, code: 1, error: `写入配置失败:${e.message}` };
158
+ }
159
+
160
+ log(
161
+ '\n接下来:\n' +
162
+ ' gateway-agent 命令行对话(现在直接可用)\n' +
163
+ ' gateway-task 本机 Web UI 与任务页 → http://127.0.0.1:3100\n' +
164
+ ' gateway-agent config list 看所有配置项与它们的「来源」\n' +
165
+ '改网关地址 / 模型:gateway-agent config set baseUrl|model …,或在任务页「设置 → 其他配置」里改。\n' +
166
+ '密钥明文只在网关后台能重新生成;这里只回掩码。\n',
167
+ );
168
+
169
+ return { ok: true, code: 0, baseUrl: baseUrl.replace(/\/+$/, ''), key, file: resolveSettings({ file, env }).file, secretFile: wrote.file, secretName: SECRET_FILE_NAME };
170
+ }
171
+
172
+ /** `setup --help` 的正文 */
173
+ export function setupHelp() {
174
+ return `用法:gateway-agent setup [--base-url <地址>] [--key sk-xxx]
175
+
176
+ 首次配置引导:问一次「网关地址」和「网关密钥」,写进本机的两个文件 ——
177
+ 网关地址 → ${settingsFilePath()}
178
+ 网关密钥 → ${secretFilePath()}(不进 config.json)
179
+
180
+ --base-url <地址> 直接给定,不再提问(必须 http:// 或 https:// 开头)
181
+ --key sk-xxx 直接给定,不再提问
182
+ 两个都给 → 完全不问,适合脚本 / CI
183
+ 一个都不给 → 进入交互问答(默认值以 [方括号] 给出,回车即采用)
184
+
185
+ 等价做法(任选,读写的是同一份配置):
186
+ 网页「设置」面板(任务页「其他配置」里地址与密钥都有)
187
+ gateway-agent config set baseUrl <地址> / config set key sk-xxx
188
+ 环境变量 GATEWAY_BASE_URL / SK / GATEWAY_KEY,或当前目录的 .env
189
+ `;
190
+ }
191
+
192
+ /**
193
+ * 造一个真终端问答器(TTY 专用)。
194
+ *
195
+ * 不用 readline 的私有 `_writeToOutput` 做掩码:那种写法随 Node 版本变,
196
+ * 这里直接上 raw 模式自己回显 —— 普通问题回显字符,密钥只回 `*`。
197
+ * 非 TTY 返回 null,调用方据此走 `--base-url/--key` 的非交互路径。
198
+ *
199
+ * @returns {null | ((label:string, opts?:{default?:string, mask?:boolean}) => Promise<string>)}
200
+ */
201
+ export function createTerminalAsk({ input = process.stdin, output = process.stdout } = {}) {
202
+ if (!input.isTTY) return null;
203
+
204
+ function readLine(prompt, mask) {
205
+ return new Promise((resolve) => {
206
+ output.write(prompt);
207
+ const wasRaw = input.isRaw === true;
208
+ let text = '';
209
+ const finish = () => {
210
+ input.removeListener('data', onData);
211
+ try {
212
+ input.setRawMode(wasRaw);
213
+ } catch {
214
+ /* 平台不支持就保持原状 */
215
+ }
216
+ input.pause();
217
+ };
218
+ const onData = (chunk) => {
219
+ for (const ch of String(chunk)) {
220
+ if (ch === '\r' || ch === '\n') {
221
+ finish();
222
+ output.write('\n');
223
+ resolve(text);
224
+ return;
225
+ }
226
+ if (ch === '\u0003') {
227
+ // Ctrl+C:别把终端留在 raw 模式里
228
+ finish();
229
+ output.write('\n');
230
+ process.exit(130);
231
+ }
232
+ if (ch === '\u007f' || ch === '\b') {
233
+ if (text) {
234
+ text = text.slice(0, -1);
235
+ output.write('\b \b');
236
+ }
237
+ continue;
238
+ }
239
+ if (ch < ' ') continue; // 其余控制字符(方向键等)忽略
240
+ text += ch;
241
+ output.write(mask ? '*' : ch);
242
+ }
243
+ };
244
+ try {
245
+ input.setRawMode(true);
246
+ } catch {
247
+ /* 某些环境(如 Windows 的旧终端)可能拒绝,退化成带回显 */
248
+ }
249
+ input.setEncoding('utf8');
250
+ input.resume();
251
+ input.on('data', onData);
252
+ });
253
+ }
254
+
255
+ return (label, { default: def = '', mask = false } = {}) => {
256
+ const suffix = def ? ` [${def}]` : '';
257
+ const hint = mask ? '(sk- 开头,输入不回显)' : '';
258
+ return readLine(`${label}${suffix}${hint}:`, mask);
259
+ };
260
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-api-gateway-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "CLI 测试工具:验证 LLM API Gateway(http://127.0.0.1:9000)的 OpenAI / Anthropic / 原生 Agent / Claude Code 多种接入方式",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -61,6 +61,9 @@ const dom = {
61
61
  setHistoryChars: document.getElementById('set-history-chars'),
62
62
  setKey: document.getElementById('set-key'),
63
63
  keyHint: document.getElementById('key-hint'),
64
+ setBaseUrl: document.getElementById('set-base-url'),
65
+ baseHint: document.getElementById('base-hint'),
66
+ btnSaveBase: document.getElementById('btn-save-base'),
64
67
  btnSaveKey: document.getElementById('btn-save-key'),
65
68
  btnRefreshModels: document.getElementById('btn-refresh-models'),
66
69
  btnReset: document.getElementById('btn-reset'),
@@ -849,6 +852,10 @@ async function loadConfig() {
849
852
  const resp = await fetch('/api/config');
850
853
  state.config = await resp.json();
851
854
  setInfo(dom.infoBase, state.config.baseUrl);
855
+ // 地址是可改项(下一行的 infoBase 只是只读回显):回填输入框,但别踩用户正在输入的内容
856
+ if (dom.setBaseUrl && document.activeElement !== dom.setBaseUrl) {
857
+ dom.setBaseUrl.value = state.config.baseUrl || '';
858
+ }
852
859
  setInfo(dom.infoKey, state.config.hasKey
853
860
  ? `${state.config.keyMask}${state.config.keySourceText ? ` · ${state.config.keySourceText}` : ''}`
854
861
  : '未配置');
@@ -876,6 +883,38 @@ async function loadConfig() {
876
883
  }
877
884
  }
878
885
 
886
+ /**
887
+ * 保存网关地址。
888
+ *
889
+ * 这一页的设置项大多存在浏览器里,地址和密钥是例外:它们决定**服务端**往哪发请求、用什么凭据,
890
+ * 所以必须走 `PUT /api/settings`(地址 → config.json,密钥 → credentials.json)。
891
+ * 任务页「设置 → 其他配置 → 网关地址」走的是同一个接口、同一份文件,两处不会各存一处。
892
+ */
893
+ async function saveBaseUrl() {
894
+ const value = String(dom.setBaseUrl.value || '').trim();
895
+ if (!value) {
896
+ setStatus('网关地址不能为空(默认 http://127.0.0.1:9000)', 'err');
897
+ return;
898
+ }
899
+ dom.btnSaveBase.disabled = true;
900
+ try {
901
+ const resp = await fetch('/api/settings', {
902
+ method: 'PUT',
903
+ headers: { 'content-type': 'application/json' },
904
+ body: JSON.stringify({ values: { baseUrl: value } }),
905
+ });
906
+ const data = await resp.json().catch(() => ({}));
907
+ if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
908
+ await loadConfig();
909
+ await loadModels();
910
+ setStatus(data.note || `网关地址已保存:${state.config?.baseUrl || value}`, 'ok');
911
+ } catch (e) {
912
+ setStatus(`保存网关地址失败:${e.message}`, 'err');
913
+ } finally {
914
+ dom.btnSaveBase.disabled = false;
915
+ }
916
+ }
917
+
879
918
  /**
880
919
  * 保存密钥。
881
920
  *
@@ -1022,6 +1061,18 @@ function init() {
1022
1061
  dom.setSystem.addEventListener('blur', readSettingsFromUI);
1023
1062
 
1024
1063
  dom.btnRefreshModels.addEventListener('click', loadModels);
1064
+ // 网关地址:和密钥一样属于**服务端配置**(它决定服务端往哪发请求,写进 config.json),
1065
+ // 所以不能像这页其它项那样存浏览器本地 —— 走服务端的 PUT /api/settings,
1066
+ // 与任务页「设置 → 其他配置 → 网关地址」是同一条路、同一份文件。
1067
+ if (dom.btnSaveBase) {
1068
+ dom.btnSaveBase.addEventListener('click', saveBaseUrl);
1069
+ dom.setBaseUrl.addEventListener('keydown', (e) => {
1070
+ if (e.key === 'Enter') {
1071
+ e.preventDefault();
1072
+ saveBaseUrl();
1073
+ }
1074
+ });
1075
+ }
1025
1076
  // 密钥:唯一的写入路径是服务端的 PUT /api/settings(它写 credentials.json,不写 config.json)。
1026
1077
  // 这页的设置项大多存在浏览器里,密钥是例外 —— 它是服务端的事,所以单独一个「保存密钥」按钮。
1027
1078
  dom.btnSaveKey.addEventListener('click', saveKey);
package/public/index.html CHANGED
@@ -110,6 +110,13 @@
110
110
  <textarea id="set-system" rows="4" placeholder="留空则不发送 system 消息"></textarea>
111
111
  </label>
112
112
 
113
+ <label class="field">
114
+ <span>网关地址</span>
115
+ <input id="set-base-url" type="text" autocomplete="off" spellcheck="false"
116
+ placeholder="http://127.0.0.1:9000" />
117
+ <small id="base-hint">http:// 或 https:// 开头。与「网关密钥」一样属于服务端配置(写 config.json,不进浏览器本地),改完立即生效。</small>
118
+ </label>
119
+
113
120
  <label class="field">
114
121
  <span>网关密钥</span>
115
122
  <input id="set-key" type="password" autocomplete="off" spellcheck="false"
@@ -145,6 +152,7 @@
145
152
  </label>
146
153
 
147
154
  <div class="settings-actions">
155
+ <button class="btn ghost" id="btn-save-base" type="button">保存地址</button>
148
156
  <button class="btn ghost" id="btn-save-key" type="button">保存密钥</button>
149
157
  <button class="btn ghost" id="btn-refresh-models" type="button">刷新模型</button>
150
158
  <button class="btn ghost" id="btn-reset" type="button">恢复默认</button>
@@ -75,6 +75,19 @@
75
75
  <b>注意:装完还差一步「配密钥」</b> —— 但这一步<b>不用手写 <code>.env</code></b>:起服务后在界面「设置」里填一次,
76
76
  或者 <code>gateway-agent config set key sk-xxx</code>。没配之前页面能打开,只是发消息会被提示去配密钥(见下面「首次配置」)。
77
77
  </p>
78
+ <p class="manual-note">
79
+ <b>装完先做三件事(都可以让命令自己引导)</b>:
80
+ ① <b>配一次</b>:<code>gateway-agent setup</code> 会问「网关地址」和「网关密钥」并写进本机
81
+ (想一步到位就 <code>gateway-agent setup --base-url http://127.0.0.1:9000 --key sk-xxx</code>;
82
+ <b>全新机器首次运行会自动进入这一步</b>,不再只丢一句「缺少密钥」)。
83
+ ② <b>选做什么、在哪跑</b>:<b>裸敲</b> <code>llm-api-gateway-cli</code>(或 <code>gateway-agent</code>)会先问
84
+ 「① 对话 / ② 任务」,再问「① 命令行窗口 / ② 网页 UI」,选完直接进入对应形态 —— 不替你默认。
85
+ ③ 只有选了「网页 UI」才会起服务;<b>起服务后要自己用浏览器</b>打开
86
+ <code>http://127.0.0.1:3100/</code>(聊天)或 <code>/task</code>(任务),
87
+ 而<b>这一页手册本身就是 3100 上的 <code>/manual</code></b>,也要服务起着才看得到。
88
+ 网关地址与密钥在任务页「设置 → 其他配置」里也能改;命令行是 <code>gateway-agent config set baseUrl|key …</code>。
89
+ 想跳过启动器:<code>-p "问题"</code>(单轮)· <code>-i</code>(直接进对话)· <code>gateway-task</code>(直接起网页)。
90
+ </p>
78
91
  <p>
79
92
  <b>前置条件只有一个:Node.js ≥ 18</b>(用 <code>node -v</code> 自检)。脚本<b>不会替你装 Node</b> ——
80
93
  缺失或版本过低时它明确报错并停下,不动你的系统运行时。
@@ -159,9 +172,18 @@ $env:LLM_GATEWAY_INSTALL_BASE = 'https://your-mirror.example.com'; irm .../insta
159
172
  </p>
160
173
  <ol>
161
174
  <li>
162
- <b>路线一(推荐):起服务 → 在界面里填</b>
175
+ <b>路线零(推荐,装完第一步):<code>gateway-agent setup</code></b>
176
+ <div class="code-block"><code>gateway-agent setup # 交互问答:先问网关地址,再问密钥(不回显)
177
+ gateway-agent setup --base-url http://127.0.0.1:9000 --key sk-xxx # 两个都给就不问,适合脚本 / CI</code></div>
178
+ 地址写进 <code>config.json</code>、密钥写进 <code>credentials.json</code>,与界面 / <code>config set</code> 是同一份。
179
+ 默认值以 <code>[方括号]</code> 形式给出,<b>回车才采用</b>,也可以直接改;填错会重问,连错三次则<b>一个文件都不写</b>。
180
+ 全新机器首次运行(无 config.json / credentials.json / 环境变量 / <code>.env</code>)会自动进入这一步;
181
+ <b>非交互终端(管道、CI)不会触发</b>,仍按老样子报错退出。
182
+ </li>
183
+ <li>
184
+ <b>路线一:起服务 → 在界面里填</b>
163
185
  <div class="code-block"><code>gateway-web # 或 gateway-task,等价于 node server.js / node task-server.js
164
- # 打开 http://127.0.0.1:3100/task → 右上「设置」→「网关密钥」→ 粘贴 sk-xxx → 回车/保存</code></div>
186
+ # 打开 http://127.0.0.1:3100/task → 右上「设置」→「其他配置」→ 网关地址 / 网关密钥</code></div>
165
187
  没有密钥时启动横幅会打印「密钥 未配置」,并给出三种填法;页面的信息行显示「未配置」,发消息会被挡下并提示你打开设置
166
188
  (聊天页与任务页都有这个输入框,写的是同一个文件)。<b>保存后立即生效,不用重启服务。</b>
167
189
  </li>
@@ -178,6 +200,8 @@ gateway-agent config unset key # 清掉,回到「未配置」</code><
178
200
  <code>OPENAI_API_KEY</code> / <code>ANTHROPIC_API_KEY</code> / <code>ANTHROPIC_AUTH_TOKEN</code>)&gt;
179
201
  <code>.env</code> 里写 <code>GATEWAY_KEY=sk-xxx</code> &gt; <code>credentials.json</code>。
180
202
  环境里给了密钥时,那个文件<b>不参与</b>(<code>config list</code> 的「来源」列会告诉你是哪一层在生效)。
203
+ <b>四个命令行入口(agent / openai / anthropic / claude-code)也读 <code>credentials.json</code></b>——
204
+ 也就是说「路线一 / 二」配一次,Web 与命令行<b>都认</b>,不必为了命令行再手写 <code>.env</code>。
181
205
  <code>.env</code> 的加载顺序是<b>①安装目录 → ②当前工作目录</b>;<b>npm 全局安装后包目录里只有
182
206
  <code>.env.example</code></b>,所以要用 <code>.env</code> 就放在<b>你起服务的那个目录</b>:
183
207
  <div class="code-block"><code># 在你干活的项目目录里(可选,不是必须)
@@ -217,6 +241,17 @@ gateway-agent config set mode auto # 新任务的默认审批模式</co
217
241
  启动横幅会打印<b>网关 / 模型 / 密钥(脱敏 + 来源)/ 工具 / 存储 / 配置文件与密钥文件路径</b> ——
218
242
  先对着这几行核一遍,比在页面上猜快:密钥那行写「未配置」时,同一段还有三条填法。
219
243
  </li>
244
+ <li>
245
+ <b>每个值都带「来源」——看到有值不等于配过了</b>:横幅上写的是
246
+ <code>网关 http://127.0.0.1:9000(内置默认)</code>、<code>模型 qwen3:8b(内置默认)</code> 这样,
247
+ 来源分四种:<b>内置默认</b> / <b>启动参数</b> / <b>环境变量(含 <code>.env</code>,会连文件路径一起写出来)</b> /
248
+ <b>配置文件 <code>config.json</code></b>。命令行 agent 的横幅同样带来源,并且有一行脱敏的密钥。
249
+ <div class="code-block"><code>[gateway] 原生 Agent · base=http://127.0.0.1:9000(内置默认) model=deepseek-v4-flash(环境变量 GATEWAY_MODEL(来自 .env:E:\proj\.env))
250
+ 密钥 sk-abcd****wxyz(环境变量 GATEWAY_KEY(来自 .env:E:\proj\.env))</code></div>
251
+ 这条信息专门用来破一个高频误会:<b>在别的项目目录里跑,会读到那个目录的 <code>.env</code></b>
252
+ (查找顺序是「安装目录 → 当前工作目录」),于是「我明明没配过,怎么已经有值了」。
253
+ 全新机器第一次跑则是另一副样子:两个值都标「内置默认」,并多一句「这台机器还没有任何配置」+ 三条填法。
254
+ </li>
220
255
  <li>打开 <code>http://127.0.0.1:3100/task</code> —— 你正在看的这一页就是它。</li>
221
256
  <li>
222
257
  <b>连不上网关时怎么判断</b>:<code>curl http://127.0.0.1:9000/v1/models</code>(Windows:<code>irm http://127.0.0.1:9000/v1/models</code>)
@@ -224,9 +259,15 @@ gateway-agent config set mode auto # 新任务的默认审批模式</co
224
259
  </li>
225
260
  </ol>
226
261
  <p class="manual-note">
227
- 卸载:删掉安装目录(<code>rm -rf ~/.llm-api-gateway</code>,Windows 删 <code>$HOME\.llm-api-gateway</code>),
262
+ <b>卸载:按你的安装路线选一条。</b><b>npm 全局安装</b>的用
263
+ <code>npm uninstall -g llm-api-gateway-cli</code>(<b><code>-g</code> 不能少</b>,少了是去当前目录的
264
+ <code>node_modules</code> 里找它)—— 包目录与 <code>gateway-agent</code> / <code>gateway-web</code> /
265
+ <code>gateway-task</code> / <code>llm-api-gateway-cli</code> 等命令入口会一起删掉;
266
+ 验一下干不干净:<code>where gateway-agent</code>(POSIX:<code>which gateway-agent</code>)报找不到就是干净了。
267
+ <b>脚本安装</b>的则删掉安装目录(<code>rm -rf ~/.llm-api-gateway</code>,Windows 删 <code>$HOME\.llm-api-gateway</code>),
228
268
  再把写进 shell 启动文件 / 用户 PATH 的那一行删掉。数据(任务与配置,在 <code>~/.llm-api-gateway-cli</code>)
229
- 与安装目录是两回事,卸载不会连带删数据。
269
+ 与安装目录是两回事,卸载不会连带删数据 —— 想连密钥与任务一起清掉,再删那个目录
270
+ (<code>credentials.json</code> 里有你的 <code>sk-</code> 密钥,删掉就要重新配一次)。
230
271
  </p>
231
272
  <p class="manual-note">
232
273
  更细的说明(换镜像、源码安装、CI 产物)见仓库