llm-api-gateway-cli 1.0.5 → 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/lib/commands.js CHANGED
@@ -22,8 +22,17 @@
22
22
 
23
23
  import { formatCost } from './pricing.js';
24
24
  import { configCommand, splitConfigArg } from './configcmd.js';
25
- // /mcp:本密钥可用的 MCP 工具清单来自网关(GET /v1/mcp/tools),本文件只负责把它打印给人看
25
+ // /mcp:清单来自网关 GET /v1/mcp/tools;绑定与自助登记走管理面(同一条命令,见 mcpCommand)
26
26
  import { refreshMcpTools, mcpStatusText, mcpToolList } from './mcp.js';
27
+ import {
28
+ getMcpBinding, patchMcpBinding, createMcpServer, deleteMcpServer,
29
+ parseHeaderArgs, urlHasCredential, maskUrl, serverLine, selfServerText, MCP_SUBCOMMANDS,
30
+ configureMcpAdmin,
31
+ } from './mcpadmin.js';
32
+ // CLI 侧语言:表里每条命令带一份 `en` 元数据,`/help` 的正文按当前语言取。
33
+ // 注意 commandRows() / helpText() 的**默认语言是 zh**(不是 getLang())—— 网关进程用它生成
34
+ // `/api/commands` 喂给中文手册页,绝不能被 `node server.js --lang en` 带跑偏。
35
+ import { pick, getLang } from './i18n.js';
27
36
 
28
37
  /** 压缩上下文时保留最近几条消息(不含 system) */
29
38
  const COMPACT_KEEP = 6;
