llm-api-gateway-cli 1.0.4 → 1.0.6

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/cli-agent.js CHANGED
@@ -27,7 +27,7 @@ import { spawn } from 'node:child_process';
27
27
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
28
28
 
29
29
  import { loadDotEnv, parseArgs, DEFAULT_BASE_URL, maskKey } from './lib/common.js';
30
- import { resolveConfig, numArg, fail, KEY_HINT } from './lib/config.js';
30
+ import { resolveConfig, numArg, fail, keyHint } from './lib/config.js';
31
31
  import { checkWorkDir, setBashEnabled, availableTools } from './lib/tools.js';
32
32
  import { createSession, restoreSession, runTurn, resumePendingTurn, countTurns } from './lib/runner.js';
33
33
  import { createSessionStore, resolveSessionDir, SESSION_TTL_MS } from './lib/sessionstore.js';
@@ -40,6 +40,8 @@ import { secretSourceText } from './lib/secrets.js';
40
40
  import { AGENT_LIMITS } from './lib/agent.js';
41
41
  import { MEMORY_FILES, readMemory } from './lib/memory.js';
42
42
  import { refreshMcpTools, mcpStatusText } from './lib/mcp.js';
43
+ // CLI 侧语言(只覆盖命令行输出;Web 界面与手册页仍是中文)。默认 zh,见 lib/i18n.js
44
+ import { pick, setLang, resolveLang } from './lib/i18n.js';
43
45
 
44
46
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
45
47
  loadDotEnv(SCRIPT_DIR);
@@ -47,7 +49,7 @@ loadDotEnv(SCRIPT_DIR);
47
49
  const DEFAULT_MODEL = process.env.GATEWAY_MODEL || 'qwen3:8b';
48
50
  const HISTORY_LIMIT = 200;
49
51
 
50
- const HELP = `用法:gateway-agent 启动器:先问「对话 / 任务」,再问「命令行 / 网页」
52
+ const HELP_ZH = `用法:gateway-agent 启动器:先问「对话 / 任务」,再问「命令行 / 网页」
51
53
  gateway-agent [选项] [提示词]
52
54
  gateway-agent setup [--base-url <地址>] [--key sk-xxx] 首次配置引导
53
55
  gateway-agent config <list|get|set|unset|path> [键] [值]
@@ -62,6 +64,7 @@ const HELP = `用法:gateway-agent 启动器:先问「对
62
64
  http://127.0.0.1:3100/ 聊天页
63
65
  http://127.0.0.1:3100/task 任务页(「设置」里可改网关地址 / 模型 / 密钥)
64
66
  http://127.0.0.1:3100/manual 操作手册页(安装、首次配置、指令一览的完整版)
67
+ http://127.0.0.1:3100/sessions 命令行会话的只读存档(就是这里聊过的对话,--resume 接着聊)
65
68
 
66
69
  子命令:
67
70
  setup 首次配置引导:问一次「网关地址」与「网关密钥」并写进本机配置
@@ -93,11 +96,73 @@ const HELP = `用法:gateway-agent 启动器:先问「对
93
96
  --yes 自动批准所有**文件写入**(危险:模型可直接改文件,慎用)。MCP 调用不需要它
94
97
  --verbose 打印工具结果正文(默认只显示一行摘要)
95
98
  --no-color 关闭彩色输出(也遵循 NO_COLOR 环境变量)
99
+ --lang <zh|en> 输出语言(默认 zh,也可用环境变量 LLM_GATEWAY_LANG)
96
100
  -h, --help 显示帮助
97
101
 
98
102
  单个任务最多 ${AGENT_LIMITS.MAX_STEPS} 轮工具调用(--max-steps 可调)。写入被拒后模型会改为向你说明思路。
