llm-api-gateway-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.env.example +10 -0
  2. package/README.md +1127 -0
  3. package/cli-agent.js +666 -0
  4. package/cli-anthropic.js +236 -0
  5. package/cli-claude-code.js +317 -0
  6. package/cli-openai.js +212 -0
  7. package/completions/_llm-api-gateway-cli +65 -0
  8. package/completions/llm-api-gateway-cli.bash +64 -0
  9. package/completions/llm-api-gateway-cli.fish +43 -0
  10. package/images/chat.png +0 -0
  11. package/images/settings.png +0 -0
  12. package/images/task.png +0 -0
  13. package/lib/agent.js +607 -0
  14. package/lib/commands.js +468 -0
  15. package/lib/common.js +196 -0
  16. package/lib/config.js +70 -0
  17. package/lib/configcmd.js +230 -0
  18. package/lib/hub.js +1494 -0
  19. package/lib/jsonstore.js +49 -0
  20. package/lib/mcp.js +375 -0
  21. package/lib/memory.js +109 -0
  22. package/lib/plandoc.js +178 -0
  23. package/lib/pricing.js +52 -0
  24. package/lib/runner.js +234 -0
  25. package/lib/runstore.js +96 -0
  26. package/lib/secrets.js +198 -0
  27. package/lib/sessionstore.js +269 -0
  28. package/lib/settings.js +517 -0
  29. package/lib/tasksession.js +594 -0
  30. package/lib/taskstore.js +740 -0
  31. package/lib/tools.js +927 -0
  32. package/package.json +55 -0
  33. package/public/app.js +1055 -0
  34. package/public/index.html +167 -0
  35. package/public/manual.css +215 -0
  36. package/public/manual.html +381 -0
  37. package/public/manual.js +186 -0
  38. package/public/models.js +121 -0
  39. package/public/render.js +250 -0
  40. package/public/styles.css +955 -0
  41. package/public/task-slash.js +493 -0
  42. package/public/task.css +739 -0
  43. package/public/task.html +220 -0
  44. package/public/task.js +3127 -0
  45. package/public/theme.js +91 -0
  46. package/public/tint.js +261 -0
  47. package/scripts/install.ps1 +537 -0
  48. package/scripts/install.sh +510 -0
  49. package/server.js +14 -0
  50. package/task-server.js +15 -0
