chatccc 0.2.197 → 0.2.199
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/agent-prompts/cursor_specific.md +13 -13
- package/bin/cccagent.mjs +17 -17
- package/config.sample.json +27 -27
- package/package.json +2 -1
- package/src/__tests__/agent-reload-config-rpc.test.ts +99 -99
- package/src/__tests__/builtin-chat-session.test.ts +277 -277
- package/src/__tests__/builtin-cli-json.test.ts +39 -39
- package/src/__tests__/builtin-config.test.ts +33 -33
- package/src/__tests__/builtin-context.test.ts +163 -163
- package/src/__tests__/builtin-file-tools.test.ts +224 -224
- package/src/__tests__/builtin-session-select.test.ts +116 -116
- package/src/__tests__/builtin-sigint.test.ts +56 -56
- package/src/__tests__/cards.test.ts +109 -109
- package/src/__tests__/ccc-adapter.test.ts +114 -114
- package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
- package/src/__tests__/chatgpt-subscription.test.ts +135 -135
- package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
- package/src/__tests__/claude-raw-stream-log.test.ts +87 -87
- package/src/__tests__/codex-raw-stream-log.test.ts +163 -120
- package/src/__tests__/config-reload.test.ts +10 -10
- package/src/__tests__/config-sample.test.ts +18 -18
- package/src/__tests__/cursor-adapter.test.ts +114 -1
- package/src/__tests__/feishu-avatar.test.ts +40 -40
- package/src/__tests__/jsonl-stream.test.ts +79 -0
- package/src/__tests__/orchestrator.test.ts +200 -200
- package/src/__tests__/raw-stream-log.test.ts +106 -106
- package/src/__tests__/session.test.ts +40 -40
- package/src/__tests__/sim-platform.test.ts +12 -12
- package/src/__tests__/web-ui.test.ts +209 -209
- package/src/adapters/ccc-adapter.ts +121 -121
- package/src/adapters/claude-adapter.ts +603 -603
- package/src/adapters/codex-adapter.ts +380 -392
- package/src/adapters/cursor-adapter.ts +56 -30
- package/src/adapters/jsonl-stream.ts +157 -0
- package/src/adapters/raw-stream-log.ts +124 -124
- package/src/agent-reload-config-rpc.ts +34 -34
- package/src/builtin/cli.ts +473 -473
- package/src/builtin/context.ts +323 -323
- package/src/builtin/file-tools.ts +1072 -1072
- package/src/builtin/index.ts +404 -404
- package/src/builtin/session-select.ts +48 -48
- package/src/builtin/sigint.ts +50 -50
- package/src/cards.ts +195 -195
- package/src/chatgpt-subscription-rpc.ts +27 -27
- package/src/chatgpt-subscription.ts +299 -299
- package/src/chrome-devtools-guard.ts +318 -318
- package/src/config.ts +125 -125
- package/src/feishu-api.ts +49 -49
- package/src/orchestrator.ts +179 -179
- package/src/runtime-reload.ts +34 -34
- package/src/session.ts +141 -141
- package/src/web-ui.ts +205 -205
|
@@ -1,1072 +1,1072 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { createReadStream } from "node:fs";
|
|
4
|
-
import { copyFile, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
-
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
6
|
-
|
|
7
|
-
import { jsonSchema, tool, type ToolSet } from "ai";
|
|
8
|
-
|
|
9
|
-
import { killProcessTree } from "../adapters/proc-tree-kill.ts";
|
|
10
|
-
|
|
11
|
-
const MAX_READ_BYTES = 1024 * 1024;
|
|
12
|
-
const MAX_LIST_ENTRIES = 200;
|
|
13
|
-
const MAX_SEARCH_RESULTS = 100;
|
|
14
|
-
const MAX_SEARCH_BYTES = 256 * 1024;
|
|
15
|
-
const MAX_EDIT_BYTES = 2 * 1024 * 1024;
|
|
16
|
-
const MAX_CREATE_BYTES = 2 * 1024 * 1024;
|
|
17
|
-
const MAX_PATCH_BYTES = 512 * 1024;
|
|
18
|
-
const SEARCH_TIMEOUT_MS = 15_000;
|
|
19
|
-
const MAX_COMMAND_OUTPUT_BYTES = 256 * 1024;
|
|
20
|
-
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
21
|
-
const MAX_COMMAND_TIMEOUT_MS = 900_000;
|
|
22
|
-
|
|
23
|
-
export interface ReadFileInput {
|
|
24
|
-
path: string;
|
|
25
|
-
startLine?: number;
|
|
26
|
-
endLine?: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface ReadFileOutput {
|
|
30
|
-
path: string;
|
|
31
|
-
size: number;
|
|
32
|
-
sha256: string;
|
|
33
|
-
isBinary: boolean;
|
|
34
|
-
truncated: boolean;
|
|
35
|
-
startLine?: number;
|
|
36
|
-
endLine?: number;
|
|
37
|
-
totalLines?: number;
|
|
38
|
-
content: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface ListDirInput {
|
|
42
|
-
path?: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface ListDirEntry {
|
|
46
|
-
name: string;
|
|
47
|
-
path: string;
|
|
48
|
-
type: "file" | "directory" | "symlink" | "other";
|
|
49
|
-
size?: number;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface ListDirOutput {
|
|
53
|
-
path: string;
|
|
54
|
-
entries: ListDirEntry[];
|
|
55
|
-
truncated: boolean;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface SearchCodeInput {
|
|
59
|
-
query: string;
|
|
60
|
-
path?: string;
|
|
61
|
-
glob?: string;
|
|
62
|
-
maxResults?: number;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface SearchCodeMatch {
|
|
66
|
-
path: string;
|
|
67
|
-
line: number;
|
|
68
|
-
column: number;
|
|
69
|
-
text: string;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface SearchCodeOutput {
|
|
73
|
-
query: string;
|
|
74
|
-
path: string;
|
|
75
|
-
glob?: string;
|
|
76
|
-
matches: SearchCodeMatch[];
|
|
77
|
-
truncated: boolean;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface RunCommandInput {
|
|
81
|
-
command: string;
|
|
82
|
-
cwd?: string;
|
|
83
|
-
timeoutMs?: number;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface RunCommandOutput {
|
|
87
|
-
command: string;
|
|
88
|
-
cwd: string;
|
|
89
|
-
exitCode: number | null;
|
|
90
|
-
signal: string | null;
|
|
91
|
-
stdout: string;
|
|
92
|
-
stderr: string;
|
|
93
|
-
timedOut: boolean;
|
|
94
|
-
truncated: boolean;
|
|
95
|
-
durationMs: number;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface FileEdit {
|
|
99
|
-
oldText: string;
|
|
100
|
-
newText: string;
|
|
101
|
-
replaceAll?: boolean;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export interface EditFileInput {
|
|
105
|
-
path: string;
|
|
106
|
-
expectedSha256?: string;
|
|
107
|
-
edits: FileEdit[];
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export interface FileWriteOutput {
|
|
111
|
-
path: string;
|
|
112
|
-
beforeSha256?: string;
|
|
113
|
-
afterSha256?: string;
|
|
114
|
-
bytesWritten?: number;
|
|
115
|
-
changed: boolean;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export interface EditFileOutput extends FileWriteOutput {
|
|
119
|
-
editsApplied: number;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export interface CreateFileInput {
|
|
123
|
-
path: string;
|
|
124
|
-
content: string;
|
|
125
|
-
overwrite?: boolean;
|
|
126
|
-
expectedSha256?: string;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export interface DeleteFileInput {
|
|
130
|
-
path: string;
|
|
131
|
-
expectedSha256?: string;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export interface DeleteFileOutput {
|
|
135
|
-
path: string;
|
|
136
|
-
beforeSha256: string;
|
|
137
|
-
deleted: true;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export interface MoveFileInput {
|
|
141
|
-
sourcePath: string;
|
|
142
|
-
destinationPath: string;
|
|
143
|
-
overwrite?: boolean;
|
|
144
|
-
expectedSourceSha256?: string;
|
|
145
|
-
expectedDestinationSha256?: string;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export interface MoveFileOutput {
|
|
149
|
-
sourcePath: string;
|
|
150
|
-
destinationPath: string;
|
|
151
|
-
sourceSha256: string;
|
|
152
|
-
overwrittenDestinationSha256?: string;
|
|
153
|
-
moved: true;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export interface ApplyPatchInput {
|
|
157
|
-
patch: string;
|
|
158
|
-
expectedSha256ByPath?: Record<string, string>;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export interface ApplyPatchFileChange {
|
|
162
|
-
path: string;
|
|
163
|
-
action: "create" | "edit" | "delete";
|
|
164
|
-
beforeSha256?: string;
|
|
165
|
-
afterSha256?: string;
|
|
166
|
-
bytesWritten?: number;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export interface ApplyPatchOutput {
|
|
170
|
-
changedFiles: ApplyPatchFileChange[];
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function resolveToolPath(cwd: string, value: string | undefined): string {
|
|
174
|
-
const raw = value?.trim();
|
|
175
|
-
if (!raw) return resolve(cwd);
|
|
176
|
-
return isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function toPositiveInt(value: number | undefined): number | undefined {
|
|
180
|
-
if (value === undefined) return undefined;
|
|
181
|
-
if (!Number.isFinite(value)) return undefined;
|
|
182
|
-
const rounded = Math.floor(value);
|
|
183
|
-
return rounded > 0 ? rounded : undefined;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function isBinaryBuffer(buffer: Buffer): boolean {
|
|
187
|
-
const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
|
|
188
|
-
return sample.includes(0);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
async function pathExists(path: string): Promise<boolean> {
|
|
192
|
-
try {
|
|
193
|
-
await stat(path);
|
|
194
|
-
return true;
|
|
195
|
-
} catch {
|
|
196
|
-
return false;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function sha256(buffer: Buffer | string): string {
|
|
201
|
-
return createHash("sha256").update(buffer).digest("hex");
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async function sha256File(path: string): Promise<string> {
|
|
205
|
-
const hash = createHash("sha256");
|
|
206
|
-
for await (const chunk of createReadStream(path)) {
|
|
207
|
-
hash.update(chunk);
|
|
208
|
-
}
|
|
209
|
-
return hash.digest("hex");
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function assertExpectedSha256(path: string, actual: string, expected: string | undefined): void {
|
|
213
|
-
if (expected && expected !== actual) {
|
|
214
|
-
throw new Error(`SHA-256 mismatch for ${path}: expected ${expected}, got ${actual}`);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function assertTextSize(path: string, text: string, maxBytes: number): void {
|
|
219
|
-
const bytes = Buffer.byteLength(text, "utf8");
|
|
220
|
-
if (bytes > maxBytes) {
|
|
221
|
-
throw new Error(`Content for ${path} is too large: ${bytes} bytes, max ${maxBytes}`);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function readEditableTextFile(path: string): Promise<{ text: string; buffer: Buffer; sha: string }> {
|
|
226
|
-
const info = await stat(path);
|
|
227
|
-
if (info.isDirectory()) {
|
|
228
|
-
throw new Error(`Path is a directory: ${path}`);
|
|
229
|
-
}
|
|
230
|
-
if (info.size > MAX_EDIT_BYTES) {
|
|
231
|
-
throw new Error(`File is too large to edit: ${path} (${info.size} bytes, max ${MAX_EDIT_BYTES})`);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const buffer = await readFile(path);
|
|
235
|
-
if (isBinaryBuffer(buffer)) {
|
|
236
|
-
throw new Error(`Refusing to edit binary file: ${path}`);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return {
|
|
240
|
-
text: buffer.toString("utf8"),
|
|
241
|
-
buffer,
|
|
242
|
-
sha: sha256(buffer),
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
async function atomicWriteTextFile(path: string, text: string): Promise<void> {
|
|
247
|
-
await mkdir(dirname(path), { recursive: true });
|
|
248
|
-
const tempPath = resolve(
|
|
249
|
-
dirname(path),
|
|
250
|
-
`.chatccc-${basename(path)}-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`,
|
|
251
|
-
);
|
|
252
|
-
try {
|
|
253
|
-
await writeFile(tempPath, text, "utf8");
|
|
254
|
-
await rename(tempPath, path);
|
|
255
|
-
} catch (err) {
|
|
256
|
-
try {
|
|
257
|
-
await unlink(tempPath);
|
|
258
|
-
} catch {
|
|
259
|
-
// Best-effort cleanup.
|
|
260
|
-
}
|
|
261
|
-
throw err;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function countOccurrences(text: string, needle: string): number {
|
|
266
|
-
if (!needle) return 0;
|
|
267
|
-
let count = 0;
|
|
268
|
-
let index = 0;
|
|
269
|
-
while (true) {
|
|
270
|
-
const found = text.indexOf(needle, index);
|
|
271
|
-
if (found === -1) return count;
|
|
272
|
-
count++;
|
|
273
|
-
index = found + needle.length;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function replaceAllLiteral(text: string, oldText: string, newText: string): string {
|
|
278
|
-
return text.split(oldText).join(newText);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function detectEol(text: string): "\r\n" | "\n" {
|
|
282
|
-
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function normalizeCommandTimeoutMs(value: number | undefined): number {
|
|
286
|
-
if (value === undefined || !Number.isFinite(value)) return DEFAULT_COMMAND_TIMEOUT_MS;
|
|
287
|
-
return Math.min(Math.max(Math.floor(value), 1_000), MAX_COMMAND_TIMEOUT_MS);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function appendLimitedOutput(
|
|
291
|
-
target: { chunks: string[]; bytes: number; truncated: boolean },
|
|
292
|
-
chunk: Buffer,
|
|
293
|
-
): void {
|
|
294
|
-
const remaining = MAX_COMMAND_OUTPUT_BYTES - target.bytes;
|
|
295
|
-
if (remaining <= 0) {
|
|
296
|
-
target.truncated = true;
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (chunk.byteLength <= remaining) {
|
|
301
|
-
target.chunks.push(chunk.toString("utf8"));
|
|
302
|
-
target.bytes += chunk.byteLength;
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
target.chunks.push(chunk.subarray(0, remaining).toString("utf8"));
|
|
307
|
-
target.bytes += remaining;
|
|
308
|
-
target.truncated = true;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function splitPatchPath(value: string): string | null {
|
|
312
|
-
const token = value.trim().split(/\s+/)[0];
|
|
313
|
-
if (!token || token === "/dev/null") return null;
|
|
314
|
-
if ((token.startsWith("a/") || token.startsWith("b/")) && token.length > 2) {
|
|
315
|
-
return token.slice(2);
|
|
316
|
-
}
|
|
317
|
-
return token;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
interface ParsedPatchLine {
|
|
321
|
-
kind: "context" | "add" | "remove";
|
|
322
|
-
text: string;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
interface ParsedPatchHunk {
|
|
326
|
-
oldStart: number;
|
|
327
|
-
lines: ParsedPatchLine[];
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
interface ParsedPatchFile {
|
|
331
|
-
oldPath: string | null;
|
|
332
|
-
newPath: string | null;
|
|
333
|
-
hunks: ParsedPatchHunk[];
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function parseHunkHeader(line: string): number {
|
|
337
|
-
const match = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/.exec(line);
|
|
338
|
-
if (!match) {
|
|
339
|
-
throw new Error(`Invalid hunk header: ${line}`);
|
|
340
|
-
}
|
|
341
|
-
return Number(match[1]);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function parseUnifiedPatch(patch: string): ParsedPatchFile[] {
|
|
345
|
-
assertTextSize("patch", patch, MAX_PATCH_BYTES);
|
|
346
|
-
const normalizedPatch = patch.replace(/\r\n/g, "\n");
|
|
347
|
-
const lines = (normalizedPatch.endsWith("\n") ? normalizedPatch.slice(0, -1) : normalizedPatch).split("\n");
|
|
348
|
-
const files: ParsedPatchFile[] = [];
|
|
349
|
-
let current: ParsedPatchFile | null = null;
|
|
350
|
-
let currentHunk: ParsedPatchHunk | null = null;
|
|
351
|
-
|
|
352
|
-
const finishFile = () => {
|
|
353
|
-
if (!current) return;
|
|
354
|
-
if (!current.oldPath && !current.newPath) {
|
|
355
|
-
current = null;
|
|
356
|
-
currentHunk = null;
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
files.push(current);
|
|
360
|
-
current = null;
|
|
361
|
-
currentHunk = null;
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
for (const line of lines) {
|
|
365
|
-
if (line.startsWith("diff --git ")) {
|
|
366
|
-
finishFile();
|
|
367
|
-
current = { oldPath: null, newPath: null, hunks: [] };
|
|
368
|
-
continue;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
if (line.startsWith("--- ")) {
|
|
372
|
-
if (current?.hunks.length) finishFile();
|
|
373
|
-
current ??= { oldPath: null, newPath: null, hunks: [] };
|
|
374
|
-
current.oldPath = splitPatchPath(line.slice(4));
|
|
375
|
-
currentHunk = null;
|
|
376
|
-
continue;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (line.startsWith("+++ ")) {
|
|
380
|
-
current ??= { oldPath: null, newPath: null, hunks: [] };
|
|
381
|
-
current.newPath = splitPatchPath(line.slice(4));
|
|
382
|
-
currentHunk = null;
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
if (line.startsWith("@@ ")) {
|
|
387
|
-
if (!current) throw new Error(`Hunk without file header: ${line}`);
|
|
388
|
-
currentHunk = { oldStart: parseHunkHeader(line), lines: [] };
|
|
389
|
-
current.hunks.push(currentHunk);
|
|
390
|
-
continue;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (line === "\") continue;
|
|
394
|
-
if (!currentHunk) continue;
|
|
395
|
-
|
|
396
|
-
if (line.startsWith(" ")) {
|
|
397
|
-
currentHunk.lines.push({ kind: "context", text: line.slice(1) });
|
|
398
|
-
} else if (line.startsWith("+")) {
|
|
399
|
-
currentHunk.lines.push({ kind: "add", text: line.slice(1) });
|
|
400
|
-
} else if (line.startsWith("-")) {
|
|
401
|
-
currentHunk.lines.push({ kind: "remove", text: line.slice(1) });
|
|
402
|
-
} else {
|
|
403
|
-
throw new Error(`Invalid patch line: ${line}`);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
finishFile();
|
|
408
|
-
if (files.length === 0) throw new Error("Patch does not contain any file changes");
|
|
409
|
-
return files;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function splitContentLines(text: string): string[] {
|
|
413
|
-
if (text.length === 0) return [];
|
|
414
|
-
return text.replace(/\r\n/g, "\n").split("\n");
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function applyParsedHunks(path: string, text: string, hunks: ParsedPatchHunk[]): string {
|
|
418
|
-
const eol = detectEol(text);
|
|
419
|
-
const original = splitContentLines(text);
|
|
420
|
-
const output: string[] = [];
|
|
421
|
-
let oldIndex = 0;
|
|
422
|
-
|
|
423
|
-
for (const hunk of hunks) {
|
|
424
|
-
const targetIndex = Math.max(0, hunk.oldStart - 1);
|
|
425
|
-
if (targetIndex < oldIndex) {
|
|
426
|
-
throw new Error(`Overlapping hunk in patch for ${path}`);
|
|
427
|
-
}
|
|
428
|
-
output.push(...original.slice(oldIndex, targetIndex));
|
|
429
|
-
oldIndex = targetIndex;
|
|
430
|
-
|
|
431
|
-
for (const line of hunk.lines) {
|
|
432
|
-
if (line.kind === "add") {
|
|
433
|
-
output.push(line.text);
|
|
434
|
-
continue;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if (oldIndex >= original.length || original[oldIndex] !== line.text) {
|
|
438
|
-
throw new Error(`Patch context mismatch in ${path} near line ${oldIndex + 1}`);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
if (line.kind === "context") {
|
|
442
|
-
output.push(original[oldIndex]);
|
|
443
|
-
}
|
|
444
|
-
oldIndex++;
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
output.push(...original.slice(oldIndex));
|
|
449
|
-
return output.join(eol);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
export async function readFileForTool(cwd: string, input: ReadFileInput): Promise<ReadFileOutput> {
|
|
453
|
-
const filePath = resolveToolPath(cwd, input.path);
|
|
454
|
-
const info = await stat(filePath);
|
|
455
|
-
if (info.isDirectory()) {
|
|
456
|
-
throw new Error(`Path is a directory: ${filePath}`);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const bytesToRead = Math.min(info.size, MAX_READ_BYTES);
|
|
460
|
-
const buffer = Buffer.alloc(bytesToRead);
|
|
461
|
-
const handle = await open(filePath, "r");
|
|
462
|
-
try {
|
|
463
|
-
await handle.read(buffer, 0, bytesToRead, 0);
|
|
464
|
-
} finally {
|
|
465
|
-
await handle.close();
|
|
466
|
-
}
|
|
467
|
-
const fileSha256 = await sha256File(filePath);
|
|
468
|
-
const isBinary = isBinaryBuffer(buffer);
|
|
469
|
-
if (isBinary) {
|
|
470
|
-
return {
|
|
471
|
-
path: filePath,
|
|
472
|
-
size: info.size,
|
|
473
|
-
sha256: fileSha256,
|
|
474
|
-
isBinary: true,
|
|
475
|
-
truncated: info.size > bytesToRead,
|
|
476
|
-
content: "",
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const text = buffer.toString("utf8");
|
|
481
|
-
const lines = text.split(/\r?\n/);
|
|
482
|
-
const totalLines = lines.length;
|
|
483
|
-
const startLine = toPositiveInt(input.startLine) ?? 1;
|
|
484
|
-
const endLine = toPositiveInt(input.endLine) ?? totalLines;
|
|
485
|
-
const normalizedEnd = Math.max(startLine, Math.min(endLine, totalLines));
|
|
486
|
-
const content = lines.slice(startLine - 1, normalizedEnd).join("\n");
|
|
487
|
-
|
|
488
|
-
return {
|
|
489
|
-
path: filePath,
|
|
490
|
-
size: info.size,
|
|
491
|
-
sha256: fileSha256,
|
|
492
|
-
isBinary: false,
|
|
493
|
-
truncated: info.size > bytesToRead || normalizedEnd < totalLines || startLine > 1,
|
|
494
|
-
startLine,
|
|
495
|
-
endLine: normalizedEnd,
|
|
496
|
-
totalLines,
|
|
497
|
-
content,
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
export async function listDirForTool(cwd: string, input: ListDirInput = {}): Promise<ListDirOutput> {
|
|
502
|
-
const dirPath = resolveToolPath(cwd, input.path);
|
|
503
|
-
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
504
|
-
const selected = entries.slice(0, MAX_LIST_ENTRIES);
|
|
505
|
-
const result: ListDirEntry[] = [];
|
|
506
|
-
|
|
507
|
-
for (const entry of selected) {
|
|
508
|
-
const entryPath = resolve(dirPath, entry.name);
|
|
509
|
-
let size: number | undefined;
|
|
510
|
-
if (entry.isFile()) {
|
|
511
|
-
try {
|
|
512
|
-
size = (await stat(entryPath)).size;
|
|
513
|
-
} catch {
|
|
514
|
-
size = undefined;
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
result.push({
|
|
518
|
-
name: entry.name,
|
|
519
|
-
path: entryPath,
|
|
520
|
-
type: entry.isDirectory()
|
|
521
|
-
? "directory"
|
|
522
|
-
: entry.isFile()
|
|
523
|
-
? "file"
|
|
524
|
-
: entry.isSymbolicLink()
|
|
525
|
-
? "symlink"
|
|
526
|
-
: "other",
|
|
527
|
-
...(size !== undefined ? { size } : {}),
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
return {
|
|
532
|
-
path: dirPath,
|
|
533
|
-
entries: result,
|
|
534
|
-
truncated: entries.length > selected.length,
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function parseRgLine(line: string): SearchCodeMatch | null {
|
|
539
|
-
const match = /^(.*?):(\d+):(\d+):(.*)$/.exec(line);
|
|
540
|
-
if (!match) return null;
|
|
541
|
-
return {
|
|
542
|
-
path: match[1],
|
|
543
|
-
line: Number(match[2]),
|
|
544
|
-
column: Number(match[3]),
|
|
545
|
-
text: match[4],
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
export async function searchCodeForTool(
|
|
550
|
-
cwd: string,
|
|
551
|
-
input: SearchCodeInput,
|
|
552
|
-
signal?: AbortSignal,
|
|
553
|
-
): Promise<SearchCodeOutput> {
|
|
554
|
-
const query = input.query?.trim();
|
|
555
|
-
if (!query) throw new Error("query is required");
|
|
556
|
-
|
|
557
|
-
const searchPath = resolveToolPath(cwd, input.path);
|
|
558
|
-
const maxResults = Math.min(toPositiveInt(input.maxResults) ?? 50, MAX_SEARCH_RESULTS);
|
|
559
|
-
const args = [
|
|
560
|
-
"--line-number",
|
|
561
|
-
"--column",
|
|
562
|
-
"--no-heading",
|
|
563
|
-
"--color",
|
|
564
|
-
"never",
|
|
565
|
-
"--max-count",
|
|
566
|
-
String(maxResults),
|
|
567
|
-
];
|
|
568
|
-
if (input.glob?.trim()) {
|
|
569
|
-
args.push("--glob", input.glob.trim());
|
|
570
|
-
}
|
|
571
|
-
args.push("--", query, searchPath);
|
|
572
|
-
|
|
573
|
-
const output = await new Promise<{ stdout: string; stderr: string; truncated: boolean }>((resolvePromise, reject) => {
|
|
574
|
-
const child = spawn("rg", args, {
|
|
575
|
-
cwd,
|
|
576
|
-
shell: false,
|
|
577
|
-
windowsHide: true,
|
|
578
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
let stdout = "";
|
|
582
|
-
let stderr = "";
|
|
583
|
-
let truncated = false;
|
|
584
|
-
const timeout = setTimeout(() => {
|
|
585
|
-
child.kill();
|
|
586
|
-
reject(new Error(`search_code timed out after ${SEARCH_TIMEOUT_MS}ms`));
|
|
587
|
-
}, SEARCH_TIMEOUT_MS);
|
|
588
|
-
|
|
589
|
-
const abort = () => {
|
|
590
|
-
child.kill();
|
|
591
|
-
reject(new Error("search_code aborted"));
|
|
592
|
-
};
|
|
593
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
594
|
-
|
|
595
|
-
child.stdout?.on("data", (chunk: Buffer) => {
|
|
596
|
-
if (stdout.length >= MAX_SEARCH_BYTES) {
|
|
597
|
-
truncated = true;
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
stdout += chunk.toString("utf8");
|
|
601
|
-
if (stdout.length > MAX_SEARCH_BYTES) {
|
|
602
|
-
stdout = stdout.slice(0, MAX_SEARCH_BYTES);
|
|
603
|
-
truncated = true;
|
|
604
|
-
}
|
|
605
|
-
});
|
|
606
|
-
child.stderr?.on("data", (chunk: Buffer) => {
|
|
607
|
-
stderr += chunk.toString("utf8");
|
|
608
|
-
});
|
|
609
|
-
child.on("error", (err) => {
|
|
610
|
-
clearTimeout(timeout);
|
|
611
|
-
signal?.removeEventListener("abort", abort);
|
|
612
|
-
reject(err);
|
|
613
|
-
});
|
|
614
|
-
child.on("close", (code) => {
|
|
615
|
-
clearTimeout(timeout);
|
|
616
|
-
signal?.removeEventListener("abort", abort);
|
|
617
|
-
if (code !== 0 && code !== 1) {
|
|
618
|
-
reject(new Error(stderr.trim() || `rg exited with code ${code}`));
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
resolvePromise({ stdout, stderr, truncated });
|
|
622
|
-
});
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
const matches = output.stdout
|
|
626
|
-
.split(/\r?\n/)
|
|
627
|
-
.filter(Boolean)
|
|
628
|
-
.map(parseRgLine)
|
|
629
|
-
.filter((match): match is SearchCodeMatch => !!match)
|
|
630
|
-
.slice(0, maxResults);
|
|
631
|
-
|
|
632
|
-
return {
|
|
633
|
-
query,
|
|
634
|
-
path: searchPath,
|
|
635
|
-
...(input.glob?.trim() ? { glob: input.glob.trim() } : {}),
|
|
636
|
-
matches,
|
|
637
|
-
truncated: output.truncated || matches.length >= maxResults,
|
|
638
|
-
};
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
export async function runCommandForTool(
|
|
642
|
-
cwd: string,
|
|
643
|
-
input: RunCommandInput,
|
|
644
|
-
abortSignal?: AbortSignal,
|
|
645
|
-
): Promise<RunCommandOutput> {
|
|
646
|
-
const command = input.command?.trim();
|
|
647
|
-
if (!command) throw new Error("command is required");
|
|
648
|
-
|
|
649
|
-
const commandCwd = resolveToolPath(cwd, input.cwd);
|
|
650
|
-
const cwdInfo = await stat(commandCwd);
|
|
651
|
-
if (!cwdInfo.isDirectory()) {
|
|
652
|
-
throw new Error(`cwd is not a directory: ${commandCwd}`);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
const timeoutMs = normalizeCommandTimeoutMs(input.timeoutMs);
|
|
656
|
-
const startedAt = Date.now();
|
|
657
|
-
const stdout = { chunks: [] as string[], bytes: 0, truncated: false };
|
|
658
|
-
const stderr = { chunks: [] as string[], bytes: 0, truncated: false };
|
|
659
|
-
|
|
660
|
-
return new Promise<RunCommandOutput>((resolvePromise, reject) => {
|
|
661
|
-
let settled = false;
|
|
662
|
-
let timedOut = false;
|
|
663
|
-
let timeout: NodeJS.Timeout;
|
|
664
|
-
let fallbackTimer: NodeJS.Timeout | undefined;
|
|
665
|
-
|
|
666
|
-
const child = spawn(command, {
|
|
667
|
-
cwd: commandCwd,
|
|
668
|
-
shell: true,
|
|
669
|
-
windowsHide: true,
|
|
670
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
671
|
-
detached: process.platform !== "win32",
|
|
672
|
-
});
|
|
673
|
-
|
|
674
|
-
const cleanup = () => {
|
|
675
|
-
clearTimeout(timeout);
|
|
676
|
-
if (fallbackTimer) clearTimeout(fallbackTimer);
|
|
677
|
-
abortSignal?.removeEventListener("abort", abort);
|
|
678
|
-
};
|
|
679
|
-
|
|
680
|
-
const finish = (exitCode: number | null, signal: NodeJS.Signals | string | null) => {
|
|
681
|
-
if (settled) return;
|
|
682
|
-
settled = true;
|
|
683
|
-
cleanup();
|
|
684
|
-
resolvePromise({
|
|
685
|
-
command,
|
|
686
|
-
cwd: commandCwd,
|
|
687
|
-
exitCode,
|
|
688
|
-
signal,
|
|
689
|
-
stdout: stdout.chunks.join(""),
|
|
690
|
-
stderr: stderr.chunks.join(""),
|
|
691
|
-
timedOut,
|
|
692
|
-
truncated: stdout.truncated || stderr.truncated,
|
|
693
|
-
durationMs: Date.now() - startedAt,
|
|
694
|
-
});
|
|
695
|
-
};
|
|
696
|
-
|
|
697
|
-
const requestKill = (reason: NodeJS.Signals | "timeout" | "abort") => {
|
|
698
|
-
void killProcessTree(child.pid);
|
|
699
|
-
fallbackTimer = setTimeout(() => {
|
|
700
|
-
child.stdout?.destroy();
|
|
701
|
-
child.stderr?.destroy();
|
|
702
|
-
finish(null, reason === "timeout" ? "SIGTERM" : reason);
|
|
703
|
-
}, 5_000);
|
|
704
|
-
fallbackTimer.unref?.();
|
|
705
|
-
};
|
|
706
|
-
|
|
707
|
-
timeout = setTimeout(() => {
|
|
708
|
-
timedOut = true;
|
|
709
|
-
requestKill("timeout");
|
|
710
|
-
}, timeoutMs);
|
|
711
|
-
|
|
712
|
-
const abort = () => {
|
|
713
|
-
requestKill("abort");
|
|
714
|
-
};
|
|
715
|
-
abortSignal?.addEventListener("abort", abort, { once: true });
|
|
716
|
-
|
|
717
|
-
child.stdout?.on("data", (chunk: Buffer) => {
|
|
718
|
-
appendLimitedOutput(stdout, chunk);
|
|
719
|
-
});
|
|
720
|
-
child.stderr?.on("data", (chunk: Buffer) => {
|
|
721
|
-
appendLimitedOutput(stderr, chunk);
|
|
722
|
-
});
|
|
723
|
-
child.once("error", (err) => {
|
|
724
|
-
if (settled) return;
|
|
725
|
-
settled = true;
|
|
726
|
-
cleanup();
|
|
727
|
-
reject(err);
|
|
728
|
-
});
|
|
729
|
-
child.once("close", (code, signal) => {
|
|
730
|
-
finish(code, signal);
|
|
731
|
-
});
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
export async function editFileForTool(cwd: string, input: EditFileInput): Promise<EditFileOutput> {
|
|
736
|
-
if (!Array.isArray(input.edits) || input.edits.length === 0) {
|
|
737
|
-
throw new Error("edits must contain at least one replacement");
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
const filePath = resolveToolPath(cwd, input.path);
|
|
741
|
-
const before = await readEditableTextFile(filePath);
|
|
742
|
-
assertExpectedSha256(filePath, before.sha, input.expectedSha256);
|
|
743
|
-
|
|
744
|
-
let text = before.text;
|
|
745
|
-
let editsApplied = 0;
|
|
746
|
-
for (const [index, edit] of input.edits.entries()) {
|
|
747
|
-
if (!edit.oldText) {
|
|
748
|
-
throw new Error(`edit ${index + 1} oldText must not be empty`);
|
|
749
|
-
}
|
|
750
|
-
const count = countOccurrences(text, edit.oldText);
|
|
751
|
-
if (count === 0) {
|
|
752
|
-
throw new Error(`edit ${index + 1} oldText was not found in ${filePath}`);
|
|
753
|
-
}
|
|
754
|
-
if (count > 1 && !edit.replaceAll) {
|
|
755
|
-
throw new Error(`edit ${index + 1} oldText matched ${count} times in ${filePath}; set replaceAll=true or provide more context`);
|
|
756
|
-
}
|
|
757
|
-
text = edit.replaceAll
|
|
758
|
-
? replaceAllLiteral(text, edit.oldText, edit.newText)
|
|
759
|
-
: text.replace(edit.oldText, edit.newText);
|
|
760
|
-
editsApplied += edit.replaceAll ? count : 1;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
assertTextSize(filePath, text, MAX_EDIT_BYTES);
|
|
764
|
-
const afterSha = sha256(text);
|
|
765
|
-
const changed = afterSha !== before.sha;
|
|
766
|
-
if (changed) {
|
|
767
|
-
await atomicWriteTextFile(filePath, text);
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
return {
|
|
771
|
-
path: filePath,
|
|
772
|
-
beforeSha256: before.sha,
|
|
773
|
-
afterSha256: afterSha,
|
|
774
|
-
bytesWritten: changed ? Buffer.byteLength(text, "utf8") : 0,
|
|
775
|
-
changed,
|
|
776
|
-
editsApplied,
|
|
777
|
-
};
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
export async function createFileForTool(cwd: string, input: CreateFileInput): Promise<FileWriteOutput> {
|
|
781
|
-
const filePath = resolveToolPath(cwd, input.path);
|
|
782
|
-
assertTextSize(filePath, input.content, MAX_CREATE_BYTES);
|
|
783
|
-
|
|
784
|
-
let beforeSha: string | undefined;
|
|
785
|
-
if (await pathExists(filePath)) {
|
|
786
|
-
const existing = await readEditableTextFile(filePath);
|
|
787
|
-
beforeSha = existing.sha;
|
|
788
|
-
assertExpectedSha256(filePath, existing.sha, input.expectedSha256);
|
|
789
|
-
if (!input.overwrite) {
|
|
790
|
-
throw new Error(`File already exists: ${filePath}`);
|
|
791
|
-
}
|
|
792
|
-
} else if (input.expectedSha256) {
|
|
793
|
-
throw new Error(`Cannot check expectedSha256 because file does not exist: ${filePath}`);
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
const afterSha = sha256(input.content);
|
|
797
|
-
const changed = beforeSha !== afterSha;
|
|
798
|
-
if (changed) {
|
|
799
|
-
await atomicWriteTextFile(filePath, input.content);
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
return {
|
|
803
|
-
path: filePath,
|
|
804
|
-
...(beforeSha ? { beforeSha256: beforeSha } : {}),
|
|
805
|
-
afterSha256: afterSha,
|
|
806
|
-
bytesWritten: changed ? Buffer.byteLength(input.content, "utf8") : 0,
|
|
807
|
-
changed,
|
|
808
|
-
};
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
export async function deleteFileForTool(cwd: string, input: DeleteFileInput): Promise<DeleteFileOutput> {
|
|
812
|
-
const filePath = resolveToolPath(cwd, input.path);
|
|
813
|
-
const before = await readEditableTextFile(filePath);
|
|
814
|
-
assertExpectedSha256(filePath, before.sha, input.expectedSha256);
|
|
815
|
-
await unlink(filePath);
|
|
816
|
-
return {
|
|
817
|
-
path: filePath,
|
|
818
|
-
beforeSha256: before.sha,
|
|
819
|
-
deleted: true,
|
|
820
|
-
};
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
export async function moveFileForTool(cwd: string, input: MoveFileInput): Promise<MoveFileOutput> {
|
|
824
|
-
const sourcePath = resolveToolPath(cwd, input.sourcePath);
|
|
825
|
-
const destinationPath = resolveToolPath(cwd, input.destinationPath);
|
|
826
|
-
const source = await readEditableTextFile(sourcePath);
|
|
827
|
-
assertExpectedSha256(sourcePath, source.sha, input.expectedSourceSha256);
|
|
828
|
-
|
|
829
|
-
let destinationSha: string | undefined;
|
|
830
|
-
if (await pathExists(destinationPath)) {
|
|
831
|
-
const destination = await readEditableTextFile(destinationPath);
|
|
832
|
-
destinationSha = destination.sha;
|
|
833
|
-
assertExpectedSha256(destinationPath, destination.sha, input.expectedDestinationSha256);
|
|
834
|
-
if (!input.overwrite) {
|
|
835
|
-
throw new Error(`Destination already exists: ${destinationPath}`);
|
|
836
|
-
}
|
|
837
|
-
} else if (input.expectedDestinationSha256) {
|
|
838
|
-
throw new Error(`Cannot check expectedDestinationSha256 because destination does not exist: ${destinationPath}`);
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
await mkdir(dirname(destinationPath), { recursive: true });
|
|
842
|
-
try {
|
|
843
|
-
await rename(sourcePath, destinationPath);
|
|
844
|
-
} catch (err) {
|
|
845
|
-
if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err;
|
|
846
|
-
await copyFile(sourcePath, destinationPath);
|
|
847
|
-
await unlink(sourcePath);
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
return {
|
|
851
|
-
sourcePath,
|
|
852
|
-
destinationPath,
|
|
853
|
-
sourceSha256: source.sha,
|
|
854
|
-
...(destinationSha ? { overwrittenDestinationSha256: destinationSha } : {}),
|
|
855
|
-
moved: true,
|
|
856
|
-
};
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
function expectedPatchHash(
|
|
860
|
-
input: ApplyPatchInput,
|
|
861
|
-
absolutePath: string,
|
|
862
|
-
patchPath: string,
|
|
863
|
-
): string | undefined {
|
|
864
|
-
return input.expectedSha256ByPath?.[absolutePath] ?? input.expectedSha256ByPath?.[patchPath];
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
export async function applyPatchForTool(cwd: string, input: ApplyPatchInput): Promise<ApplyPatchOutput> {
|
|
868
|
-
const files = parseUnifiedPatch(input.patch);
|
|
869
|
-
const changedFiles: ApplyPatchFileChange[] = [];
|
|
870
|
-
|
|
871
|
-
for (const file of files) {
|
|
872
|
-
const patchPath = file.newPath ?? file.oldPath;
|
|
873
|
-
if (!patchPath) throw new Error("Patch file is missing both old and new paths");
|
|
874
|
-
const targetPath = resolveToolPath(cwd, patchPath);
|
|
875
|
-
const action: ApplyPatchFileChange["action"] =
|
|
876
|
-
file.oldPath === null ? "create" :
|
|
877
|
-
file.newPath === null ? "delete" :
|
|
878
|
-
"edit";
|
|
879
|
-
|
|
880
|
-
if (action === "create") {
|
|
881
|
-
if (await pathExists(targetPath)) {
|
|
882
|
-
throw new Error(`Patch target already exists: ${targetPath}`);
|
|
883
|
-
}
|
|
884
|
-
const text = applyParsedHunks(targetPath, "", file.hunks);
|
|
885
|
-
assertTextSize(targetPath, text, MAX_CREATE_BYTES);
|
|
886
|
-
await atomicWriteTextFile(targetPath, text);
|
|
887
|
-
changedFiles.push({
|
|
888
|
-
path: targetPath,
|
|
889
|
-
action,
|
|
890
|
-
afterSha256: sha256(text),
|
|
891
|
-
bytesWritten: Buffer.byteLength(text, "utf8"),
|
|
892
|
-
});
|
|
893
|
-
continue;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
const before = await readEditableTextFile(targetPath);
|
|
897
|
-
assertExpectedSha256(targetPath, before.sha, expectedPatchHash(input, targetPath, patchPath));
|
|
898
|
-
const text = applyParsedHunks(targetPath, before.text, file.hunks);
|
|
899
|
-
|
|
900
|
-
if (action === "delete") {
|
|
901
|
-
await unlink(targetPath);
|
|
902
|
-
changedFiles.push({
|
|
903
|
-
path: targetPath,
|
|
904
|
-
action,
|
|
905
|
-
beforeSha256: before.sha,
|
|
906
|
-
});
|
|
907
|
-
continue;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
assertTextSize(targetPath, text, MAX_EDIT_BYTES);
|
|
911
|
-
const afterSha = sha256(text);
|
|
912
|
-
if (afterSha !== before.sha) {
|
|
913
|
-
await atomicWriteTextFile(targetPath, text);
|
|
914
|
-
}
|
|
915
|
-
changedFiles.push({
|
|
916
|
-
path: targetPath,
|
|
917
|
-
action,
|
|
918
|
-
beforeSha256: before.sha,
|
|
919
|
-
afterSha256: afterSha,
|
|
920
|
-
bytesWritten: afterSha !== before.sha ? Buffer.byteLength(text, "utf8") : 0,
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
return { changedFiles };
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
export function createBuiltinFileTools(cwd: string): ToolSet {
|
|
928
|
-
return {
|
|
929
|
-
read_file: tool<ReadFileInput, ReadFileOutput>({
|
|
930
|
-
description: "Read a UTF-8 text file from the local filesystem. Use line ranges for large files.",
|
|
931
|
-
inputSchema: jsonSchema<ReadFileInput>({
|
|
932
|
-
type: "object",
|
|
933
|
-
additionalProperties: false,
|
|
934
|
-
properties: {
|
|
935
|
-
path: { type: "string", description: "Absolute path or path relative to the session cwd." },
|
|
936
|
-
startLine: { type: "number", description: "Optional 1-based first line to return." },
|
|
937
|
-
endLine: { type: "number", description: "Optional 1-based last line to return." },
|
|
938
|
-
},
|
|
939
|
-
required: ["path"],
|
|
940
|
-
}),
|
|
941
|
-
execute: (input) => readFileForTool(cwd, input),
|
|
942
|
-
}),
|
|
943
|
-
list_dir: tool<ListDirInput, ListDirOutput>({
|
|
944
|
-
description: "List files in a local directory.",
|
|
945
|
-
inputSchema: jsonSchema<ListDirInput>({
|
|
946
|
-
type: "object",
|
|
947
|
-
additionalProperties: false,
|
|
948
|
-
properties: {
|
|
949
|
-
path: { type: "string", description: "Directory path. Defaults to the session cwd." },
|
|
950
|
-
},
|
|
951
|
-
}),
|
|
952
|
-
execute: (input) => listDirForTool(cwd, input),
|
|
953
|
-
}),
|
|
954
|
-
search_code: tool<SearchCodeInput, SearchCodeOutput>({
|
|
955
|
-
description: "Search local files with ripgrep without invoking a shell.",
|
|
956
|
-
inputSchema: jsonSchema<SearchCodeInput>({
|
|
957
|
-
type: "object",
|
|
958
|
-
additionalProperties: false,
|
|
959
|
-
properties: {
|
|
960
|
-
query: { type: "string", description: "Text or regex query passed to ripgrep." },
|
|
961
|
-
path: { type: "string", description: "File or directory to search. Defaults to the session cwd." },
|
|
962
|
-
glob: { type: "string", description: "Optional ripgrep glob filter, for example **/*.ts." },
|
|
963
|
-
maxResults: { type: "number", description: "Maximum result lines, capped internally." },
|
|
964
|
-
},
|
|
965
|
-
required: ["query"],
|
|
966
|
-
}),
|
|
967
|
-
execute: (input, options) => searchCodeForTool(cwd, input, options.abortSignal),
|
|
968
|
-
}),
|
|
969
|
-
run_command: tool<RunCommandInput, RunCommandOutput>({
|
|
970
|
-
description: "Run a non-interactive shell command in the local workspace. Use for tests, git, and package scripts. Returns stdout/stderr and exitCode; non-zero exit codes are not tool errors.",
|
|
971
|
-
inputSchema: jsonSchema<RunCommandInput>({
|
|
972
|
-
type: "object",
|
|
973
|
-
additionalProperties: false,
|
|
974
|
-
properties: {
|
|
975
|
-
command: { type: "string", description: "Command line to run in the platform shell." },
|
|
976
|
-
cwd: { type: "string", description: "Optional working directory. Defaults to the session cwd." },
|
|
977
|
-
timeoutMs: { type: "number", description: `Optional timeout in milliseconds, capped at ${MAX_COMMAND_TIMEOUT_MS}.` },
|
|
978
|
-
},
|
|
979
|
-
required: ["command"],
|
|
980
|
-
}),
|
|
981
|
-
execute: (input, options) => runCommandForTool(cwd, input, options.abortSignal),
|
|
982
|
-
}),
|
|
983
|
-
edit_file: tool<EditFileInput, EditFileOutput>({
|
|
984
|
-
description: "Edit an existing UTF-8 text file by applying exact oldText -> newText replacements. Uses optional SHA-256 precondition to avoid overwriting concurrent edits.",
|
|
985
|
-
inputSchema: jsonSchema<EditFileInput>({
|
|
986
|
-
type: "object",
|
|
987
|
-
additionalProperties: false,
|
|
988
|
-
properties: {
|
|
989
|
-
path: { type: "string", description: "Absolute path or path relative to the session cwd." },
|
|
990
|
-
expectedSha256: { type: "string", description: "Optional SHA-256 hash of the current file content." },
|
|
991
|
-
edits: {
|
|
992
|
-
type: "array",
|
|
993
|
-
minItems: 1,
|
|
994
|
-
items: {
|
|
995
|
-
type: "object",
|
|
996
|
-
additionalProperties: false,
|
|
997
|
-
properties: {
|
|
998
|
-
oldText: { type: "string", description: "Exact text to replace. Include enough context to make it unique." },
|
|
999
|
-
newText: { type: "string", description: "Replacement text." },
|
|
1000
|
-
replaceAll: { type: "boolean", description: "Replace every occurrence when oldText appears multiple times." },
|
|
1001
|
-
},
|
|
1002
|
-
required: ["oldText", "newText"],
|
|
1003
|
-
},
|
|
1004
|
-
},
|
|
1005
|
-
},
|
|
1006
|
-
required: ["path", "edits"],
|
|
1007
|
-
}),
|
|
1008
|
-
execute: (input) => editFileForTool(cwd, input),
|
|
1009
|
-
}),
|
|
1010
|
-
create_file: tool<CreateFileInput, FileWriteOutput>({
|
|
1011
|
-
description: "Create a UTF-8 text file, or overwrite an existing one when overwrite=true.",
|
|
1012
|
-
inputSchema: jsonSchema<CreateFileInput>({
|
|
1013
|
-
type: "object",
|
|
1014
|
-
additionalProperties: false,
|
|
1015
|
-
properties: {
|
|
1016
|
-
path: { type: "string", description: "Absolute path or path relative to the session cwd." },
|
|
1017
|
-
content: { type: "string", description: "Complete file content to write." },
|
|
1018
|
-
overwrite: { type: "boolean", description: "Allow replacing an existing file." },
|
|
1019
|
-
expectedSha256: { type: "string", description: "Optional SHA-256 hash required when overwriting an existing file." },
|
|
1020
|
-
},
|
|
1021
|
-
required: ["path", "content"],
|
|
1022
|
-
}),
|
|
1023
|
-
execute: (input) => createFileForTool(cwd, input),
|
|
1024
|
-
}),
|
|
1025
|
-
delete_file: tool<DeleteFileInput, DeleteFileOutput>({
|
|
1026
|
-
description: "Delete an existing text file. Use expectedSha256 to avoid deleting a file that changed after reading.",
|
|
1027
|
-
inputSchema: jsonSchema<DeleteFileInput>({
|
|
1028
|
-
type: "object",
|
|
1029
|
-
additionalProperties: false,
|
|
1030
|
-
properties: {
|
|
1031
|
-
path: { type: "string", description: "Absolute path or path relative to the session cwd." },
|
|
1032
|
-
expectedSha256: { type: "string", description: "Optional SHA-256 hash of the file that must be deleted." },
|
|
1033
|
-
},
|
|
1034
|
-
required: ["path"],
|
|
1035
|
-
}),
|
|
1036
|
-
execute: (input) => deleteFileForTool(cwd, input),
|
|
1037
|
-
}),
|
|
1038
|
-
move_file: tool<MoveFileInput, MoveFileOutput>({
|
|
1039
|
-
description: "Move or rename an existing text file. Can overwrite an existing destination only when overwrite=true.",
|
|
1040
|
-
inputSchema: jsonSchema<MoveFileInput>({
|
|
1041
|
-
type: "object",
|
|
1042
|
-
additionalProperties: false,
|
|
1043
|
-
properties: {
|
|
1044
|
-
sourcePath: { type: "string", description: "Existing source file path." },
|
|
1045
|
-
destinationPath: { type: "string", description: "Destination file path." },
|
|
1046
|
-
overwrite: { type: "boolean", description: "Allow replacing an existing destination file." },
|
|
1047
|
-
expectedSourceSha256: { type: "string", description: "Optional SHA-256 hash of the source file." },
|
|
1048
|
-
expectedDestinationSha256: { type: "string", description: "Optional SHA-256 hash of the destination file when overwriting." },
|
|
1049
|
-
},
|
|
1050
|
-
required: ["sourcePath", "destinationPath"],
|
|
1051
|
-
}),
|
|
1052
|
-
execute: (input) => moveFileForTool(cwd, input),
|
|
1053
|
-
}),
|
|
1054
|
-
apply_patch: tool<ApplyPatchInput, ApplyPatchOutput>({
|
|
1055
|
-
description: "Apply a unified diff patch to one or more UTF-8 text files. Prefer edit_file for small targeted edits.",
|
|
1056
|
-
inputSchema: jsonSchema<ApplyPatchInput>({
|
|
1057
|
-
type: "object",
|
|
1058
|
-
additionalProperties: false,
|
|
1059
|
-
properties: {
|
|
1060
|
-
patch: { type: "string", description: "Unified diff patch text." },
|
|
1061
|
-
expectedSha256ByPath: {
|
|
1062
|
-
type: "object",
|
|
1063
|
-
description: "Optional map of patch path or absolute path to expected SHA-256 before applying.",
|
|
1064
|
-
additionalProperties: { type: "string" },
|
|
1065
|
-
},
|
|
1066
|
-
},
|
|
1067
|
-
required: ["patch"],
|
|
1068
|
-
}),
|
|
1069
|
-
execute: (input) => applyPatchForTool(cwd, input),
|
|
1070
|
-
}),
|
|
1071
|
-
};
|
|
1072
|
-
}
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { copyFile, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { jsonSchema, tool, type ToolSet } from "ai";
|
|
8
|
+
|
|
9
|
+
import { killProcessTree } from "../adapters/proc-tree-kill.ts";
|
|
10
|
+
|
|
11
|
+
const MAX_READ_BYTES = 1024 * 1024;
|
|
12
|
+
const MAX_LIST_ENTRIES = 200;
|
|
13
|
+
const MAX_SEARCH_RESULTS = 100;
|
|
14
|
+
const MAX_SEARCH_BYTES = 256 * 1024;
|
|
15
|
+
const MAX_EDIT_BYTES = 2 * 1024 * 1024;
|
|
16
|
+
const MAX_CREATE_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
const MAX_PATCH_BYTES = 512 * 1024;
|
|
18
|
+
const SEARCH_TIMEOUT_MS = 15_000;
|
|
19
|
+
const MAX_COMMAND_OUTPUT_BYTES = 256 * 1024;
|
|
20
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
21
|
+
const MAX_COMMAND_TIMEOUT_MS = 900_000;
|
|
22
|
+
|
|
23
|
+
export interface ReadFileInput {
|
|
24
|
+
path: string;
|
|
25
|
+
startLine?: number;
|
|
26
|
+
endLine?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ReadFileOutput {
|
|
30
|
+
path: string;
|
|
31
|
+
size: number;
|
|
32
|
+
sha256: string;
|
|
33
|
+
isBinary: boolean;
|
|
34
|
+
truncated: boolean;
|
|
35
|
+
startLine?: number;
|
|
36
|
+
endLine?: number;
|
|
37
|
+
totalLines?: number;
|
|
38
|
+
content: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ListDirInput {
|
|
42
|
+
path?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ListDirEntry {
|
|
46
|
+
name: string;
|
|
47
|
+
path: string;
|
|
48
|
+
type: "file" | "directory" | "symlink" | "other";
|
|
49
|
+
size?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ListDirOutput {
|
|
53
|
+
path: string;
|
|
54
|
+
entries: ListDirEntry[];
|
|
55
|
+
truncated: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SearchCodeInput {
|
|
59
|
+
query: string;
|
|
60
|
+
path?: string;
|
|
61
|
+
glob?: string;
|
|
62
|
+
maxResults?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SearchCodeMatch {
|
|
66
|
+
path: string;
|
|
67
|
+
line: number;
|
|
68
|
+
column: number;
|
|
69
|
+
text: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SearchCodeOutput {
|
|
73
|
+
query: string;
|
|
74
|
+
path: string;
|
|
75
|
+
glob?: string;
|
|
76
|
+
matches: SearchCodeMatch[];
|
|
77
|
+
truncated: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RunCommandInput {
|
|
81
|
+
command: string;
|
|
82
|
+
cwd?: string;
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RunCommandOutput {
|
|
87
|
+
command: string;
|
|
88
|
+
cwd: string;
|
|
89
|
+
exitCode: number | null;
|
|
90
|
+
signal: string | null;
|
|
91
|
+
stdout: string;
|
|
92
|
+
stderr: string;
|
|
93
|
+
timedOut: boolean;
|
|
94
|
+
truncated: boolean;
|
|
95
|
+
durationMs: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface FileEdit {
|
|
99
|
+
oldText: string;
|
|
100
|
+
newText: string;
|
|
101
|
+
replaceAll?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface EditFileInput {
|
|
105
|
+
path: string;
|
|
106
|
+
expectedSha256?: string;
|
|
107
|
+
edits: FileEdit[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FileWriteOutput {
|
|
111
|
+
path: string;
|
|
112
|
+
beforeSha256?: string;
|
|
113
|
+
afterSha256?: string;
|
|
114
|
+
bytesWritten?: number;
|
|
115
|
+
changed: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface EditFileOutput extends FileWriteOutput {
|
|
119
|
+
editsApplied: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface CreateFileInput {
|
|
123
|
+
path: string;
|
|
124
|
+
content: string;
|
|
125
|
+
overwrite?: boolean;
|
|
126
|
+
expectedSha256?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface DeleteFileInput {
|
|
130
|
+
path: string;
|
|
131
|
+
expectedSha256?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface DeleteFileOutput {
|
|
135
|
+
path: string;
|
|
136
|
+
beforeSha256: string;
|
|
137
|
+
deleted: true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface MoveFileInput {
|
|
141
|
+
sourcePath: string;
|
|
142
|
+
destinationPath: string;
|
|
143
|
+
overwrite?: boolean;
|
|
144
|
+
expectedSourceSha256?: string;
|
|
145
|
+
expectedDestinationSha256?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface MoveFileOutput {
|
|
149
|
+
sourcePath: string;
|
|
150
|
+
destinationPath: string;
|
|
151
|
+
sourceSha256: string;
|
|
152
|
+
overwrittenDestinationSha256?: string;
|
|
153
|
+
moved: true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ApplyPatchInput {
|
|
157
|
+
patch: string;
|
|
158
|
+
expectedSha256ByPath?: Record<string, string>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ApplyPatchFileChange {
|
|
162
|
+
path: string;
|
|
163
|
+
action: "create" | "edit" | "delete";
|
|
164
|
+
beforeSha256?: string;
|
|
165
|
+
afterSha256?: string;
|
|
166
|
+
bytesWritten?: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface ApplyPatchOutput {
|
|
170
|
+
changedFiles: ApplyPatchFileChange[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveToolPath(cwd: string, value: string | undefined): string {
|
|
174
|
+
const raw = value?.trim();
|
|
175
|
+
if (!raw) return resolve(cwd);
|
|
176
|
+
return isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function toPositiveInt(value: number | undefined): number | undefined {
|
|
180
|
+
if (value === undefined) return undefined;
|
|
181
|
+
if (!Number.isFinite(value)) return undefined;
|
|
182
|
+
const rounded = Math.floor(value);
|
|
183
|
+
return rounded > 0 ? rounded : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isBinaryBuffer(buffer: Buffer): boolean {
|
|
187
|
+
const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
|
|
188
|
+
return sample.includes(0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
192
|
+
try {
|
|
193
|
+
await stat(path);
|
|
194
|
+
return true;
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sha256(buffer: Buffer | string): string {
|
|
201
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function sha256File(path: string): Promise<string> {
|
|
205
|
+
const hash = createHash("sha256");
|
|
206
|
+
for await (const chunk of createReadStream(path)) {
|
|
207
|
+
hash.update(chunk);
|
|
208
|
+
}
|
|
209
|
+
return hash.digest("hex");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function assertExpectedSha256(path: string, actual: string, expected: string | undefined): void {
|
|
213
|
+
if (expected && expected !== actual) {
|
|
214
|
+
throw new Error(`SHA-256 mismatch for ${path}: expected ${expected}, got ${actual}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function assertTextSize(path: string, text: string, maxBytes: number): void {
|
|
219
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
220
|
+
if (bytes > maxBytes) {
|
|
221
|
+
throw new Error(`Content for ${path} is too large: ${bytes} bytes, max ${maxBytes}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function readEditableTextFile(path: string): Promise<{ text: string; buffer: Buffer; sha: string }> {
|
|
226
|
+
const info = await stat(path);
|
|
227
|
+
if (info.isDirectory()) {
|
|
228
|
+
throw new Error(`Path is a directory: ${path}`);
|
|
229
|
+
}
|
|
230
|
+
if (info.size > MAX_EDIT_BYTES) {
|
|
231
|
+
throw new Error(`File is too large to edit: ${path} (${info.size} bytes, max ${MAX_EDIT_BYTES})`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const buffer = await readFile(path);
|
|
235
|
+
if (isBinaryBuffer(buffer)) {
|
|
236
|
+
throw new Error(`Refusing to edit binary file: ${path}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
text: buffer.toString("utf8"),
|
|
241
|
+
buffer,
|
|
242
|
+
sha: sha256(buffer),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function atomicWriteTextFile(path: string, text: string): Promise<void> {
|
|
247
|
+
await mkdir(dirname(path), { recursive: true });
|
|
248
|
+
const tempPath = resolve(
|
|
249
|
+
dirname(path),
|
|
250
|
+
`.chatccc-${basename(path)}-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`,
|
|
251
|
+
);
|
|
252
|
+
try {
|
|
253
|
+
await writeFile(tempPath, text, "utf8");
|
|
254
|
+
await rename(tempPath, path);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
try {
|
|
257
|
+
await unlink(tempPath);
|
|
258
|
+
} catch {
|
|
259
|
+
// Best-effort cleanup.
|
|
260
|
+
}
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function countOccurrences(text: string, needle: string): number {
|
|
266
|
+
if (!needle) return 0;
|
|
267
|
+
let count = 0;
|
|
268
|
+
let index = 0;
|
|
269
|
+
while (true) {
|
|
270
|
+
const found = text.indexOf(needle, index);
|
|
271
|
+
if (found === -1) return count;
|
|
272
|
+
count++;
|
|
273
|
+
index = found + needle.length;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function replaceAllLiteral(text: string, oldText: string, newText: string): string {
|
|
278
|
+
return text.split(oldText).join(newText);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function detectEol(text: string): "\r\n" | "\n" {
|
|
282
|
+
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function normalizeCommandTimeoutMs(value: number | undefined): number {
|
|
286
|
+
if (value === undefined || !Number.isFinite(value)) return DEFAULT_COMMAND_TIMEOUT_MS;
|
|
287
|
+
return Math.min(Math.max(Math.floor(value), 1_000), MAX_COMMAND_TIMEOUT_MS);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function appendLimitedOutput(
|
|
291
|
+
target: { chunks: string[]; bytes: number; truncated: boolean },
|
|
292
|
+
chunk: Buffer,
|
|
293
|
+
): void {
|
|
294
|
+
const remaining = MAX_COMMAND_OUTPUT_BYTES - target.bytes;
|
|
295
|
+
if (remaining <= 0) {
|
|
296
|
+
target.truncated = true;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (chunk.byteLength <= remaining) {
|
|
301
|
+
target.chunks.push(chunk.toString("utf8"));
|
|
302
|
+
target.bytes += chunk.byteLength;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
target.chunks.push(chunk.subarray(0, remaining).toString("utf8"));
|
|
307
|
+
target.bytes += remaining;
|
|
308
|
+
target.truncated = true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function splitPatchPath(value: string): string | null {
|
|
312
|
+
const token = value.trim().split(/\s+/)[0];
|
|
313
|
+
if (!token || token === "/dev/null") return null;
|
|
314
|
+
if ((token.startsWith("a/") || token.startsWith("b/")) && token.length > 2) {
|
|
315
|
+
return token.slice(2);
|
|
316
|
+
}
|
|
317
|
+
return token;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface ParsedPatchLine {
|
|
321
|
+
kind: "context" | "add" | "remove";
|
|
322
|
+
text: string;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
interface ParsedPatchHunk {
|
|
326
|
+
oldStart: number;
|
|
327
|
+
lines: ParsedPatchLine[];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
interface ParsedPatchFile {
|
|
331
|
+
oldPath: string | null;
|
|
332
|
+
newPath: string | null;
|
|
333
|
+
hunks: ParsedPatchHunk[];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseHunkHeader(line: string): number {
|
|
337
|
+
const match = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/.exec(line);
|
|
338
|
+
if (!match) {
|
|
339
|
+
throw new Error(`Invalid hunk header: ${line}`);
|
|
340
|
+
}
|
|
341
|
+
return Number(match[1]);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function parseUnifiedPatch(patch: string): ParsedPatchFile[] {
|
|
345
|
+
assertTextSize("patch", patch, MAX_PATCH_BYTES);
|
|
346
|
+
const normalizedPatch = patch.replace(/\r\n/g, "\n");
|
|
347
|
+
const lines = (normalizedPatch.endsWith("\n") ? normalizedPatch.slice(0, -1) : normalizedPatch).split("\n");
|
|
348
|
+
const files: ParsedPatchFile[] = [];
|
|
349
|
+
let current: ParsedPatchFile | null = null;
|
|
350
|
+
let currentHunk: ParsedPatchHunk | null = null;
|
|
351
|
+
|
|
352
|
+
const finishFile = () => {
|
|
353
|
+
if (!current) return;
|
|
354
|
+
if (!current.oldPath && !current.newPath) {
|
|
355
|
+
current = null;
|
|
356
|
+
currentHunk = null;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
files.push(current);
|
|
360
|
+
current = null;
|
|
361
|
+
currentHunk = null;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
for (const line of lines) {
|
|
365
|
+
if (line.startsWith("diff --git ")) {
|
|
366
|
+
finishFile();
|
|
367
|
+
current = { oldPath: null, newPath: null, hunks: [] };
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (line.startsWith("--- ")) {
|
|
372
|
+
if (current?.hunks.length) finishFile();
|
|
373
|
+
current ??= { oldPath: null, newPath: null, hunks: [] };
|
|
374
|
+
current.oldPath = splitPatchPath(line.slice(4));
|
|
375
|
+
currentHunk = null;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (line.startsWith("+++ ")) {
|
|
380
|
+
current ??= { oldPath: null, newPath: null, hunks: [] };
|
|
381
|
+
current.newPath = splitPatchPath(line.slice(4));
|
|
382
|
+
currentHunk = null;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (line.startsWith("@@ ")) {
|
|
387
|
+
if (!current) throw new Error(`Hunk without file header: ${line}`);
|
|
388
|
+
currentHunk = { oldStart: parseHunkHeader(line), lines: [] };
|
|
389
|
+
current.hunks.push(currentHunk);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (line === "\") continue;
|
|
394
|
+
if (!currentHunk) continue;
|
|
395
|
+
|
|
396
|
+
if (line.startsWith(" ")) {
|
|
397
|
+
currentHunk.lines.push({ kind: "context", text: line.slice(1) });
|
|
398
|
+
} else if (line.startsWith("+")) {
|
|
399
|
+
currentHunk.lines.push({ kind: "add", text: line.slice(1) });
|
|
400
|
+
} else if (line.startsWith("-")) {
|
|
401
|
+
currentHunk.lines.push({ kind: "remove", text: line.slice(1) });
|
|
402
|
+
} else {
|
|
403
|
+
throw new Error(`Invalid patch line: ${line}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
finishFile();
|
|
408
|
+
if (files.length === 0) throw new Error("Patch does not contain any file changes");
|
|
409
|
+
return files;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function splitContentLines(text: string): string[] {
|
|
413
|
+
if (text.length === 0) return [];
|
|
414
|
+
return text.replace(/\r\n/g, "\n").split("\n");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function applyParsedHunks(path: string, text: string, hunks: ParsedPatchHunk[]): string {
|
|
418
|
+
const eol = detectEol(text);
|
|
419
|
+
const original = splitContentLines(text);
|
|
420
|
+
const output: string[] = [];
|
|
421
|
+
let oldIndex = 0;
|
|
422
|
+
|
|
423
|
+
for (const hunk of hunks) {
|
|
424
|
+
const targetIndex = Math.max(0, hunk.oldStart - 1);
|
|
425
|
+
if (targetIndex < oldIndex) {
|
|
426
|
+
throw new Error(`Overlapping hunk in patch for ${path}`);
|
|
427
|
+
}
|
|
428
|
+
output.push(...original.slice(oldIndex, targetIndex));
|
|
429
|
+
oldIndex = targetIndex;
|
|
430
|
+
|
|
431
|
+
for (const line of hunk.lines) {
|
|
432
|
+
if (line.kind === "add") {
|
|
433
|
+
output.push(line.text);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (oldIndex >= original.length || original[oldIndex] !== line.text) {
|
|
438
|
+
throw new Error(`Patch context mismatch in ${path} near line ${oldIndex + 1}`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (line.kind === "context") {
|
|
442
|
+
output.push(original[oldIndex]);
|
|
443
|
+
}
|
|
444
|
+
oldIndex++;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
output.push(...original.slice(oldIndex));
|
|
449
|
+
return output.join(eol);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export async function readFileForTool(cwd: string, input: ReadFileInput): Promise<ReadFileOutput> {
|
|
453
|
+
const filePath = resolveToolPath(cwd, input.path);
|
|
454
|
+
const info = await stat(filePath);
|
|
455
|
+
if (info.isDirectory()) {
|
|
456
|
+
throw new Error(`Path is a directory: ${filePath}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const bytesToRead = Math.min(info.size, MAX_READ_BYTES);
|
|
460
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
461
|
+
const handle = await open(filePath, "r");
|
|
462
|
+
try {
|
|
463
|
+
await handle.read(buffer, 0, bytesToRead, 0);
|
|
464
|
+
} finally {
|
|
465
|
+
await handle.close();
|
|
466
|
+
}
|
|
467
|
+
const fileSha256 = await sha256File(filePath);
|
|
468
|
+
const isBinary = isBinaryBuffer(buffer);
|
|
469
|
+
if (isBinary) {
|
|
470
|
+
return {
|
|
471
|
+
path: filePath,
|
|
472
|
+
size: info.size,
|
|
473
|
+
sha256: fileSha256,
|
|
474
|
+
isBinary: true,
|
|
475
|
+
truncated: info.size > bytesToRead,
|
|
476
|
+
content: "",
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const text = buffer.toString("utf8");
|
|
481
|
+
const lines = text.split(/\r?\n/);
|
|
482
|
+
const totalLines = lines.length;
|
|
483
|
+
const startLine = toPositiveInt(input.startLine) ?? 1;
|
|
484
|
+
const endLine = toPositiveInt(input.endLine) ?? totalLines;
|
|
485
|
+
const normalizedEnd = Math.max(startLine, Math.min(endLine, totalLines));
|
|
486
|
+
const content = lines.slice(startLine - 1, normalizedEnd).join("\n");
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
path: filePath,
|
|
490
|
+
size: info.size,
|
|
491
|
+
sha256: fileSha256,
|
|
492
|
+
isBinary: false,
|
|
493
|
+
truncated: info.size > bytesToRead || normalizedEnd < totalLines || startLine > 1,
|
|
494
|
+
startLine,
|
|
495
|
+
endLine: normalizedEnd,
|
|
496
|
+
totalLines,
|
|
497
|
+
content,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export async function listDirForTool(cwd: string, input: ListDirInput = {}): Promise<ListDirOutput> {
|
|
502
|
+
const dirPath = resolveToolPath(cwd, input.path);
|
|
503
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
504
|
+
const selected = entries.slice(0, MAX_LIST_ENTRIES);
|
|
505
|
+
const result: ListDirEntry[] = [];
|
|
506
|
+
|
|
507
|
+
for (const entry of selected) {
|
|
508
|
+
const entryPath = resolve(dirPath, entry.name);
|
|
509
|
+
let size: number | undefined;
|
|
510
|
+
if (entry.isFile()) {
|
|
511
|
+
try {
|
|
512
|
+
size = (await stat(entryPath)).size;
|
|
513
|
+
} catch {
|
|
514
|
+
size = undefined;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
result.push({
|
|
518
|
+
name: entry.name,
|
|
519
|
+
path: entryPath,
|
|
520
|
+
type: entry.isDirectory()
|
|
521
|
+
? "directory"
|
|
522
|
+
: entry.isFile()
|
|
523
|
+
? "file"
|
|
524
|
+
: entry.isSymbolicLink()
|
|
525
|
+
? "symlink"
|
|
526
|
+
: "other",
|
|
527
|
+
...(size !== undefined ? { size } : {}),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
path: dirPath,
|
|
533
|
+
entries: result,
|
|
534
|
+
truncated: entries.length > selected.length,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function parseRgLine(line: string): SearchCodeMatch | null {
|
|
539
|
+
const match = /^(.*?):(\d+):(\d+):(.*)$/.exec(line);
|
|
540
|
+
if (!match) return null;
|
|
541
|
+
return {
|
|
542
|
+
path: match[1],
|
|
543
|
+
line: Number(match[2]),
|
|
544
|
+
column: Number(match[3]),
|
|
545
|
+
text: match[4],
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export async function searchCodeForTool(
|
|
550
|
+
cwd: string,
|
|
551
|
+
input: SearchCodeInput,
|
|
552
|
+
signal?: AbortSignal,
|
|
553
|
+
): Promise<SearchCodeOutput> {
|
|
554
|
+
const query = input.query?.trim();
|
|
555
|
+
if (!query) throw new Error("query is required");
|
|
556
|
+
|
|
557
|
+
const searchPath = resolveToolPath(cwd, input.path);
|
|
558
|
+
const maxResults = Math.min(toPositiveInt(input.maxResults) ?? 50, MAX_SEARCH_RESULTS);
|
|
559
|
+
const args = [
|
|
560
|
+
"--line-number",
|
|
561
|
+
"--column",
|
|
562
|
+
"--no-heading",
|
|
563
|
+
"--color",
|
|
564
|
+
"never",
|
|
565
|
+
"--max-count",
|
|
566
|
+
String(maxResults),
|
|
567
|
+
];
|
|
568
|
+
if (input.glob?.trim()) {
|
|
569
|
+
args.push("--glob", input.glob.trim());
|
|
570
|
+
}
|
|
571
|
+
args.push("--", query, searchPath);
|
|
572
|
+
|
|
573
|
+
const output = await new Promise<{ stdout: string; stderr: string; truncated: boolean }>((resolvePromise, reject) => {
|
|
574
|
+
const child = spawn("rg", args, {
|
|
575
|
+
cwd,
|
|
576
|
+
shell: false,
|
|
577
|
+
windowsHide: true,
|
|
578
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
let stdout = "";
|
|
582
|
+
let stderr = "";
|
|
583
|
+
let truncated = false;
|
|
584
|
+
const timeout = setTimeout(() => {
|
|
585
|
+
child.kill();
|
|
586
|
+
reject(new Error(`search_code timed out after ${SEARCH_TIMEOUT_MS}ms`));
|
|
587
|
+
}, SEARCH_TIMEOUT_MS);
|
|
588
|
+
|
|
589
|
+
const abort = () => {
|
|
590
|
+
child.kill();
|
|
591
|
+
reject(new Error("search_code aborted"));
|
|
592
|
+
};
|
|
593
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
594
|
+
|
|
595
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
596
|
+
if (stdout.length >= MAX_SEARCH_BYTES) {
|
|
597
|
+
truncated = true;
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
stdout += chunk.toString("utf8");
|
|
601
|
+
if (stdout.length > MAX_SEARCH_BYTES) {
|
|
602
|
+
stdout = stdout.slice(0, MAX_SEARCH_BYTES);
|
|
603
|
+
truncated = true;
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
607
|
+
stderr += chunk.toString("utf8");
|
|
608
|
+
});
|
|
609
|
+
child.on("error", (err) => {
|
|
610
|
+
clearTimeout(timeout);
|
|
611
|
+
signal?.removeEventListener("abort", abort);
|
|
612
|
+
reject(err);
|
|
613
|
+
});
|
|
614
|
+
child.on("close", (code) => {
|
|
615
|
+
clearTimeout(timeout);
|
|
616
|
+
signal?.removeEventListener("abort", abort);
|
|
617
|
+
if (code !== 0 && code !== 1) {
|
|
618
|
+
reject(new Error(stderr.trim() || `rg exited with code ${code}`));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
resolvePromise({ stdout, stderr, truncated });
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const matches = output.stdout
|
|
626
|
+
.split(/\r?\n/)
|
|
627
|
+
.filter(Boolean)
|
|
628
|
+
.map(parseRgLine)
|
|
629
|
+
.filter((match): match is SearchCodeMatch => !!match)
|
|
630
|
+
.slice(0, maxResults);
|
|
631
|
+
|
|
632
|
+
return {
|
|
633
|
+
query,
|
|
634
|
+
path: searchPath,
|
|
635
|
+
...(input.glob?.trim() ? { glob: input.glob.trim() } : {}),
|
|
636
|
+
matches,
|
|
637
|
+
truncated: output.truncated || matches.length >= maxResults,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export async function runCommandForTool(
|
|
642
|
+
cwd: string,
|
|
643
|
+
input: RunCommandInput,
|
|
644
|
+
abortSignal?: AbortSignal,
|
|
645
|
+
): Promise<RunCommandOutput> {
|
|
646
|
+
const command = input.command?.trim();
|
|
647
|
+
if (!command) throw new Error("command is required");
|
|
648
|
+
|
|
649
|
+
const commandCwd = resolveToolPath(cwd, input.cwd);
|
|
650
|
+
const cwdInfo = await stat(commandCwd);
|
|
651
|
+
if (!cwdInfo.isDirectory()) {
|
|
652
|
+
throw new Error(`cwd is not a directory: ${commandCwd}`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const timeoutMs = normalizeCommandTimeoutMs(input.timeoutMs);
|
|
656
|
+
const startedAt = Date.now();
|
|
657
|
+
const stdout = { chunks: [] as string[], bytes: 0, truncated: false };
|
|
658
|
+
const stderr = { chunks: [] as string[], bytes: 0, truncated: false };
|
|
659
|
+
|
|
660
|
+
return new Promise<RunCommandOutput>((resolvePromise, reject) => {
|
|
661
|
+
let settled = false;
|
|
662
|
+
let timedOut = false;
|
|
663
|
+
let timeout: NodeJS.Timeout;
|
|
664
|
+
let fallbackTimer: NodeJS.Timeout | undefined;
|
|
665
|
+
|
|
666
|
+
const child = spawn(command, {
|
|
667
|
+
cwd: commandCwd,
|
|
668
|
+
shell: true,
|
|
669
|
+
windowsHide: true,
|
|
670
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
671
|
+
detached: process.platform !== "win32",
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const cleanup = () => {
|
|
675
|
+
clearTimeout(timeout);
|
|
676
|
+
if (fallbackTimer) clearTimeout(fallbackTimer);
|
|
677
|
+
abortSignal?.removeEventListener("abort", abort);
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const finish = (exitCode: number | null, signal: NodeJS.Signals | string | null) => {
|
|
681
|
+
if (settled) return;
|
|
682
|
+
settled = true;
|
|
683
|
+
cleanup();
|
|
684
|
+
resolvePromise({
|
|
685
|
+
command,
|
|
686
|
+
cwd: commandCwd,
|
|
687
|
+
exitCode,
|
|
688
|
+
signal,
|
|
689
|
+
stdout: stdout.chunks.join(""),
|
|
690
|
+
stderr: stderr.chunks.join(""),
|
|
691
|
+
timedOut,
|
|
692
|
+
truncated: stdout.truncated || stderr.truncated,
|
|
693
|
+
durationMs: Date.now() - startedAt,
|
|
694
|
+
});
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const requestKill = (reason: NodeJS.Signals | "timeout" | "abort") => {
|
|
698
|
+
void killProcessTree(child.pid);
|
|
699
|
+
fallbackTimer = setTimeout(() => {
|
|
700
|
+
child.stdout?.destroy();
|
|
701
|
+
child.stderr?.destroy();
|
|
702
|
+
finish(null, reason === "timeout" ? "SIGTERM" : reason);
|
|
703
|
+
}, 5_000);
|
|
704
|
+
fallbackTimer.unref?.();
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
timeout = setTimeout(() => {
|
|
708
|
+
timedOut = true;
|
|
709
|
+
requestKill("timeout");
|
|
710
|
+
}, timeoutMs);
|
|
711
|
+
|
|
712
|
+
const abort = () => {
|
|
713
|
+
requestKill("abort");
|
|
714
|
+
};
|
|
715
|
+
abortSignal?.addEventListener("abort", abort, { once: true });
|
|
716
|
+
|
|
717
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
718
|
+
appendLimitedOutput(stdout, chunk);
|
|
719
|
+
});
|
|
720
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
721
|
+
appendLimitedOutput(stderr, chunk);
|
|
722
|
+
});
|
|
723
|
+
child.once("error", (err) => {
|
|
724
|
+
if (settled) return;
|
|
725
|
+
settled = true;
|
|
726
|
+
cleanup();
|
|
727
|
+
reject(err);
|
|
728
|
+
});
|
|
729
|
+
child.once("close", (code, signal) => {
|
|
730
|
+
finish(code, signal);
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export async function editFileForTool(cwd: string, input: EditFileInput): Promise<EditFileOutput> {
|
|
736
|
+
if (!Array.isArray(input.edits) || input.edits.length === 0) {
|
|
737
|
+
throw new Error("edits must contain at least one replacement");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const filePath = resolveToolPath(cwd, input.path);
|
|
741
|
+
const before = await readEditableTextFile(filePath);
|
|
742
|
+
assertExpectedSha256(filePath, before.sha, input.expectedSha256);
|
|
743
|
+
|
|
744
|
+
let text = before.text;
|
|
745
|
+
let editsApplied = 0;
|
|
746
|
+
for (const [index, edit] of input.edits.entries()) {
|
|
747
|
+
if (!edit.oldText) {
|
|
748
|
+
throw new Error(`edit ${index + 1} oldText must not be empty`);
|
|
749
|
+
}
|
|
750
|
+
const count = countOccurrences(text, edit.oldText);
|
|
751
|
+
if (count === 0) {
|
|
752
|
+
throw new Error(`edit ${index + 1} oldText was not found in ${filePath}`);
|
|
753
|
+
}
|
|
754
|
+
if (count > 1 && !edit.replaceAll) {
|
|
755
|
+
throw new Error(`edit ${index + 1} oldText matched ${count} times in ${filePath}; set replaceAll=true or provide more context`);
|
|
756
|
+
}
|
|
757
|
+
text = edit.replaceAll
|
|
758
|
+
? replaceAllLiteral(text, edit.oldText, edit.newText)
|
|
759
|
+
: text.replace(edit.oldText, edit.newText);
|
|
760
|
+
editsApplied += edit.replaceAll ? count : 1;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
assertTextSize(filePath, text, MAX_EDIT_BYTES);
|
|
764
|
+
const afterSha = sha256(text);
|
|
765
|
+
const changed = afterSha !== before.sha;
|
|
766
|
+
if (changed) {
|
|
767
|
+
await atomicWriteTextFile(filePath, text);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
path: filePath,
|
|
772
|
+
beforeSha256: before.sha,
|
|
773
|
+
afterSha256: afterSha,
|
|
774
|
+
bytesWritten: changed ? Buffer.byteLength(text, "utf8") : 0,
|
|
775
|
+
changed,
|
|
776
|
+
editsApplied,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export async function createFileForTool(cwd: string, input: CreateFileInput): Promise<FileWriteOutput> {
|
|
781
|
+
const filePath = resolveToolPath(cwd, input.path);
|
|
782
|
+
assertTextSize(filePath, input.content, MAX_CREATE_BYTES);
|
|
783
|
+
|
|
784
|
+
let beforeSha: string | undefined;
|
|
785
|
+
if (await pathExists(filePath)) {
|
|
786
|
+
const existing = await readEditableTextFile(filePath);
|
|
787
|
+
beforeSha = existing.sha;
|
|
788
|
+
assertExpectedSha256(filePath, existing.sha, input.expectedSha256);
|
|
789
|
+
if (!input.overwrite) {
|
|
790
|
+
throw new Error(`File already exists: ${filePath}`);
|
|
791
|
+
}
|
|
792
|
+
} else if (input.expectedSha256) {
|
|
793
|
+
throw new Error(`Cannot check expectedSha256 because file does not exist: ${filePath}`);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const afterSha = sha256(input.content);
|
|
797
|
+
const changed = beforeSha !== afterSha;
|
|
798
|
+
if (changed) {
|
|
799
|
+
await atomicWriteTextFile(filePath, input.content);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return {
|
|
803
|
+
path: filePath,
|
|
804
|
+
...(beforeSha ? { beforeSha256: beforeSha } : {}),
|
|
805
|
+
afterSha256: afterSha,
|
|
806
|
+
bytesWritten: changed ? Buffer.byteLength(input.content, "utf8") : 0,
|
|
807
|
+
changed,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export async function deleteFileForTool(cwd: string, input: DeleteFileInput): Promise<DeleteFileOutput> {
|
|
812
|
+
const filePath = resolveToolPath(cwd, input.path);
|
|
813
|
+
const before = await readEditableTextFile(filePath);
|
|
814
|
+
assertExpectedSha256(filePath, before.sha, input.expectedSha256);
|
|
815
|
+
await unlink(filePath);
|
|
816
|
+
return {
|
|
817
|
+
path: filePath,
|
|
818
|
+
beforeSha256: before.sha,
|
|
819
|
+
deleted: true,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export async function moveFileForTool(cwd: string, input: MoveFileInput): Promise<MoveFileOutput> {
|
|
824
|
+
const sourcePath = resolveToolPath(cwd, input.sourcePath);
|
|
825
|
+
const destinationPath = resolveToolPath(cwd, input.destinationPath);
|
|
826
|
+
const source = await readEditableTextFile(sourcePath);
|
|
827
|
+
assertExpectedSha256(sourcePath, source.sha, input.expectedSourceSha256);
|
|
828
|
+
|
|
829
|
+
let destinationSha: string | undefined;
|
|
830
|
+
if (await pathExists(destinationPath)) {
|
|
831
|
+
const destination = await readEditableTextFile(destinationPath);
|
|
832
|
+
destinationSha = destination.sha;
|
|
833
|
+
assertExpectedSha256(destinationPath, destination.sha, input.expectedDestinationSha256);
|
|
834
|
+
if (!input.overwrite) {
|
|
835
|
+
throw new Error(`Destination already exists: ${destinationPath}`);
|
|
836
|
+
}
|
|
837
|
+
} else if (input.expectedDestinationSha256) {
|
|
838
|
+
throw new Error(`Cannot check expectedDestinationSha256 because destination does not exist: ${destinationPath}`);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
await mkdir(dirname(destinationPath), { recursive: true });
|
|
842
|
+
try {
|
|
843
|
+
await rename(sourcePath, destinationPath);
|
|
844
|
+
} catch (err) {
|
|
845
|
+
if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err;
|
|
846
|
+
await copyFile(sourcePath, destinationPath);
|
|
847
|
+
await unlink(sourcePath);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
sourcePath,
|
|
852
|
+
destinationPath,
|
|
853
|
+
sourceSha256: source.sha,
|
|
854
|
+
...(destinationSha ? { overwrittenDestinationSha256: destinationSha } : {}),
|
|
855
|
+
moved: true,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function expectedPatchHash(
|
|
860
|
+
input: ApplyPatchInput,
|
|
861
|
+
absolutePath: string,
|
|
862
|
+
patchPath: string,
|
|
863
|
+
): string | undefined {
|
|
864
|
+
return input.expectedSha256ByPath?.[absolutePath] ?? input.expectedSha256ByPath?.[patchPath];
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
export async function applyPatchForTool(cwd: string, input: ApplyPatchInput): Promise<ApplyPatchOutput> {
|
|
868
|
+
const files = parseUnifiedPatch(input.patch);
|
|
869
|
+
const changedFiles: ApplyPatchFileChange[] = [];
|
|
870
|
+
|
|
871
|
+
for (const file of files) {
|
|
872
|
+
const patchPath = file.newPath ?? file.oldPath;
|
|
873
|
+
if (!patchPath) throw new Error("Patch file is missing both old and new paths");
|
|
874
|
+
const targetPath = resolveToolPath(cwd, patchPath);
|
|
875
|
+
const action: ApplyPatchFileChange["action"] =
|
|
876
|
+
file.oldPath === null ? "create" :
|
|
877
|
+
file.newPath === null ? "delete" :
|
|
878
|
+
"edit";
|
|
879
|
+
|
|
880
|
+
if (action === "create") {
|
|
881
|
+
if (await pathExists(targetPath)) {
|
|
882
|
+
throw new Error(`Patch target already exists: ${targetPath}`);
|
|
883
|
+
}
|
|
884
|
+
const text = applyParsedHunks(targetPath, "", file.hunks);
|
|
885
|
+
assertTextSize(targetPath, text, MAX_CREATE_BYTES);
|
|
886
|
+
await atomicWriteTextFile(targetPath, text);
|
|
887
|
+
changedFiles.push({
|
|
888
|
+
path: targetPath,
|
|
889
|
+
action,
|
|
890
|
+
afterSha256: sha256(text),
|
|
891
|
+
bytesWritten: Buffer.byteLength(text, "utf8"),
|
|
892
|
+
});
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const before = await readEditableTextFile(targetPath);
|
|
897
|
+
assertExpectedSha256(targetPath, before.sha, expectedPatchHash(input, targetPath, patchPath));
|
|
898
|
+
const text = applyParsedHunks(targetPath, before.text, file.hunks);
|
|
899
|
+
|
|
900
|
+
if (action === "delete") {
|
|
901
|
+
await unlink(targetPath);
|
|
902
|
+
changedFiles.push({
|
|
903
|
+
path: targetPath,
|
|
904
|
+
action,
|
|
905
|
+
beforeSha256: before.sha,
|
|
906
|
+
});
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
assertTextSize(targetPath, text, MAX_EDIT_BYTES);
|
|
911
|
+
const afterSha = sha256(text);
|
|
912
|
+
if (afterSha !== before.sha) {
|
|
913
|
+
await atomicWriteTextFile(targetPath, text);
|
|
914
|
+
}
|
|
915
|
+
changedFiles.push({
|
|
916
|
+
path: targetPath,
|
|
917
|
+
action,
|
|
918
|
+
beforeSha256: before.sha,
|
|
919
|
+
afterSha256: afterSha,
|
|
920
|
+
bytesWritten: afterSha !== before.sha ? Buffer.byteLength(text, "utf8") : 0,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return { changedFiles };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
export function createBuiltinFileTools(cwd: string): ToolSet {
|
|
928
|
+
return {
|
|
929
|
+
read_file: tool<ReadFileInput, ReadFileOutput>({
|
|
930
|
+
description: "Read a UTF-8 text file from the local filesystem. Use line ranges for large files.",
|
|
931
|
+
inputSchema: jsonSchema<ReadFileInput>({
|
|
932
|
+
type: "object",
|
|
933
|
+
additionalProperties: false,
|
|
934
|
+
properties: {
|
|
935
|
+
path: { type: "string", description: "Absolute path or path relative to the session cwd." },
|
|
936
|
+
startLine: { type: "number", description: "Optional 1-based first line to return." },
|
|
937
|
+
endLine: { type: "number", description: "Optional 1-based last line to return." },
|
|
938
|
+
},
|
|
939
|
+
required: ["path"],
|
|
940
|
+
}),
|
|
941
|
+
execute: (input) => readFileForTool(cwd, input),
|
|
942
|
+
}),
|
|
943
|
+
list_dir: tool<ListDirInput, ListDirOutput>({
|
|
944
|
+
description: "List files in a local directory.",
|
|
945
|
+
inputSchema: jsonSchema<ListDirInput>({
|
|
946
|
+
type: "object",
|
|
947
|
+
additionalProperties: false,
|
|
948
|
+
properties: {
|
|
949
|
+
path: { type: "string", description: "Directory path. Defaults to the session cwd." },
|
|
950
|
+
},
|
|
951
|
+
}),
|
|
952
|
+
execute: (input) => listDirForTool(cwd, input),
|
|
953
|
+
}),
|
|
954
|
+
search_code: tool<SearchCodeInput, SearchCodeOutput>({
|
|
955
|
+
description: "Search local files with ripgrep without invoking a shell.",
|
|
956
|
+
inputSchema: jsonSchema<SearchCodeInput>({
|
|
957
|
+
type: "object",
|
|
958
|
+
additionalProperties: false,
|
|
959
|
+
properties: {
|
|
960
|
+
query: { type: "string", description: "Text or regex query passed to ripgrep." },
|
|
961
|
+
path: { type: "string", description: "File or directory to search. Defaults to the session cwd." },
|
|
962
|
+
glob: { type: "string", description: "Optional ripgrep glob filter, for example **/*.ts." },
|
|
963
|
+
maxResults: { type: "number", description: "Maximum result lines, capped internally." },
|
|
964
|
+
},
|
|
965
|
+
required: ["query"],
|
|
966
|
+
}),
|
|
967
|
+
execute: (input, options) => searchCodeForTool(cwd, input, options.abortSignal),
|
|
968
|
+
}),
|
|
969
|
+
run_command: tool<RunCommandInput, RunCommandOutput>({
|
|
970
|
+
description: "Run a non-interactive shell command in the local workspace. Use for tests, git, and package scripts. Returns stdout/stderr and exitCode; non-zero exit codes are not tool errors.",
|
|
971
|
+
inputSchema: jsonSchema<RunCommandInput>({
|
|
972
|
+
type: "object",
|
|
973
|
+
additionalProperties: false,
|
|
974
|
+
properties: {
|
|
975
|
+
command: { type: "string", description: "Command line to run in the platform shell." },
|
|
976
|
+
cwd: { type: "string", description: "Optional working directory. Defaults to the session cwd." },
|
|
977
|
+
timeoutMs: { type: "number", description: `Optional timeout in milliseconds, capped at ${MAX_COMMAND_TIMEOUT_MS}.` },
|
|
978
|
+
},
|
|
979
|
+
required: ["command"],
|
|
980
|
+
}),
|
|
981
|
+
execute: (input, options) => runCommandForTool(cwd, input, options.abortSignal),
|
|
982
|
+
}),
|
|
983
|
+
edit_file: tool<EditFileInput, EditFileOutput>({
|
|
984
|
+
description: "Edit an existing UTF-8 text file by applying exact oldText -> newText replacements. Uses optional SHA-256 precondition to avoid overwriting concurrent edits.",
|
|
985
|
+
inputSchema: jsonSchema<EditFileInput>({
|
|
986
|
+
type: "object",
|
|
987
|
+
additionalProperties: false,
|
|
988
|
+
properties: {
|
|
989
|
+
path: { type: "string", description: "Absolute path or path relative to the session cwd." },
|
|
990
|
+
expectedSha256: { type: "string", description: "Optional SHA-256 hash of the current file content." },
|
|
991
|
+
edits: {
|
|
992
|
+
type: "array",
|
|
993
|
+
minItems: 1,
|
|
994
|
+
items: {
|
|
995
|
+
type: "object",
|
|
996
|
+
additionalProperties: false,
|
|
997
|
+
properties: {
|
|
998
|
+
oldText: { type: "string", description: "Exact text to replace. Include enough context to make it unique." },
|
|
999
|
+
newText: { type: "string", description: "Replacement text." },
|
|
1000
|
+
replaceAll: { type: "boolean", description: "Replace every occurrence when oldText appears multiple times." },
|
|
1001
|
+
},
|
|
1002
|
+
required: ["oldText", "newText"],
|
|
1003
|
+
},
|
|
1004
|
+
},
|
|
1005
|
+
},
|
|
1006
|
+
required: ["path", "edits"],
|
|
1007
|
+
}),
|
|
1008
|
+
execute: (input) => editFileForTool(cwd, input),
|
|
1009
|
+
}),
|
|
1010
|
+
create_file: tool<CreateFileInput, FileWriteOutput>({
|
|
1011
|
+
description: "Create a UTF-8 text file, or overwrite an existing one when overwrite=true.",
|
|
1012
|
+
inputSchema: jsonSchema<CreateFileInput>({
|
|
1013
|
+
type: "object",
|
|
1014
|
+
additionalProperties: false,
|
|
1015
|
+
properties: {
|
|
1016
|
+
path: { type: "string", description: "Absolute path or path relative to the session cwd." },
|
|
1017
|
+
content: { type: "string", description: "Complete file content to write." },
|
|
1018
|
+
overwrite: { type: "boolean", description: "Allow replacing an existing file." },
|
|
1019
|
+
expectedSha256: { type: "string", description: "Optional SHA-256 hash required when overwriting an existing file." },
|
|
1020
|
+
},
|
|
1021
|
+
required: ["path", "content"],
|
|
1022
|
+
}),
|
|
1023
|
+
execute: (input) => createFileForTool(cwd, input),
|
|
1024
|
+
}),
|
|
1025
|
+
delete_file: tool<DeleteFileInput, DeleteFileOutput>({
|
|
1026
|
+
description: "Delete an existing text file. Use expectedSha256 to avoid deleting a file that changed after reading.",
|
|
1027
|
+
inputSchema: jsonSchema<DeleteFileInput>({
|
|
1028
|
+
type: "object",
|
|
1029
|
+
additionalProperties: false,
|
|
1030
|
+
properties: {
|
|
1031
|
+
path: { type: "string", description: "Absolute path or path relative to the session cwd." },
|
|
1032
|
+
expectedSha256: { type: "string", description: "Optional SHA-256 hash of the file that must be deleted." },
|
|
1033
|
+
},
|
|
1034
|
+
required: ["path"],
|
|
1035
|
+
}),
|
|
1036
|
+
execute: (input) => deleteFileForTool(cwd, input),
|
|
1037
|
+
}),
|
|
1038
|
+
move_file: tool<MoveFileInput, MoveFileOutput>({
|
|
1039
|
+
description: "Move or rename an existing text file. Can overwrite an existing destination only when overwrite=true.",
|
|
1040
|
+
inputSchema: jsonSchema<MoveFileInput>({
|
|
1041
|
+
type: "object",
|
|
1042
|
+
additionalProperties: false,
|
|
1043
|
+
properties: {
|
|
1044
|
+
sourcePath: { type: "string", description: "Existing source file path." },
|
|
1045
|
+
destinationPath: { type: "string", description: "Destination file path." },
|
|
1046
|
+
overwrite: { type: "boolean", description: "Allow replacing an existing destination file." },
|
|
1047
|
+
expectedSourceSha256: { type: "string", description: "Optional SHA-256 hash of the source file." },
|
|
1048
|
+
expectedDestinationSha256: { type: "string", description: "Optional SHA-256 hash of the destination file when overwriting." },
|
|
1049
|
+
},
|
|
1050
|
+
required: ["sourcePath", "destinationPath"],
|
|
1051
|
+
}),
|
|
1052
|
+
execute: (input) => moveFileForTool(cwd, input),
|
|
1053
|
+
}),
|
|
1054
|
+
apply_patch: tool<ApplyPatchInput, ApplyPatchOutput>({
|
|
1055
|
+
description: "Apply a unified diff patch to one or more UTF-8 text files. Prefer edit_file for small targeted edits.",
|
|
1056
|
+
inputSchema: jsonSchema<ApplyPatchInput>({
|
|
1057
|
+
type: "object",
|
|
1058
|
+
additionalProperties: false,
|
|
1059
|
+
properties: {
|
|
1060
|
+
patch: { type: "string", description: "Unified diff patch text." },
|
|
1061
|
+
expectedSha256ByPath: {
|
|
1062
|
+
type: "object",
|
|
1063
|
+
description: "Optional map of patch path or absolute path to expected SHA-256 before applying.",
|
|
1064
|
+
additionalProperties: { type: "string" },
|
|
1065
|
+
},
|
|
1066
|
+
},
|
|
1067
|
+
required: ["patch"],
|
|
1068
|
+
}),
|
|
1069
|
+
execute: (input) => applyPatchForTool(cwd, input),
|
|
1070
|
+
}),
|
|
1071
|
+
};
|
|
1072
|
+
}
|