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.
- package/.env.example +10 -0
- package/README.md +1127 -0
- package/cli-agent.js +666 -0
- package/cli-anthropic.js +236 -0
- package/cli-claude-code.js +317 -0
- package/cli-openai.js +212 -0
- package/completions/_llm-api-gateway-cli +65 -0
- package/completions/llm-api-gateway-cli.bash +64 -0
- package/completions/llm-api-gateway-cli.fish +43 -0
- package/images/chat.png +0 -0
- package/images/settings.png +0 -0
- package/images/task.png +0 -0
- package/lib/agent.js +607 -0
- package/lib/commands.js +468 -0
- package/lib/common.js +196 -0
- package/lib/config.js +70 -0
- package/lib/configcmd.js +230 -0
- package/lib/hub.js +1494 -0
- package/lib/jsonstore.js +49 -0
- package/lib/mcp.js +375 -0
- package/lib/memory.js +109 -0
- package/lib/plandoc.js +178 -0
- package/lib/pricing.js +52 -0
- package/lib/runner.js +234 -0
- package/lib/runstore.js +96 -0
- package/lib/secrets.js +198 -0
- package/lib/sessionstore.js +269 -0
- package/lib/settings.js +517 -0
- package/lib/tasksession.js +594 -0
- package/lib/taskstore.js +740 -0
- package/lib/tools.js +927 -0
- package/package.json +55 -0
- package/public/app.js +1055 -0
- package/public/index.html +167 -0
- package/public/manual.css +215 -0
- package/public/manual.html +381 -0
- package/public/manual.js +186 -0
- package/public/models.js +121 -0
- package/public/render.js +250 -0
- package/public/styles.css +955 -0
- package/public/task-slash.js +493 -0
- package/public/task.css +739 -0
- package/public/task.html +220 -0
- package/public/task.js +3127 -0
- package/public/theme.js +91 -0
- package/public/tint.js +261 -0
- package/scripts/install.ps1 +537 -0
- package/scripts/install.sh +510 -0
- package/server.js +14 -0
- package/task-server.js +15 -0
package/cli-agent.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 模式六 · 原生 Agent CLI(不依赖 Claude Code)
|
|
4
|
+
*
|
|
5
|
+
* 这是一个自带工具循环的终端编程助手:复用 lib/agent.js 的循环与 lib/tools.js 的沙箱,
|
|
6
|
+
* 直接在命令行里读写工作目录内的文件,写入前在终端里向你确认。
|
|
7
|
+
* 与 cli-claude-code.js 的区别:那边是把外部 `claude` 可执行文件接到网关上,
|
|
8
|
+
* 这边不依赖任何外部 Agent —— Node 原生 fetch 直连网关的 /v1/chat/completions。
|
|
9
|
+
*
|
|
10
|
+
* 用法示例:
|
|
11
|
+
* node cli-agent.js -C ./myproject -p "看下 README 再帮我加一节安装说明"
|
|
12
|
+
* node cli-agent.js -i # 在终端里多轮对话(默认工作目录为当前目录)
|
|
13
|
+
* node cli-agent.js -i --continue # 接着最近一次会话继续聊
|
|
14
|
+
* node cli-agent.js -i --resume <会话id> # 切到指定会话
|
|
15
|
+
* echo "列出目录并总结" | node cli-agent.js # 从标准输入取提示词
|
|
16
|
+
*
|
|
17
|
+
* 安全的写入闸门:模型每次写入都会先展示预览(覆盖已有文件时带行级 diff)并等你输入 y;
|
|
18
|
+
* 非交互环境(管道)默认**拒绝**写入,确需自动批准请显式加 --yes。
|
|
19
|
+
*
|
|
20
|
+
* 密钥来源与其余入口一致(lib/config.js):--key > SK / GATEWAY_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY > .env
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import readline from 'node:readline';
|
|
26
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
+
|
|
28
|
+
import { loadDotEnv, parseArgs, DEFAULT_BASE_URL } from './lib/common.js';
|
|
29
|
+
import { resolveConfig, numArg, fail, KEY_HINT } from './lib/config.js';
|
|
30
|
+
import { checkWorkDir, setBashEnabled, availableTools } from './lib/tools.js';
|
|
31
|
+
import { createSession, restoreSession, runTurn, resumePendingTurn, countTurns } from './lib/runner.js';
|
|
32
|
+
import { createSessionStore, resolveSessionDir, SESSION_TTL_MS } from './lib/sessionstore.js';
|
|
33
|
+
import { COMMANDS, parseSlash, unescapeSlash, suggest, helpText, diffLines } from './lib/commands.js';
|
|
34
|
+
import { configCommand, configHelp } from './lib/configcmd.js';
|
|
35
|
+
import { settingsFilePath } from './lib/settings.js';
|
|
36
|
+
import { AGENT_LIMITS } from './lib/agent.js';
|
|
37
|
+
import { MEMORY_FILES, readMemory } from './lib/memory.js';
|
|
38
|
+
import { refreshMcpTools, mcpStatusText } from './lib/mcp.js';
|
|
39
|
+
|
|
40
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
loadDotEnv(SCRIPT_DIR);
|
|
42
|
+
|
|
43
|
+
const DEFAULT_MODEL = process.env.GATEWAY_MODEL || 'qwen3:8b';
|
|
44
|
+
const HISTORY_LIMIT = 200;
|
|
45
|
+
|
|
46
|
+
const HELP = `用法:node cli-agent.js [选项] [提示词]
|
|
47
|
+
node cli-agent.js config <list|get|set|unset|path> [键] [值]
|
|
48
|
+
|
|
49
|
+
模式六 · 原生 Agent:自带工具循环,直接在终端里读写工作目录内的文件,写入前需确认。
|
|
50
|
+
不依赖 Claude Code(无需安装 @anthropic-ai/claude-code)。
|
|
51
|
+
|
|
52
|
+
子命令:
|
|
53
|
+
config list|get|set|unset|path
|
|
54
|
+
查看/修改持久化配置(存到磁盘,清缓存不丢)。config list 会标出
|
|
55
|
+
每一项「当前生效值」来自命令行、环境变量、配置文件还是内置默认
|
|
56
|
+
—— 改了没生效时看这一列就知道被谁盖住了。
|
|
57
|
+
|
|
58
|
+
模式六 · 原生 Agent:自带工具循环,直接在终端里读写工作目录内的文件,写入前需确认。
|
|
59
|
+
不依赖 Claude Code(无需安装 @anthropic-ai/claude-code)。
|
|
60
|
+
|
|
61
|
+
选项:
|
|
62
|
+
-p, --prompt <文本> 单轮任务(省略则读位置参数 / 标准输入)
|
|
63
|
+
-i, --interactive 进入多轮交互(REPL)
|
|
64
|
+
-C, --cwd <目录> 工作目录,工具被沙箱限制在该目录内(默认当前目录)
|
|
65
|
+
-m, --model <模型> 模型名(默认 ${DEFAULT_MODEL})
|
|
66
|
+
-k, --key <sk-密钥> 网关密钥(也可用环境变量 SK/GATEWAY_KEY/OPENAI_API_KEY 或 .env)
|
|
67
|
+
--base-url <地址> 网关地址(默认 ${DEFAULT_BASE_URL})
|
|
68
|
+
-s, --system <文本> 追加的系统提示词
|
|
69
|
+
--temperature <值> 采样温度
|
|
70
|
+
--max-tokens <值> 最大生成 token 数(思考模型给小了可能只见思维链)
|
|
71
|
+
--max-steps <轮数> 单个任务最多几轮模型调用(默认 ${AGENT_LIMITS.MAX_STEPS})
|
|
72
|
+
--continue 接着最近一次会话继续(恢复上下文与待批准态)
|
|
73
|
+
--resume <会话id> 恢复指定会话(UUID)
|
|
74
|
+
--session-dir <目录> 会话落盘目录(默认在用户主目录下的 .llm-api-gateway-cli)
|
|
75
|
+
--no-session 不落盘会话(纯内存,退出即丢)
|
|
76
|
+
--no-memory 不读取 AGENTS.md / CLAUDE.md 项目记忆
|
|
77
|
+
--allow-bash 开启 bash 工具(默认关闭;开启后每次执行仍需批准)
|
|
78
|
+
--output-format <值> 输出格式:text(默认)或 stream-json(逐行 JSON 事件,便于管道)
|
|
79
|
+
--yes 自动批准所有**文件写入**(危险:模型可直接改文件,慎用)。MCP 调用不需要它
|
|
80
|
+
--verbose 打印工具结果正文(默认只显示一行摘要)
|
|
81
|
+
--no-color 关闭彩色输出(也遵循 NO_COLOR 环境变量)
|
|
82
|
+
-h, --help 显示帮助
|
|
83
|
+
|
|
84
|
+
单个任务最多 ${AGENT_LIMITS.MAX_STEPS} 轮工具调用(--max-steps 可调)。写入被拒后模型会改为向你说明思路。
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
const VALUE_FLAGS = {
|
|
88
|
+
'--prompt': 'prompt',
|
|
89
|
+
'-p': 'prompt',
|
|
90
|
+
'--cwd': 'cwd',
|
|
91
|
+
'-C': 'cwd',
|
|
92
|
+
'--model': 'model',
|
|
93
|
+
'-m': 'model',
|
|
94
|
+
'--key': 'key',
|
|
95
|
+
'-k': 'key',
|
|
96
|
+
'--base-url': 'base-url',
|
|
97
|
+
'--system': 'system',
|
|
98
|
+
'-s': 'system',
|
|
99
|
+
'--temperature': 'temperature',
|
|
100
|
+
'--max-tokens': 'max-tokens',
|
|
101
|
+
'--max-steps': 'max-steps',
|
|
102
|
+
'--resume': 'resume',
|
|
103
|
+
'--session-dir': 'session-dir',
|
|
104
|
+
'--output-format': 'output-format',
|
|
105
|
+
};
|
|
106
|
+
const BOOL_FLAGS = {
|
|
107
|
+
'--interactive': 'interactive',
|
|
108
|
+
'-i': 'interactive',
|
|
109
|
+
'--continue': 'continue',
|
|
110
|
+
'--no-session': 'no-session',
|
|
111
|
+
'--no-memory': 'no-memory',
|
|
112
|
+
'--allow-bash': 'allow-bash',
|
|
113
|
+
'--yes': 'yes',
|
|
114
|
+
'--verbose': 'verbose',
|
|
115
|
+
'--no-color': 'no-color',
|
|
116
|
+
'--help': 'help',
|
|
117
|
+
'-h': 'help',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function readStdin() {
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
if (process.stdin.isTTY) return resolve('');
|
|
123
|
+
let data = '';
|
|
124
|
+
process.stdin.setEncoding('utf8');
|
|
125
|
+
process.stdin.on('data', (c) => (data += c));
|
|
126
|
+
process.stdin.on('end', () => resolve(data.trim()));
|
|
127
|
+
process.stdin.on('error', () => resolve(''));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 读工作目录里的项目记忆(AGENTS.md / CLAUDE.md)—— 与 Web 任务页的 /instructions 同一实现 */
|
|
132
|
+
function loadProjectMemory(workDir) {
|
|
133
|
+
return readMemory(workDir).text;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------- 颜色 ----------
|
|
137
|
+
|
|
138
|
+
function makeColor(enabled) {
|
|
139
|
+
const wrap = (code) => (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
140
|
+
return { dim: wrap('2'), cyan: wrap('36'), green: wrap('32'), red: wrap('31'), yellow: wrap('33'), bold: wrap('1') };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------- 终端事件渲染 ----------
|
|
144
|
+
|
|
145
|
+
function createRenderer({ color, verbose, json }) {
|
|
146
|
+
let outMid = false; // stdout 是否停在没有换行的一行上
|
|
147
|
+
let reasonOpen = false; // 思维链是否处于输出中
|
|
148
|
+
|
|
149
|
+
const closeOut = () => {
|
|
150
|
+
if (outMid) {
|
|
151
|
+
process.stdout.write('\n');
|
|
152
|
+
outMid = false;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const closeReason = () => {
|
|
156
|
+
if (reasonOpen) {
|
|
157
|
+
process.stderr.write('\n');
|
|
158
|
+
reasonOpen = false;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
handle(ev) {
|
|
164
|
+
// stream-json:每个事件一行 JSON,直接给管道/CI 用;人看的提示仍走 stderr
|
|
165
|
+
if (json) {
|
|
166
|
+
process.stdout.write(`${JSON.stringify(ev)}\n`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
switch (ev.type) {
|
|
170
|
+
case 'reasoning':
|
|
171
|
+
closeOut();
|
|
172
|
+
if (!reasonOpen) {
|
|
173
|
+
process.stderr.write(color.dim('💭 '));
|
|
174
|
+
reasonOpen = true;
|
|
175
|
+
}
|
|
176
|
+
process.stderr.write(color.dim(ev.text));
|
|
177
|
+
break;
|
|
178
|
+
case 'delta':
|
|
179
|
+
closeReason();
|
|
180
|
+
process.stdout.write(ev.text);
|
|
181
|
+
outMid = true;
|
|
182
|
+
break;
|
|
183
|
+
case 'assistant_text_end':
|
|
184
|
+
closeOut();
|
|
185
|
+
break;
|
|
186
|
+
case 'tool_call':
|
|
187
|
+
closeReason();
|
|
188
|
+
closeOut();
|
|
189
|
+
process.stderr.write(`${color.cyan('⚙')} ${ev.label}\n`);
|
|
190
|
+
break;
|
|
191
|
+
case 'tool_result': {
|
|
192
|
+
closeReason();
|
|
193
|
+
closeOut();
|
|
194
|
+
const mark = ev.ok ? color.green('✓') : color.red('✗');
|
|
195
|
+
process.stderr.write(` ${mark} ${ev.summary}\n`);
|
|
196
|
+
if (verbose && ev.content) {
|
|
197
|
+
for (const line of String(ev.content).split('\n').slice(0, 200)) {
|
|
198
|
+
process.stderr.write(color.dim(` ${line}\n`));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case 'notice':
|
|
204
|
+
closeReason();
|
|
205
|
+
closeOut();
|
|
206
|
+
process.stderr.write(`${color.yellow('!')} ${ev.text}\n`);
|
|
207
|
+
break;
|
|
208
|
+
default:
|
|
209
|
+
break; // usage / usage_total 由调用方在收尾统一打印
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
finish() {
|
|
213
|
+
closeReason();
|
|
214
|
+
closeOut();
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------- 写入批准 ----------
|
|
220
|
+
|
|
221
|
+
function renderApproval(preview, color) {
|
|
222
|
+
const top = '┌─ 待批准写入 ─────────────────────────────';
|
|
223
|
+
const mid = '├──────────────────────────────────────────';
|
|
224
|
+
const bot = '└──────────────────────────────────────────';
|
|
225
|
+
const kind = preview.shell ? '执行命令' : preview.patch ? '增量修改' : preview.exists ? '覆盖已有文件' : '新建文件';
|
|
226
|
+
const out = [top, `│ ${preview.path} (${kind})`, `│ ${preview.lines} 行 / ${preview.bytes} 字节`];
|
|
227
|
+
|
|
228
|
+
// 覆盖已有文件时给出行级 diff:只看新内容根本判断不出改了什么
|
|
229
|
+
const canDiff = !preview.shell && preview.exists && typeof preview.old === 'string' && preview.old !== preview.content;
|
|
230
|
+
if (canDiff) {
|
|
231
|
+
const { lines, removed, added } = diffLines(preview.old, preview.content);
|
|
232
|
+
out.push(mid, `│ 改动:-${removed} 行 / +${added} 行`);
|
|
233
|
+
for (const line of lines) {
|
|
234
|
+
const painted = line.startsWith('-') ? color.red(line) : line.startsWith('+') ? color.green(line) : line;
|
|
235
|
+
out.push(`│ ${painted}`);
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
out.push(mid);
|
|
239
|
+
const contentLines = String(preview.content ?? '').split('\n');
|
|
240
|
+
const shown = contentLines.slice(0, 40);
|
|
241
|
+
for (const line of shown) out.push(`│ ${line}`);
|
|
242
|
+
if (contentLines.length > shown.length) out.push(`│ …(预览截断,共 ${contentLines.length} 行)`);
|
|
243
|
+
}
|
|
244
|
+
if (preview.note) out.push(`│ ${color.yellow(preview.note)}`);
|
|
245
|
+
out.push(bot);
|
|
246
|
+
process.stderr.write(color.dim(out.join('\n')) + '\n');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function question(rl, prompt) {
|
|
250
|
+
return new Promise((resolve) => rl.question(prompt, resolve));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 读一行输入,支持行尾 `\` 续行(多行提示词不用引号包一坨)。
|
|
255
|
+
* 不引 TUI 库:readline 就够,多行只是多问几次。
|
|
256
|
+
*/
|
|
257
|
+
async function readInput(rl, color) {
|
|
258
|
+
const first = await question(rl, color.cyan('你> '));
|
|
259
|
+
if (!first.trimEnd().endsWith('\\')) return first;
|
|
260
|
+
const parts = [first.trimEnd().slice(0, -1)];
|
|
261
|
+
for (;;) {
|
|
262
|
+
const more = await question(rl, color.dim('... '));
|
|
263
|
+
if (more.trimEnd().endsWith('\\')) {
|
|
264
|
+
parts.push(more.trimEnd().slice(0, -1));
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
parts.push(more);
|
|
268
|
+
return parts.join('\n');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 造一个批准函数:终端的 y/N 闸门。
|
|
273
|
+
* --yes 自动批准;非交互环境默认拒绝(安全),避免脚本误改文件。
|
|
274
|
+
* 只对**文件写入**(write_file / apply_patch / bash)调用;远端 MCP 调用不走这道闸门
|
|
275
|
+
* ——逐个批准会让一次调研的十几次地图查询全被拒,而放开又只能靠 --yes(连文件写入一起放开)。
|
|
276
|
+
*/
|
|
277
|
+
function makeApproval({ autoApprove, rl, color }) {
|
|
278
|
+
return async (pending, preview) => {
|
|
279
|
+
renderApproval(preview, color);
|
|
280
|
+
if (autoApprove) {
|
|
281
|
+
process.stderr.write(color.yellow(' [--yes] 已自动批准这次写入\n'));
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
if (!rl || !process.stdin.isTTY) {
|
|
285
|
+
process.stderr.write(color.red(' 非交互环境,默认拒绝文件写入(确需自动批准请加 --yes;MCP 调用不受此限)\n'));
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
const ans = await question(rl, color.cyan(`批准 ${preview.shell ? '执行该命令' : `写入 ${preview.path}`} ?(y/N) `));
|
|
289
|
+
return /^(y|yes)$/i.test(ans.trim());
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------- 命令历史落盘 ----------
|
|
294
|
+
|
|
295
|
+
function historyPath(sessionDir) {
|
|
296
|
+
return sessionDir ? path.join(sessionDir, 'history') : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function loadHistory(sessionDir) {
|
|
300
|
+
const file = historyPath(sessionDir);
|
|
301
|
+
if (!file) return [];
|
|
302
|
+
try {
|
|
303
|
+
if (!existsSync(file)) return [];
|
|
304
|
+
// readline 的 history 是「新的在前」
|
|
305
|
+
return readFileSync(file, 'utf8')
|
|
306
|
+
.split('\n')
|
|
307
|
+
.map((s) => s.trim())
|
|
308
|
+
.filter(Boolean)
|
|
309
|
+
.slice(-HISTORY_LIMIT)
|
|
310
|
+
.reverse();
|
|
311
|
+
} catch {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function saveHistory(sessionDir, rl) {
|
|
317
|
+
const file = historyPath(sessionDir);
|
|
318
|
+
const hist = Array.isArray(rl?.history) ? rl.history : [];
|
|
319
|
+
if (!file || !hist.length) return;
|
|
320
|
+
try {
|
|
321
|
+
// 去重后保留最近 HISTORY_LIMIT 条,落盘顺序是「旧的在前」,下次读回来再反转
|
|
322
|
+
const seen = new Set();
|
|
323
|
+
const out = [];
|
|
324
|
+
for (const line of hist) {
|
|
325
|
+
const t = String(line).trim();
|
|
326
|
+
if (!t || seen.has(t)) continue;
|
|
327
|
+
seen.add(t);
|
|
328
|
+
out.push(t);
|
|
329
|
+
if (out.length >= HISTORY_LIMIT) break;
|
|
330
|
+
}
|
|
331
|
+
writeFileSync(file, out.reverse().join('\n'), 'utf8');
|
|
332
|
+
} catch {
|
|
333
|
+
/* 历史存不下不影响主流程 */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------- 主流程 ----------
|
|
338
|
+
|
|
339
|
+
async function main() {
|
|
340
|
+
// `gateway-agent config …`:配置子命令。放在 parseArgs 之前,而且不需要密钥 ——
|
|
341
|
+
// 「配置还没弄好」恰恰是用户最可能先跑它的场景;交给 parseArgs 还会把
|
|
342
|
+
// `set system --foo` 这类取值当成选项吃掉。
|
|
343
|
+
const raw = process.argv.slice(2);
|
|
344
|
+
if (raw[0] === 'config') {
|
|
345
|
+
const rest = raw.slice(1);
|
|
346
|
+
if (rest.includes('-h') || rest.includes('--help')) {
|
|
347
|
+
process.stdout.write(configHelp());
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const r = configCommand(rest, { env: process.env, file: settingsFilePath() });
|
|
351
|
+
if (r.out) process.stdout.write(r.out);
|
|
352
|
+
if (r.err) process.stderr.write(r.err);
|
|
353
|
+
if (r.code) process.exitCode = r.code;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const args = parseArgs(process.argv.slice(2), VALUE_FLAGS, BOOL_FLAGS);
|
|
358
|
+
|
|
359
|
+
if (args.help) {
|
|
360
|
+
console.log(HELP);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// bash 默认关闭:命令级权限不该默默打开
|
|
365
|
+
setBashEnabled(Boolean(args['allow-bash']));
|
|
366
|
+
|
|
367
|
+
const { key, baseUrl, model, temperature, maxTokens } = resolveConfig(args, { defaultModel: DEFAULT_MODEL });
|
|
368
|
+
// 模型轮次上限:与 Web 侧同名同义;非法值交给 resolveMaxSteps 回落到默认
|
|
369
|
+
const maxSteps = numArg(args['max-steps']);
|
|
370
|
+
if (!key) {
|
|
371
|
+
process.stderr.write(`[错误] ${KEY_HINT}\n`);
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 会话存储(可关):--no-session 时 store 为 null,行为退回纯内存
|
|
376
|
+
let store = null;
|
|
377
|
+
let sessionDir = null;
|
|
378
|
+
let sessionWarning = null;
|
|
379
|
+
let sessionNotice = null;
|
|
380
|
+
if (!args['no-session']) {
|
|
381
|
+
try {
|
|
382
|
+
const resolved = resolveSessionDir(typeof args['session-dir'] === 'string' ? args['session-dir'] : '', SCRIPT_DIR);
|
|
383
|
+
sessionDir = resolved.dir;
|
|
384
|
+
sessionWarning = resolved.warning;
|
|
385
|
+
sessionNotice = resolved.notice;
|
|
386
|
+
store = createSessionStore(sessionDir);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
sessionWarning = `会话落盘不可用(${e.message}),本次会话不会保存`;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 先看要恢复哪个会话,再决定工作目录 —— 恢复时优先沿用会话里记的工作目录
|
|
393
|
+
let restored = null;
|
|
394
|
+
if (args.resume !== undefined) {
|
|
395
|
+
const id = typeof args.resume === 'string' ? args.resume.trim() : '';
|
|
396
|
+
if (!store) {
|
|
397
|
+
process.stderr.write('[错误] --resume 与 --no-session 不能同时用\n');
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
if (!id) {
|
|
401
|
+
process.stderr.write(`[错误] --resume 需要一个会话 id(UUID)。会话目录:${sessionDir}\n`);
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
restored = store.read(id);
|
|
405
|
+
if (!restored) {
|
|
406
|
+
process.stderr.write(`[错误] 找不到会话 ${id}(id 需为 UUID,且未被 TTL 清理)。会话目录:${sessionDir}\n`);
|
|
407
|
+
process.exit(1);
|
|
408
|
+
}
|
|
409
|
+
} else if (args.continue) {
|
|
410
|
+
restored = store ? store.latest(SESSION_TTL_MS) : null;
|
|
411
|
+
if (!restored) {
|
|
412
|
+
const plain = makeColor(false);
|
|
413
|
+
process.stderr.write(`${plain.yellow('!')} --continue 没有找到可恢复的会话,将新建一个\n`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let workDir;
|
|
418
|
+
try {
|
|
419
|
+
const want = typeof args.cwd === 'string' ? args.cwd : restored?.workingDir || process.cwd();
|
|
420
|
+
workDir = checkWorkDir(want);
|
|
421
|
+
} catch (e) {
|
|
422
|
+
process.stderr.write(`[错误] 工作目录无效:${e.message}\n`);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const memory = args['no-memory'] ? '' : loadProjectMemory(workDir);
|
|
427
|
+
const systemParts = [typeof args.system === 'string' ? args.system : '', memory].filter(Boolean);
|
|
428
|
+
const system = systemParts.length ? systemParts.join('\n\n') : undefined;
|
|
429
|
+
|
|
430
|
+
const makeSession = () => createSession({ workingDir: workDir, model, temperature, maxTokens, system });
|
|
431
|
+
|
|
432
|
+
// 恢复:沿用存下来的 messages,只覆盖元数据;没有可恢复的就新建
|
|
433
|
+
let session = (restored && restoreSession(restored, { workingDir: workDir, model })) || null;
|
|
434
|
+
const resumedFrom = session ? session.id : null;
|
|
435
|
+
if (!session) session = makeSession();
|
|
436
|
+
// 轮次上限只作用于本次进程(--max-steps 是运行时开关,不随会话落盘)
|
|
437
|
+
if (maxSteps) session.maxSteps = maxSteps;
|
|
438
|
+
|
|
439
|
+
const color = makeColor(!args['no-color'] && !process.env.NO_COLOR && Boolean(process.stdout.isTTY));
|
|
440
|
+
const jsonOut = args['output-format'] === 'stream-json';
|
|
441
|
+
if (args['output-format'] && !jsonOut && args['output-format'] !== 'text') {
|
|
442
|
+
process.stderr.write('[错误] --output-format 只支持 text / stream-json\n');
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
const renderer = createRenderer({ color, verbose: Boolean(args.verbose), json: jsonOut });
|
|
446
|
+
const cfg = { baseUrl, key };
|
|
447
|
+
const persist = () => {
|
|
448
|
+
if (!store) return;
|
|
449
|
+
try {
|
|
450
|
+
store.write(session);
|
|
451
|
+
} catch (e) {
|
|
452
|
+
process.stderr.write(color.yellow(`! 会话落盘失败:${e.message}\n`));
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
let interactive = Boolean(args.interactive);
|
|
457
|
+
let prompt = typeof args.prompt === 'string' ? args.prompt : args._.join(' ').trim();
|
|
458
|
+
|
|
459
|
+
// 没给提示词又不在交互模式:先尝试标准输入;TTY 下(没有输入)则退化为交互模式
|
|
460
|
+
if (!interactive && !prompt) {
|
|
461
|
+
prompt = await readStdin();
|
|
462
|
+
if (!prompt && process.stdin.isTTY) interactive = true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!interactive && !prompt) {
|
|
466
|
+
console.log(HELP);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 需要一个 readline:交互模式要它读输入,单轮 TTY 模式要它做写入确认
|
|
471
|
+
const rl =
|
|
472
|
+
interactive || process.stdin.isTTY
|
|
473
|
+
? readline.createInterface({
|
|
474
|
+
input: process.stdin,
|
|
475
|
+
output: process.stdout,
|
|
476
|
+
terminal: Boolean(process.stdin.isTTY),
|
|
477
|
+
history: loadHistory(sessionDir),
|
|
478
|
+
historySize: HISTORY_LIMIT,
|
|
479
|
+
})
|
|
480
|
+
: null;
|
|
481
|
+
|
|
482
|
+
const askApproval = makeApproval({ autoApprove: Boolean(args.yes), rl, color });
|
|
483
|
+
|
|
484
|
+
let controller = null;
|
|
485
|
+
const onSigint = () => {
|
|
486
|
+
if (controller) {
|
|
487
|
+
process.stderr.write(`\n${color.yellow('!')} 已中断当前任务\n`);
|
|
488
|
+
controller.abort();
|
|
489
|
+
controller = null;
|
|
490
|
+
} else if (rl) {
|
|
491
|
+
rl.close();
|
|
492
|
+
saveHistory(sessionDir, rl);
|
|
493
|
+
process.exit(130);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
process.on('SIGINT', onSigint);
|
|
497
|
+
|
|
498
|
+
// 启动时就向网关要一次 MCP 工具清单,不等第一轮对话才顺手拉。
|
|
499
|
+
// 为什么必须放在横幅之前:横幅上的「工具」列表读的是 availableTools(),而它是**同步读缓存**的
|
|
500
|
+
// —— 不先刷新,这里永远只列内置工具,用户(以及排障的人)根本看不出 CLI 到底有没有参与 MCP。
|
|
501
|
+
// Web 侧的 /api/config 一直有这一步(页面一刷新就能看到请求),CLI 之前一直没有,两边表现不一致。
|
|
502
|
+
// best-effort:网关没开 MCP / 断网 / 密钥无效都只记一行原因,绝不拦住启动(本模块的硬约束 1);
|
|
503
|
+
// 失败后会走 lib/mcp.js 的冷却,所以第一轮对话不会为一个注定失败的请求再等一次超时。
|
|
504
|
+
const mcpInfo = await refreshMcpTools({ baseUrl, key });
|
|
505
|
+
|
|
506
|
+
const toolNames = availableTools()
|
|
507
|
+
.map((t) => t.function.name)
|
|
508
|
+
.join(' / ');
|
|
509
|
+
// 失败/关闭时把「到底发没发请求」直说出来:否则「CLI 压根没拉清单」(GATEWAY_MCP=off /
|
|
510
|
+
// 密钥配置不全)与「拉了但网关没给」(404/断网)在用户眼里长得一模一样,只能去翻网关日志。
|
|
511
|
+
// attemptedAt 由 lib/mcp.js 在真正发请求时记下,所以这句话是事实而不是推测。
|
|
512
|
+
// 注意后半句的措辞:`GATEWAY_MCP=off` 时工具是网关自己注入的,说「没有 MCP」会误导,
|
|
513
|
+
// 只能保证「CLI 不携带」。
|
|
514
|
+
const mcpLine = mcpInfo?.error
|
|
515
|
+
? `${mcpStatusText()} · ${mcpInfo.attemptedAt
|
|
516
|
+
? `已请求 GET ${baseUrl}/v1/mcp/tools`
|
|
517
|
+
: '按上述原因未向网关发出请求'}(CLI 本次不携带 MCP 工具)`
|
|
518
|
+
: mcpStatusText();
|
|
519
|
+
process.stderr.write(
|
|
520
|
+
`[gateway] 原生 Agent · base=${baseUrl} model=${model}\n` +
|
|
521
|
+
` 工作目录 ${workDir}\n` +
|
|
522
|
+
` 工具 ${toolNames}${args['allow-bash'] ? ' [bash 已开启]' : ''}\n` +
|
|
523
|
+
` ${mcpLine}\n` +
|
|
524
|
+
` 轮次上限 ${session.maxSteps || AGENT_LIMITS.MAX_STEPS} 轮\n` +
|
|
525
|
+
(resumedFrom ? ` 已恢复会话 ${resumedFrom}(${countTurns(session)} 条消息)\n` : '') +
|
|
526
|
+
(store ? ` 会话目录 ${sessionDir}\n` : '') +
|
|
527
|
+
(sessionNotice ? `${color.yellow('!')} ${sessionNotice}\n` : '') +
|
|
528
|
+
(sessionWarning ? `${color.yellow('!')} ${sessionWarning}\n` : '') +
|
|
529
|
+
(memory ? ` 已注入项目记忆(${MEMORY_FILES.join(' / ')})\n` : ''),
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
const runOnce = async (text) => {
|
|
533
|
+
controller = new AbortController();
|
|
534
|
+
try {
|
|
535
|
+
await runTurn({ cfg, session, prompt: text, onEvent: renderer.handle, askApproval, persist, signal: controller.signal });
|
|
536
|
+
} finally {
|
|
537
|
+
controller = null;
|
|
538
|
+
renderer.finish();
|
|
539
|
+
}
|
|
540
|
+
persist();
|
|
541
|
+
const u = session.usage;
|
|
542
|
+
process.stderr.write(color.dim(`[usage] 输入 ${u.prompt} / 输出 ${u.completion} / 合计 ${u.prompt + u.completion} tokens\n`));
|
|
543
|
+
if (jsonOut) process.stdout.write(`${JSON.stringify({ type: 'done', sessionId: session.id, usage: { ...u } })}\n`);
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// 会话里有待批准的写入(进程退出前留下的)→ 先问批不批再接着跑
|
|
547
|
+
if (session.pending) {
|
|
548
|
+
process.stderr.write(color.yellow(`! 恢复了一个待批准的写入:${session.pending.name}\n`));
|
|
549
|
+
controller = new AbortController();
|
|
550
|
+
try {
|
|
551
|
+
const outcome = await resumePendingTurn({ cfg, session, onEvent: renderer.handle, askApproval, persist, signal: controller.signal });
|
|
552
|
+
if (outcome) process.stderr.write(color.dim(`[恢复完成] 状态 ${outcome.status}\n`));
|
|
553
|
+
} catch (e) {
|
|
554
|
+
fail(e, { exit: false });
|
|
555
|
+
} finally {
|
|
556
|
+
controller = null;
|
|
557
|
+
renderer.finish();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (interactive) {
|
|
562
|
+
process.stderr.write(color.dim('交互模式:直接输入任务;/help 看命令,/exit 退出。\n'));
|
|
563
|
+
const ctx = {
|
|
564
|
+
get session() {
|
|
565
|
+
return session;
|
|
566
|
+
},
|
|
567
|
+
setSession: (next) => {
|
|
568
|
+
session = next;
|
|
569
|
+
},
|
|
570
|
+
newSession: makeSession,
|
|
571
|
+
setModel: (m) => {
|
|
572
|
+
session.model = m;
|
|
573
|
+
},
|
|
574
|
+
cfg,
|
|
575
|
+
say: (t) => process.stderr.write(`${t}\n`),
|
|
576
|
+
persist,
|
|
577
|
+
loadSession: (id) => {
|
|
578
|
+
const rec = store?.read(id);
|
|
579
|
+
return rec ? restoreSession(rec, { workingDir: workDir, model }) : null;
|
|
580
|
+
},
|
|
581
|
+
listSessions: () =>
|
|
582
|
+
(store ? store.list(SESSION_TTL_MS) : []).map((r) => ({
|
|
583
|
+
id: r.id,
|
|
584
|
+
model: r.model,
|
|
585
|
+
messages: r.messages?.length || 0,
|
|
586
|
+
touchedAt: r.touchedAt,
|
|
587
|
+
})),
|
|
588
|
+
// /config 用:与 `gateway-agent config` 完全同一份实现与同一个文件
|
|
589
|
+
configOpts: () => ({ env: process.env, file: settingsFilePath() }),
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
for (;;) {
|
|
593
|
+
let line;
|
|
594
|
+
try {
|
|
595
|
+
line = (await readInput(rl, color)).trim();
|
|
596
|
+
} catch {
|
|
597
|
+
break; // 输入流关闭
|
|
598
|
+
}
|
|
599
|
+
if (!line) continue;
|
|
600
|
+
|
|
601
|
+
// /exit /quit / exit / quit 全部由命令表的 `bare` 标记兜住(run 返回 { exit: true }),
|
|
602
|
+
// 这里不再硬编码 —— 硬编码会让"命令表"不再是唯一出处(A2 对齐,见 lib/commands.js 文件头)。
|
|
603
|
+
const cmd = parseSlash(line);
|
|
604
|
+
if (cmd) {
|
|
605
|
+
if (cmd.error) {
|
|
606
|
+
process.stderr.write(color.red(` ${cmd.error}\n 用法:${cmd.cmd.usage}\n`));
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
// await 是必须的:命令可以是异步的(如 /mcp 要出网重拉清单)。
|
|
610
|
+
// 不 await 的话 promise 会被丢掉,输出时序也会乱(「命令跑完了但没打印」就是它)。
|
|
611
|
+
const result = await cmd.cmd.run(ctx, cmd.arg, cmd.argv);
|
|
612
|
+
if (result?.exit) break;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// `//xxx` 是转义:去掉一个斜杠后**当普通提问发给模型**(与网关 Web 端 R3 同一口径)
|
|
617
|
+
const escaped = unescapeSlash(line);
|
|
618
|
+
if (escaped !== null) {
|
|
619
|
+
try {
|
|
620
|
+
await runOnce(escaped);
|
|
621
|
+
} catch (e) {
|
|
622
|
+
fail(e, { exit: false });
|
|
623
|
+
}
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (line.startsWith('/') || line.startsWith('/')) {
|
|
628
|
+
// 前缀只补全、不执行;REPL 没有候选面板(readInput 用 rl.question,无 raw mode),
|
|
629
|
+
// 所以候选以文本列表打印(对齐方案 §1.3)
|
|
630
|
+
const head = line.split(/\s/)[0].replace(/^//, '/').toLowerCase();
|
|
631
|
+
const near = suggest(head.slice(1));
|
|
632
|
+
if (near.length) {
|
|
633
|
+
process.stderr.write(color.dim(
|
|
634
|
+
` 命令名不完整:${head}\n 候选:${near.map((c) => c.usage.split(' ')[0]).join(' ')}\n`));
|
|
635
|
+
} else {
|
|
636
|
+
process.stderr.write(color.dim(`未知命令 ${head}(/help 看可用命令)。\n`));
|
|
637
|
+
}
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
await runOnce(line);
|
|
643
|
+
} catch (e) {
|
|
644
|
+
fail(e, { exit: false });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (rl) {
|
|
648
|
+
rl.close();
|
|
649
|
+
saveHistory(sessionDir, rl);
|
|
650
|
+
}
|
|
651
|
+
process.stderr.write('再见。\n');
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
await runOnce(prompt);
|
|
657
|
+
} catch (e) {
|
|
658
|
+
fail(e);
|
|
659
|
+
}
|
|
660
|
+
if (rl) {
|
|
661
|
+
rl.close();
|
|
662
|
+
saveHistory(sessionDir, rl);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
main();
|