@@ -46,7 +55,7 @@ export function tokenize(s) {
46
55
  if (i >= raw.length) break;
47
56
  if (raw[i] === '"') {
48
57
  const end = raw.indexOf('"', i + 1);
49
- if (end < 0) throw new Error('引号没有闭合(用 " 包住带空格的参数)');
58
+ if (end < 0) throw new Error(pick('引号没有闭合(用 " 包住带空格的参数)', 'unclosed quote (wrap arguments containing spaces in ")'));
50
59
  out.push(raw.slice(i + 1, end));
51
60
  i = end + 1;
52
61
  } else {
@@ -209,7 +218,8 @@ export const COMMANDS = {
209
218
  surfaces: ['cli'],
210
219
  usage: '/help [命令名]',
211
220
  desc: '显示命令列表,或查看某条命令的用法',
212
- run: (ctx, arg) => ctx.say(helpText(arg)),
221
+ en: { args: '[command]', group: 'Help', usage: '/help [command]', desc: 'List commands, or show the usage of one command' },
222
+ run: (ctx, arg) => ctx.say(helpText(arg, getLang())),
213
223
  },
214
224
  '/model': {
215
225
  name: 'model',
@@ -221,11 +231,14 @@ export const COMMANDS = {
221
231
  surfaces: ['cli'],
222
232
  usage: '/model [模型名]',
223
233
  desc: '查看或切换当前模型(保留上下文)',
234
+ en: { args: '[model]', group: 'Model & request', usage: '/model [model]', desc: 'Show or switch the current model (context is kept)' },
224
235
  run: (ctx, arg) => {
225
- if (!arg) return ctx.say(`当前模型:${ctx.session.model}(用法:/model <模型名>)`);
236
+ if (!arg) {
237
+ return ctx.say(pick(`当前模型:${ctx.session.model}(用法:/model <模型名>)`, `Current model: ${ctx.session.model} (usage: /model <model>)`));
238
+ }
226
239
  ctx.setModel(arg);
227
240
  ctx.persist();
228
- ctx.say(`模型已切换为 ${arg}(上下文保留,下一步生效)。`);
241
+ ctx.say(pick(`模型已切换为 ${arg}(上下文保留,下一步生效)。`, `Model switched to ${arg} (context kept, effective from the next turn).`));
229
242
  },
230
243
  },
231
244
  '/config': {
@@ -238,12 +251,13 @@ export const COMMANDS = {
238
251
  surfaces: ['cli'],
239
252
  usage: '/config [list|get|set|unset …]',
240
253
  desc: '查看或修改持久化配置(与 gateway-agent config 同一套)',
254
+ en: { group: 'Config', desc: 'View or change persisted settings (same as gateway-agent config)' },
241
255
  run: (ctx, arg) => {
242
256
  const r = configCommand(splitConfigArg(arg), ctx.configOpts ? ctx.configOpts() : {});
243
257
  // 成功走 say,失败也走 say —— REPL 里没有独立的 stderr 通道,
244
258
  // 而且这条命令的输出本来就是给人看的
245
259
  const text = (r.code === 0 ? r.out : r.err).trimEnd();
246
- ctx.say(text || '(没有输出)');
260
+ ctx.say(text || pick('(没有输出)', '(no output)'));
247
261
  },
248
262
  },
249
263
  '/cost': {
@@ -256,53 +270,32 @@ export const COMMANDS = {
256
270
  surfaces: ['cli'],
257
271
  usage: '/cost',
258
272
  desc: '查看本会话累计 token 与费用粗估(口径:内置单价表)',
273
+ en: { group: 'Usage & cost', desc: 'Show cumulative tokens and a rough cost estimate for this session (built-in price table)' },
259
274
  run: (ctx) => {
260
275
  ctx.say(formatCost(ctx.session.usage, ctx.session.model));
261
276
  // 自报口径:网关是记账方,内置表只是本机估算。两端口径必须能被一眼区分,
262
277
  // 否则同一台机器上 /cost 与网关账单会"各自说各自的数"(对齐方案 §3.2)。
263
- ctx.say('口径:内置单价表估算,非网关计费口径(网关 /admin/prices 才是记账口径)。');
278
+ ctx.say(pick('口径:内置单价表估算,非网关计费口径(网关 /admin/prices 才是记账口径)。', 'Note: estimated from the built-in price table, not the gateway billing figure (the gateway /admin/prices is authoritative).'));
264
279
  },
265
280
  },
266
281
  '/mcp': {
267
282
  name: 'mcp',
268
283
  aliases: [],
269
- args: '[refresh]',
284
+ args: '[refresh|bind|mode|add|rm]',
270
285
  needsArgs: false,
271
286
  group: '工具与 MCP',
272
287
  capability: 'api', // 清单来自网关 GET /v1/mcp/tools
273
288
  surfaces: ['cli'],
274
- usage: '/mcp [refresh]',
275
- desc: '查看本密钥可用的 MCP 工具清单(refresh 立即重拉,否则用缓存)',
276
- // 异步:refresh 要真的出网(REPL 循环对 run 的返回值已经 await)
277
- run: async (ctx, arg) => {
278
- const force = String(arg || '').trim().toLowerCase() === 'refresh';
279
- const cfg = ctx.cfg || {};
280
- // refreshMcpTools 永不抛(best-effort):网关没开 MCP / 断网也只记一行原因
281
- const info = await refreshMcpTools({ force, baseUrl: cfg.baseUrl, key: cfg.key });
282
- ctx.say(mcpStatusText());
283
- for (const t of mcpToolList()) {
284
- const what = [t.server, t.tool].filter(Boolean).join('/');
285
- const brief = String(t.description || '').split('\n')[0].slice(0, 60);
286
- ctx.say(` ${t.name}${what ? ` [${what}]` : ''}${brief ? ` ${brief}` : ''}`);
287
- }
288
- if (info?.error) {
289
- // 把「发没发请求、为什么没有」直说出来:否则「CLI 没拉清单」(GATEWAY_MCP=off、
290
- // 密钥配置不全)与「拉了但网关没给」(404/断网)在用户眼里长得一模一样,只能去翻网关日志。
291
- // attemptedAt 是 lib/mcp.js 在真正发请求时记下的,所以这句是事实而不是推测。
292
- ctx.say(info.attemptedAt
293
- ? ` · 已请求 GET ${cfg.baseUrl || '?'}/v1/mcp/tools(${force ? '强制刷新' : '按需'}):${info.error}`
294
- : ` · 未向网关发出请求:${info.error}`);
295
- // GATEWAY_MCP=off 是用户自己关的,再让他去查网关就是误导
296
- if (!/GATEWAY_MCP=off/.test(info.error)) {
297
- ctx.say(' · 常见原因:网关未开启 MCP / 该密钥未绑定任何工具 / 网关版本不带这个接口');
298
- ctx.say(' · 看细节:GATEWAY_MCP_DEBUG=1 重跑,每次拉取与调用都会打到 stderr');
299
- }
300
- } else if (force) {
301
- ctx.say(' · 已强制重拉(后续对话立即用这份新清单)');
302
- } else {
303
- ctx.say(' · 清单来自网关 GET /v1/mcp/tools(60 秒 TTL);/mcp refresh 可立即重拉');
304
- }
289
+ usage: '/mcp [refresh|bind|mode|add|rm]',
290
+ desc: 'MCP:看清单与绑定;bind 改绑、mode 选模式、add 登记自己的服务器、rm 删掉',
291
+ en: {
292
+ args: '[refresh|bind|mode|add|rm]',
293
+ group: 'Tools & MCP',
294
+ usage: '/mcp [refresh|bind|mode|add|rm]',
295
+ desc: 'MCP: list tools and bindings; bind/mode change them, add registers your own server, rm removes one',
305
296
  },
297
+ // 异步:除了总览,其余子命令都要真的出网(REPL 循环对 run 的返回值已经 await)
298
+ run: (ctx, arg) => mcpCommand(ctx, arg),
306
299
  },
307
300
  '/reset': {
308
301
  name: 'reset',
@@ -314,10 +307,11 @@ export const COMMANDS = {
314
307
  surfaces: ['cli'],
315
308
  usage: '/reset',
316
309
  desc: '重开会话(清空上下文,工作目录不变)',
310
+ en: { group: 'Session & context', desc: 'Restart the session (context cleared, working directory unchanged)' },
317
311
  run: (ctx) => {
318
312
  ctx.setSession(ctx.newSession());
319
313
  ctx.persist();
320
- ctx.say('已重开会话(上下文清空,工作目录不变)。');
314
+ ctx.say(pick('已重开会话(上下文清空,工作目录不变)。', 'Session restarted (context cleared, working directory unchanged).'));
321
315
  },
322
316
  },
323
317
  '/clear': {
@@ -330,6 +324,7 @@ export const COMMANDS = {
330
324
  surfaces: ['cli'],
331
325
  usage: '/clear',
332
326
  desc: '/reset 的别名(清空上下文重开)',
327
+ en: { group: 'Session & context', desc: 'alias of /reset (clear the context and start over)' },
333
328
  run: (ctx, arg, argv) => COMMANDS['/reset'].run(ctx, arg, argv),
334
329
  },
335
330
  '/resume': {
@@ -342,18 +337,22 @@ export const COMMANDS = {
342
337
  surfaces: ['cli'],
343
338
  usage: '/resume <会话id>',
344
339
  desc: '切到另一个已保存的会话(不带参数列出最近会话)',
340
+ en: { args: '<session-id>', group: 'Session & context', usage: '/resume <session-id>', desc: 'Switch to another saved session (without arguments, list recent sessions)' },
345
341
  run: (ctx, arg) => {
346
342
  if (!arg) {
347
343
  const list = ctx.listSessions ? ctx.listSessions() : [];
348
- if (!list.length) return ctx.say('没有已保存的会话。(用 /resume <id> 恢复;会话目录见启动横幅)');
349
- const lines = list.slice(0, 10).map((s) => ` ${s.id} ${s.model || '?'} ${s.messages || 0} 条 ${new Date(s.touchedAt).toLocaleString()}`);
350
- return ctx.say(`最近的会话:\n${lines.join('\n')}\n用 /resume <id> 恢复其中一个。`);
344
+ if (!list.length) return ctx.say(pick('没有已保存的会话。(用 /resume <id> 恢复;会话目录见启动横幅)', 'No saved sessions. (Use /resume <id> to resume; the session directory is shown in the startup banner.)'));
345
+ const lines = list.slice(0, 10).map((s) => pick(
346
+ ` ${s.id} ${s.model || '?'} ${s.messages || 0} 条 ${new Date(s.touchedAt).toLocaleString()}`,
347
+ ` ${s.id} ${s.model || '?'} ${s.messages || 0} msgs ${new Date(s.touchedAt).toLocaleString()}`,
348
+ ));
349
+ return ctx.say(pick(`最近的会话:\n${lines.join('\n')}\n用 /resume <id> 恢复其中一个。`, `Recent sessions:\n${lines.join('\n')}\nResume one with /resume <id>.`));
351
350
  }
352
351
  const rec = ctx.loadSession ? ctx.loadSession(arg) : null;
353
- if (!rec) return ctx.say(`找不到会话 ${arg}(id 是 UUID;可用 /resume 不带参数列出)。`);
352
+ if (!rec) return ctx.say(pick(`找不到会话 ${arg}(id 是 UUID;可用 /resume 不带参数列出)。`, `Session ${arg} not found (the id is a UUID; run /resume without arguments to list them).`));
354
353
  ctx.setSession(rec);
355
354
  ctx.persist();
356
- ctx.say(`已切换到会话 ${rec.id}(${rec.model || '?'},工作目录 ${rec.workingDir || '?'})。`);
355
+ ctx.say(pick(`已切换到会话 ${rec.id}(${rec.model || '?'},工作目录 ${rec.workingDir || '?'})。`, `Switched to session ${rec.id} (${rec.model || '?'}, working directory ${rec.workingDir || '?'}).`));
357
356
  },
358
357
  },
359
358
  '/compact': {
@@ -366,18 +365,20 @@ export const COMMANDS = {
366
365
  surfaces: ['cli'],
367
366
  usage: '/compact',
368
367
  desc: `压缩上下文:只保留最近 ${COMPACT_KEEP} 条消息(本地操作,不调用模型)`,
368
+ en: { group: 'Session & context', desc: `Compact the context: keep only the most recent ${COMPACT_KEEP} messages (local, no model call)` },
369
369
  run: (ctx) => {
370
370
  const msgs = ctx.session.messages;
371
- if (!Array.isArray(msgs) || msgs.length <= COMPACT_KEEP + 1) return ctx.say('上下文不长,无需压缩。');
371
+ if (!Array.isArray(msgs) || msgs.length <= COMPACT_KEEP + 1) return ctx.say(pick('上下文不长,无需压缩。', 'The context is short; nothing to compact.'));
372
372
  const head = msgs[0];
373
373
  const dropped = msgs.length - 1 - COMPACT_KEEP;
374
374
  const note = {
375
375
  role: 'user',
376
+ // 这条是**发进对话**的说明(模型侧文本),与系统提示词同口径:本轮不随界面语言翻译
376
377
  content: `(本地已压缩上下文:较早的 ${dropped} 条消息被丢弃。如仍需要那些信息,请重新说明。)`,
377
378
  };
378
379
  ctx.session.messages = [head, note, ...msgs.slice(-COMPACT_KEEP)];
379
380
  ctx.persist();
380
- ctx.say(`已压缩上下文:丢弃 ${dropped} 条较早消息,保留最近 ${COMPACT_KEEP} 条。`);
381
+ ctx.say(pick(`已压缩上下文:丢弃 ${dropped} 条较早消息,保留最近 ${COMPACT_KEEP} 条。`, `Context compacted: dropped ${dropped} earlier messages, kept the most recent ${COMPACT_KEEP}.`));
381
382
  },
382
383
  },
383
384
  '/exit': {
@@ -391,24 +392,318 @@ export const COMMANDS = {
391
392
  bare: true, // 裸词 exit / quit 也认(原来硬编码在 cli-agent 的循环里)
392
393
  usage: '/exit',
393
394
  desc: '退出(裸词 exit / quit 同样有效)',
395
+ en: { group: 'Process', desc: 'Quit (the bare words exit / quit work too)' },
394
396
  run: () => ({ exit: true }),
395
397
  },
396
398
  };
397
399
 
400
+ /* ============================ /mcp(清单 + 绑定 + 自助登记) ============================ */
401
+
402
+ /**
403
+ * `/mcp` 的分派。
404
+ *
405
+ * 分成两类,纪律完全不同:
406
+ * · **清单**(无参数 / `refresh`)走 `lib/mcp.js`:best-effort、失败静默降级,
407
+ * 因为它也在对话开场路径上,绝不能把一次正常对话搞挂。
408
+ * · **管理**(`bind` / `mode` / `add` / `rm`)走 `lib/mcpadmin.js`:**显式失败**。
409
+ * 用户敲了「加」,就必须看到成没成;在这里静默是缺陷不是稳健。
410
+ *
411
+ * 「能不能自助登记」的判据一律来自网关(`self_server.allowed`),CLI 不猜默认值——
412
+ * 猜错的表现是「显示了入口、一点就 403」或者「明明开了却不给用」。
413
+ */
414
+ async function mcpCommand(ctx, arg) {
415
+ const cfg = ctx.cfg || {};
416
+ // 管理面(lib/mcpadmin.js)用的是**自己的**连接状态,正常由 refreshMcpTools → configureMcp 顺带同步。
417
+ // 但命令层手里直接就有 cfg,这里显式同步一次,免得两条路各说各话:
418
+ // · 宿主没先刷过清单(或刷完又 /config set key / set baseUrl)时,绑定信息会用空/旧的地址与密钥,
419
+ // 表现是「工具清单是新的,绑定信息却是旧的,甚至误报『未配置网关地址或密钥』」(实测踩到)。
420
+ if (cfg.baseUrl || cfg.key) configureMcpAdmin({ baseUrl: cfg.baseUrl, key: cfg.key });
421
+ const argv = tokenize(String(arg || ''));
422
+ const head = String(argv[0] || '').toLowerCase();
423
+ if (!head || head === 'help' || head === '?') return mcpOverview(ctx, cfg);
424
+ if (head === 'refresh') return mcpRefresh(ctx, cfg, true);
425
+ // 认不出的**第一个词**按子命令处理并给出正确提示;认不出的后续参数交给各子命令自己报错
426
+ if (MCP_SUBCOMMANDS.includes(head)) {
427
+ if (head === 'bind') return mcpBind(ctx, cfg, argv.slice(1));
428
+ if (head === 'mode') return mcpMode(ctx, cfg, argv.slice(1));
429
+ if (head === 'add') return mcpAdd(ctx, cfg, argv.slice(1));
430
+ if (head === 'rm') return mcpRemove(ctx, cfg, argv.slice(1));
431
+ }
432
+ if (/^[+-=]?\d/.test(head)) {
433
+ ctx.say(pick(` · 不认识的参数:${head} 要改绑定请写 /mcp bind ${head}`,
434
+ ` · unknown argument: ${head} — to change bindings write /mcp bind ${head}`));
435
+ }
436
+ return mcpOverview(ctx, cfg);
437
+ }
438
+
439
+ /** 总览:本密钥绑了什么 / 可自助加什么 / 我自建了什么 + 工具清单(**不含地址与凭据**) */
440
+ async function mcpOverview(ctx, cfg) {
441
+ const b = await getMcpBinding();
442
+ if (b.ok) {
443
+ ctx.say(pick(`绑定模式:${b.mode || '未报'} 总开关:${b.enabled ? '开' : '关'}`,
444
+ `Binding mode: ${b.mode || 'n/a'} master switch: ${b.enabled ? 'on' : 'off'}`));
445
+ const row = (label, list, empty) => {
446
+ ctx.say(pick(`${label}(${list.length})`, `${label} (${list.length})`));
447
+ if (!list.length) { ctx.say(` ${empty}`); return; }
448
+ list.forEach((s) => ctx.say(` ${serverLine(s)}`));
449
+ };
450
+ row(pick('已绑定', 'Bound'), b.bound, pick('(没有:/mcp bind +<id> 加上一台)', '(none: /mcp bind +<id> to add one)'));
451
+ row(pick('可自助加', 'Addable'), b.addable, pick('(没有:管理员没把服务器标成可自助)', '(none: no server is marked self-service)'));
452
+ row(pick('我自建的', 'Registered by me'), b.mine, pick('(没有)', '(none)'));
453
+ ctx.say(' ' + selfServerText(b));
454
+ if (b.selfServer.allowed) {
455
+ ctx.say(pick(' · 登记自己的:/mcp add <名字> <URL> 删掉:/mcp rm <名字|id>',
456
+ ' · register your own: /mcp add <name> <URL> remove: /mcp rm <name|id>'));
457
+ ctx.say(pick(' · 只支持 http/https 且必须是公网可达地址(指向本机/内网的会被网关拒绝)',
458
+ ' · only public http/https addresses (loopback/private ones are rejected by the gateway)'));
459
+ }
460
+ } else {
461
+ // 管理面失败**不**连累清单:清单走的是另一条 best-effort 路径,网关老版本没有这个接口时
462
+ // 也照样能看工具。所以这里只把原因说清,然后继续拉清单。
463
+ ctx.say(pick(`绑定信息不可用:${b.message}`, `binding info unavailable: ${b.message}`));
464
+ if (b.hint) ctx.say(` · ${b.hint}`);
465
+ }
466
+ return mcpRefresh(ctx, cfg, false);
467
+ }
468
+
469
+ /** 拉清单并打印(refresh 立即重拉,否则按 TTL)。失败文案要能区分「没发请求」与「发了失败」 */
470
+ async function mcpRefresh(ctx, cfg, force) {
471
+ // refreshMcpTools 永不抛(best-effort):网关没开 MCP / 断网也只记一行原因
472
+ const info = await refreshMcpTools({ force, baseUrl: cfg.baseUrl, key: cfg.key });
473
+ ctx.say(mcpStatusText());
474
+ for (const t of mcpToolList()) {
475
+ const what = [t.server, t.tool].filter(Boolean).join('/');
476
+ const brief = String(t.description || '').split('\n')[0].slice(0, 60);
477
+ ctx.say(` ${t.name}${what ? ` [${what}]` : ''}${brief ? ` ${brief}` : ''}`);
478
+ }
479
+ if (info?.error) {
480
+ // 把「发没发请求、为什么没有」直说出来:否则「CLI 没拉清单」(GATEWAY_MCP=off、
481
+ // 密钥配置不全)与「拉了但网关没给」(404/断网)在用户眼里长得一模一样,只能去翻网关日志。
482
+ // attemptedAt 是 lib/mcp.js 在真正发请求时记下的,所以这句是事实而不是推测。
483
+ ctx.say(info.attemptedAt
484
+ ? pick(
485
+ ` · 已请求 GET ${cfg.baseUrl || '?'}/v1/mcp/tools(${force ? '强制刷新' : '按需'}):${info.error}`,
486
+ ` · requested GET ${cfg.baseUrl || '?'}/v1/mcp/tools (${force ? 'forced refresh' : 'on demand'}): ${info.error}`,
487
+ )
488
+ : pick(` · 未向网关发出请求:${info.error}`, ` · no request sent to the gateway: ${info.error}`));
489
+ // GATEWAY_MCP=off 是用户自己关的,再让他去查网关就是误导
490
+ if (!/GATEWAY_MCP=off/.test(info.error)) {
491
+ ctx.say(pick(' · 常见原因:网关未开启 MCP / 该密钥未绑定任何工具 / 网关版本不带这个接口', ' · usual causes: MCP is off on the gateway / this key has no tools bound / the gateway version lacks this endpoint'));
492
+ ctx.say(pick(' · 看细节:GATEWAY_MCP_DEBUG=1 重跑,每次拉取与调用都会打到 stderr', ' · for details: rerun with GATEWAY_MCP_DEBUG=1; every fetch and call is traced to stderr'));
493
+ }
494
+ } else if (force) {
495
+ ctx.say(pick(' · 已强制重拉(后续对话立即用这份新清单)', ' · force-refreshed (later turns use this new list immediately)'));
496
+ } else {
497
+ ctx.say(pick(' · 清单来自网关 GET /v1/mcp/tools(60 秒 TTL);/mcp refresh 可立即重拉', ' · list comes from the gateway GET /v1/mcp/tools (60s TTL); /mcp refresh re-fetches now'));
498
+ }
499
+ ctx.say(pick(` · 改绑:/mcp bind +<id> -<id> 或 =<id,...> 改模式:/mcp mode off|inject|loop 看全部:/help mcp`,
500
+ ` · bindings: /mcp bind +<id> -<id> or =<id,...> mode: /mcp mode off|inject|loop all: /help mcp`));
501
+ }
502
+
503
+ /** `/mcp bind +3 -5` 或 `/mcp bind =2,7`:在我可选的范围内改绑 */
504
+ async function mcpBind(ctx, cfg, argv) {
505
+ const b = await getMcpBinding();
506
+ if (!b.ok) return mcpAdminFailed(ctx, b, pick('改绑未执行', 'binding not changed'));
507
+ if (!argv.length) {
508
+ ctx.say(pick('用法:/mcp bind +<id> -<id>(加减)或 /mcp bind =<id,...>(整份设定)',
509
+ 'usage: /mcp bind +<id> -<id> (add/remove) or /mcp bind =<id,...> (set all)'));
510
+ ctx.say(pick(` 当前绑定:${b.bound.length ? b.bound.map((s) => `#${s.id}`).join(' ') : '(空)'}`,
511
+ ` current: ${b.bound.length ? b.bound.map((s) => `#${s.id}`).join(' ') : '(empty)'}`));
512
+ ctx.say(pick(` 可选 id:${b.selectableIds.join(' ') || '(无)'}`, ` selectable ids: ${b.selectableIds.join(' ') || '(none)'}`));
513
+ return;
514
+ }
515
+ let want;
516
+ const setAll = argv.find((a) => String(a).startsWith('='));
517
+ if (setAll) {
518
+ want = String(setAll).slice(1).split(',').map((s) => Number(String(s).trim())).filter((n) => Number.isFinite(n));
519
+ } else {
520
+ // 不认识的记号(例如 `3` 少打了 +)必须报错:静默忽略会让用户以为「加上了」
521
+ const unknown = argv.filter((a) => !/^[+-]\d+$/.test(String(a)));
522
+ if (unknown.length) {
523
+ ctx.say(pick(`参数要写成 +id / -id(缺了 +/- 的:${unknown.join(' ')})`,
524
+ `arguments must be +id / -id (missing +/-, got: ${unknown.join(' ')})`));
525
+ ctx.say(pick(' 例:/mcp bind +3 -5 或 /mcp bind =2,7', ' e.g. /mcp bind +3 -5 or /mcp bind =2,7'));
526
+ return;
527
+ }
528
+ const cur = b.bound.map((s) => Number(s.id));
529
+ const out = cur.slice();
530
+ argv.forEach((a) => {
531
+ const s = String(a);
532
+ const id = Number(s.slice(1));
533
+ if (s[0] === '+') { if (!out.includes(id)) out.push(id); }
534
+ else { const i = out.indexOf(id); if (i >= 0) out.splice(i, 1); }
535
+ });
536
+ want = out;
537
+ }
538
+ // 越权/不存在的 id 本地先拦:网关也会整单拒绝(并点名),但白跑一趟没必要
539
+ const bad = want.filter((id) => !b.selectableIds.includes(id));
540
+ if (bad.length) {
541
+ ctx.say(pick(`这几个不在你可选的范围内:${bad.join(' ')}`, `not in your selectable range: ${bad.join(' ')}`));
542
+ ctx.say(pick(` 可选 id:${b.selectableIds.join(' ') || '(无)'} 可自助加的:${b.addable.map((s) => `#${s.id} ${s.name}`).join(',') || '(无)'}`,
543
+ ` selectable ids: ${b.selectableIds.join(' ') || '(none)'} addable: ${b.addable.map((s) => `#${s.id} ${s.name}`).join(', ') || '(none)'}`));
544
+ ctx.say(pick(' 想从零加一台新的:/mcp add <名字> <URL>(或让管理员把它标成可自助)',
545
+ ' to add a brand-new one: /mcp add <name> <URL> (or ask an admin to mark it self-service)'));
546
+ return;
547
+ }
548
+ const r = await patchMcpBinding({ serverIds: want });
549
+ if (!r.ok) return mcpAdminFailed(ctx, r, pick('改绑未生效', 'binding not changed'));
550
+ const now = Array.isArray(r.data?.bound) ? r.data.bound : [];
551
+ ctx.say(pick(`已改绑:现在 ${now.length} 台${now.length ? `(${now.map((s) => `#${s.id} ${s.name}`).join(',')})` : ''}`,
552
+ `bindings updated: ${now.length} server(s)${now.length ? ` (${now.map((s) => `#${s.id} ${s.name}`).join(', ')})` : ''}`));
553
+ await mcpRefresh(ctx, cfg, true);
554
+ }
555
+
556
+ /** `/mcp mode off|inject|loop`:只改模式。**不下发 server_ids**(那会把绑定解掉) */
557
+ async function mcpMode(ctx, cfg, argv) {
558
+ const m = String(argv[0] || '').toLowerCase();
559
+ if (!['off', 'inject', 'loop'].includes(m)) {
560
+ ctx.say(pick('用法:/mcp mode off|inject|loop', 'usage: /mcp mode off|inject|loop'));
561
+ ctx.say(pick(' off:本密钥不用 MCP(工具仍可列、仍可由 CLI 执行) inject:只把工具定义注入上游 loop:网关代执行',
562
+ ' off: this key does not use MCP (tools can still be listed/executed by the CLI) inject: definitions injected upstream loop: gateway executes'));
563
+ return;
564
+ }
565
+ const r = await patchMcpBinding({ mode: m });
566
+ if (!r.ok) return mcpAdminFailed(ctx, r, pick('模式未改', 'mode not changed'));
567
+ const bound = Array.isArray(r.data?.bound) ? r.data.bound : [];
568
+ ctx.say(pick(`模式已改为 ${m}(绑定未动:${bound.length} 台)`, `mode set to ${m} (bindings untouched: ${bound.length})`));
569
+ await mcpRefresh(ctx, cfg, true);
570
+ }
571
+
572
+ /** `/mcp add <名字> <URL> [--header "k: v"]...`:登记自己的 HTTP MCP 服务器(BYO-A) */
573
+ async function mcpAdd(ctx, cfg, argv) {
574
+ let name = '';
575
+ let url = '';
576
+ const rest = [];
577
+ for (let i = 0; i < argv.length; i++) {
578
+ const a = String(argv[i]);
579
+ if (a === '--header' || a === '-H' || /^--header=/i.test(a)) { rest.push(a); continue; }
580
+ if (!name) name = a;
581
+ else if (!url) url = a;
582
+ else rest.push(a);
583
+ }
584
+ // 少参数时:**有交互通道就问**,没有就直接说用法(不静默取默认值)
585
+ const hasAsk = typeof ctx.ask === 'function';
586
+ if (!name && hasAsk) name = String((await ctx.ask(pick('服务器名(用于绑定与识别,全局唯一):', 'server name (globally unique, used for binding): '))) || '').trim();
587
+ if (!url && hasAsk) url = String((await ctx.ask(pick('MCP 地址(http/https 公网可达):', 'MCP URL (public http/https): '))) || '').trim();
588
+ if (!name || !url) {
589
+ ctx.say(pick('用法:/mcp add <名字> <URL> [--header "Authorization: Bearer xxx"]',
590
+ 'usage: /mcp add <name> <URL> [--header "Authorization: Bearer xxx"]'));
591
+ ctx.say(pick(' · 只支持 http/https,且必须是**公网可达**地址(指向本机/内网的会被网关拒绝)',
592
+ ' · http/https only, and the address must be publicly reachable (loopback/private ones are rejected)'));
593
+ ctx.say(pick(' · 凭据可以放在查询参数里(?key=…);CLI 只显示掩码,明文存在网关侧',
594
+ ' · credentials may live in the query string (?key=…); the CLI only shows a masked form, the plaintext is kept on the gateway'));
595
+ return;
596
+ }
597
+ const { headers, bad } = parseHeaderArgs(rest);
598
+ if (bad.length) {
599
+ ctx.say(pick(`--header 要写成 "名字: 值"(这两条不合法:${bad.join(' | ')})`,
600
+ `--header must be "name: value" (invalid: ${bad.join(' | ')})`));
601
+ return;
602
+ }
603
+ // 先读一次绑定:① 判据来自网关(能不能自助登记)② allowed=false 时**不发那次注定 403 的请求**
604
+ const b = await getMcpBinding();
605
+ if (b.ok && !b.selfServer.allowed) {
606
+ ctx.say(pick('网关未开放自助登记:需要部署方在 .env 设 MCP_ALLOW_SELF_SERVERS=true 后**重启网关**(重启才生效)',
607
+ 'the gateway has self-registration closed: the operator must set MCP_ALLOW_SELF_SERVERS=true in .env and restart the gateway'));
608
+ ctx.say(pick(' · 现在仍可用管理员登记好的、标了「可自助」的服务器:/mcp bind +<id>',
609
+ ' · you can still bind admin-registered servers marked self-service: /mcp bind +<id>'));
610
+ return;
611
+ }
612
+ if (b.ok && b.selfServer.max && b.selfServer.used >= b.selfServer.max) {
613
+ ctx.say(pick(`自助登记已满(${b.selfServer.used}/${b.selfServer.max}):先 /mcp rm 掉不用的`,
614
+ `self-registration is full (${b.selfServer.used}/${b.selfServer.max}): /mcp rm an unused one first`));
615
+ return;
616
+ }
617
+ // 明文 URL 只在这一行内部存在:发给网关是必须的,打印出来的每一处都过掩码
618
+ ctx.say(pick(`登记中:${name} ← ${maskUrl(url)}${Object.keys(headers).length ? `(自定义头:${Object.keys(headers).join(', ')},值不显示)` : ''}`,
619
+ `registering: ${name} ← ${maskUrl(url)}${Object.keys(headers).length ? ` (headers: ${Object.keys(headers).join(', ')}, values hidden)` : ''}`));
620
+ if (urlHasCredential(url)) {
621
+ ctx.say(pick(' · 这个地址带凭据(查询参数):CLI 只显示掩码,明文保存在网关侧(与管理员登记的服务器同一套机制)',
622
+ ' · this URL carries credentials in its query string: the CLI only shows a masked form; the plaintext is stored on the gateway'));
623
+ }
624
+ const r = await createMcpServer({ name, url, headers });
625
+ if (!r.ok) return mcpAdminFailed(ctx, r, pick('登记未完成', 'registration failed'));
626
+ const s = r.server || {};
627
+ ctx.say(pick(`已登记 #${s.id} ${s.name}(工具 ${Number(s.tool_count || 0)} 个${s.enabled === false ? ',已置为停用' : ''}),并已绑定到本密钥`,
628
+ `registered #${s.id} ${s.name} (${Number(s.tool_count || 0)} tools${s.enabled === false ? ', disabled' : ''}) and bound to this key`));
629
+ if (r.probeError) {
630
+ // 探测失败 ≠ 登记失败:网关留着行、置成停用(地址可能只是暂时不可达)
631
+ ctx.say(pick(` · 探测失败(这台已置为停用):${r.probeError}`, ` · probe failed (server disabled): ${r.probeError}`));
632
+ ctx.say(pick(' · 改完地址后到管理页或重新登记一次探测即可启用', ' · fix the address and probe again to enable it'));
633
+ } else if (r.note) {
634
+ ctx.say(` · ${r.note}`);
635
+ }
636
+ await mcpRefresh(ctx, cfg, true);
637
+ }
638
+
639
+ /** `/mcp rm <名字|id>`:删掉**自己登记**的服务器。先确认,且只认 mine 里的 */
640
+ async function mcpRemove(ctx, cfg, argv) {
641
+ const key = String(argv[0] || '').trim();
642
+ const b = await getMcpBinding();
643
+ if (!b.ok) return mcpAdminFailed(ctx, b, pick('删除未执行', 'delete not executed'));
644
+ if (!key) {
645
+ ctx.say(pick('用法:/mcp rm <名字|id>(只能删你自己登记的)', 'usage: /mcp rm <name|id> (only your own registrations)'));
646
+ ctx.say(pick(` 你登记的:${b.mine.length ? b.mine.map((s) => `#${s.id} ${s.name}`).join(',') : '(没有)'}`,
647
+ ` yours: ${b.mine.length ? b.mine.map((s) => `#${s.id} ${s.name}`).join(', ') : '(none)'}`));
648
+ return;
649
+ }
650
+ const target = /^\d+$/.test(key)
651
+ ? b.mine.find((s) => Number(s.id) === Number(key))
652
+ : b.mine.find((s) => String(s.name) === key);
653
+ if (!target) {
654
+ // 刻意不区分「不是你的」与「不存在」:与网关的 404 同一态度(不给出存在性信号)
655
+ ctx.say(pick(`你登记的服务器里没有 ${key}`, `no registration of yours matches ${key}`));
656
+ ctx.say(pick(` 你登记的:${b.mine.length ? b.mine.map((s) => `#${s.id} ${s.name}`).join(',') : '(没有)'}`,
657
+ ` yours: ${b.mine.length ? b.mine.map((s) => `#${s.id} ${s.name}`).join(', ') : '(none)'}`));
658
+ if (!b.selfServer.allowed) ctx.say(' ' + selfServerText(b));
659
+ return;
660
+ }
661
+ // 删是不可逆的:能问就问一句。没有交互通道(非 TTY)时**不删**,让用户写成确定的参数
662
+ if (typeof ctx.ask === 'function') {
663
+ const a = String((await ctx.ask(pick(`确认删除 #${target.id} ${target.name}?(y/N)`, `delete #${target.id} ${target.name}? (y/N) `))) || '').trim().toLowerCase();
664
+ if (a !== 'y' && a !== 'yes') {
665
+ ctx.say(pick('已取消(没有删除任何东西)', 'cancelled (nothing was deleted)'));
666
+ return;
667
+ }
668
+ } else {
669
+ ctx.say(pick('非交互环境不自动删:请把名字或 id 写全(/mcp rm <名字|id>)',
670
+ 'refusing to delete in a non-interactive environment: pass the name or id explicitly (/mcp rm <name|id>)'));
671
+ return;
672
+ }
673
+ const r = await deleteMcpServer(target.id);
674
+ if (!r.ok) return mcpAdminFailed(ctx, r, pick('删除未完成', 'delete failed'));
675
+ ctx.say(r.message);
676
+ if (r.note) ctx.say(` · ${r.note}`);
677
+ await mcpRefresh(ctx, cfg, true);
678
+ }
679
+
680
+ /** 管理面失败的统一出口:原因 + 能照着修的一句(hint 来自 lib/mcpadmin.js) */
681
+ function mcpAdminFailed(ctx, r, what) {
682
+ ctx.say(pick(`${what}:${r.message}`, `${what}: ${r.message}`));
683
+ if (r.hint) ctx.say(` · ${r.hint}`);
684
+ }
685
+
398
686
  /** 表里的全部主名,按定义顺序(帮助、补全、测试都用它)。 */
399
687
  export const COMMAND_NAMES = Object.keys(COMMANDS);
400
688
 
401
- /** 机器可读的命令行(跨端契约断言用;字段与网关侧 commandRows() 一致)。 */
402
- export function commandRows(registry = COMMANDS) {
689
+ /**
690
+ * 机器可读的命令行(跨端契约断言用;字段与网关侧 commandRows() 一致)。
691
+ *
692
+ * `lang` **默认 zh**:网关用它生成 `/api/commands` 喂给中文手册页,
693
+ * 不能被 `node server.js --lang en` 带跑偏(那样手册正文是中文、表格是英文)。
694
+ */
695
+ export function commandRows(registry = COMMANDS, lang = 'zh') {
696
+ const en = lang === 'en';
403
697
  return Object.keys(registry).map((key) => {
404
698
  const c = registry[key];
699
+ const e = (en && c.en) || {};
405
700
  return {
406
701
  name: c.name,
407
702
  aliases: (c.aliases || []).slice(),
408
- args: c.args || '—',
409
- usage: c.usage,
410
- help: c.desc,
411
- group: c.group,
703
+ args: e.args || c.args || '—',
704
+ usage: e.usage || c.usage,
705
+ help: e.desc || c.desc,
706
+ group: e.group || c.group,
412
707
  capability: c.capability,
413
708
  surfaces: (c.surfaces || []).slice(),
414
709
  needsArgs: !!c.needsArgs,
@@ -416,51 +711,83 @@ export function commandRows(registry = COMMANDS) {
416
711
  });
417
712
  }
418
713
 
714
+ /**
715
+ * 表内取词:按**传入的 lang** 取,而不是进程语言 ——
716
+ * 否则 `helpText(x, 'zh')` 会出现「标题中文、正文英文」这种半中半英(两处语言来源不同)。
717
+ */
718
+ const byLang = (lang, zh, en) => (lang === 'en' && en ? en : zh);
719
+
419
720
  /** 能力标记(写给终端看的,不是机器口径)。 */
420
- function capNote(c) {
421
- if (c.capability === 'fs') return '(读写本地文件)';
422
- if (c.capability === 'api') return '(需网关接口)';
721
+ function capNote(c, lang = 'zh') {
722
+ if (c.capability === 'fs') return byLang(lang, '(读写本地文件)', ' (reads/writes local files)');
723
+ if (c.capability === 'api') return byLang(lang, '(需网关接口)', ' (needs the gateway API)');
423
724
  return '';
424
725
  }
425
726
 
426
727
  /**
427
728
  * 帮助文本:无参数 = 分组总表;带参数 = 单条用法。
428
729
  * 不做 Markdown(终端里难看),也不做候选面板(无 raw mode)。
730
+ *
731
+ * `lang` 默认取**当前进程语言**:只有 CLI 的 `/help` 调它(Web 侧走 commandRows,默认 zh)。
732
+ * 整段(含标题与脚注)都由同一个 lang 决定,不混用 pick()。
429
733
  */
430
- export function helpText(name) {
734
+ export function helpText(name, lang = getLang()) {
735
+ const L = (zh, en) => byLang(lang, zh, en);
736
+ /** 表里那条命令在当前语言下的元数据 */
737
+ const meta = (c) => {
738
+ const e = (lang === 'en' && c.en) || {};
739
+ return {
740
+ usage: e.usage || c.usage,
741
+ desc: e.desc || c.desc,
742
+ group: e.group || c.group || L('其他', 'Other'),
743
+ args: e.args || c.args,
744
+ };
745
+ };
431
746
  if (name) {
432
747
  const bare = String(name).replace(/^\//, '');
433
748
  const key = '/' + bare.toLowerCase();
434
749
  const cmd = COMMANDS[key] || COMMANDS[findByAlias(bare.toLowerCase()) || ''];
435
- if (!cmd) return `没有这条命令:${name}(用 /help 看全部)`;
750
+ if (!cmd) {
751
+ return L(`没有这条命令:${name}(用 /help 看全部)`, `No such command: ${name} (use /help to list all)`);
752
+ }
753
+ const m = meta(cmd);
436
754
  const lines = [
437
- `${cmd.usage} ${cmd.desc}`,
438
- ` 别名:${(cmd.aliases || []).length ? cmd.aliases.map((a) => '/' + a).join(' ') : '—'}`,
439
- ` 能力:${cmd.capability}${capNote(cmd)} 端:${(cmd.surfaces || []).join(' / ')}`,
755
+ `${m.usage} ${m.desc}`,
756
+ L(
757
+ ` 别名:${(cmd.aliases || []).length ? cmd.aliases.map((a) => '/' + a).join(' ') : '—'}`,
758
+ ` aliases: ${(cmd.aliases || []).length ? cmd.aliases.map((a) => '/' + a).join(' ') : '—'}`,
759
+ ),
760
+ L(
761
+ ` 能力:${cmd.capability}${capNote(cmd, lang)} 端:${(cmd.surfaces || []).join(' / ')}`,
762
+ ` capability: ${cmd.capability}${capNote(cmd, lang)} surfaces: ${(cmd.surfaces || []).join(' / ')}`,
763
+ ),
440
764
  ];
441
- if (cmd.bare) lines.push(` 裸词也可以:${cmd.name}`);
765
+ if (cmd.bare) lines.push(L(` 裸词也可以:${cmd.name}`, ` the bare word works too: ${cmd.name}`));
442
766
  return lines.join('\n');
443
767
  }
444
- const width = Math.max(...COMMAND_NAMES.map((n) => COMMANDS[n].usage.length));
768
+ const width = Math.max(...COMMAND_NAMES.map((n) => meta(COMMANDS[n]).usage.length));
445
769
  const groups = [];
446
770
  COMMAND_NAMES.forEach((n) => {
447
- const g = COMMANDS[n].group || '其他';
771
+ const g = meta(COMMANDS[n]).group;
448
772
  if (!groups.includes(g)) groups.push(g);
449
773
  });
450
774
  const rows = [];
451
775
  groups.forEach((g) => {
452
776
  rows.push(` ${g}`);
453
- COMMAND_NAMES.filter((n) => (COMMANDS[n].group || '其他') === g).forEach((n) => {
454
- const c = COMMANDS[n];
455
- rows.push(` ${c.usage.padEnd(width)} ${c.desc}`);
777
+ COMMAND_NAMES.filter((n) => meta(COMMANDS[n]).group === g).forEach((n) => {
778
+ const m = meta(COMMANDS[n]);
779
+ rows.push(` ${m.usage.padEnd(width)} ${m.desc}`);
456
780
  });
457
781
  });
458
782
  return [
459
- '斜杠命令(/help <命令名> 看单条用法;同名命令与网关 Web 端语义一致):',
783
+ L(
784
+ '斜杠命令(/help <命令名> 看单条用法;同名命令与网关 Web 端语义一致):',
785
+ 'Slash commands (/help <command> for one command; same-name commands mean the same on the gateway web UI):',
786
+ ),
460
787
  ...rows,
461
788
  '',
462
- '其它输入直接交给模型;行尾写 \\ 可续行。',
463
- '想让 /clear 这样的文字原样发给模型:写成 //clear。',
789
+ L('其它输入直接交给模型;行尾写 \\ 可续行。', 'Anything else goes straight to the model; end a line with \\ to continue it.'),
790
+ L('想让 /clear 这样的文字原样发给模型:写成 //clear。', 'To send text like /clear to the model verbatim, write //clear.'),
464
791
  ].join('\n');
465
792
  }
466
793