99
103
  `;
100
104
 
105
+ const HELP_EN = `Usage: gateway-agent launcher: asks "chat / task", then "terminal / web"
106
+ gateway-agent [options] [prompt]
107
+ gateway-agent setup [--base-url <url>] [--key sk-xxx] first-time setup
108
+ gateway-agent config <list|get|set|unset|path> [key] [value]
109
+ (alias of the same entry point: llm-api-gateway-cli; from the sources: node cli-agent.js …)
110
+
111
+ Mode 6 · native agent: brings its own tool loop, reads and writes files inside the working
112
+ directory and asks before every write. No Claude Code required (no @anthropic-ai/claude-code).
113
+
114
+ Related commands (local Web UI — this command never opens a browser for you):
115
+ gateway-task [--port 3100] start the local Web UI and task page (gateway-web is an alias);
116
+ it starts without an API key; set the gateway URL and key on the
117
+ task page under "Settings"
118
+ http://127.0.0.1:3100/ chat
119
+ http://127.0.0.1:3100/task task mode (gateway URL / model / key are editable in "Settings")
120
+ http://127.0.0.1:3100/manual operation manual (install, first-time setup, full command list)
121
+ http://127.0.0.1:3100/sessions read-only archive of CLI sessions (conversations started here)
122
+
123
+ Subcommands:
124
+ setup first-time setup: asks once for the gateway URL and the key, then writes
125
+ them to your machine (URL -> config.json, key -> credentials.json).
126
+ With --base-url / --key it writes without asking; a fresh machine enters
127
+ this step automatically on the first run.
128
+ config list|get|set|unset|path
129
+ view or change persisted settings (stored on disk, survives cache
130
+ clearing). config list marks where each effective value comes from:
131
+ command line, environment, config file or built-in default — that column
132
+ tells you what is overriding the value you just set.
133
+
134
+ Options:
135
+ -p, --prompt <text> one-shot task (falls back to the positional argument / stdin)
136
+ -i, --interactive multi-turn interactive mode (REPL)
137
+ -C, --cwd <dir> working directory; tools are sandboxed inside it (default: current dir)
138
+ -m, --model <model> model name (default ${DEFAULT_MODEL})
139
+ -k, --key <sk-key> gateway API key (or env SK/GATEWAY_KEY/OPENAI_API_KEY, or .env)
140
+ --base-url <url> gateway base URL (default ${DEFAULT_BASE_URL})
141
+ -s, --system <text> extra system prompt
142
+ --temperature <v> sampling temperature
143
+ --max-tokens <v> max generated tokens (a thinking model given too few may only emit reasoning)
144
+ --max-steps <n> max model turns per task (default ${AGENT_LIMITS.MAX_STEPS})
145
+ --continue continue the most recent session (context + pending approval)
146
+ --resume <session-id> resume the given session (UUID)
147
+ --session-dir <dir> where sessions are written (default: .llm-api-gateway-cli in your home dir)
148
+ --no-session do not persist the session (memory only, lost on exit)
149
+ --no-memory do not read AGENTS.md / CLAUDE.md project memory
150
+ --allow-bash enable the bash tool (off by default; each command still needs approval)
151
+ --output-format <v> output format: text (default) or stream-json (one JSON event per line)
152
+ --yes auto-approve all **file writes** (dangerous: the model edits files directly). MCP calls never need it
153
+ --verbose print tool output in full (default: a one-line summary)
154
+ --no-color disable colors (also honors the NO_COLOR env var)
155
+ --lang <zh|en> output language (default zh; env LLM_GATEWAY_LANG)
156
+ -h, --help show this help
157
+
158
+ One task runs at most ${AGENT_LIMITS.MAX_STEPS} model turns (--max-steps). After a rejected write the model explains its plan instead.
159
+ `;
160
+
161
+ /** 大段帮助取词必须在**调用时**(模块顶层调 pick 会早于 setLang,永远拿到中文) */
162
+ function mainHelp() {
163
+ return pick(HELP_ZH, HELP_EN);
164
+ }
165
+
101
166
  const VALUE_FLAGS = {
102
167
  '--prompt': 'prompt',
103
168
  '-p': 'prompt',
@@ -116,6 +181,7 @@ const VALUE_FLAGS = {
116
181
  '--resume': 'resume',
117
182
  '--session-dir': 'session-dir',
118
183
  '--output-format': 'output-format',
184
+ '--lang': 'lang',
119
185
  };
120
186
  const BOOL_FLAGS = {
121
187
  '--interactive': 'interactive',
@@ -131,6 +197,14 @@ const BOOL_FLAGS = {
131
197
  '-h': 'help',
132
198
  };
133
199
 
200
+ /** 从原始 argv 里取 --lang:要在 parseArgs 之前用(setup / config 子命令也会输出) */
201
+ function langFromArgv(argv) {
202
+ const i = argv.indexOf('--lang');
203
+ if (i >= 0 && argv[i + 1] && !String(argv[i + 1]).startsWith('-')) return argv[i + 1];
204
+ const eq = argv.find((a) => String(a).startsWith('--lang='));
205
+ return eq ? String(eq).slice('--lang='.length) : '';
206
+ }
207
+
134
208
  function readStdin() {
135
209
  return new Promise((resolve) => {
136
210
  if (process.stdin.isTTY) return resolve('');
@@ -233,17 +307,27 @@ function createRenderer({ color, verbose, json }) {
233
307
  // ---------- 写入批准 ----------
234
308
 
235
309
  function renderApproval(preview, color) {
236
- const top = '┌─ 待批准写入 ─────────────────────────────';
310
+ const top = pick('┌─ 待批准写入 ─────────────────────────────', '┌─ pending write ──────────────────────────');
237
311
  const mid = '├──────────────────────────────────────────';
238
312
  const bot = '└──────────────────────────────────────────';
239
- const kind = preview.shell ? '执行命令' : preview.patch ? '增量修改' : preview.exists ? '覆盖已有文件' : '新建文件';
240
- const out = [top, `│ ${preview.path} (${kind})`, `│ ${preview.lines} 行 / ${preview.bytes} 字节`];
313
+ const kind = preview.shell
314
+ ? pick('执行命令', 'run command')
315
+ : preview.patch
316
+ ? pick('增量修改', 'patch')
317
+ : preview.exists
318
+ ? pick('覆盖已有文件', 'overwrite existing file')
319
+ : pick('新建文件', 'create file');
320
+ const out = [
321
+ top,
322
+ `│ ${preview.path} (${kind})`,
323
+ pick(`│ ${preview.lines} 行 / ${preview.bytes} 字节`, `│ ${preview.lines} lines / ${preview.bytes} bytes`),
324
+ ];
241
325
 
242
326
  // 覆盖已有文件时给出行级 diff:只看新内容根本判断不出改了什么
243
327
  const canDiff = !preview.shell && preview.exists && typeof preview.old === 'string' && preview.old !== preview.content;
244
328
  if (canDiff) {
245
329
  const { lines, removed, added } = diffLines(preview.old, preview.content);
246
- out.push(mid, `│ 改动:-${removed} 行 / +${added} 行`);
330
+ out.push(mid, pick(`│ 改动:-${removed} 行 / +${added} 行`, `│ changes: -${removed} lines / +${added} lines`));
247
331
  for (const line of lines) {
248
332
  const painted = line.startsWith('-') ? color.red(line) : line.startsWith('+') ? color.green(line) : line;
249
333
  out.push(`│ ${painted}`);
@@ -253,7 +337,9 @@ function renderApproval(preview, color) {
253
337
  const contentLines = String(preview.content ?? '').split('\n');
254
338
  const shown = contentLines.slice(0, 40);
255
339
  for (const line of shown) out.push(`│ ${line}`);
256
- if (contentLines.length > shown.length) out.push(`│ …(预览截断,共 ${contentLines.length} 行)`);
340
+ if (contentLines.length > shown.length) {
341
+ out.push(pick(`│ …(预览截断,共 ${contentLines.length} 行)`, `│ ... (preview truncated, ${contentLines.length} lines in total)`));
342
+ }
257
343
  }
258
344
  if (preview.note) out.push(`│ ${color.yellow(preview.note)}`);
259
345
  out.push(bot);
@@ -269,7 +355,7 @@ function question(rl, prompt) {
269
355
  * 不引 TUI 库:readline 就够,多行只是多问几次。
270
356
  */
271
357
  async function readInput(rl, color) {
272
- const first = await question(rl, color.cyan('你> '));
358
+ const first = await question(rl, color.cyan(pick('你> ', 'you> ')));
273
359
  if (!first.trimEnd().endsWith('\\')) return first;
274
360
  const parts = [first.trimEnd().slice(0, -1)];
275
361
  for (;;) {
@@ -292,14 +378,22 @@ function makeApproval({ autoApprove, rl, color }) {
292
378
  return async (pending, preview) => {
293
379
  renderApproval(preview, color);
294
380
  if (autoApprove) {
295
- process.stderr.write(color.yellow(' [--yes] 已自动批准这次写入\n'));
381
+ process.stderr.write(color.yellow(pick(' [--yes] 已自动批准这次写入\n', ' [--yes] write auto-approved\n')));
296
382
  return true;
297
383
  }
298
384
  if (!rl || !process.stdin.isTTY) {
299
- process.stderr.write(color.red(' 非交互环境,默认拒绝文件写入(确需自动批准请加 --yes;MCP 调用不受此限)\n'));
385
+ process.stderr.write(color.red(pick(' 非交互环境,默认拒绝文件写入(确需自动批准请加 --yes;MCP 调用不受此限)\n', ' non-interactive environment: file writes are denied by default (pass --yes to auto-approve; MCP calls are not affected)\n')));
300
386
  return false;
301
387
  }
302
- const ans = await question(rl, color.cyan(`批准 ${preview.shell ? '执行该命令' : `写入 ${preview.path}`} ?(y/N) `));
388
+ const ans = await question(
389
+ rl,
390
+ color.cyan(
391
+ pick(
392
+ `批准 ${preview.shell ? '执行该命令' : `写入 ${preview.path}`} ?(y/N) `,
393
+ `Approve ${preview.shell ? 'running this command' : `writing ${preview.path}`}? (y/N) `,
394
+ ),
395
+ ),
396
+ );
303
397
  return /^(y|yes)$/i.test(ans.trim());
304
398
  };
305
399
  }
@@ -385,6 +479,9 @@ async function main() {
385
479
  // 「配置还没弄好」恰恰是用户最可能先跑它的场景;交给 parseArgs 还会把
386
480
  // `set system --foo` 这类取值当成选项吃掉。
387
481
  const raw = process.argv.slice(2);
482
+ // 语言要在**任何输出之前**定下来:setup / config 子命令走的是 parseArgs 之前的路径,
483
+ // 所以这里从原始 argv 里取一次 --lang(不重复 parseArgs,免得未知参数被报两遍)。
484
+ setLang(resolveLang({ lang: langFromArgv(raw) }, process.env));
388
485
  // `gateway-agent setup`:首次配置引导。和 `config` 一样放在 parseArgs 之前 ——
389
486
  // 「还没配」正是最可能先跑它的场景,而它自己收 --base-url / --key(给了就不问)。
390
487
  if (raw[0] === 'setup') {
@@ -413,7 +510,7 @@ async function main() {
413
510
  const args = parseArgs(process.argv.slice(2), VALUE_FLAGS, BOOL_FLAGS);
414
511
 
415
512
  if (args.help) {
416
- console.log(HELP);
513
+ console.log(mainHelp());
417
514
  return;
418
515
  }
419
516
 
@@ -440,15 +537,27 @@ async function main() {
440
537
  const maxSteps = numArg(args['max-steps']);
441
538
  if (!key) {
442
539
  process.stderr.write(
443
- `[gateway] 还没配密钥 · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n` +
540
+ pick(
541
+ `[gateway] 还没配密钥 · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n`,
542
+ `[gateway] no API key configured · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n`,
543
+ ) +
444
544
  (srcInfo.allDefault && !srcInfo.configExists
445
- ? ' 这台机器还没有任何配置(没找到 config.json / credentials.json / .env / 相关环境变量):\n' +
446
- ' 上面那两个值是**内置默认**,不是「已经配好了」——配一次之后就一直用它。\n'
545
+ ? pick(
546
+ ' 这台机器还没有任何配置(没找到 config.json / credentials.json / .env / 相关环境变量):\n' +
547
+ ' 上面那两个值是**内置默认**,不是「已经配好了」——配一次之后就一直用它。\n',
548
+ ' This machine has no configuration yet (no config.json / credentials.json / .env / env vars):\n' +
549
+ ' the two values above are **built-in defaults**, not something you configured — set them once and they stick.\n',
550
+ )
447
551
  : ''),
448
552
  );
449
553
  // 没密钥时最实用的出口是 Web UI:它没有密钥也能起(KEY_HINT 第①条没说那个页面怎么起起来)
450
- process.stderr.write(' Web UI(没密钥也能起):gateway-task → http://127.0.0.1:3100/task\n');
451
- process.stderr.write(`[错误] ${KEY_HINT}\n`);
554
+ process.stderr.write(
555
+ pick(
556
+ ' Web UI(没密钥也能起):gateway-task → http://127.0.0.1:3100/task\n',
557
+ ' Web UI (starts without a key): gateway-task -> http://127.0.0.1:3100/task\n',
558
+ ),
559
+ );
560
+ process.stderr.write(`${pick('[错误]', '[error]')} ${keyHint()}\n`);
452
561
  process.exit(1);
453
562
  }
454
563
 
@@ -474,7 +583,7 @@ async function main() {
474
583
  },
475
584
  });
476
585
  if (!r.ok) {
477
- process.stderr.write(`[错误] ${r.error}\n`);
586
+ process.stderr.write(pick(`[错误] ${r.error}\n`, `[error] ${r.error}\n`));
478
587
  process.exitCode = r.code || 1;
479
588
  return;
480
589
  }
@@ -495,7 +604,7 @@ async function main() {
495
604
  sessionNotice = resolved.notice;
496
605
  store = createSessionStore(sessionDir);
497
606
  } catch (e) {
498
- sessionWarning = `会话落盘不可用(${e.message}),本次会话不会保存`;
607
+ sessionWarning = pick(`会话落盘不可用(${e.message}),本次会话不会保存`, `Session persistence unavailable (${e.message}); this session will not be saved`);
499
608
  }
500
609
  }
501
610
 
@@ -504,23 +613,23 @@ async function main() {
504
613
  if (args.resume !== undefined) {
505
614
  const id = typeof args.resume === 'string' ? args.resume.trim() : '';
506
615
  if (!store) {
507
- process.stderr.write('[错误] --resume 与 --no-session 不能同时用\n');
616
+ process.stderr.write(pick('[错误] --resume 与 --no-session 不能同时用\n', '[error] --resume and --no-session cannot be used together\n'));
508
617
  process.exit(1);
509
618
  }
510
619
  if (!id) {
511
- process.stderr.write(`[错误] --resume 需要一个会话 id(UUID)。会话目录:${sessionDir}\n`);
620
+ process.stderr.write(pick(`[错误] --resume 需要一个会话 id(UUID)。会话目录:${sessionDir}\n`, `[error] --resume needs a session id (UUID). Session directory: ${sessionDir}\n`));
512
621
  process.exit(1);
513
622
  }
514
623
  restored = store.read(id);
515
624
  if (!restored) {
516
- process.stderr.write(`[错误] 找不到会话 ${id}(id 需为 UUID,且未被 TTL 清理)。会话目录:${sessionDir}\n`);
625
+ process.stderr.write(pick(`[错误] 找不到会话 ${id}(id 需为 UUID,且未被 TTL 清理)。会话目录:${sessionDir}\n`, `[error] session ${id} not found (id must be a UUID and not expired by TTL). Session directory: ${sessionDir}\n`));
517
626
  process.exit(1);
518
627
  }
519
628
  } else if (args.continue) {
520
629
  restored = store ? store.latest(SESSION_TTL_MS) : null;
521
630
  if (!restored) {
522
631
  const plain = makeColor(false);
523
- process.stderr.write(`${plain.yellow('!')} --continue 没有找到可恢复的会话,将新建一个\n`);
632
+ process.stderr.write(pick(`${plain.yellow('!')} --continue 没有找到可恢复的会话,将新建一个\n`, `${plain.yellow('!')} --continue found no session to resume; starting a new one\n`));
524
633
  }
525
634
  }
526
635
 
@@ -529,7 +638,7 @@ async function main() {
529
638
  const want = typeof args.cwd === 'string' ? args.cwd : restored?.workingDir || process.cwd();
530
639
  workDir = checkWorkDir(want);
531
640
  } catch (e) {
532
- process.stderr.write(`[错误] 工作目录无效:${e.message}\n`);
641
+ process.stderr.write(pick(`[错误] 工作目录无效:${e.message}\n`, `[error] invalid working directory: ${e.message}\n`));
533
642
  process.exit(1);
534
643
  }
535
644
 
@@ -549,7 +658,7 @@ async function main() {
549
658
  const color = makeColor(!args['no-color'] && !process.env.NO_COLOR && Boolean(process.stdout.isTTY));
550
659
  const jsonOut = args['output-format'] === 'stream-json';
551
660
  if (args['output-format'] && !jsonOut && args['output-format'] !== 'text') {
552
- process.stderr.write('[错误] --output-format 只支持 text / stream-json\n');
661
+ process.stderr.write(pick('[错误] --output-format 只支持 text / stream-json\n', '[error] --output-format only supports text / stream-json\n'));
553
662
  process.exit(1);
554
663
  }
555
664
  const renderer = createRenderer({ color, verbose: Boolean(args.verbose), json: jsonOut });
@@ -559,7 +668,7 @@ async function main() {
559
668
  try {
560
669
  store.write(session);
561
670
  } catch (e) {
562
- process.stderr.write(color.yellow(`! 会话落盘失败:${e.message}\n`));
671
+ process.stderr.write(color.yellow(pick(`! 会话落盘失败:${e.message}\n`, `! could not save the session: ${e.message}\n`)));
563
672
  }
564
673
  };
565
674
 
@@ -573,7 +682,7 @@ async function main() {
573
682
  }
574
683
 
575
684
  if (!interactive && !prompt) {
576
- console.log(HELP);
685
+ console.log(mainHelp());
577
686
  return;
578
687
  }
579
688
 
@@ -594,7 +703,7 @@ async function main() {
594
703
  let controller = null;
595
704
  const onSigint = () => {
596
705
  if (controller) {
597
- process.stderr.write(`\n${color.yellow('!')} 已中断当前任务\n`);
706
+ process.stderr.write(pick(`\n${color.yellow('!')} 已中断当前任务\n`, `\n${color.yellow('!')} current task interrupted\n`));
598
707
  controller.abort();
599
708
  controller = null;
600
709
  } else if (rl) {
@@ -622,25 +731,46 @@ async function main() {
622
731
  // 注意后半句的措辞:`GATEWAY_MCP=off` 时工具是网关自己注入的,说「没有 MCP」会误导,
623
732
  // 只能保证「CLI 不携带」。
624
733
  const mcpLine = mcpInfo?.error
625
- ? `${mcpStatusText()} · ${mcpInfo.attemptedAt
626
- ? `已请求 GET ${baseUrl}/v1/mcp/tools`
627
- : '按上述原因未向网关发出请求'}(CLI 本次不携带 MCP 工具)`
734
+ ? `${mcpStatusText()} · ${
735
+ mcpInfo.attemptedAt
736
+ ? pick(`已请求 GET ${baseUrl}/v1/mcp/tools`, `requested GET ${baseUrl}/v1/mcp/tools`)
737
+ : pick('按上述原因未向网关发出请求', 'no request sent to the gateway (see above)')
738
+ }${pick('(CLI 本次不携带 MCP 工具)', ' (this CLI run carries no MCP tools)')}`
628
739
  : mcpStatusText();
629
740
  process.stderr.write(
630
- `[gateway] 原生 Agent · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n` +
741
+ pick(
742
+ `[gateway] 原生 Agent · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n`,
743
+ `[gateway] native agent · base=${baseUrl}${srcOf('baseUrl')} model=${model}${srcOf('model')}\n`,
744
+ ) +
631
745
  // 密钥这一行上轮只做在 Web 横幅里,CLI 的横幅反倒不说密钥从哪来 —— 而「我没配过密钥,
632
746
  // 它怎么跑起来了」正是最需要一句解释的情形(多半是当前目录的 .env 给的)
633
- ` 密钥 ${maskKey(key)}(${keySrcOf})\n` +
634
- ` 工作目录 ${workDir}\n` +
635
- ` 工具 ${toolNames}${args['allow-bash'] ? ' [bash 已开启]' : ''}\n` +
636
- ` Web UI gateway-task http://127.0.0.1:3100/task(本命令不会替你打开浏览器)\n` +
747
+ pick(` 密钥 ${maskKey(key)}(${keySrcOf})\n`, ` key ${maskKey(key)} (${keySrcOf})\n`) +
748
+ pick(` 工作目录 ${workDir}\n`, ` working dir ${workDir}\n`) +
749
+ pick(
750
+ ` 工具 ${toolNames}${args['allow-bash'] ? ' [bash 已开启]' : ''}\n`,
751
+ ` tools ${toolNames}${args['allow-bash'] ? ' [bash enabled]' : ''}\n`,
752
+ ) +
753
+ pick(
754
+ ' Web UI gateway-task → http://127.0.0.1:3100/task(本命令不会替你打开浏览器)\n',
755
+ ' Web UI: gateway-task -> http://127.0.0.1:3100/task (this command never opens a browser)\n',
756
+ ) +
637
757
  ` ${mcpLine}\n` +
