@mafdet/cli 0.3.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/CHANGELOG.md +86 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/README.zh-CN.md +148 -0
- package/dist/agent/approvals.d.ts +38 -0
- package/dist/agent/approvals.d.ts.map +1 -0
- package/dist/agent/approvals.js +53 -0
- package/dist/agent/command-policy.d.ts +36 -0
- package/dist/agent/command-policy.d.ts.map +1 -0
- package/dist/agent/command-policy.js +133 -0
- package/dist/agent/loop.d.ts +107 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/loop.js +242 -0
- package/dist/agent/session.d.ts +62 -0
- package/dist/agent/session.d.ts.map +1 -0
- package/dist/agent/session.js +290 -0
- package/dist/agent/tools/read-only.d.ts +15 -0
- package/dist/agent/tools/read-only.d.ts.map +1 -0
- package/dist/agent/tools/read-only.js +254 -0
- package/dist/agent/tools/run-command.d.ts +20 -0
- package/dist/agent/tools/run-command.d.ts.map +1 -0
- package/dist/agent/tools/run-command.js +116 -0
- package/dist/agent/tools/types.d.ts +46 -0
- package/dist/agent/tools/types.d.ts.map +1 -0
- package/dist/agent/tools/types.js +16 -0
- package/dist/agent/tools/write.d.ts +3 -0
- package/dist/agent/tools/write.d.ts.map +1 -0
- package/dist/agent/tools/write.js +68 -0
- package/dist/api/account.d.ts +39 -0
- package/dist/api/account.d.ts.map +1 -0
- package/dist/api/account.js +39 -0
- package/dist/api/errors.d.ts +38 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +149 -0
- package/dist/api/http.d.ts +63 -0
- package/dist/api/http.d.ts.map +1 -0
- package/dist/api/http.js +207 -0
- package/dist/api/models.d.ts +68 -0
- package/dist/api/models.d.ts.map +1 -0
- package/dist/api/models.js +91 -0
- package/dist/api/sse-parser.d.ts +72 -0
- package/dist/api/sse-parser.d.ts.map +1 -0
- package/dist/api/sse-parser.js +163 -0
- package/dist/commands/account.d.ts +4 -0
- package/dist/commands/account.d.ts.map +1 -0
- package/dist/commands/account.js +103 -0
- package/dist/commands/agent.d.ts +10 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +268 -0
- package/dist/commands/chat.d.ts +13 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/chat.js +191 -0
- package/dist/commands/config.d.ts +15 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +74 -0
- package/dist/commands/doctor.d.ts +15 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +157 -0
- package/dist/commands/embed.d.ts +7 -0
- package/dist/commands/embed.d.ts.map +1 -0
- package/dist/commands/embed.js +105 -0
- package/dist/commands/models.d.ts +4 -0
- package/dist/commands/models.d.ts.map +1 -0
- package/dist/commands/models.js +151 -0
- package/dist/commands/runtime.d.ts +29 -0
- package/dist/commands/runtime.d.ts.map +1 -0
- package/dist/commands/runtime.js +56 -0
- package/dist/commands/speech.d.ts +7 -0
- package/dist/commands/speech.d.ts.map +1 -0
- package/dist/commands/speech.js +111 -0
- package/dist/config/config.d.ts +57 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +129 -0
- package/dist/config/load.d.ts +20 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +48 -0
- package/dist/config/paths.d.ts +11 -0
- package/dist/config/paths.d.ts.map +1 -0
- package/dist/config/paths.js +26 -0
- package/dist/filesystem/diff.d.ts +12 -0
- package/dist/filesystem/diff.d.ts.map +1 -0
- package/dist/filesystem/diff.js +109 -0
- package/dist/filesystem/sensitive-files.d.ts +19 -0
- package/dist/filesystem/sensitive-files.d.ts.map +1 -0
- package/dist/filesystem/sensitive-files.js +46 -0
- package/dist/filesystem/workspace.d.ts +92 -0
- package/dist/filesystem/workspace.d.ts.map +1 -0
- package/dist/filesystem/workspace.js +264 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/output/exit-codes.d.ts +25 -0
- package/dist/output/exit-codes.d.ts.map +1 -0
- package/dist/output/exit-codes.js +23 -0
- package/dist/output/format.d.ts +28 -0
- package/dist/output/format.d.ts.map +1 -0
- package/dist/output/format.js +56 -0
- package/dist/output/money.d.ts +16 -0
- package/dist/output/money.d.ts.map +1 -0
- package/dist/output/money.js +35 -0
- package/dist/output/write-file.d.ts +11 -0
- package/dist/output/write-file.d.ts.map +1 -0
- package/dist/output/write-file.js +38 -0
- package/dist/security/redact.d.ts +16 -0
- package/dist/security/redact.d.ts.map +1 -0
- package/dist/security/redact.js +61 -0
- package/package.json +51 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { isAgentEligible } from "../api/models.js";
|
|
3
|
+
import { formatUsd } from "../output/money.js";
|
|
4
|
+
import { renderRecord } from "../output/format.js";
|
|
5
|
+
import { runAgent } from "./loop.js";
|
|
6
|
+
const HELP = `Session commands:
|
|
7
|
+
/model [id] Show or switch the model
|
|
8
|
+
/permissions Show what this session may do
|
|
9
|
+
/diff Show uncommitted changes in the workspace
|
|
10
|
+
/cost Tokens and spend so far
|
|
11
|
+
/compact Shorten the conversation, keeping the goal and recent turns
|
|
12
|
+
/exit End the session
|
|
13
|
+
|
|
14
|
+
Anything else is sent to the model as the next goal.
|
|
15
|
+
`;
|
|
16
|
+
/**
|
|
17
|
+
* The interactive session (plan §4.4).
|
|
18
|
+
*
|
|
19
|
+
* One conversation across many goals, so the agent keeps what it learned. The
|
|
20
|
+
* session owns the history — which is what makes /compact possible — and the
|
|
21
|
+
* running totals, so /cost and the closing summary describe the whole session
|
|
22
|
+
* rather than the last question.
|
|
23
|
+
*/
|
|
24
|
+
export async function runSession(options) {
|
|
25
|
+
const history = [];
|
|
26
|
+
const summary = {
|
|
27
|
+
goals: 0,
|
|
28
|
+
turns: 0,
|
|
29
|
+
modelRequests: 0,
|
|
30
|
+
toolRuns: 0,
|
|
31
|
+
promptTokens: 0,
|
|
32
|
+
completionTokens: 0,
|
|
33
|
+
costMicroUsd: 0n,
|
|
34
|
+
};
|
|
35
|
+
let model = options.model;
|
|
36
|
+
let pricing = options.pricing;
|
|
37
|
+
const source = options.readLine
|
|
38
|
+
? undefined
|
|
39
|
+
: lineSource(options.input ?? process.stdin, options.stderr);
|
|
40
|
+
const readLine = options.readLine ??
|
|
41
|
+
(() => {
|
|
42
|
+
options.stderr("\n> ");
|
|
43
|
+
return source.read();
|
|
44
|
+
});
|
|
45
|
+
options.stderr(`mafdet agent session - ${model} (${options.mode}). /help for commands, /exit to leave.\n`);
|
|
46
|
+
try {
|
|
47
|
+
for (;;) {
|
|
48
|
+
if (options.signal?.aborted)
|
|
49
|
+
break;
|
|
50
|
+
const raw = await readLine();
|
|
51
|
+
if (raw === undefined)
|
|
52
|
+
break; // Ctrl+D, or the input stream ended.
|
|
53
|
+
const line = raw.trim();
|
|
54
|
+
if (line.length === 0)
|
|
55
|
+
continue;
|
|
56
|
+
if (line.startsWith("/")) {
|
|
57
|
+
const done = await handleCommand(line, {
|
|
58
|
+
options,
|
|
59
|
+
history,
|
|
60
|
+
summary,
|
|
61
|
+
model,
|
|
62
|
+
setModel: (next, nextPricing) => {
|
|
63
|
+
model = next;
|
|
64
|
+
pricing = nextPricing;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
if (done)
|
|
68
|
+
break;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
summary.goals += 1;
|
|
72
|
+
const report = await runAgent({
|
|
73
|
+
client: options.client,
|
|
74
|
+
model,
|
|
75
|
+
pricing,
|
|
76
|
+
goal: line,
|
|
77
|
+
systemPrompt: options.systemPrompt,
|
|
78
|
+
tools: options.tools,
|
|
79
|
+
toolContext: options.toolContext,
|
|
80
|
+
history,
|
|
81
|
+
approver: options.approver,
|
|
82
|
+
signal: options.signal,
|
|
83
|
+
onEvent: (event) => reportEvent(event, options),
|
|
84
|
+
});
|
|
85
|
+
summary.turns += report.turns;
|
|
86
|
+
summary.modelRequests += report.modelRequests;
|
|
87
|
+
summary.toolRuns += report.toolRuns.length;
|
|
88
|
+
summary.promptTokens += report.promptTokens;
|
|
89
|
+
summary.completionTokens += report.completionTokens;
|
|
90
|
+
summary.costMicroUsd += report.costMicroUsd;
|
|
91
|
+
if (report.finalText)
|
|
92
|
+
options.stdout(`${report.finalText}\n`);
|
|
93
|
+
if (report.stopReason !== "completed") {
|
|
94
|
+
options.stderr(`[${report.stopReason}${report.error ? `: ${report.error}` : ""}]\n`);
|
|
95
|
+
}
|
|
96
|
+
if (report.stopReason === "cancelled" && options.signal?.aborted)
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
source?.close();
|
|
102
|
+
}
|
|
103
|
+
return summary;
|
|
104
|
+
}
|
|
105
|
+
/** @returns true when the session should end. */
|
|
106
|
+
async function handleCommand(line, context) {
|
|
107
|
+
const [command, ...rest] = line.split(/\s+/);
|
|
108
|
+
const { options } = context;
|
|
109
|
+
switch (command) {
|
|
110
|
+
case "/exit":
|
|
111
|
+
case "/quit":
|
|
112
|
+
return true;
|
|
113
|
+
case "/help":
|
|
114
|
+
options.stderr(HELP);
|
|
115
|
+
return false;
|
|
116
|
+
case "/model": {
|
|
117
|
+
const requested = rest[0];
|
|
118
|
+
if (!requested) {
|
|
119
|
+
options.stderr(`model: ${context.model}\n`);
|
|
120
|
+
const eligible = options.catalog
|
|
121
|
+
.filter((entry) => isAgentEligible(entry))
|
|
122
|
+
.map((entry) => entry.modelId);
|
|
123
|
+
options.stderr(`agent-eligible: ${eligible.join(", ") || "(none)"}\n`);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
const next = options.catalog.find((entry) => entry.modelId === requested);
|
|
127
|
+
// Switching mid-session must obey the same admission rule as starting:
|
|
128
|
+
// an unverified model would fail on its first tool call.
|
|
129
|
+
if (!next || !isAgentEligible(next)) {
|
|
130
|
+
options.stderr(`${requested} is not verified for tool calling; keeping ${context.model}\n`);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
context.setModel(requested, next);
|
|
134
|
+
options.stderr(`model: ${requested}\n`);
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
case "/permissions":
|
|
138
|
+
options.stderr(renderRecord([
|
|
139
|
+
["mode", options.mode],
|
|
140
|
+
["workspace", options.toolContext.workspace.root],
|
|
141
|
+
["tools", options.tools.map((tool) => tool.name).join(", ")],
|
|
142
|
+
[
|
|
143
|
+
"pre-approved commands",
|
|
144
|
+
options.allowedCommands.length > 0
|
|
145
|
+
? options.allowedCommands.join(", ")
|
|
146
|
+
: "(none)",
|
|
147
|
+
],
|
|
148
|
+
]));
|
|
149
|
+
return false;
|
|
150
|
+
case "/diff": {
|
|
151
|
+
const gitDiff = options.tools.find((tool) => tool.name === "git_diff");
|
|
152
|
+
if (!gitDiff?.run) {
|
|
153
|
+
options.stderr("git_diff is not available in this session\n");
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
options.stderr(`${gitDiff.run({}, options.toolContext)}\n`);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
options.stderr(`${error.message}\n`);
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
case "/cost":
|
|
165
|
+
options.stderr(renderRecord([
|
|
166
|
+
["goals", `${context.summary.goals}`],
|
|
167
|
+
["model requests", `${context.summary.modelRequests}`],
|
|
168
|
+
[
|
|
169
|
+
"tokens",
|
|
170
|
+
`${context.summary.promptTokens} in / ${context.summary.completionTokens} out`,
|
|
171
|
+
],
|
|
172
|
+
["cost", `~${formatUsd(context.summary.costMicroUsd)}`],
|
|
173
|
+
]));
|
|
174
|
+
return false;
|
|
175
|
+
case "/compact": {
|
|
176
|
+
const before = context.history.length;
|
|
177
|
+
const removed = compactHistory(context.history);
|
|
178
|
+
options.stderr(removed === 0
|
|
179
|
+
? `nothing to compact (${before} messages)\n`
|
|
180
|
+
: `compacted: ${before} -> ${context.history.length} messages\n`);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
options.stderr(`unknown command ${command}. /help for the list.\n`);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Shorten the conversation in place.
|
|
190
|
+
*
|
|
191
|
+
* This truncates rather than summarising: a summary would cost a model call
|
|
192
|
+
* and could quietly invent detail. It keeps the system prompt and the opening
|
|
193
|
+
* goal, then cuts to the most recent user message.
|
|
194
|
+
*
|
|
195
|
+
* The cut must land on a user message. An assistant turn that carries
|
|
196
|
+
* `tool_calls` and its matching `tool` results form one indivisible unit — cut
|
|
197
|
+
* between them and the next request is rejected for referencing a tool call
|
|
198
|
+
* that is no longer there.
|
|
199
|
+
*
|
|
200
|
+
* @returns how many messages were removed.
|
|
201
|
+
*/
|
|
202
|
+
export function compactHistory(history) {
|
|
203
|
+
if (history.length <= 3)
|
|
204
|
+
return 0;
|
|
205
|
+
const system = history[0]?.role === "system" ? history[0] : undefined;
|
|
206
|
+
const firstGoal = history.find((message) => message.role === "user");
|
|
207
|
+
// Find the last user message that is not the opening goal.
|
|
208
|
+
let cut = -1;
|
|
209
|
+
for (let i = history.length - 1; i > 0; i -= 1) {
|
|
210
|
+
if (history[i].role === "user" && history[i] !== firstGoal) {
|
|
211
|
+
cut = i;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (cut === -1)
|
|
216
|
+
return 0;
|
|
217
|
+
const kept = [];
|
|
218
|
+
if (system)
|
|
219
|
+
kept.push(system);
|
|
220
|
+
if (firstGoal && firstGoal !== history[cut])
|
|
221
|
+
kept.push(firstGoal);
|
|
222
|
+
kept.push(...history.slice(cut));
|
|
223
|
+
const removed = history.length - kept.length;
|
|
224
|
+
if (removed <= 0)
|
|
225
|
+
return 0;
|
|
226
|
+
history.length = 0;
|
|
227
|
+
history.push(...kept);
|
|
228
|
+
return removed;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Lines from the terminal, queued.
|
|
232
|
+
*
|
|
233
|
+
* readline's `question` drops whatever is already buffered when the stream
|
|
234
|
+
* ends, which loses every line after the first when input is pasted or piped
|
|
235
|
+
* — found by running a real session under a pty. Collecting `line` events into
|
|
236
|
+
* a queue keeps them, and end-of-input only reports undefined once the queue
|
|
237
|
+
* is actually empty.
|
|
238
|
+
*/
|
|
239
|
+
function lineSource(input, write) {
|
|
240
|
+
const rl = createInterface({
|
|
241
|
+
input,
|
|
242
|
+
output: process.stderr,
|
|
243
|
+
terminal: false,
|
|
244
|
+
});
|
|
245
|
+
const queue = [];
|
|
246
|
+
let waiting;
|
|
247
|
+
let closed = false;
|
|
248
|
+
rl.on("line", (line) => {
|
|
249
|
+
if (waiting) {
|
|
250
|
+
const resolve = waiting;
|
|
251
|
+
waiting = undefined;
|
|
252
|
+
resolve(line);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
queue.push(line);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
rl.on("close", () => {
|
|
259
|
+
closed = true;
|
|
260
|
+
if (waiting) {
|
|
261
|
+
const resolve = waiting;
|
|
262
|
+
waiting = undefined;
|
|
263
|
+
resolve(undefined);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
return {
|
|
267
|
+
read() {
|
|
268
|
+
if (queue.length > 0)
|
|
269
|
+
return Promise.resolve(queue.shift());
|
|
270
|
+
if (closed)
|
|
271
|
+
return Promise.resolve(undefined);
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
waiting = resolve;
|
|
274
|
+
});
|
|
275
|
+
},
|
|
276
|
+
close: () => rl.close(),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function reportEvent(event, options) {
|
|
280
|
+
if (event.type === "tool") {
|
|
281
|
+
const first = event.detail.split("\n")[0].slice(0, 100);
|
|
282
|
+
options.stderr(` ${event.ok ? "-" : "!"} ${event.name}: ${first}\n`);
|
|
283
|
+
}
|
|
284
|
+
else if (event.type === "declined") {
|
|
285
|
+
options.stderr(` declined: ${event.summary}\n`);
|
|
286
|
+
}
|
|
287
|
+
else if (event.type === "rate_limited") {
|
|
288
|
+
options.stderr(` rate limited, waiting ${event.waitMs}ms (attempt ${event.attempt})\n`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ToolDefinition } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Everything a tool returns passes through here before it reaches the model
|
|
4
|
+
* or the terminal: strip terminal control, redact, then cap the size.
|
|
5
|
+
*
|
|
6
|
+
* Truncation keeps one runaway file from eating the context window, and
|
|
7
|
+
* redaction matters more than usual: a repository can contain a key the
|
|
8
|
+
* sensitive-file policy did not recognise, and sending it upstream would be
|
|
9
|
+
* the CLI leaking it, not the user.
|
|
10
|
+
*/
|
|
11
|
+
export declare function finishOutput(text: string): string;
|
|
12
|
+
/** A tiny glob: `*` within a segment, `**` across segments, `?` one character. */
|
|
13
|
+
export declare function globToRegExp(glob: string): RegExp;
|
|
14
|
+
export declare const READ_ONLY_TOOLS: ToolDefinition[];
|
|
15
|
+
//# sourceMappingURL=read-only.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-only.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/read-only.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAe,cAAc,EAAE,MAAM,YAAY,CAAC;AAmB9D;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAYjD;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAuBjD;AAmLD,eAAO,MAAM,eAAe,EAAE,cAAc,EAM3C,CAAC"}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { MAX_TOOL_OUTPUT_BYTES, WorkspaceError, } from "../../filesystem/workspace.js";
|
|
6
|
+
import { redact } from "../../security/redact.js";
|
|
7
|
+
import { stripAnsi } from "../command-policy.js";
|
|
8
|
+
/** Never walked: enormous, uninteresting, and full of other people's code. */
|
|
9
|
+
const SKIPPED_DIRECTORIES = new Set([
|
|
10
|
+
".git",
|
|
11
|
+
"node_modules",
|
|
12
|
+
".turbo",
|
|
13
|
+
".next",
|
|
14
|
+
"dist",
|
|
15
|
+
"build",
|
|
16
|
+
".venv",
|
|
17
|
+
"__pycache__",
|
|
18
|
+
]);
|
|
19
|
+
const MAX_LISTED_FILES = 500;
|
|
20
|
+
const MAX_SEARCH_RESULTS = 200;
|
|
21
|
+
/** Git output is unbounded in principle; a diff of a vendored tree is huge. */
|
|
22
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
23
|
+
/**
|
|
24
|
+
* Everything a tool returns passes through here before it reaches the model
|
|
25
|
+
* or the terminal: strip terminal control, redact, then cap the size.
|
|
26
|
+
*
|
|
27
|
+
* Truncation keeps one runaway file from eating the context window, and
|
|
28
|
+
* redaction matters more than usual: a repository can contain a key the
|
|
29
|
+
* sensitive-file policy did not recognise, and sending it upstream would be
|
|
30
|
+
* the CLI leaking it, not the user.
|
|
31
|
+
*/
|
|
32
|
+
export function finishOutput(text) {
|
|
33
|
+
// Terminal control is stripped here, at the one boundary every tool result
|
|
34
|
+
// crosses. A repository file can contain raw escape bytes, and read_file
|
|
35
|
+
// echoes a preview of its result to the user's terminal - a file must not be
|
|
36
|
+
// able to clear the screen, retitle the window or hide what follows it.
|
|
37
|
+
const safe = redact(stripAnsi(text));
|
|
38
|
+
if (Buffer.byteLength(safe, "utf8") <= MAX_TOOL_OUTPUT_BYTES)
|
|
39
|
+
return safe;
|
|
40
|
+
const clipped = Buffer.from(safe, "utf8")
|
|
41
|
+
.subarray(0, MAX_TOOL_OUTPUT_BYTES)
|
|
42
|
+
.toString("utf8");
|
|
43
|
+
return `${clipped}\n[truncated at ${MAX_TOOL_OUTPUT_BYTES} bytes]`;
|
|
44
|
+
}
|
|
45
|
+
/** A tiny glob: `*` within a segment, `**` across segments, `?` one character. */
|
|
46
|
+
export function globToRegExp(glob) {
|
|
47
|
+
let out = "";
|
|
48
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
49
|
+
const char = glob[i];
|
|
50
|
+
if (char === "*") {
|
|
51
|
+
if (glob[i + 1] === "*") {
|
|
52
|
+
// `**/` may also match zero directories, so `**/*.ts` finds `a.ts`.
|
|
53
|
+
if (glob[i + 2] === "/") {
|
|
54
|
+
out += "(?:.*/)?";
|
|
55
|
+
i += 2;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
out += ".*";
|
|
59
|
+
i += 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
out += "[^/]*";
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (char === "?")
|
|
68
|
+
out += "[^/]";
|
|
69
|
+
else
|
|
70
|
+
out += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
71
|
+
}
|
|
72
|
+
return new RegExp(`^${out}$`);
|
|
73
|
+
}
|
|
74
|
+
function walk(workspace, onFile) {
|
|
75
|
+
const stack = [workspace.root];
|
|
76
|
+
while (stack.length > 0) {
|
|
77
|
+
const current = stack.pop();
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue; // Unreadable directory: skip rather than abort the whole walk.
|
|
84
|
+
}
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const full = join(current, entry.name);
|
|
87
|
+
if (entry.isDirectory()) {
|
|
88
|
+
if (SKIPPED_DIRECTORIES.has(entry.name))
|
|
89
|
+
continue;
|
|
90
|
+
stack.push(full);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!entry.isFile())
|
|
94
|
+
continue; // Sockets, fifos, dangling symlinks.
|
|
95
|
+
const rel = toPosix(relative(workspace.root, full));
|
|
96
|
+
// The sensitive policy applies to listing too: a secret's *name* is
|
|
97
|
+
// information, and offering it invites the model to ask for it.
|
|
98
|
+
if (!workspace.allows(rel))
|
|
99
|
+
continue;
|
|
100
|
+
if (!onFile(rel))
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const listFiles = {
|
|
106
|
+
name: "list_files",
|
|
107
|
+
description: "List files in the workspace, optionally filtered by a glob such as src/**/*.ts. Skips .git, node_modules and build output.",
|
|
108
|
+
autoApproved: true,
|
|
109
|
+
schema: z.object({
|
|
110
|
+
glob: z
|
|
111
|
+
.string()
|
|
112
|
+
.min(1)
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("Glob relative to the workspace root, e.g. src/**/*.ts"),
|
|
115
|
+
}),
|
|
116
|
+
run(args, { workspace }) {
|
|
117
|
+
const pattern = args.glob ? globToRegExp(args.glob) : undefined;
|
|
118
|
+
const found = [];
|
|
119
|
+
walk(workspace, (rel) => {
|
|
120
|
+
if (!pattern || pattern.test(rel))
|
|
121
|
+
found.push(rel);
|
|
122
|
+
return found.length < MAX_LISTED_FILES;
|
|
123
|
+
});
|
|
124
|
+
if (found.length === 0)
|
|
125
|
+
return finishOutput("no files matched");
|
|
126
|
+
const suffix = found.length >= MAX_LISTED_FILES
|
|
127
|
+
? `\n[stopped at ${MAX_LISTED_FILES} files]`
|
|
128
|
+
: "";
|
|
129
|
+
return finishOutput(found.sort().join("\n") + suffix);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
const searchText = {
|
|
133
|
+
name: "search_text",
|
|
134
|
+
description: "Search the workspace for a literal string and return matching lines as path:line:text.",
|
|
135
|
+
autoApproved: true,
|
|
136
|
+
schema: z.object({
|
|
137
|
+
query: z
|
|
138
|
+
.string()
|
|
139
|
+
.min(1)
|
|
140
|
+
.describe("Literal text to find (not a regular expression)"),
|
|
141
|
+
glob: z
|
|
142
|
+
.string()
|
|
143
|
+
.min(1)
|
|
144
|
+
.optional()
|
|
145
|
+
.describe("Restrict the search to matching files"),
|
|
146
|
+
}),
|
|
147
|
+
run(args, { workspace }) {
|
|
148
|
+
const pattern = args.glob ? globToRegExp(args.glob) : undefined;
|
|
149
|
+
const hits = [];
|
|
150
|
+
walk(workspace, (rel) => {
|
|
151
|
+
if (pattern && !pattern.test(rel))
|
|
152
|
+
return true;
|
|
153
|
+
let content;
|
|
154
|
+
try {
|
|
155
|
+
// Reuse the reader so size, binary and sensitivity rules apply here
|
|
156
|
+
// too — a search must not become a way around read_file's limits.
|
|
157
|
+
content = workspace.readTextFile(rel).content;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
const lines = content.split("\n");
|
|
163
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
164
|
+
if (!lines[i].includes(args.query))
|
|
165
|
+
continue;
|
|
166
|
+
hits.push(`${rel}:${i + 1}:${lines[i].trim().slice(0, 300)}`);
|
|
167
|
+
if (hits.length >= MAX_SEARCH_RESULTS)
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
if (hits.length === 0)
|
|
173
|
+
return finishOutput(`no matches for ${args.query}`);
|
|
174
|
+
const suffix = hits.length >= MAX_SEARCH_RESULTS
|
|
175
|
+
? `\n[stopped at ${MAX_SEARCH_RESULTS} matches]`
|
|
176
|
+
: "";
|
|
177
|
+
return finishOutput(hits.join("\n") + suffix);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
const readFile = {
|
|
181
|
+
name: "read_file",
|
|
182
|
+
description: "Read a UTF-8 text file from the workspace.",
|
|
183
|
+
autoApproved: true,
|
|
184
|
+
schema: z.object({
|
|
185
|
+
path: z
|
|
186
|
+
.string()
|
|
187
|
+
.min(1)
|
|
188
|
+
.describe("Workspace-relative path, e.g. src/index.ts"),
|
|
189
|
+
}),
|
|
190
|
+
run(args, { workspace }) {
|
|
191
|
+
const file = workspace.readTextFile(args.path);
|
|
192
|
+
return finishOutput(file.content);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
const gitStatus = {
|
|
196
|
+
name: "git_status",
|
|
197
|
+
description: "Show the working tree status (porcelain format). Read-only.",
|
|
198
|
+
autoApproved: true,
|
|
199
|
+
schema: z.object({}),
|
|
200
|
+
run(_args, context) {
|
|
201
|
+
const out = git(["status", "--porcelain=v1", "--branch"], context);
|
|
202
|
+
return finishOutput(out.trim() || "clean working tree");
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const gitDiff = {
|
|
206
|
+
name: "git_diff",
|
|
207
|
+
description: "Show uncommitted changes, optionally for one path. Read-only.",
|
|
208
|
+
autoApproved: true,
|
|
209
|
+
schema: z.object({
|
|
210
|
+
path: z.string().min(1).optional().describe("Limit the diff to this path"),
|
|
211
|
+
}),
|
|
212
|
+
run(args, context) {
|
|
213
|
+
const argv = ["diff", "--no-color"];
|
|
214
|
+
if (args.path) {
|
|
215
|
+
// Route it through the boundary: `git diff ../../etc` would otherwise
|
|
216
|
+
// read outside the workspace.
|
|
217
|
+
argv.push("--", context.workspace.resolvePath(args.path).relative);
|
|
218
|
+
}
|
|
219
|
+
const out = git(argv, context);
|
|
220
|
+
return finishOutput(out.trim() || "no uncommitted changes");
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
function git(args, context) {
|
|
224
|
+
if (context.runGit)
|
|
225
|
+
return context.runGit(args, context.workspace.root);
|
|
226
|
+
try {
|
|
227
|
+
return execFileSync("git", args, {
|
|
228
|
+
cwd: context.workspace.root,
|
|
229
|
+
encoding: "utf8",
|
|
230
|
+
timeout: GIT_TIMEOUT_MS,
|
|
231
|
+
maxBuffer: MAX_TOOL_OUTPUT_BYTES,
|
|
232
|
+
// The child gets no credentials: nothing here needs them, and a hook or
|
|
233
|
+
// an alias in the repository must not be handed the user's API key.
|
|
234
|
+
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
const message = error.message;
|
|
239
|
+
if (/not a git repository/i.test(message)) {
|
|
240
|
+
throw new WorkspaceError("this workspace is not a git repository");
|
|
241
|
+
}
|
|
242
|
+
throw new WorkspaceError(`git failed: ${message.split("\n")[0]}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
export const READ_ONLY_TOOLS = [
|
|
246
|
+
listFiles,
|
|
247
|
+
searchText,
|
|
248
|
+
readFile,
|
|
249
|
+
gitStatus,
|
|
250
|
+
gitDiff,
|
|
251
|
+
];
|
|
252
|
+
function toPosix(value) {
|
|
253
|
+
return sep === "/" ? value : value.split(sep).join("/");
|
|
254
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ToolDefinition } from "./types.js";
|
|
2
|
+
/** Plan §7.4: a single local command gets two minutes. */
|
|
3
|
+
export declare const COMMAND_TIMEOUT_MS = 120000;
|
|
4
|
+
export interface CommandResult {
|
|
5
|
+
code: number | null;
|
|
6
|
+
signal: string | null;
|
|
7
|
+
output: string;
|
|
8
|
+
timedOut: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Run one program, with no shell and nothing inherited that it does not need.
|
|
12
|
+
*
|
|
13
|
+
* `detached` puts the child in its own process group so a timeout can kill the
|
|
14
|
+
* whole tree. Killing only the parent leaves the grandchildren running — the
|
|
15
|
+
* failure mode where a "timed out" test run keeps holding a port.
|
|
16
|
+
*/
|
|
17
|
+
export declare function executeCommand(argv: readonly string[], cwd: string, timeoutMs?: number): Promise<CommandResult>;
|
|
18
|
+
export declare const runCommandTool: ToolDefinition;
|
|
19
|
+
export declare const COMMAND_TOOLS: ToolDefinition[];
|
|
20
|
+
//# sourceMappingURL=run-command.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-command.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/run-command.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAA+B,cAAc,EAAE,MAAM,YAAY,CAAC;AAE9E,0DAA0D;AAC1D,eAAO,MAAM,kBAAkB,SAAU,CAAC;AAG1C,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,GAAG,EAAE,MAAM,EACX,SAAS,GAAE,MAA2B,GACrC,OAAO,CAAC,aAAa,CAAC,CAkDxB;AAwBD,eAAO,MAAM,cAAc,EAAE,cAmC5B,CAAC;AAEF,eAAO,MAAM,aAAa,EAAE,cAAc,EAAqB,CAAC"}
|