@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +173 -0
  4. package/README.zh-CN.md +148 -0
  5. package/dist/agent/approvals.d.ts +38 -0
  6. package/dist/agent/approvals.d.ts.map +1 -0
  7. package/dist/agent/approvals.js +53 -0
  8. package/dist/agent/command-policy.d.ts +36 -0
  9. package/dist/agent/command-policy.d.ts.map +1 -0
  10. package/dist/agent/command-policy.js +133 -0
  11. package/dist/agent/loop.d.ts +107 -0
  12. package/dist/agent/loop.d.ts.map +1 -0
  13. package/dist/agent/loop.js +242 -0
  14. package/dist/agent/session.d.ts +62 -0
  15. package/dist/agent/session.d.ts.map +1 -0
  16. package/dist/agent/session.js +290 -0
  17. package/dist/agent/tools/read-only.d.ts +15 -0
  18. package/dist/agent/tools/read-only.d.ts.map +1 -0
  19. package/dist/agent/tools/read-only.js +254 -0
  20. package/dist/agent/tools/run-command.d.ts +20 -0
  21. package/dist/agent/tools/run-command.d.ts.map +1 -0
  22. package/dist/agent/tools/run-command.js +116 -0
  23. package/dist/agent/tools/types.d.ts +46 -0
  24. package/dist/agent/tools/types.d.ts.map +1 -0
  25. package/dist/agent/tools/types.js +16 -0
  26. package/dist/agent/tools/write.d.ts +3 -0
  27. package/dist/agent/tools/write.d.ts.map +1 -0
  28. package/dist/agent/tools/write.js +68 -0
  29. package/dist/api/account.d.ts +39 -0
  30. package/dist/api/account.d.ts.map +1 -0
  31. package/dist/api/account.js +39 -0
  32. package/dist/api/errors.d.ts +38 -0
  33. package/dist/api/errors.d.ts.map +1 -0
  34. package/dist/api/errors.js +149 -0
  35. package/dist/api/http.d.ts +63 -0
  36. package/dist/api/http.d.ts.map +1 -0
  37. package/dist/api/http.js +207 -0
  38. package/dist/api/models.d.ts +68 -0
  39. package/dist/api/models.d.ts.map +1 -0
  40. package/dist/api/models.js +91 -0
  41. package/dist/api/sse-parser.d.ts +72 -0
  42. package/dist/api/sse-parser.d.ts.map +1 -0
  43. package/dist/api/sse-parser.js +163 -0
  44. package/dist/commands/account.d.ts +4 -0
  45. package/dist/commands/account.d.ts.map +1 -0
  46. package/dist/commands/account.js +103 -0
  47. package/dist/commands/agent.d.ts +10 -0
  48. package/dist/commands/agent.d.ts.map +1 -0
  49. package/dist/commands/agent.js +268 -0
  50. package/dist/commands/chat.d.ts +13 -0
  51. package/dist/commands/chat.d.ts.map +1 -0
  52. package/dist/commands/chat.js +191 -0
  53. package/dist/commands/config.d.ts +15 -0
  54. package/dist/commands/config.d.ts.map +1 -0
  55. package/dist/commands/config.js +74 -0
  56. package/dist/commands/doctor.d.ts +15 -0
  57. package/dist/commands/doctor.d.ts.map +1 -0
  58. package/dist/commands/doctor.js +157 -0
  59. package/dist/commands/embed.d.ts +7 -0
  60. package/dist/commands/embed.d.ts.map +1 -0
  61. package/dist/commands/embed.js +105 -0
  62. package/dist/commands/models.d.ts +4 -0
  63. package/dist/commands/models.d.ts.map +1 -0
  64. package/dist/commands/models.js +151 -0
  65. package/dist/commands/runtime.d.ts +29 -0
  66. package/dist/commands/runtime.d.ts.map +1 -0
  67. package/dist/commands/runtime.js +56 -0
  68. package/dist/commands/speech.d.ts +7 -0
  69. package/dist/commands/speech.d.ts.map +1 -0
  70. package/dist/commands/speech.js +111 -0
  71. package/dist/config/config.d.ts +57 -0
  72. package/dist/config/config.d.ts.map +1 -0
  73. package/dist/config/config.js +129 -0
  74. package/dist/config/load.d.ts +20 -0
  75. package/dist/config/load.d.ts.map +1 -0
  76. package/dist/config/load.js +48 -0
  77. package/dist/config/paths.d.ts +11 -0
  78. package/dist/config/paths.d.ts.map +1 -0
  79. package/dist/config/paths.js +26 -0
  80. package/dist/filesystem/diff.d.ts +12 -0
  81. package/dist/filesystem/diff.d.ts.map +1 -0
  82. package/dist/filesystem/diff.js +109 -0
  83. package/dist/filesystem/sensitive-files.d.ts +19 -0
  84. package/dist/filesystem/sensitive-files.d.ts.map +1 -0
  85. package/dist/filesystem/sensitive-files.js +46 -0
  86. package/dist/filesystem/workspace.d.ts +92 -0
  87. package/dist/filesystem/workspace.d.ts.map +1 -0
  88. package/dist/filesystem/workspace.js +264 -0
  89. package/dist/index.d.ts +4 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +109 -0
  92. package/dist/output/exit-codes.d.ts +25 -0
  93. package/dist/output/exit-codes.d.ts.map +1 -0
  94. package/dist/output/exit-codes.js +23 -0
  95. package/dist/output/format.d.ts +28 -0
  96. package/dist/output/format.d.ts.map +1 -0
  97. package/dist/output/format.js +56 -0
  98. package/dist/output/money.d.ts +16 -0
  99. package/dist/output/money.d.ts.map +1 -0
  100. package/dist/output/money.js +35 -0
  101. package/dist/output/write-file.d.ts +11 -0
  102. package/dist/output/write-file.d.ts.map +1 -0
  103. package/dist/output/write-file.js +38 -0
  104. package/dist/security/redact.d.ts +16 -0
  105. package/dist/security/redact.d.ts.map +1 -0
  106. package/dist/security/redact.js +61 -0
  107. 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"}