638
- ` 轮次上限 ${session.maxSteps || AGENT_LIMITS.MAX_STEPS} 轮\n` +
639
- (resumedFrom ? ` 已恢复会话 ${resumedFrom}(${countTurns(session)} 条消息)\n` : '') +
640
- (store ? ` 会话目录 ${sessionDir}\n` : '') +
758
+ pick(
759
+ ` 轮次上限 ${session.maxSteps || AGENT_LIMITS.MAX_STEPS} 轮\n`,
760
+ ` max steps ${session.maxSteps || AGENT_LIMITS.MAX_STEPS}\n`,
761
+ ) +
762
+ (resumedFrom
763
+ ? pick(` 已恢复会话 ${resumedFrom}(${countTurns(session)} 条消息)\n`, ` resumed session ${resumedFrom} (${countTurns(session)} messages)\n`)
764
+ : '') +
765
+ (store ? pick(` 会话目录 ${sessionDir}\n`, ` session dir ${sessionDir}\n`) : '') +
641
766
  (sessionNotice ? `${color.yellow('!')} ${sessionNotice}\n` : '') +
642
767
  (sessionWarning ? `${color.yellow('!')} ${sessionWarning}\n` : '') +
643
- (memory ? ` 已注入项目记忆(${MEMORY_FILES.join(' / ')})\n` : ''),
768
+ (memory
769
+ ? pick(
770
+ ` 已注入项目记忆(${MEMORY_FILES.join(' / ')})\n`,
771
+ ` project memory injected (${MEMORY_FILES.join(' / ')})\n`,
772
+ )
773
+ : ''),
644
774
  );