@@ -0,0 +1,468 @@
1
+ /**
2
+ * 交互式斜杠命令(M2 → A2 跨端对齐)
3
+ *
4
+ * 结构没变:一张表(COMMANDS)+ 一个只依赖 ctx 的 run;循环只做查表,不认识就当作普通输入丢给模型。
5
+ * A2 对齐做的四件事(口径见网关仓库 docs/SLASH-CLI-ALIGNMENT-20260916.md §3/§4/§6.2):
6
+ * · 每条命令补齐**统一元数据**:name / aliases / args / needsArgs / group / capability / surfaces。
7
+ * 其中 `capability`(能不能跑:local / api / fs)与 `surfaces`(在这端有没有意义:cli / web / hub)正交,
8
+ * 与网关侧 `proxy/app/static/js/slash.js` 同一套字段名 —— 同名指令的 args/capability 由两侧的
9
+ * 跨端契约断言钉住(本文件 commandRows() 是给断言用的机器可读视图)。
10
+ * · parseSlash 升级:支持别名、双引号分词、大小写不敏感、全角斜杠、`bare` 裸词(exit / quit),
11
+ * 并返回 argv。**大小写不敏感是有意的行为变更**:网关 Web 侧一直不敏感,同一台机器上不该两套规矩。
12
+ * · 新增 suggest():REPL **没有候选面板**(`cli-agent.js` 的 readInput 用 rl.question,无 raw mode,
13
+ * 项目取向是"不引 TUI 库"),所以前缀只补全不执行,候选以**文本列表**打印。
14
+ * · `/exit` `/quit` / `exit` / `quit` 从 cli-agent 的硬编码判断搬进本表(`bare: true`),
15
+ * 让"表"重新成为唯一出处;assertRegistry() 在加载时自检(别名撞车、capability/surfaces 非法、
16
+ * 缺 run)——这类错误的表现是"某条命令永远调不到",不报错、不崩,最难查。
17
+ *
18
+ * ctx 约定(cli-agent 提供实现,测试提供假实现):
19
+ * { session, setSession(next), newSession(), setModel(name), cfg,
20
+ * say(text), persist() }
21
+ */
22
+
23
+ import { formatCost } from './pricing.js';
24
+ import { configCommand, splitConfigArg } from './configcmd.js';
25
+ // /mcp:本密钥可用的 MCP 工具清单来自网关(GET /v1/mcp/tools),本文件只负责把它打印给人看
26
+ import { refreshMcpTools, mcpStatusText, mcpToolList } from './mcp.js';
27
+
28
+ /** 压缩上下文时保留最近几条消息(不含 system) */
29
+ const COMPACT_KEEP = 6;
30
+
31
+ /** 能力:local = 纯本地;api = 需要网关接口;fs = 需要本地文件/工作目录。 */
32
+ export const CAPABILITIES = ['local', 'api', 'fs'];
33
+
34
+ /** 端:这条命令在哪一端有意义。CLI 目前只声明 cli;hub(Web 任务页)要等 A3 落地后再加。 */
35
+ export const SURFACES = ['web', 'cli', 'hub'];
36
+
37
+ /* ============================ 解析 ============================ */
38
+
39
+ /** 按空白切分,支持双引号包裹带空格的参数;引号未闭合 → 抛错(由 parseSlash 转成命令错误)。 */
40
+ export function tokenize(s) {
41
+ const out = [];
42
+ const raw = String(s ?? '');
43
+ let i = 0;
44
+ while (i < raw.length) {
45
+ while (i < raw.length && /\s/.test(raw[i])) i++;
46
+ if (i >= raw.length) break;
47
+ if (raw[i] === '"') {
48
+ const end = raw.indexOf('"', i + 1);
49
+ if (end < 0) throw new Error('引号没有闭合(用 " 包住带空格的参数)');
50
+ out.push(raw.slice(i + 1, end));
51
+ i = end + 1;
52
+ } else {
53
+ let j = i;
54
+ while (j < raw.length && !/\s/.test(raw[j])) j++;
55
+ out.push(raw.slice(i, j));
56
+ i = j;
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function findByAlias(word) {
63
+ const key = String(word || '').toLowerCase();
64
+ return Object.keys(COMMANDS).find((n) =>
65
+ (COMMANDS[n].aliases || []).some((a) => String(a).toLowerCase() === key)) || null;
66
+ }
67
+
68
+ /**
69
+ * 把一行输入解析成命令;不是命令、未知命令、`//` 转义都返回 null(调用方按普通输入处理)。
70
+ *
71
+ * 返回 `{ name, cmd, arg, argv, error? }`:
72
+ * name 规范化后的表键(带 `/`,如 `/model`)—— 保留旧字段,循环里仍可用 COMMANDS[name]
73
+ * arg 参数原串(保留旧契约,`/model gpt-4o` → 'gpt-4o')
74
+ * argv 分词结果(分词失败时为空数组 + error),供需要多参数的命令使用
75
+ * error 参数不合法(引号未闭合等):调用方应打印错误与用法,**不要**执行
76
+ */
77
+ export function parseSlash(line) {
78
+ const raw0 = typeof line === 'string' ? line : '';
79
+ const raw = raw0.trim().replace(/^//, '/');
80
+ if (!raw) return null;
81
+ if (raw.startsWith('//')) return null; // 转义:当普通文本(见 unescapeSlash)
82
+
83
+ if (!raw.startsWith('/')) { // 裸词(只有 bare: true 的命令认)
84
+ const word = raw.toLowerCase();
85
+ const name = COMMANDS['/' + word] ? '/' + word : findByAlias(word);
86
+ const cmd = name ? COMMANDS[name] : null;
87
+ return cmd && cmd.bare ? { name, cmd, arg: '', argv: [] } : null;
88
+ }
89
+
90
+ const sp = raw.search(/\s/);
91
+ const head = (sp === -1 ? raw : raw.slice(0, sp)).toLowerCase();
92
+ const rest = sp === -1 ? '' : raw.slice(sp + 1).trim();
93
+ const name = COMMANDS[head] ? head : findByAlias(head.slice(1));
94
+ if (!name) return null;
95
+
96
+ let argv = [];
97
+ let error;
98
+ try {
99
+ argv = tokenize(rest);
100
+ } catch (e) {
101
+ error = e.message;
102
+ }
103
+ return { name, cmd: COMMANDS[name], arg: rest, argv, error };
104
+ }
105
+
106
+ /** `//xxx` → 该发给模型的文本(去掉一个斜杠);不是转义写法则返回 null。 */
107
+ export function unescapeSlash(line) {
108
+ const raw = typeof line === 'string' ? line.trim() : '';
109
+ return raw.startsWith('//') ? raw.slice(1) : null;
110
+ }
111
+
112
+ /** 前缀候选(供 REPL 打印列表):主名前缀 + 别名前缀;主名优先,返回命令条目数组。 */
113
+ export function suggest(prefix) {
114
+ const p = String(prefix ?? '').toLowerCase();
115
+ if (!p) return [];
116
+ const scored = [];
117
+ Object.keys(COMMANDS).forEach((n) => {
118
+ const cmd = COMMANDS[n];
119
+ const name = n.slice(1).toLowerCase();
120
+ let score = -1;
121
+ if (name === p) score = 0;
122
+ else if (name.startsWith(p)) score = 1;
123
+ else if ((cmd.aliases || []).some((a) => String(a).toLowerCase().startsWith(p))) score = 2;
124
+ if (score >= 0) scored.push({ cmd, score, name });
125
+ });
126
+ scored.sort((a, b) => (a.score - b.score) || a.name.localeCompare(b.name));
127
+ return scored.map((s) => s.cmd);
128
+ }
129
+
130
+ /** 改过名的旧名(与网关侧同一机制)。CLI 目前没有改名的命令 —— 保留空表与查找函数,
131
+ * 是为了将来改名时"回显指引"这条路已经通了,而不是临时塞一个 if。 */
132
+ export const MOVED = [];
133
+
134
+ export function findMoved(word) {
135
+ const key = String(word || '').toLowerCase().replace(/^\//, '');
136
+ return MOVED.find((m) => m.name === key || (m.aliases || []).includes(key)) || null;
137
+ }
138
+
139
+ /* ============================ 自检(加载即跑) ============================ */
140
+
141
+ /** 别名/主名唯一、capability/surfaces 合法、必填字段齐全、MOVED 不撞名。 */
142
+ export function assertRegistry(registry = COMMANDS) {
143
+ const seen = new Map();
144
+ Object.keys(registry).forEach((key) => {
145
+ const c = registry[key];
146
+ if (!key.startsWith('/')) throw new Error(`命令键必须以 / 开头:${key}`);
147
+ if (!c || !c.name) throw new Error(`${key} 缺少 name`);
148
+ if (key !== '/' + c.name) throw new Error(`${key} 与 name=${c.name} 不一致`);
149
+ if (CAPABILITIES.indexOf(c.capability) < 0) {
150
+ throw new Error(`/${c.name} 的 capability 非法:${c.capability}(应为 ${CAPABILITIES.join(' / ')})`);
151
+ }
152
+ if (!Array.isArray(c.surfaces) || !c.surfaces.length ||
153
+ c.surfaces.some((s) => SURFACES.indexOf(s) < 0)) {
154
+ throw new Error(`/${c.name} 的 surfaces 非法:${JSON.stringify(c.surfaces)}(应为 ${SURFACES.join(' / ')} 的子集)`);
155
+ }
156
+ if (!c.group) throw new Error(`/${c.name} 缺少 group`);
157
+ if (typeof c.run !== 'function') throw new Error(`/${c.name} 缺少 run()`);
158
+ [c.name].concat(c.aliases || []).forEach((n) => {
159
+ const k = String(n).toLowerCase();
160
+ if (seen.has(k)) throw new Error(`命令名冲突:${k} 同时属于 ${seen.get(k)} 与 /${c.name}`);
161
+ seen.set(k, '/' + c.name);
162
+ });
163
+ });
164
+ MOVED.forEach((m) => {
165
+ if (seen.has(m.name)) throw new Error(`改名表冲突:${m.name} 既在命令表又在 MOVED 表`);
166
+ });
167
+ return true;
168
+ }
169
+
170
+ /* ============================ 命令表 ============================ */
171
+
172
+ /**
173
+ * 极简行级 diff:掐掉公共前后缀,中间的就是改动。
174
+ *
175
+ * 为什么不上正经 diff 算法:审批预览里只需要「让我看出改了哪几行」,
176
+ * 而引入 diff 库与项目「零第三方依赖(除两个官方 SDK)」的取向冲突。
177
+ * 前缀/后缀对齐对「改几处」这种场景足够,最坏情况多打几行而已。
178
+ *
179
+ * @returns {{lines:string[], removed:number, added:number}}
180
+ */
181
+ export function diffLines(oldText, newText, { maxLines = 40 } = {}) {
182
+ const a = String(oldText ?? '').split('\n');
183
+ const b = String(newText ?? '').split('\n');
184
+ let start = 0;
185
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
186
+ let endA = a.length;
187
+ let endB = b.length;
188
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
189
+ endA--;
190
+ endB--;
191
+ }
192
+ const removed = a.slice(start, endA);
193
+ const added = b.slice(start, endB);
194
+ const lines = [];
195
+ for (const l of removed) lines.push(`- ${l}`);
196
+ for (const l of added) lines.push(`+ ${l}`);
197
+ const clipped = lines.length > maxLines ? [...lines.slice(0, maxLines), `…(另有 ${lines.length - maxLines} 行改动未显示)`] : lines;
198
+ return { lines: clipped, removed: removed.length, added: added.length };
199
+ }
200
+
201
+ export const COMMANDS = {
202
+ '/help': {
203
+ name: 'help',
204
+ aliases: ['?', 'h'],
205
+ args: '[命令名]',
206
+ needsArgs: false,
207
+ group: '帮助',
208
+ capability: 'local',
209
+ surfaces: ['cli'],
210
+ usage: '/help [命令名]',
211
+ desc: '显示命令列表,或查看某条命令的用法',
212
+ run: (ctx, arg) => ctx.say(helpText(arg)),
213
+ },
214
+ '/model': {
215
+ name: 'model',
216
+ aliases: [],
217
+ args: '[模型名]',
218
+ needsArgs: false,
219
+ group: '模型与请求参数',
220
+ capability: 'local',
221
+ surfaces: ['cli'],
222
+ usage: '/model [模型名]',
223
+ desc: '查看或切换当前模型(保留上下文)',
224
+ run: (ctx, arg) => {
225
+ if (!arg) return ctx.say(`当前模型:${ctx.session.model}(用法:/model <模型名>)`);
226
+ ctx.setModel(arg);
227
+ ctx.persist();
228
+ ctx.say(`模型已切换为 ${arg}(上下文保留,下一步生效)。`);
229
+ },
230
+ },
231
+ '/config': {
232
+ name: 'config',
233
+ aliases: [],
234
+ args: '[list|get|set|unset …]',
235
+ needsArgs: false,
236
+ group: '配置',
237
+ capability: 'fs', // 读写本地配置文件(settingsFilePath())
238
+ surfaces: ['cli'],
239
+ usage: '/config [list|get|set|unset …]',
240
+ desc: '查看或修改持久化配置(与 gateway-agent config 同一套)',
241
+ run: (ctx, arg) => {
242
+ const r = configCommand(splitConfigArg(arg), ctx.configOpts ? ctx.configOpts() : {});
243
+ // 成功走 say,失败也走 say —— REPL 里没有独立的 stderr 通道,
244
+ // 而且这条命令的输出本来就是给人看的
245
+ const text = (r.code === 0 ? r.out : r.err).trimEnd();
246
+ ctx.say(text || '(没有输出)');
247
+ },
248
+ },
249
+ '/cost': {
250
+ name: 'cost',
251
+ aliases: [],
252
+ args: '—',
253
+ needsArgs: false,
254
+ group: '用量与费用',
255
+ capability: 'local',
256
+ surfaces: ['cli'],
257
+ usage: '/cost',
258
+ desc: '查看本会话累计 token 与费用粗估(口径:内置单价表)',
259
+ run: (ctx) => {
260
+ ctx.say(formatCost(ctx.session.usage, ctx.session.model));
261
+ // 自报口径:网关是记账方,内置表只是本机估算。两端口径必须能被一眼区分,
262
+ // 否则同一台机器上 /cost 与网关账单会"各自说各自的数"(对齐方案 §3.2)。
263
+ ctx.say('口径:内置单价表估算,非网关计费口径(网关 /admin/prices 才是记账口径)。');
264
+ },
265
+ },
266
+ '/mcp': {
267
+ name: 'mcp',
268
+ aliases: [],
269
+ args: '[refresh]',
270
+ needsArgs: false,
271
+ group: '工具与 MCP',
272
+ capability: 'api', // 清单来自网关 GET /v1/mcp/tools
273
+ 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
+ }
305
+ },
306
+ },
307
+ '/reset': {
308
+ name: 'reset',
309
+ aliases: [],
310
+ args: '—',
311
+ needsArgs: false,
312
+ group: '会话与上下文',
313
+ capability: 'local',
314
+ surfaces: ['cli'],
315
+ usage: '/reset',
316
+ desc: '重开会话(清空上下文,工作目录不变)',
317
+ run: (ctx) => {
318
+ ctx.setSession(ctx.newSession());
319
+ ctx.persist();
320
+ ctx.say('已重开会话(上下文清空,工作目录不变)。');
321
+ },
322
+ },
323
+ '/clear': {
324
+ name: 'clear',
325
+ aliases: [],
326
+ args: '—',
327
+ needsArgs: false,
328
+ group: '会话与上下文',
329
+ capability: 'local',
330
+ surfaces: ['cli'],
331
+ usage: '/clear',
332
+ desc: '/reset 的别名(清空上下文重开)',
333
+ run: (ctx, arg, argv) => COMMANDS['/reset'].run(ctx, arg, argv),
334
+ },
335
+ '/resume': {
336
+ name: 'resume',
337
+ aliases: [],
338
+ args: '<会话id>',
339
+ needsArgs: false,
340
+ group: '会话与上下文',
341
+ capability: 'fs', // 会话记录在本地磁盘
342
+ surfaces: ['cli'],
343
+ usage: '/resume <会话id>',
344
+ desc: '切到另一个已保存的会话(不带参数列出最近会话)',
345
+ run: (ctx, arg) => {
346
+ if (!arg) {
347
+ 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> 恢复其中一个。`);
351
+ }
352
+ const rec = ctx.loadSession ? ctx.loadSession(arg) : null;
353
+ if (!rec) return ctx.say(`找不到会话 ${arg}(id 是 UUID;可用 /resume 不带参数列出)。`);
354
+ ctx.setSession(rec);
355
+ ctx.persist();
356
+ ctx.say(`已切换到会话 ${rec.id}(${rec.model || '?'},工作目录 ${rec.workingDir || '?'})。`);
357
+ },
358
+ },
359
+ '/compact': {
360
+ name: 'compact',
361
+ aliases: [],
362
+ args: '—',
363
+ needsArgs: false,
364
+ group: '会话与上下文',
365
+ capability: 'local',
366
+ surfaces: ['cli'],
367
+ usage: '/compact',
368
+ desc: `压缩上下文:只保留最近 ${COMPACT_KEEP} 条消息(本地操作,不调用模型)`,
369
+ run: (ctx) => {
370
+ const msgs = ctx.session.messages;
371
+ if (!Array.isArray(msgs) || msgs.length <= COMPACT_KEEP + 1) return ctx.say('上下文不长,无需压缩。');
372
+ const head = msgs[0];
373
+ const dropped = msgs.length - 1 - COMPACT_KEEP;
374
+ const note = {
375
+ role: 'user',
376
+ content: `(本地已压缩上下文:较早的 ${dropped} 条消息被丢弃。如仍需要那些信息,请重新说明。)`,
377
+ };
378
+ ctx.session.messages = [head, note, ...msgs.slice(-COMPACT_KEEP)];
379
+ ctx.persist();
380
+ ctx.say(`已压缩上下文:丢弃 ${dropped} 条较早消息,保留最近 ${COMPACT_KEEP} 条。`);
381
+ },
382
+ },
383
+ '/exit': {
384
+ name: 'exit',
385
+ aliases: ['quit'],
386
+ args: '—',
387
+ needsArgs: false,
388
+ group: '进程',
389
+ capability: 'local',
390
+ surfaces: ['cli'],
391
+ bare: true, // 裸词 exit / quit 也认(原来硬编码在 cli-agent 的循环里)
392
+ usage: '/exit',
393
+ desc: '退出(裸词 exit / quit 同样有效)',
394
+ run: () => ({ exit: true }),
395
+ },
396
+ };
397
+
398
+ /** 表里的全部主名,按定义顺序(帮助、补全、测试都用它)。 */
399
+ export const COMMAND_NAMES = Object.keys(COMMANDS);
400
+
401
+ /** 机器可读的命令行(跨端契约断言用;字段与网关侧 commandRows() 一致)。 */
402
+ export function commandRows(registry = COMMANDS) {
403
+ return Object.keys(registry).map((key) => {
404
+ const c = registry[key];
405
+ return {
406
+ name: c.name,
407
+ aliases: (c.aliases || []).slice(),
408
+ args: c.args || '—',
409
+ usage: c.usage,
410
+ help: c.desc,
411
+ group: c.group,
412
+ capability: c.capability,
413
+ surfaces: (c.surfaces || []).slice(),
414
+ needsArgs: !!c.needsArgs,
415
+ };
416
+ });
417
+ }
418
+
419
+ /** 能力标记(写给终端看的,不是机器口径)。 */
420
+ function capNote(c) {
421
+ if (c.capability === 'fs') return '(读写本地文件)';
422
+ if (c.capability === 'api') return '(需网关接口)';
423
+ return '';
424
+ }
425
+
426
+ /**
427
+ * 帮助文本:无参数 = 分组总表;带参数 = 单条用法。
428
+ * 不做 Markdown(终端里难看),也不做候选面板(无 raw mode)。
429
+ */
430
+ export function helpText(name) {
431
+ if (name) {
432
+ const bare = String(name).replace(/^\//, '');
433
+ const key = '/' + bare.toLowerCase();
434
+ const cmd = COMMANDS[key] || COMMANDS[findByAlias(bare.toLowerCase()) || ''];
435
+ if (!cmd) return `没有这条命令:${name}(用 /help 看全部)`;
436
+ 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(' / ')}`,
440
+ ];
441
+ if (cmd.bare) lines.push(` 裸词也可以:${cmd.name}`);
442
+ return lines.join('\n');
443
+ }
444
+ const width = Math.max(...COMMAND_NAMES.map((n) => COMMANDS[n].usage.length));
445
+ const groups = [];
446
+ COMMAND_NAMES.forEach((n) => {
447
+ const g = COMMANDS[n].group || '其他';
448
+ if (!groups.includes(g)) groups.push(g);
449
+ });
450
+ const rows = [];
451
+ groups.forEach((g) => {
452
+ 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}`);
456
+ });
457
+ });
458
+ return [
459
+ '斜杠命令(/help <命令名> 看单条用法;同名命令与网关 Web 端语义一致):',
460
+ ...rows,
461
+ '',
462
+ '其它输入直接交给模型;行尾写 \\ 可续行。',
463
+ '想让 /clear 这样的文字原样发给模型:写成 //clear。',
464
+ ].join('\n');
465
+ }
466
+
467
+ // 加载即自检:配置错误在这里就炸,而不是让某条命令静默失效。
468
+ assertRegistry(COMMANDS);
package/lib/common.js ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * 模式四 / 模式五 共用的基础工具:
3
+ * .env 加载、命令行参数解析、JSON 收发、SSE 输出、密钥脱敏。
4
+ * 抽出来是因为两个服务(server.js 与 task-server.js)都要用同一套约定,
5
+ * 避免各自抄一遍后行为漂移。
6
+ */
7
+
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ export const DEFAULT_BASE_URL = 'http://127.0.0.1:9000';
12
+
13
+ /**
14
+ * 读取 .env(先脚本目录、再当前工作目录),不覆盖已存在的环境变量。
15
+ * 用函数声明是为了能在模块顶层前置调用,让 GATEWAY_MODEL 等参与默认值计算。
16
+ */
17
+ export function loadDotEnv(scriptDir) {
18
+ for (const file of [path.join(scriptDir, '.env'), path.join(process.cwd(), '.env')]) {
19
+ if (!existsSync(file)) continue;
20
+ for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
21
+ const m = raw.match(/^\s*([A-Za-z_][\w.-]*)\s*=\s*(.*)\s*$/);
22
+ if (!m) continue;
23
+ const value = m[2].replace(/^['"]|['"]$/g, '');
24
+ if (!(m[1] in process.env)) process.env[m[1]] = value;
25
+ }
26
+ }
27
+ }
28
+
29
+ /** 只接受真正的字符串参数值:值型选项缺值时会退化成布尔 true,必须挡掉 */
30
+ export function strArg(v) {
31
+ return typeof v === 'string' && v.trim() ? v.trim() : '';
32
+ }
33
+
34
+ /**
35
+ * 取密钥:--key > SK / GATEWAY_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN > .env
36
+ * 五种别名都认,是为了让四个 CLI 的密钥来源完全一致 —— 之前 cli-anthropic 只认
37
+ * ANTHROPIC_API_KEY、cli-claude-code 只认 ANTHROPIC_AUTH_TOKEN,同一个 .env 换个入口就报缺密钥。
38
+ */
39
+ export function resolveKey(args, env = process.env) {
40
+ for (const c of [args?.key, env.SK, env.GATEWAY_KEY, env.OPENAI_API_KEY, env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN]) {
41
+ const v = strArg(c);
42
+ if (v) return v;
43
+ }
44
+ return '';
45
+ }
46
+
47
+ export function resolveBaseUrl(args, env = process.env) {
48
+ const raw = strArg(args?.['base-url']) || strArg(env.GATEWAY_BASE_URL) || DEFAULT_BASE_URL;
49
+ return raw.replace(/\/+$/, '');
50
+ }
51
+
52
+ /** 端口解析:非法值返回 null,由调用方报错退出 */
53
+ export function resolvePort(args, env, envName, fallback) {
54
+ const raw = strArg(args?.port) || strArg(env?.[envName]) || String(fallback);
55
+ const n = Number(raw);
56
+ return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null;
57
+ }
58
+
59
+ /**
60
+ * 通用参数解析。valueFlags/boolFlags 形如 { '--port': 'port', '-p': 'port' }。
61
+ * 值型选项后面若紧跟另一个选项(以 - 开头)则视为缺值。
62
+ * `--` 之后的参数原样收进 `_`,不再当选项解析(透传给下游命令时要用)。
63
+ */
64
+ export function parseArgs(argv, valueFlags = {}, boolFlags = {}) {
65
+ const args = { _: [] };
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const a = argv[i];
68
+ if (a === '--') {
69
+ args._.push(...argv.slice(i + 1));
70
+ break;
71
+ }
72
+ if (a in valueFlags) {
73
+ const next = argv[i + 1];
74
+ if (next !== undefined && !next.startsWith('-')) {
75
+ args[valueFlags[a]] = next;
76
+ i++;
77
+ } else {
78
+ args[valueFlags[a]] = true;
79
+ }
80
+ continue;
81
+ }
82
+ if (a in boolFlags) {
83
+ args[boolFlags[a]] = true;
84
+ continue;
85
+ }
86
+ if (a.startsWith('--') && a.includes('=')) {
87
+ const eq = a.indexOf('=');
88
+ args[a.slice(2, eq)] = a.slice(eq + 1);
89
+ continue;
90
+ }
91
+ if (a.startsWith('--')) {
92
+ args[a.slice(2)] = true;
93
+ continue;
94
+ }
95
+ args._.push(a);
96
+ }
97
+ return args;
98
+ }
99
+
100
+ export function sendJson(res, status, obj) {
101
+ const payload = JSON.stringify(obj);
102
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
103
+ res.end(payload);
104
+ }
105
+
106
+ export function readBody(req, limit = 8 * 1024 * 1024) {
107
+ return new Promise((resolve, reject) => {
108
+ let data = '';
109
+ let size = 0;
110
+ req.setEncoding('utf8');
111
+ req.on('data', (c) => {
112
+ size += Buffer.byteLength(c);
113
+ if (size > limit) {
114
+ reject(Object.assign(new Error('请求体过大'), { status: 413 }));
115
+ req.destroy();
116
+ return;
117
+ }
118
+ data += c;
119
+ });
120
+ req.on('end', () => {
121
+ if (!data) return resolve({});
122
+ try {
123
+ resolve(JSON.parse(data));
124
+ } catch {
125
+ reject(Object.assign(new Error('请求体不是合法 JSON'), { status: 400 }));
126
+ }
127
+ });
128
+ req.on('error', reject);
129
+ });
130
+ }
131
+
132
+ export function maskKey(key) {
133
+ if (!key) return '';
134
+ if (key.length <= 12) return `${key.slice(0, 4)}****`;
135
+ return `${key.slice(0, 8)}****${key.slice(-4)}`;
136
+ }
137
+
138
+ /**
139
+ * 会话/任务 id 的安全字符集。W3C Baggage 的 baggage-octet 不允许逗号、分号、
140
+ * 反斜杠、引号、括号和空白(它们会把 baggage 语法拆坏),所以只放行这一小撮字符。
141
+ * UUID 的全部字符都在里面。
142
+ */
143
+ export const BAGGAGE_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
144
+
145
+ /**
146
+ * 把「这次请求属于哪个任务/会话」编成 W3C Baggage 头值,id 不合法就返回 null。
147
+ *
148
+ * 为什么是 baggage:这是 W3C 为跨服务传播 session.id 这类值定义的标准头,
149
+ * 网关、链路追踪、日志系统都能直接读它,不需要谁去认识一套私有约定;
150
+ * 而且标准头本来就该原样透传,代理转发它不算篡改请求语义。
151
+ *
152
+ * 为什么宁可不发也不硬塞:id 里混进逗号/分号会破坏 baggage 语法,
153
+ * 让接收方解析出别的东西。发送方拿不到合法 id 时就不发 —— 网关会退回
154
+ * 自己的推断口径(system + 首条用户指令指纹),功能照常,只是不够权威。
155
+ */
156
+ export function sessionBaggage(id) {
157
+ const v = String(id ?? '');
158
+ return BAGGAGE_ID_RE.test(v) ? `session.id=${v}` : null;
159
+ }
160
+
161
+ /** 从上游错误响应里尽量挖出可读的中文原因 */
162
+ export async function errorText(resp) {
163
+ const text = await resp.text().catch(() => '');
164
+ try {
165
+ const j = JSON.parse(text);
166
+ return j?.error?.message || j?.message || j?.detail || text.slice(0, 500) || `HTTP ${resp.status}`;
167
+ } catch {
168
+ return text.slice(0, 500) || `HTTP ${resp.status}`;
169
+ }
170
+ }
171
+
172
+ /** 建立 SSE 响应,返回 send(obj) —— 每个事件一帧 data: {...} */
173
+ export function openSse(res) {
174
+ res.writeHead(200, {
175
+ 'content-type': 'text/event-stream; charset=utf-8',
176
+ 'cache-control': 'no-cache, no-transform',
177
+ connection: 'keep-alive',
178
+ 'x-accel-buffering': 'no',
179
+ });
180
+ return (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
181
+ }
182
+
183
+ /** 判断监听地址是否只对本机开放(决定要不要开文件系统浏览接口) */
184
+ export function isLoopbackHost(host) {
185
+ if (!host) return false;
186
+ const h = String(host).toLowerCase().replace(/^\[|\]$/g, '');
187
+ return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h.startsWith('127.');
188
+ }
189
+
190
+ /** 静态资源安全解析:解析后的路径必须仍在 baseDir 内 */
191
+ export function resolveStatic(baseDir, urlPath) {
192
+ const rel = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath).replace(/^\/+/, '');
193
+ const file = path.resolve(baseDir, rel);
194
+ if (file !== baseDir && !file.startsWith(baseDir + path.sep)) return null;
195
+ return file;
196
+ }