645
775
 
646
776
  const runOnce = async (text) => {
@@ -653,17 +783,17 @@ async function main() {
653
783
  }
654
784
  persist();
655
785
  const u = session.usage;
656
- process.stderr.write(color.dim(`[usage] 输入 ${u.prompt} / 输出 ${u.completion} / 合计 ${u.prompt + u.completion} tokens\n`));
786
+ process.stderr.write(color.dim(pick(`[usage] 输入 ${u.prompt} / 输出 ${u.completion} / 合计 ${u.prompt + u.completion} tokens\n`, `[usage] prompt ${u.prompt} / completion ${u.completion} / total ${u.prompt + u.completion} tokens\n`)));
657
787
  if (jsonOut) process.stdout.write(`${JSON.stringify({ type: 'done', sessionId: session.id, usage: { ...u } })}\n`);
658
788
  };
659
789
 
660
790
  // 会话里有待批准的写入(进程退出前留下的)→ 先问批不批再接着跑
661
791
  if (session.pending) {
662
- process.stderr.write(color.yellow(`! 恢复了一个待批准的写入:${session.pending.name}\n`));
792
+ process.stderr.write(color.yellow(pick(`! 恢复了一个待批准的写入:${session.pending.name}\n`, `! recovered a pending write: ${session.pending.name}\n`)));
663
793
  controller = new AbortController();
664
794
  try {
665
795
  const outcome = await resumePendingTurn({ cfg, session, onEvent: renderer.handle, askApproval, persist, signal: controller.signal });
666
- if (outcome) process.stderr.write(color.dim(`[恢复完成] 状态 ${outcome.status}\n`));
796
+ if (outcome) process.stderr.write(color.dim(pick(`[恢复完成] 状态 ${outcome.status}\n`, `[resume done] status ${outcome.status}\n`)));
667
797
  } catch (e) {
668
798
  fail(e, { exit: false });
669
799
  } finally {
@@ -673,7 +803,7 @@ async function main() {
673
803
  }
674
804
 
675
805
  if (interactive) {
676
- process.stderr.write(color.dim('交互模式:直接输入任务;/help 看命令,/exit 退出。\n'));
806
+ process.stderr.write(color.dim(pick('交互模式:直接输入任务;/help 看命令,/exit 退出。\n', 'Interactive mode: type a task; /help lists commands, /exit quits.\n')));
677
807
  const ctx = {
678
808
  get session() {
679
809
  return session;
@@ -701,6 +831,10 @@ async function main() {
701
831
  })),
702
832
  // /config 用:与 `gateway-agent config` 完全同一份实现与同一个文件
703
833
  configOpts: () => ({ env: process.env, file: settingsFilePath() }),
834
+ // 需要用户当场回答一个问题的命令(现在只有 /mcp add 的追问与 /mcp rm 的确认)。
835
+ // **非 TTY 返回 null**:那种环境里没人能回答,命令层据此改成「要求写成参数」,
836
+ // 而不是把 null 当默认值用——删服务器这种事猜错一次就没了。
837
+ ask: (prompt) => (rl && process.stdin.isTTY ? question(rl, prompt) : Promise.resolve(null)),
704
838
  };
705
839
 
706
840
  for (;;) {
@@ -717,7 +851,7 @@ async function main() {
717
851
  const cmd = parseSlash(line);
718
852
  if (cmd) {
719
853
  if (cmd.error) {
720
- process.stderr.write(color.red(` ${cmd.error}\n 用法:${cmd.cmd.usage}\n`));
854
+ process.stderr.write(color.red(pick(` ${cmd.error}\n 用法:${cmd.cmd.usage}\n`, ` ${cmd.error}\n usage: ${cmd.cmd.usage}\n`)));
721
855
  continue;
722
856
  }
723
857
  // await 是必须的:命令可以是异步的(如 /mcp 要出网重拉清单)。
@@ -745,9 +879,12 @@ async function main() {
745
879
  const near = suggest(head.slice(1));
746
880
  if (near.length) {
747
881
  process.stderr.write(color.dim(
748
- ` 命令名不完整:${head}\n 候选:${near.map((c) => c.usage.split(' ')[0]).join(' ')}\n`));
882
+ pick(
883
+ ` 命令名不完整:${head}\n 候选:${near.map((c) => c.usage.split(' ')[0]).join(' ')}\n`,
884
+ ` incomplete command: ${head}\n candidates: ${near.map((c) => c.usage.split(' ')[0]).join(' ')}\n`,
885
+ )));
749
886
  } else {
750
- process.stderr.write(color.dim(`未知命令 ${head}(/help 看可用命令)。\n`));
887
+ process.stderr.write(color.dim(pick(`未知命令 ${head}(/help 看可用命令)。\n`, `unknown command ${head} (see /help for the list).\n`)));
751
888
  }
752
889
  continue;
753
890
  }
@@ -762,7 +899,7 @@ async function main() {
762
899
  rl.close();
763
900
  saveHistory(sessionDir, rl);
764
901
  }
765
- process.stderr.write('再见。\n');
902
+ process.stderr.write(pick('再见。\n', 'Bye.\n'));
766
903
  return;
767
904
  }
768
905
 
@@ -778,6 +915,6 @@ async function main() {
778
915
  }
779
916
 
780
917
  main().catch((e) => {
781
- process.stderr.write(`[错误] ${e?.message || e}\n`);
918
+ process.stderr.write(pick(`[错误] ${e?.message || e}\n`, `[error] ${e?.message || e}\n`));
782
919
  process.exit(1);
783
920
  });
package/cli-anthropic.js CHANGED
@@ -27,7 +27,9 @@ import readline from 'node:readline';
27
27
  import { randomUUID } from 'node:crypto';
28
28
 
29
29
  import { loadDotEnv, parseArgs, sessionBaggage, DEFAULT_BASE_URL } from './lib/common.js';
30
- import { resolveConfig, fail, KEY_HINT } from './lib/config.js';
30
+ import { resolveConfig, fail, keyHint } from './lib/config.js';
31
+ // CLI 侧语言(只覆盖命令行输出;Web 界面与手册页仍是中文)。默认 zh,见 lib/i18n.js
32
+ import { pick, setLang, resolveLang, LANG_FLAG } from './lib/i18n.js';
31
33
 
32
34
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
33
35
  loadDotEnv(SCRIPT_DIR); // 先加载 .env,让 GATEWAY_MODEL 等环境变量参与默认值计算
@@ -35,7 +37,7 @@ loadDotEnv(SCRIPT_DIR); // 先加载 .env,让 GATEWAY_MODEL 等环境变量参
35
37
  const DEFAULT_MODEL = process.env.GATEWAY_MODEL || 'qwen3:8b';
36
38
  const DEFAULT_MAX_TOKENS = 4096;
37
39
 
38
- const HELP = `用法:node cli-anthropic.js [选项] [提示词]
40
+ const HELP_ZH = `用法:node cli-anthropic.js [选项] [提示词]
39
41
 
40
42
  选项:
41
43
  -p, --prompt <文本> 单轮提问内容(省略则读位置参数 / 标准输入)
@@ -48,9 +50,32 @@ const HELP = `用法:node cli-anthropic.js [选项] [提示词]
48
50
  --no-stream 关闭流式输出(默认开启)
49
51
  --json 输出完整原始响应(非流式)
50
52
  -i, --interactive 进入多轮交互(REPL)
53
+ --lang <zh|en> 输出语言(默认 zh,也可用环境变量 LLM_GATEWAY_LANG)
51
54
  -h, --help 显示帮助
52
55
  `;
53
56
 
57
+ const HELP_EN = `Usage: node cli-anthropic.js [options] [prompt]
58
+
59
+ Options:
60
+ -p, --prompt <text> one-shot prompt (falls back to the positional argument / stdin)
61
+ -s, --system <text> system prompt
62
+ -m, --model <model> model name (default ${DEFAULT_MODEL})
63
+ -k, --key <sk-key> gateway API key (or env SK/GATEWAY_KEY/ANTHROPIC_API_KEY, or .env)
64
+ --base-url <url> gateway base URL (default ${DEFAULT_BASE_URL})
65
+ --max-tokens <v> max generated tokens (required by Anthropic, default ${DEFAULT_MAX_TOKENS})
66
+ --temperature <v> sampling temperature
67
+ --no-stream disable streaming output (on by default)
68
+ --json print the full raw response (non-streaming)
69
+ -i, --interactive multi-turn interactive mode (REPL)
70
+ --lang <zh|en> output language (default zh; env LLM_GATEWAY_LANG)
71
+ -h, --help show this help
72
+ `;
73
+
74
+ /** 大段帮助取词必须在**调用时**(模块顶层调 pick 会早于 setLang,永远拿到中文) */
75
+ function help() {
76
+ return pick(HELP_ZH, HELP_EN);
77
+ }
78
+
54
79
  // 值型选项:会消费下一个参数作为值;布尔型选项:不消费后续参数
55
80
  const VALUE_FLAGS = {
56
81
  '--prompt': 'prompt', '-p': 'prompt',
@@ -60,6 +85,7 @@ const VALUE_FLAGS = {
60
85
  '--base-url': 'base-url',
61
86
  '--temperature': 'temperature',
62
87
  '--max-tokens': 'max-tokens',
88
+ ...LANG_FLAG, // --lang <zh|en>:与另外三个入口同一份定义(lib/i18n.js)
63
89
  };
64
90
  const BOOL_FLAGS = {
65
91
  '--no-stream': 'no-stream',
@@ -79,13 +105,23 @@ function readStdin() {
79
105
  });
80
106
  }
81
107
 
108
+ /** usage 行:Anthropic 的字段名与 OpenAI 不同,这里统一成一句按当前语言取词 */
109
+ function usageLine(input, output) {
110
+ return pick(
111
+ `[usage] 输入 ${input ?? '?'} / 输出 ${output ?? '?'} tokens`,
112
+ `[usage] input ${input ?? '?'} / output ${output ?? '?'} tokens`,
113
+ );
114
+ }
115
+
82
116
  // ---------- 主流程 ----------
83
117
 
84
118
  async function main() {
85
119
  const args = parseArgs(process.argv.slice(2), VALUE_FLAGS, BOOL_FLAGS);
120
+ // 语言要在**任何输出之前**定下来:--help / 报错 / 横幅都按它取词
121
+ setLang(resolveLang(args, process.env));
86
122
 
87
123
  if (args.help) {
88
- console.log(HELP);
124
+ console.log(help());
89
125
  return;
90
126
  }
91
127
 
@@ -95,14 +131,19 @@ async function main() {
95
131
  });
96
132
 
97
133
  if (!key) {
98
- console.error(`[错误] ${KEY_HINT}`);
134
+ console.error(`${pick('[错误]', '[error]')} ${keyHint()}`);
99
135
  process.exit(1);
100
136
  }
101
137
 
102
138
  // 一次进程运行 = 一次对话。用 W3C baggage 告诉网关,好让它把同一会话的请求聚成一条。
103
139
  const sessionId = randomUUID();
104
140
  const baggage = sessionBaggage(sessionId);
105
- if (baggage) process.stderr.write(`[gateway] 会话 ${sessionId}(W3C baggage: session.id)\n`);
141
+ if (baggage) {
142
+ process.stderr.write(pick(
143
+ `[gateway] 会话 ${sessionId}(W3C baggage: session.id)\n`,
144
+ `[gateway] session ${sessionId} (W3C baggage: session.id)\n`,
145
+ ));
146
+ }
106
147
 
107
148
  const client = new Anthropic({
108
149
  baseURL: baseUrl,
@@ -120,7 +161,7 @@ async function main() {
120
161
  if (!prompt && args._.length) prompt = args._.join(' ');
121
162
  if (!prompt) prompt = await readStdin();
122
163
  if (!prompt) {
123
- console.log(HELP);
164
+ console.log(help());
124
165
  return;
125
166
  }
126
167
 
@@ -166,7 +207,7 @@ async function chat(client, model, prompt, { system, stream, maxTokens, temperat
166
207
  process.stdout.write('\n');
167
208
  // 跨方言(Anthropic 入口 → OpenAI 上游)流式时网关不逐块回传 usage,仅在确有输出 token 时才展示
168
209
  if (outputTokens !== null) {
169
- process.stderr.write(`[usage] 输入 ${inputTokens ?? '?'} / 输出 ${outputTokens} tokens\n`);
210
+ process.stderr.write(`${usageLine(inputTokens, outputTokens)}\n`);
170
211
  }
171
212
  } else {
172
213
  const resp = await client.messages.create(req);
@@ -174,12 +215,12 @@ async function chat(client, model, prompt, { system, stream, maxTokens, temperat
174
215
  console.log(JSON.stringify(resp, null, 2));
175
216
  } else {
176
217
  const text = (resp.content || [])
177
- .map((b) => (b.type === 'text' ? b.text : b.type === 'tool_use' ? `[工具调用 ${b.name}]` : ''))
218
+ .map((b) => (b.type === 'text' ? b.text : b.type === 'tool_use' ? pick(`[工具调用 ${b.name}]`, `[tool call ${b.name}]`) : ''))
178
219
  .join('');
179
220
  console.log(text);
180
221
  }
181
222
  const u = resp.usage;
182
- if (u) process.stderr.write(`[usage] 输入 ${u.input_tokens ?? '?'} / 输出 ${u.output_tokens ?? '?'} tokens\n`);
223
+ if (u) process.stderr.write(`${usageLine(u.input_tokens, u.output_tokens)}\n`);
183
224
  }
184
225
  } catch (e) {
185
226
  fail(e, { exit: exitOnError });
@@ -187,10 +228,13 @@ async function chat(client, model, prompt, { system, stream, maxTokens, temperat
187
228
  }
188
229
 
189
230
  async function repl(client, model, opts) {
190
- console.error(`[gateway] 交互模式 model=${model},输入文本提问,\`exit\` 退出`);
231
+ console.error(pick(
232
+ `[gateway] 交互模式 model=${model},输入文本提问,\`exit\` 退出`,
233
+ `[gateway] interactive mode model=${model}; type a prompt, \`exit\` to quit`,
234
+ ));
191
235
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
192
236
  const messages = [];
193
- const ask = () => new Promise((resolve) => rl.question('你> ', resolve));
237
+ const ask = () => new Promise((resolve) => rl.question(pick('你> ', 'you> '), resolve));
194
238
  for (;;) {
195
239
  let line;
196
240
  try {
@@ -203,7 +247,7 @@ async function repl(client, model, opts) {
203
247
  messages.push({ role: 'user', content: line });
204
248
 
205
249
  // 复用底层流式,逐字打印并把助手回复累积进上下文
206
- process.stdout.write('助手> ');
250
+ process.stdout.write(pick('助手> ', 'assistant> '));
207
251
  const req = { model, max_tokens: opts.maxTokens, messages, stream: true, temperature: opts.temperature };
208
252
  try {
209
253
  let out = '';
@@ -221,7 +265,7 @@ async function repl(client, model, opts) {
221
265
  }
222
266
  process.stdout.write('\n');
223
267
  if (outputTokens !== null) {
224
- process.stderr.write(`[usage] 输入 ${inputTokens ?? '?'} / 输出 ${outputTokens} tokens\n`);
268
+ process.stderr.write(`${usageLine(inputTokens, outputTokens)}\n`);
225
269
  }
226
270
  messages.push({ role: 'assistant', content: out });
227
271
  } catch (e) {