chatccc 0.2.225 → 0.2.227

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.
@@ -1,1320 +1,1407 @@
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 { createRequire } from "node:module";
6
- import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
7
- import { createInterface } from "node:readline";
8
-
9
- import { jsonSchema, tool, type ToolSet } from "ai";
10
-
11
- import { killProcessTree } from "../adapters/proc-tree-kill.ts";
12
-
13
- const MAX_READ_BYTES = 1024 * 1024;
14
- const MAX_LIST_ENTRIES = 200;
15
- const MAX_SEARCH_RESULTS = 100;
16
- const MAX_SEARCH_BYTES = 256 * 1024;
17
- const MAX_EDIT_BYTES = 2 * 1024 * 1024;
18
- const MAX_CREATE_BYTES = 2 * 1024 * 1024;
19
- const MAX_PATCH_BYTES = 512 * 1024;
20
- const SEARCH_TIMEOUT_MS = 15_000;
21
- const MAX_COMMAND_OUTPUT_BYTES = 256 * 1024;
22
- const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
23
- const MAX_COMMAND_TIMEOUT_MS = 900_000;
24
- const requireFromHere = createRequire(import.meta.url);
25
- const FALLBACK_SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
26
-
27
- export interface ReadFileInput {
28
- path: string;
29
- startLine?: number;
30
- endLine?: number;
31
- }
32
-
33
- export interface ReadFileOutput {
34
- path: string;
35
- size: number;
36
- sha256: string;
37
- isBinary: boolean;
38
- truncated: boolean;
39
- startLine?: number;
40
- endLine?: number;
41
- totalLines?: number;
42
- content: string;
43
- }
44
-
45
- export interface ListDirInput {
46
- path?: string;
47
- }
48
-
49
- export interface ListDirEntry {
50
- name: string;
51
- path: string;
52
- type: "file" | "directory" | "symlink" | "other";
53
- size?: number;
54
- }
55
-
56
- export interface ListDirOutput {
57
- path: string;
58
- entries: ListDirEntry[];
59
- truncated: boolean;
60
- }
61
-
62
- export interface SearchCodeInput {
63
- query: string;
64
- path?: string;
65
- glob?: string;
66
- maxResults?: number;
67
- }
68
-
69
- export interface SearchCodeMatch {
70
- path: string;
71
- line: number;
72
- column: number;
73
- text: string;
74
- }
75
-
76
- export interface SearchCodeOutput {
77
- query: string;
78
- path: string;
79
- glob?: string;
80
- matches: SearchCodeMatch[];
81
- truncated: boolean;
82
- }
83
-
84
- /** @internal Allows tests to force the dependency-free fallback path. */
85
- export interface SearchCodeRuntimeOptions {
86
- ripgrepCommands?: readonly string[];
87
- }
88
-
89
- export interface RunCommandInput {
90
- command: string;
91
- cwd?: string;
92
- timeoutMs?: number;
93
- }
94
-
95
- export interface RunCommandOutput {
96
- command: string;
97
- cwd: string;
98
- exitCode: number | null;
99
- signal: string | null;
100
- stdout: string;
101
- stderr: string;
102
- timedOut: boolean;
103
- truncated: boolean;
104
- durationMs: number;
105
- }
106
-
107
- export interface FileEdit {
108
- oldText: string;
109
- newText: string;
110
- replaceAll?: boolean;
111
- }
112
-
113
- export interface EditFileInput {
114
- path: string;
115
- expectedSha256?: string;
116
- edits: FileEdit[];
117
- }
118
-
119
- export interface FileWriteOutput {
120
- path: string;
121
- beforeSha256?: string;
122
- afterSha256?: string;
123
- bytesWritten?: number;
124
- changed: boolean;
125
- }
126
-
127
- export interface EditFileOutput extends FileWriteOutput {
128
- editsApplied: number;
129
- }
130
-
131
- export interface CreateFileInput {
132
- path: string;
133
- content: string;
134
- overwrite?: boolean;
135
- expectedSha256?: string;
136
- }
137
-
138
- export interface DeleteFileInput {
139
- path: string;
140
- expectedSha256?: string;
141
- }
142
-
143
- export interface DeleteFileOutput {
144
- path: string;
145
- beforeSha256: string;
146
- deleted: true;
147
- }
148
-
149
- export interface MoveFileInput {
150
- sourcePath: string;
151
- destinationPath: string;
152
- overwrite?: boolean;
153
- expectedSourceSha256?: string;
154
- expectedDestinationSha256?: string;
155
- }
156
-
157
- export interface MoveFileOutput {
158
- sourcePath: string;
159
- destinationPath: string;
160
- sourceSha256: string;
161
- overwrittenDestinationSha256?: string;
162
- moved: true;
163
- }
164
-
165
- export interface ApplyPatchInput {
166
- patch: string;
167
- expectedSha256ByPath?: Record<string, string>;
168
- }
169
-
170
- export interface ApplyPatchFileChange {
171
- path: string;
172
- action: "create" | "edit" | "delete";
173
- beforeSha256?: string;
174
- afterSha256?: string;
175
- bytesWritten?: number;
176
- }
177
-
178
- export interface ApplyPatchOutput {
179
- changedFiles: ApplyPatchFileChange[];
180
- }
181
-
182
- function resolveToolPath(cwd: string, value: string | undefined): string {
183
- const raw = value?.trim();
184
- if (!raw) return resolve(cwd);
185
- return isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
186
- }
187
-
188
- function toPositiveInt(value: number | undefined): number | undefined {
189
- if (value === undefined) return undefined;
190
- if (!Number.isFinite(value)) return undefined;
191
- const rounded = Math.floor(value);
192
- return rounded > 0 ? rounded : undefined;
193
- }
194
-
195
- function isBinaryBuffer(buffer: Buffer): boolean {
196
- const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
197
- return sample.includes(0);
198
- }
199
-
200
- async function pathExists(path: string): Promise<boolean> {
201
- try {
202
- await stat(path);
203
- return true;
204
- } catch {
205
- return false;
206
- }
207
- }
208
-
209
- function sha256(buffer: Buffer | string): string {
210
- return createHash("sha256").update(buffer).digest("hex");
211
- }
212
-
213
- async function sha256File(path: string): Promise<string> {
214
- const hash = createHash("sha256");
215
- for await (const chunk of createReadStream(path)) {
216
- hash.update(chunk);
217
- }
218
- return hash.digest("hex");
219
- }
220
-
221
- function assertExpectedSha256(path: string, actual: string, expected: string | undefined): void {
222
- if (expected && expected !== actual) {
223
- throw new Error(`SHA-256 mismatch for ${path}: expected ${expected}, got ${actual}`);
224
- }
225
- }
226
-
227
- function assertTextSize(path: string, text: string, maxBytes: number): void {
228
- const bytes = Buffer.byteLength(text, "utf8");
229
- if (bytes > maxBytes) {
230
- throw new Error(`Content for ${path} is too large: ${bytes} bytes, max ${maxBytes}`);
231
- }
232
- }
233
-
234
- async function readEditableTextFile(path: string): Promise<{ text: string; buffer: Buffer; sha: string }> {
235
- const info = await stat(path);
236
- if (info.isDirectory()) {
237
- throw new Error(`Path is a directory: ${path}`);
238
- }
239
- if (info.size > MAX_EDIT_BYTES) {
240
- throw new Error(`File is too large to edit: ${path} (${info.size} bytes, max ${MAX_EDIT_BYTES})`);
241
- }
242
-
243
- const buffer = await readFile(path);
244
- if (isBinaryBuffer(buffer)) {
245
- throw new Error(`Refusing to edit binary file: ${path}`);
246
- }
247
-
248
- return {
249
- text: buffer.toString("utf8"),
250
- buffer,
251
- sha: sha256(buffer),
252
- };
253
- }
254
-
255
- async function atomicWriteTextFile(path: string, text: string): Promise<void> {
256
- await mkdir(dirname(path), { recursive: true });
257
- const tempPath = resolve(
258
- dirname(path),
259
- `.chatccc-${basename(path)}-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`,
260
- );
261
- try {
262
- await writeFile(tempPath, text, "utf8");
263
- await rename(tempPath, path);
264
- } catch (err) {
265
- try {
266
- await unlink(tempPath);
267
- } catch {
268
- // Best-effort cleanup.
269
- }
270
- throw err;
271
- }
272
- }
273
-
274
- function countOccurrences(text: string, needle: string): number {
275
- if (!needle) return 0;
276
- let count = 0;
277
- let index = 0;
278
- while (true) {
279
- const found = text.indexOf(needle, index);
280
- if (found === -1) return count;
281
- count++;
282
- index = found + needle.length;
283
- }
284
- }
285
-
286
- function replaceAllLiteral(text: string, oldText: string, newText: string): string {
287
- return text.split(oldText).join(newText);
288
- }
289
-
290
- function detectEol(text: string): "\r\n" | "\n" {
291
- return text.includes("\r\n") ? "\r\n" : "\n";
292
- }
293
-
294
- function normalizeCommandTimeoutMs(value: number | undefined): number {
295
- if (value === undefined || !Number.isFinite(value)) return DEFAULT_COMMAND_TIMEOUT_MS;
296
- return Math.min(Math.max(Math.floor(value), 1_000), MAX_COMMAND_TIMEOUT_MS);
297
- }
298
-
299
- function appendLimitedOutput(
300
- target: { chunks: string[]; bytes: number; truncated: boolean },
301
- chunk: Buffer,
302
- ): void {
303
- const remaining = MAX_COMMAND_OUTPUT_BYTES - target.bytes;
304
- if (remaining <= 0) {
305
- target.truncated = true;
306
- return;
307
- }
308
-
309
- if (chunk.byteLength <= remaining) {
310
- target.chunks.push(chunk.toString("utf8"));
311
- target.bytes += chunk.byteLength;
312
- return;
313
- }
314
-
315
- target.chunks.push(chunk.subarray(0, remaining).toString("utf8"));
316
- target.bytes += remaining;
317
- target.truncated = true;
318
- }
319
-
320
- function splitPatchPath(value: string): string | null {
321
- const token = value.trim().split(/\s+/)[0];
322
- if (!token || token === "/dev/null") return null;
323
- if ((token.startsWith("a/") || token.startsWith("b/")) && token.length > 2) {
324
- return token.slice(2);
325
- }
326
- return token;
327
- }
328
-
329
- interface ParsedPatchLine {
330
- kind: "context" | "add" | "remove";
331
- text: string;
332
- }
333
-
334
- interface ParsedPatchHunk {
335
- oldStart: number;
336
- lines: ParsedPatchLine[];
337
- }
338
-
339
- interface ParsedPatchFile {
340
- oldPath: string | null;
341
- newPath: string | null;
342
- hunks: ParsedPatchHunk[];
343
- }
344
-
345
- function parseHunkHeader(line: string): number {
346
- const match = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/.exec(line);
347
- if (!match) {
348
- throw new Error(`Invalid hunk header: ${line}`);
349
- }
350
- return Number(match[1]);
351
- }
352
-
353
- function parseUnifiedPatch(patch: string): ParsedPatchFile[] {
354
- assertTextSize("patch", patch, MAX_PATCH_BYTES);
355
- const normalizedPatch = patch.replace(/\r\n/g, "\n");
356
- const lines = (normalizedPatch.endsWith("\n") ? normalizedPatch.slice(0, -1) : normalizedPatch).split("\n");
357
- const files: ParsedPatchFile[] = [];
358
- let current: ParsedPatchFile | null = null;
359
- let currentHunk: ParsedPatchHunk | null = null;
360
-
361
- const finishFile = () => {
362
- if (!current) return;
363
- if (!current.oldPath && !current.newPath) {
364
- current = null;
365
- currentHunk = null;
366
- return;
367
- }
368
- files.push(current);
369
- current = null;
370
- currentHunk = null;
371
- };
372
-
373
- for (const line of lines) {
374
- if (line.startsWith("diff --git ")) {
375
- finishFile();
376
- current = { oldPath: null, newPath: null, hunks: [] };
377
- continue;
378
- }
379
-
380
- if (line.startsWith("--- ")) {
381
- if (current?.hunks.length) finishFile();
382
- current ??= { oldPath: null, newPath: null, hunks: [] };
383
- current.oldPath = splitPatchPath(line.slice(4));
384
- currentHunk = null;
385
- continue;
386
- }
387
-
388
- if (line.startsWith("+++ ")) {
389
- current ??= { oldPath: null, newPath: null, hunks: [] };
390
- current.newPath = splitPatchPath(line.slice(4));
391
- currentHunk = null;
392
- continue;
393
- }
394
-
395
- if (line.startsWith("@@ ")) {
396
- if (!current) throw new Error(`Hunk without file header: ${line}`);
397
- currentHunk = { oldStart: parseHunkHeader(line), lines: [] };
398
- current.hunks.push(currentHunk);
399
- continue;
400
- }
401
-
402
- if (line === "\") continue;
403
- if (!currentHunk) continue;
404
-
405
- if (line.startsWith(" ")) {
406
- currentHunk.lines.push({ kind: "context", text: line.slice(1) });
407
- } else if (line.startsWith("+")) {
408
- currentHunk.lines.push({ kind: "add", text: line.slice(1) });
409
- } else if (line.startsWith("-")) {
410
- currentHunk.lines.push({ kind: "remove", text: line.slice(1) });
411
- } else {
412
- throw new Error(`Invalid patch line: ${line}`);
413
- }
414
- }
415
-
416
- finishFile();
417
- if (files.length === 0) throw new Error("Patch does not contain any file changes");
418
- return files;
419
- }
420
-
421
- function splitContentLines(text: string): string[] {
422
- if (text.length === 0) return [];
423
- return text.replace(/\r\n/g, "\n").split("\n");
424
- }
425
-
426
- function applyParsedHunks(path: string, text: string, hunks: ParsedPatchHunk[]): string {
427
- const eol = detectEol(text);
428
- const original = splitContentLines(text);
429
- const output: string[] = [];
430
- let oldIndex = 0;
431
-
432
- for (const hunk of hunks) {
433
- const targetIndex = Math.max(0, hunk.oldStart - 1);
434
- if (targetIndex < oldIndex) {
435
- throw new Error(`Overlapping hunk in patch for ${path}`);
436
- }
437
- output.push(...original.slice(oldIndex, targetIndex));
438
- oldIndex = targetIndex;
439
-
440
- for (const line of hunk.lines) {
441
- if (line.kind === "add") {
442
- output.push(line.text);
443
- continue;
444
- }
445
-
446
- if (oldIndex >= original.length || original[oldIndex] !== line.text) {
447
- throw new Error(`Patch context mismatch in ${path} near line ${oldIndex + 1}`);
448
- }
449
-
450
- if (line.kind === "context") {
451
- output.push(original[oldIndex]);
452
- }
453
- oldIndex++;
454
- }
455
- }
456
-
457
- output.push(...original.slice(oldIndex));
458
- return output.join(eol);
459
- }
460
-
461
- export async function readFileForTool(cwd: string, input: ReadFileInput): Promise<ReadFileOutput> {
462
- const filePath = resolveToolPath(cwd, input.path);
463
- const info = await stat(filePath);
464
- if (info.isDirectory()) {
465
- throw new Error(`Path is a directory: ${filePath}`);
466
- }
467
-
468
- const bytesToRead = Math.min(info.size, MAX_READ_BYTES);
469
- const buffer = Buffer.alloc(bytesToRead);
470
- const handle = await open(filePath, "r");
471
- try {
472
- await handle.read(buffer, 0, bytesToRead, 0);
473
- } finally {
474
- await handle.close();
475
- }
476
- const fileSha256 = await sha256File(filePath);
477
- const isBinary = isBinaryBuffer(buffer);
478
- if (isBinary) {
479
- return {
480
- path: filePath,
481
- size: info.size,
482
- sha256: fileSha256,
483
- isBinary: true,
484
- truncated: info.size > bytesToRead,
485
- content: "",
486
- };
487
- }
488
-
489
- const text = buffer.toString("utf8");
490
- const lines = text.split(/\r?\n/);
491
- const totalLines = lines.length;
492
- const startLine = toPositiveInt(input.startLine) ?? 1;
493
- const endLine = toPositiveInt(input.endLine) ?? totalLines;
494
- const normalizedEnd = Math.max(startLine, Math.min(endLine, totalLines));
495
- const content = lines.slice(startLine - 1, normalizedEnd).join("\n");
496
-
497
- return {
498
- path: filePath,
499
- size: info.size,
500
- sha256: fileSha256,
501
- isBinary: false,
502
- truncated: info.size > bytesToRead || normalizedEnd < totalLines || startLine > 1,
503
- startLine,
504
- endLine: normalizedEnd,
505
- totalLines,
506
- content,
507
- };
508
- }
509
-
510
- export async function listDirForTool(cwd: string, input: ListDirInput = {}): Promise<ListDirOutput> {
511
- const dirPath = resolveToolPath(cwd, input.path);
512
- const entries = await readdir(dirPath, { withFileTypes: true });
513
- const selected = entries.slice(0, MAX_LIST_ENTRIES);
514
- const result: ListDirEntry[] = [];
515
-
516
- for (const entry of selected) {
517
- const entryPath = resolve(dirPath, entry.name);
518
- let size: number | undefined;
519
- if (entry.isFile()) {
520
- try {
521
- size = (await stat(entryPath)).size;
522
- } catch {
523
- size = undefined;
524
- }
525
- }
526
- result.push({
527
- name: entry.name,
528
- path: entryPath,
529
- type: entry.isDirectory()
530
- ? "directory"
531
- : entry.isFile()
532
- ? "file"
533
- : entry.isSymbolicLink()
534
- ? "symlink"
535
- : "other",
536
- ...(size !== undefined ? { size } : {}),
537
- });
538
- }
539
-
540
- return {
541
- path: dirPath,
542
- entries: result,
543
- truncated: entries.length > selected.length,
544
- };
545
- }
546
-
547
- function parseRgLine(line: string): SearchCodeMatch | null {
548
- const match = /^(.*?):(\d+):(\d+):(.*)$/.exec(line);
549
- if (!match) return null;
550
- return {
551
- path: match[1],
552
- line: Number(match[2]),
553
- column: Number(match[3]),
554
- text: match[4],
555
- };
556
- }
557
-
558
- interface RipgrepOutput {
559
- stdout: string;
560
- stderr: string;
561
- truncated: boolean;
562
- }
563
-
564
- function resolveBundledRipgrepPath(): string | undefined {
565
- try {
566
- const bundled = requireFromHere("@vscode/ripgrep") as { rgPath?: unknown };
567
- return typeof bundled.rgPath === "string" && bundled.rgPath.trim()
568
- ? bundled.rgPath
569
- : undefined;
570
- } catch {
571
- // Unsupported platforms or damaged optional platform packages must not
572
- // prevent ChatCCC itself from starting; system rg / Node fallback remain.
573
- return undefined;
574
- }
575
- }
576
-
577
- function defaultRipgrepCommands(): string[] {
578
- const bundled = resolveBundledRipgrepPath();
579
- return [...new Set([bundled, "rg"].filter((value): value is string => !!value))];
580
- }
581
-
582
- function isUnavailableExecutableError(err: unknown): boolean {
583
- const code = (err as NodeJS.ErrnoException | undefined)?.code;
584
- return code === "ENOENT" || code === "EACCES" || code === "EPERM";
585
- }
586
-
587
- async function runRipgrep(
588
- command: string,
589
- args: string[],
590
- cwd: string,
591
- signal?: AbortSignal,
592
- ): Promise<RipgrepOutput> {
593
- return new Promise<RipgrepOutput>((resolvePromise, reject) => {
594
- let settled = false;
595
- const child = spawn(command, args, {
596
- cwd,
597
- shell: false,
598
- windowsHide: true,
599
- stdio: ["ignore", "pipe", "pipe"],
600
- });
601
-
602
- let stdout = "";
603
- let stderr = "";
604
- let truncated = false;
605
- const cleanup = () => {
606
- clearTimeout(timeout);
607
- signal?.removeEventListener("abort", abort);
608
- };
609
- const rejectOnce = (err: Error) => {
610
- if (settled) return;
611
- settled = true;
612
- cleanup();
613
- reject(err);
614
- };
615
- const timeout = setTimeout(() => {
616
- child.kill();
617
- rejectOnce(new Error(`search_code timed out after ${SEARCH_TIMEOUT_MS}ms`));
618
- }, SEARCH_TIMEOUT_MS);
619
- const abort = () => {
620
- child.kill();
621
- rejectOnce(new Error("search_code aborted"));
622
- };
623
- signal?.addEventListener("abort", abort, { once: true });
624
-
625
- child.stdout?.on("data", (chunk: Buffer) => {
626
- if (stdout.length >= MAX_SEARCH_BYTES) {
627
- truncated = true;
628
- return;
629
- }
630
- stdout += chunk.toString("utf8");
631
- if (stdout.length > MAX_SEARCH_BYTES) {
632
- stdout = stdout.slice(0, MAX_SEARCH_BYTES);
633
- truncated = true;
634
- }
635
- });
636
- child.stderr?.on("data", (chunk: Buffer) => {
637
- stderr += chunk.toString("utf8");
638
- });
639
- child.on("error", (err) => rejectOnce(err));
640
- child.on("close", (code) => {
641
- if (settled) return;
642
- settled = true;
643
- cleanup();
644
- if (code !== 0 && code !== 1) {
645
- reject(new Error(stderr.trim() || `rg exited with code ${code}`));
646
- return;
647
- }
648
- resolvePromise({ stdout, stderr, truncated });
649
- });
650
- });
651
- }
652
-
653
- function expandGlobBraces(pattern: string): string[] {
654
- const openIndex = pattern.indexOf("{");
655
- if (openIndex < 0) return [pattern];
656
- const closeIndex = pattern.indexOf("}", openIndex + 1);
657
- if (closeIndex < 0) return [pattern];
658
- const alternatives = pattern.slice(openIndex + 1, closeIndex).split(",");
659
- if (alternatives.length < 2) return [pattern];
660
- return alternatives.flatMap((alternative) => expandGlobBraces(
661
- pattern.slice(0, openIndex) + alternative + pattern.slice(closeIndex + 1),
662
- ));
663
- }
664
-
665
- function globToRegExp(pattern: string): RegExp {
666
- let source = "";
667
- for (let index = 0; index < pattern.length; index += 1) {
668
- const char = pattern[index];
669
- if (char === "*") {
670
- if (pattern[index + 1] === "*") {
671
- index += 1;
672
- if (pattern[index + 1] === "/") {
673
- index += 1;
674
- source += "(?:.*/)?";
675
- } else {
676
- source += ".*";
677
- }
678
- } else {
679
- source += "[^/]*";
680
- }
681
- } else if (char === "?") {
682
- source += "[^/]";
683
- } else {
684
- source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
685
- }
686
- }
687
- return new RegExp(`^${source}$`);
688
- }
689
-
690
- function createGlobMatchers(glob: string | undefined): Array<{ regex: RegExp; basenameOnly: boolean }> {
691
- if (!glob?.trim()) return [];
692
- return expandGlobBraces(glob.trim().replaceAll("\\", "/")).map((pattern) => ({
693
- regex: globToRegExp(pattern),
694
- basenameOnly: !pattern.includes("/"),
695
- }));
696
- }
697
-
698
- function matchesFallbackGlob(
699
- filePath: string,
700
- searchRoot: string,
701
- matchers: Array<{ regex: RegExp; basenameOnly: boolean }>,
702
- ): boolean {
703
- if (matchers.length === 0) return true;
704
- const relativePath = relative(searchRoot, filePath).split(sep).join("/");
705
- return matchers.some(({ regex, basenameOnly }) => regex.test(
706
- basenameOnly ? basename(filePath) : relativePath,
707
- ));
708
- }
709
-
710
- async function searchCodeWithNode(
711
- query: string,
712
- searchPath: string,
713
- glob: string | undefined,
714
- maxResults: number,
715
- signal?: AbortSignal,
716
- ): Promise<{ matches: SearchCodeMatch[]; truncated: boolean }> {
717
- let queryRegex: RegExp;
718
- try {
719
- queryRegex = new RegExp(query);
720
- } catch (err) {
721
- throw new Error(`invalid search regex: ${(err as Error).message}`);
722
- }
723
-
724
- const startedAt = Date.now();
725
- const matches: SearchCodeMatch[] = [];
726
- const rootInfo = await stat(searchPath);
727
- const searchRoot = rootInfo.isDirectory() ? searchPath : dirname(searchPath);
728
- const globMatchers = createGlobMatchers(glob);
729
- let truncated = false;
730
- let outputBytes = 0;
731
-
732
- const ensureActive = () => {
733
- if (signal?.aborted) throw new Error("search_code aborted");
734
- if (Date.now() - startedAt >= SEARCH_TIMEOUT_MS) {
735
- throw new Error(`search_code timed out after ${SEARCH_TIMEOUT_MS}ms`);
736
- }
737
- };
738
-
739
- const searchFile = async (filePath: string) => {
740
- if (!matchesFallbackGlob(filePath, searchRoot, globMatchers)) return;
741
- ensureActive();
742
- const input = createReadStream(filePath, { encoding: "utf8" });
743
- const lines = createInterface({ input, crlfDelay: Infinity });
744
- let lineNumber = 0;
745
- try {
746
- for await (const line of lines) {
747
- ensureActive();
748
- lineNumber += 1;
749
- if (line.includes("\0")) break;
750
- const match = queryRegex.exec(line);
751
- queryRegex.lastIndex = 0;
752
- if (!match) continue;
753
- const lineBytes = Buffer.byteLength(line, "utf8");
754
- if (outputBytes + lineBytes > MAX_SEARCH_BYTES) {
755
- truncated = true;
756
- break;
757
- }
758
- outputBytes += lineBytes;
759
- matches.push({
760
- path: filePath,
761
- line: lineNumber,
762
- column: match.index + 1,
763
- text: line,
764
- });
765
- if (matches.length >= maxResults) {
766
- truncated = true;
767
- break;
768
- }
769
- }
770
- } catch (err) {
771
- if (signal?.aborted) throw new Error("search_code aborted");
772
- const code = (err as NodeJS.ErrnoException | undefined)?.code;
773
- if (code !== "EACCES" && code !== "EPERM" && code !== "ENOENT") throw err;
774
- } finally {
775
- lines.close();
776
- input.destroy();
777
- }
778
- };
779
-
780
- const visit = async (currentPath: string): Promise<void> => {
781
- ensureActive();
782
- if (truncated) return;
783
- let info;
784
- try {
785
- info = currentPath === searchPath ? rootInfo : await stat(currentPath);
786
- } catch (err) {
787
- const code = (err as NodeJS.ErrnoException | undefined)?.code;
788
- if (code === "EACCES" || code === "EPERM" || code === "ENOENT") return;
789
- throw err;
790
- }
791
- if (info.isFile()) {
792
- await searchFile(currentPath);
793
- return;
794
- }
795
- if (!info.isDirectory()) return;
796
-
797
- let entries;
798
- try {
799
- entries = await readdir(currentPath, { withFileTypes: true });
800
- } catch (err) {
801
- const code = (err as NodeJS.ErrnoException | undefined)?.code;
802
- if (code === "EACCES" || code === "EPERM" || code === "ENOENT") return;
803
- throw err;
804
- }
805
- entries.sort((left, right) => left.name.localeCompare(right.name));
806
- for (const entry of entries) {
807
- if (truncated) break;
808
- if (entry.name.startsWith(".")) continue;
809
- if (entry.isDirectory() && FALLBACK_SKIPPED_DIRECTORIES.has(entry.name)) continue;
810
- if (entry.isSymbolicLink()) continue;
811
- await visit(resolve(currentPath, entry.name));
812
- }
813
- };
814
-
815
- await visit(searchPath);
816
- return { matches, truncated };
817
- }
818
-
819
- export async function searchCodeForTool(
820
- cwd: string,
821
- input: SearchCodeInput,
822
- signal?: AbortSignal,
823
- runtimeOptions: SearchCodeRuntimeOptions = {},
824
- ): Promise<SearchCodeOutput> {
825
- const query = input.query?.trim();
826
- if (!query) throw new Error("query is required");
827
- if (signal?.aborted) throw new Error("search_code aborted");
828
-
829
- const searchPath = resolveToolPath(cwd, input.path);
830
- const maxResults = Math.min(toPositiveInt(input.maxResults) ?? 50, MAX_SEARCH_RESULTS);
831
- const args = [
832
- "--line-number",
833
- "--column",
834
- "--no-heading",
835
- "--color",
836
- "never",
837
- "--max-count",
838
- String(maxResults),
839
- ];
840
- if (input.glob?.trim()) {
841
- args.push("--glob", input.glob.trim());
842
- }
843
- args.push("--", query, searchPath);
844
-
845
- const commands = runtimeOptions.ripgrepCommands ?? defaultRipgrepCommands();
846
- let output: RipgrepOutput | undefined;
847
- for (const command of commands) {
848
- try {
849
- output = await runRipgrep(command, args, cwd, signal);
850
- break;
851
- } catch (err) {
852
- if (!isUnavailableExecutableError(err)) throw err;
853
- }
854
- }
855
-
856
- const fallback = output
857
- ? undefined
858
- : await searchCodeWithNode(query, searchPath, input.glob?.trim(), maxResults, signal);
859
- const matches = output
860
- ? output.stdout
861
- .split(/\r?\n/)
862
- .filter(Boolean)
863
- .map(parseRgLine)
864
- .filter((match): match is SearchCodeMatch => !!match)
865
- .slice(0, maxResults)
866
- : fallback!.matches;
867
-
868
- return {
869
- query,
870
- path: searchPath,
871
- ...(input.glob?.trim() ? { glob: input.glob.trim() } : {}),
872
- matches,
873
- truncated: output
874
- ? output.truncated || matches.length >= maxResults
875
- : fallback!.truncated,
876
- };
877
- }
878
-
879
- export async function runCommandForTool(
880
- cwd: string,
881
- input: RunCommandInput,
882
- abortSignal?: AbortSignal,
883
- ): Promise<RunCommandOutput> {
884
- const command = input.command?.trim();
885
- if (!command) throw new Error("command is required");
886
-
887
- const commandCwd = resolveToolPath(cwd, input.cwd);
888
- const cwdInfo = await stat(commandCwd);
889
- if (!cwdInfo.isDirectory()) {
890
- throw new Error(`cwd is not a directory: ${commandCwd}`);
891
- }
892
-
893
- const timeoutMs = normalizeCommandTimeoutMs(input.timeoutMs);
894
- const startedAt = Date.now();
895
- const stdout = { chunks: [] as string[], bytes: 0, truncated: false };
896
- const stderr = { chunks: [] as string[], bytes: 0, truncated: false };
897
-
898
- return new Promise<RunCommandOutput>((resolvePromise, reject) => {
899
- let settled = false;
900
- let timedOut = false;
901
- let timeout: NodeJS.Timeout;
902
- let fallbackTimer: NodeJS.Timeout | undefined;
903
-
904
- const child = spawn(command, {
905
- cwd: commandCwd,
906
- shell: true,
907
- windowsHide: true,
908
- stdio: ["ignore", "pipe", "pipe"],
909
- detached: process.platform !== "win32",
910
- });
911
-
912
- const cleanup = () => {
913
- clearTimeout(timeout);
914
- if (fallbackTimer) clearTimeout(fallbackTimer);
915
- abortSignal?.removeEventListener("abort", abort);
916
- };
917
-
918
- const finish = (exitCode: number | null, signal: NodeJS.Signals | string | null) => {
919
- if (settled) return;
920
- settled = true;
921
- cleanup();
922
- resolvePromise({
923
- command,
924
- cwd: commandCwd,
925
- exitCode,
926
- signal,
927
- stdout: stdout.chunks.join(""),
928
- stderr: stderr.chunks.join(""),
929
- timedOut,
930
- truncated: stdout.truncated || stderr.truncated,
931
- durationMs: Date.now() - startedAt,
932
- });
933
- };
934
-
935
- const requestKill = (reason: NodeJS.Signals | "timeout" | "abort") => {
936
- void killProcessTree(child.pid);
937
- fallbackTimer = setTimeout(() => {
938
- child.stdout?.destroy();
939
- child.stderr?.destroy();
940
- finish(null, reason === "timeout" ? "SIGTERM" : reason);
941
- }, 5_000);
942
- fallbackTimer.unref?.();
943
- };
944
-
945
- timeout = setTimeout(() => {
946
- timedOut = true;
947
- requestKill("timeout");
948
- }, timeoutMs);
949
-
950
- const abort = () => {
951
- requestKill("abort");
952
- };
953
- abortSignal?.addEventListener("abort", abort, { once: true });
954
-
955
- child.stdout?.on("data", (chunk: Buffer) => {
956
- appendLimitedOutput(stdout, chunk);
957
- });
958
- child.stderr?.on("data", (chunk: Buffer) => {
959
- appendLimitedOutput(stderr, chunk);
960
- });
961
- child.once("error", (err) => {
962
- if (settled) return;
963
- settled = true;
964
- cleanup();
965
- reject(err);
966
- });
967
- child.once("close", (code, signal) => {
968
- finish(code, signal);
969
- });
970
- });
971
- }
972
-
973
- export async function editFileForTool(cwd: string, input: EditFileInput): Promise<EditFileOutput> {
974
- if (!Array.isArray(input.edits) || input.edits.length === 0) {
975
- throw new Error("edits must contain at least one replacement");
976
- }
977
-
978
- const filePath = resolveToolPath(cwd, input.path);
979
- const before = await readEditableTextFile(filePath);
980
- assertExpectedSha256(filePath, before.sha, input.expectedSha256);
981
-
982
- // Normalize line endings before matching so that LF-based oldText/newText
983
- // (which is what models typically emit) works against CRLF files checked
984
- // out on Windows. The file's dominant EOL style is restored on write.
985
- const eol = detectEol(before.text);
986
- let text = eol === "\r\n" ? before.text.replace(/\r\n/g, "\n") : before.text;
987
- let editsApplied = 0;
988
- for (const [index, edit] of input.edits.entries()) {
989
- if (!edit.oldText) {
990
- throw new Error(`edit ${index + 1} oldText must not be empty`);
991
- }
992
- const oldText = edit.oldText.replace(/\r\n/g, "\n");
993
- const newText = edit.newText.replace(/\r\n/g, "\n");
994
- const count = countOccurrences(text, oldText);
995
- if (count === 0) {
996
- throw new Error(`edit ${index + 1} oldText was not found in ${filePath}`);
997
- }
998
- if (count > 1 && !edit.replaceAll) {
999
- throw new Error(`edit ${index + 1} oldText matched ${count} times in ${filePath}; set replaceAll=true or provide more context`);
1000
- }
1001
- text = edit.replaceAll
1002
- ? replaceAllLiteral(text, oldText, newText)
1003
- : text.replace(oldText, newText);
1004
- editsApplied += edit.replaceAll ? count : 1;
1005
- }
1006
- if (eol === "\r\n") {
1007
- // After normalization above the buffer contains only \n, so this is safe.
1008
- text = text.replace(/\n/g, "\r\n");
1009
- }
1010
-
1011
- assertTextSize(filePath, text, MAX_EDIT_BYTES);
1012
- const afterSha = sha256(text);
1013
- const changed = afterSha !== before.sha;
1014
- if (changed) {
1015
- await atomicWriteTextFile(filePath, text);
1016
- }
1017
-
1018
- return {
1019
- path: filePath,
1020
- beforeSha256: before.sha,
1021
- afterSha256: afterSha,
1022
- bytesWritten: changed ? Buffer.byteLength(text, "utf8") : 0,
1023
- changed,
1024
- editsApplied,
1025
- };
1026
- }
1027
-
1028
- export async function createFileForTool(cwd: string, input: CreateFileInput): Promise<FileWriteOutput> {
1029
- const filePath = resolveToolPath(cwd, input.path);
1030
- assertTextSize(filePath, input.content, MAX_CREATE_BYTES);
1031
-
1032
- let beforeSha: string | undefined;
1033
- if (await pathExists(filePath)) {
1034
- const existing = await readEditableTextFile(filePath);
1035
- beforeSha = existing.sha;
1036
- assertExpectedSha256(filePath, existing.sha, input.expectedSha256);
1037
- if (!input.overwrite) {
1038
- throw new Error(`File already exists: ${filePath}`);
1039
- }
1040
- } else if (input.expectedSha256) {
1041
- throw new Error(`Cannot check expectedSha256 because file does not exist: ${filePath}`);
1042
- }
1043
-
1044
- const afterSha = sha256(input.content);
1045
- const changed = beforeSha !== afterSha;
1046
- if (changed) {
1047
- await atomicWriteTextFile(filePath, input.content);
1048
- }
1049
-
1050
- return {
1051
- path: filePath,
1052
- ...(beforeSha ? { beforeSha256: beforeSha } : {}),
1053
- afterSha256: afterSha,
1054
- bytesWritten: changed ? Buffer.byteLength(input.content, "utf8") : 0,
1055
- changed,
1056
- };
1057
- }
1058
-
1059
- export async function deleteFileForTool(cwd: string, input: DeleteFileInput): Promise<DeleteFileOutput> {
1060
- const filePath = resolveToolPath(cwd, input.path);
1061
- const before = await readEditableTextFile(filePath);
1062
- assertExpectedSha256(filePath, before.sha, input.expectedSha256);
1063
- await unlink(filePath);
1064
- return {
1065
- path: filePath,
1066
- beforeSha256: before.sha,
1067
- deleted: true,
1068
- };
1069
- }
1070
-
1071
- export async function moveFileForTool(cwd: string, input: MoveFileInput): Promise<MoveFileOutput> {
1072
- const sourcePath = resolveToolPath(cwd, input.sourcePath);
1073
- const destinationPath = resolveToolPath(cwd, input.destinationPath);
1074
- const source = await readEditableTextFile(sourcePath);
1075
- assertExpectedSha256(sourcePath, source.sha, input.expectedSourceSha256);
1076
-
1077
- let destinationSha: string | undefined;
1078
- if (await pathExists(destinationPath)) {
1079
- const destination = await readEditableTextFile(destinationPath);
1080
- destinationSha = destination.sha;
1081
- assertExpectedSha256(destinationPath, destination.sha, input.expectedDestinationSha256);
1082
- if (!input.overwrite) {
1083
- throw new Error(`Destination already exists: ${destinationPath}`);
1084
- }
1085
- } else if (input.expectedDestinationSha256) {
1086
- throw new Error(`Cannot check expectedDestinationSha256 because destination does not exist: ${destinationPath}`);
1087
- }
1088
-
1089
- await mkdir(dirname(destinationPath), { recursive: true });
1090
- try {
1091
- await rename(sourcePath, destinationPath);
1092
- } catch (err) {
1093
- if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err;
1094
- await copyFile(sourcePath, destinationPath);
1095
- await unlink(sourcePath);
1096
- }
1097
-
1098
- return {
1099
- sourcePath,
1100
- destinationPath,
1101
- sourceSha256: source.sha,
1102
- ...(destinationSha ? { overwrittenDestinationSha256: destinationSha } : {}),
1103
- moved: true,
1104
- };
1105
- }
1106
-
1107
- function expectedPatchHash(
1108
- input: ApplyPatchInput,
1109
- absolutePath: string,
1110
- patchPath: string,
1111
- ): string | undefined {
1112
- return input.expectedSha256ByPath?.[absolutePath] ?? input.expectedSha256ByPath?.[patchPath];
1113
- }
1114
-
1115
- export async function applyPatchForTool(cwd: string, input: ApplyPatchInput): Promise<ApplyPatchOutput> {
1116
- const files = parseUnifiedPatch(input.patch);
1117
- const changedFiles: ApplyPatchFileChange[] = [];
1118
-
1119
- for (const file of files) {
1120
- const patchPath = file.newPath ?? file.oldPath;
1121
- if (!patchPath) throw new Error("Patch file is missing both old and new paths");
1122
- const targetPath = resolveToolPath(cwd, patchPath);
1123
- const action: ApplyPatchFileChange["action"] =
1124
- file.oldPath === null ? "create" :
1125
- file.newPath === null ? "delete" :
1126
- "edit";
1127
-
1128
- if (action === "create") {
1129
- if (await pathExists(targetPath)) {
1130
- throw new Error(`Patch target already exists: ${targetPath}`);
1131
- }
1132
- const text = applyParsedHunks(targetPath, "", file.hunks);
1133
- assertTextSize(targetPath, text, MAX_CREATE_BYTES);
1134
- await atomicWriteTextFile(targetPath, text);
1135
- changedFiles.push({
1136
- path: targetPath,
1137
- action,
1138
- afterSha256: sha256(text),
1139
- bytesWritten: Buffer.byteLength(text, "utf8"),
1140
- });
1141
- continue;
1142
- }
1143
-
1144
- const before = await readEditableTextFile(targetPath);
1145
- assertExpectedSha256(targetPath, before.sha, expectedPatchHash(input, targetPath, patchPath));
1146
- const text = applyParsedHunks(targetPath, before.text, file.hunks);
1147
-
1148
- if (action === "delete") {
1149
- await unlink(targetPath);
1150
- changedFiles.push({
1151
- path: targetPath,
1152
- action,
1153
- beforeSha256: before.sha,
1154
- });
1155
- continue;
1156
- }
1157
-
1158
- assertTextSize(targetPath, text, MAX_EDIT_BYTES);
1159
- const afterSha = sha256(text);
1160
- if (afterSha !== before.sha) {
1161
- await atomicWriteTextFile(targetPath, text);
1162
- }
1163
- changedFiles.push({
1164
- path: targetPath,
1165
- action,
1166
- beforeSha256: before.sha,
1167
- afterSha256: afterSha,
1168
- bytesWritten: afterSha !== before.sha ? Buffer.byteLength(text, "utf8") : 0,
1169
- });
1170
- }
1171
-
1172
- return { changedFiles };
1173
- }
1174
-
1175
- export function createBuiltinFileTools(cwd: string): ToolSet {
1176
- return {
1177
- read_file: tool<ReadFileInput, ReadFileOutput>({
1178
- description: "Read a UTF-8 text file from the local filesystem. Use line ranges for large files.",
1179
- inputSchema: jsonSchema<ReadFileInput>({
1180
- type: "object",
1181
- additionalProperties: false,
1182
- properties: {
1183
- path: { type: "string", description: "Absolute path or path relative to the session cwd." },
1184
- startLine: { type: "number", description: "Optional 1-based first line to return." },
1185
- endLine: { type: "number", description: "Optional 1-based last line to return." },
1186
- },
1187
- required: ["path"],
1188
- }),
1189
- execute: (input) => readFileForTool(cwd, input),
1190
- }),
1191
- list_dir: tool<ListDirInput, ListDirOutput>({
1192
- description: "List files in a local directory.",
1193
- inputSchema: jsonSchema<ListDirInput>({
1194
- type: "object",
1195
- additionalProperties: false,
1196
- properties: {
1197
- path: { type: "string", description: "Directory path. Defaults to the session cwd." },
1198
- },
1199
- }),
1200
- execute: (input) => listDirForTool(cwd, input),
1201
- }),
1202
- search_code: tool<SearchCodeInput, SearchCodeOutput>({
1203
- description: "Search local files with ripgrep without invoking a shell.",
1204
- inputSchema: jsonSchema<SearchCodeInput>({
1205
- type: "object",
1206
- additionalProperties: false,
1207
- properties: {
1208
- query: { type: "string", description: "Text or regex query passed to ripgrep." },
1209
- path: { type: "string", description: "File or directory to search. Defaults to the session cwd." },
1210
- glob: { type: "string", description: "Optional ripgrep glob filter, for example **/*.ts." },
1211
- maxResults: { type: "number", description: "Maximum result lines, capped internally." },
1212
- },
1213
- required: ["query"],
1214
- }),
1215
- execute: (input, options) => searchCodeForTool(cwd, input, options.abortSignal),
1216
- }),
1217
- run_command: tool<RunCommandInput, RunCommandOutput>({
1218
- 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.",
1219
- inputSchema: jsonSchema<RunCommandInput>({
1220
- type: "object",
1221
- additionalProperties: false,
1222
- properties: {
1223
- command: { type: "string", description: "Command line to run in the platform shell." },
1224
- cwd: { type: "string", description: "Optional working directory. Defaults to the session cwd." },
1225
- timeoutMs: { type: "number", description: `Optional timeout in milliseconds, capped at ${MAX_COMMAND_TIMEOUT_MS}.` },
1226
- },
1227
- required: ["command"],
1228
- }),
1229
- execute: (input, options) => runCommandForTool(cwd, input, options.abortSignal),
1230
- }),
1231
- edit_file: tool<EditFileInput, EditFileOutput>({
1232
- description: "Edit an existing UTF-8 text file by applying exact oldText -> newText replacements. Uses optional SHA-256 precondition to avoid overwriting concurrent edits.",
1233
- inputSchema: jsonSchema<EditFileInput>({
1234
- type: "object",
1235
- additionalProperties: false,
1236
- properties: {
1237
- path: { type: "string", description: "Absolute path or path relative to the session cwd." },
1238
- expectedSha256: { type: "string", description: "Optional SHA-256 hash of the current file content." },
1239
- edits: {
1240
- type: "array",
1241
- minItems: 1,
1242
- items: {
1243
- type: "object",
1244
- additionalProperties: false,
1245
- properties: {
1246
- oldText: { type: "string", description: "Exact text to replace. Include enough context to make it unique." },
1247
- newText: { type: "string", description: "Replacement text." },
1248
- replaceAll: { type: "boolean", description: "Replace every occurrence when oldText appears multiple times." },
1249
- },
1250
- required: ["oldText", "newText"],
1251
- },
1252
- },
1253
- },
1254
- required: ["path", "edits"],
1255
- }),
1256
- execute: (input) => editFileForTool(cwd, input),
1257
- }),
1258
- create_file: tool<CreateFileInput, FileWriteOutput>({
1259
- description: "Create a UTF-8 text file, or overwrite an existing one when overwrite=true.",
1260
- inputSchema: jsonSchema<CreateFileInput>({
1261
- type: "object",
1262
- additionalProperties: false,
1263
- properties: {
1264
- path: { type: "string", description: "Absolute path or path relative to the session cwd." },
1265
- content: { type: "string", description: "Complete file content to write." },
1266
- overwrite: { type: "boolean", description: "Allow replacing an existing file." },
1267
- expectedSha256: { type: "string", description: "Optional SHA-256 hash required when overwriting an existing file." },
1268
- },
1269
- required: ["path", "content"],
1270
- }),
1271
- execute: (input) => createFileForTool(cwd, input),
1272
- }),
1273
- delete_file: tool<DeleteFileInput, DeleteFileOutput>({
1274
- description: "Delete an existing text file. Use expectedSha256 to avoid deleting a file that changed after reading.",
1275
- inputSchema: jsonSchema<DeleteFileInput>({
1276
- type: "object",
1277
- additionalProperties: false,
1278
- properties: {
1279
- path: { type: "string", description: "Absolute path or path relative to the session cwd." },
1280
- expectedSha256: { type: "string", description: "Optional SHA-256 hash of the file that must be deleted." },
1281
- },
1282
- required: ["path"],
1283
- }),
1284
- execute: (input) => deleteFileForTool(cwd, input),
1285
- }),
1286
- move_file: tool<MoveFileInput, MoveFileOutput>({
1287
- description: "Move or rename an existing text file. Can overwrite an existing destination only when overwrite=true.",
1288
- inputSchema: jsonSchema<MoveFileInput>({
1289
- type: "object",
1290
- additionalProperties: false,
1291
- properties: {
1292
- sourcePath: { type: "string", description: "Existing source file path." },
1293
- destinationPath: { type: "string", description: "Destination file path." },
1294
- overwrite: { type: "boolean", description: "Allow replacing an existing destination file." },
1295
- expectedSourceSha256: { type: "string", description: "Optional SHA-256 hash of the source file." },
1296
- expectedDestinationSha256: { type: "string", description: "Optional SHA-256 hash of the destination file when overwriting." },
1297
- },
1298
- required: ["sourcePath", "destinationPath"],
1299
- }),
1300
- execute: (input) => moveFileForTool(cwd, input),
1301
- }),
1302
- apply_patch: tool<ApplyPatchInput, ApplyPatchOutput>({
1303
- description: "Apply a unified diff patch to one or more UTF-8 text files. Prefer edit_file for small targeted edits.",
1304
- inputSchema: jsonSchema<ApplyPatchInput>({
1305
- type: "object",
1306
- additionalProperties: false,
1307
- properties: {
1308
- patch: { type: "string", description: "Unified diff patch text." },
1309
- expectedSha256ByPath: {
1310
- type: "object",
1311
- description: "Optional map of patch path or absolute path to expected SHA-256 before applying.",
1312
- additionalProperties: { type: "string" },
1313
- },
1314
- },
1315
- required: ["patch"],
1316
- }),
1317
- execute: (input) => applyPatchForTool(cwd, input),
1318
- }),
1319
- };
1320
- }
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 { createRequire } from "node:module";
6
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
7
+ import { createInterface } from "node:readline";
8
+
9
+ import { jsonSchema, tool, type ToolSet } from "ai";
10
+
11
+ import { isDangerousCommand, type PermissionGate, type PermissionRequest } from "./permissions.js";
12
+ import { killProcessTree } from "./proc-tree-kill.js";
13
+
14
+ const MAX_READ_BYTES = 1024 * 1024;
15
+ const MAX_LIST_ENTRIES = 200;
16
+ const MAX_SEARCH_RESULTS = 100;
17
+ const MAX_SEARCH_BYTES = 256 * 1024;
18
+ const MAX_EDIT_BYTES = 2 * 1024 * 1024;
19
+ const MAX_CREATE_BYTES = 2 * 1024 * 1024;
20
+ const MAX_PATCH_BYTES = 512 * 1024;
21
+ const SEARCH_TIMEOUT_MS = 15_000;
22
+ const MAX_COMMAND_OUTPUT_BYTES = 256 * 1024;
23
+ const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
24
+ const MAX_COMMAND_TIMEOUT_MS = 900_000;
25
+ const requireFromHere = createRequire(import.meta.url);
26
+ const FALLBACK_SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
27
+
28
+ export interface ReadFileInput {
29
+ path: string;
30
+ startLine?: number;
31
+ endLine?: number;
32
+ }
33
+
34
+ export interface ReadFileOutput {
35
+ path: string;
36
+ size: number;
37
+ sha256: string;
38
+ isBinary: boolean;
39
+ truncated: boolean;
40
+ startLine?: number;
41
+ endLine?: number;
42
+ totalLines?: number;
43
+ content: string;
44
+ }
45
+
46
+ export interface ListDirInput {
47
+ path?: string;
48
+ }
49
+
50
+ export interface ListDirEntry {
51
+ name: string;
52
+ path: string;
53
+ type: "file" | "directory" | "symlink" | "other";
54
+ size?: number;
55
+ }
56
+
57
+ export interface ListDirOutput {
58
+ path: string;
59
+ entries: ListDirEntry[];
60
+ truncated: boolean;
61
+ }
62
+
63
+ export interface SearchCodeInput {
64
+ query: string;
65
+ path?: string;
66
+ glob?: string;
67
+ maxResults?: number;
68
+ }
69
+
70
+ export interface SearchCodeMatch {
71
+ path: string;
72
+ line: number;
73
+ column: number;
74
+ text: string;
75
+ }
76
+
77
+ export interface SearchCodeOutput {
78
+ query: string;
79
+ path: string;
80
+ glob?: string;
81
+ matches: SearchCodeMatch[];
82
+ truncated: boolean;
83
+ }
84
+
85
+ /** @internal Allows tests to force the dependency-free fallback path. */
86
+ export interface SearchCodeRuntimeOptions {
87
+ ripgrepCommands?: readonly string[];
88
+ }
89
+
90
+ export interface RunCommandInput {
91
+ command: string;
92
+ cwd?: string;
93
+ timeoutMs?: number;
94
+ }
95
+
96
+ export interface RunCommandOutput {
97
+ command: string;
98
+ cwd: string;
99
+ exitCode: number | null;
100
+ signal: string | null;
101
+ stdout: string;
102
+ stderr: string;
103
+ timedOut: boolean;
104
+ truncated: boolean;
105
+ durationMs: number;
106
+ }
107
+
108
+ export interface FileEdit {
109
+ oldText: string;
110
+ newText: string;
111
+ replaceAll?: boolean;
112
+ }
113
+
114
+ export interface EditFileInput {
115
+ path: string;
116
+ expectedSha256?: string;
117
+ edits: FileEdit[];
118
+ }
119
+
120
+ export interface FileWriteOutput {
121
+ path: string;
122
+ beforeSha256?: string;
123
+ afterSha256?: string;
124
+ bytesWritten?: number;
125
+ changed: boolean;
126
+ }
127
+
128
+ export interface EditFileOutput extends FileWriteOutput {
129
+ editsApplied: number;
130
+ }
131
+
132
+ export interface CreateFileInput {
133
+ path: string;
134
+ content: string;
135
+ overwrite?: boolean;
136
+ expectedSha256?: string;
137
+ }
138
+
139
+ export interface DeleteFileInput {
140
+ path: string;
141
+ expectedSha256?: string;
142
+ }
143
+
144
+ export interface DeleteFileOutput {
145
+ path: string;
146
+ beforeSha256: string;
147
+ deleted: true;
148
+ }
149
+
150
+ export interface MoveFileInput {
151
+ sourcePath: string;
152
+ destinationPath: string;
153
+ overwrite?: boolean;
154
+ expectedSourceSha256?: string;
155
+ expectedDestinationSha256?: string;
156
+ }
157
+
158
+ export interface MoveFileOutput {
159
+ sourcePath: string;
160
+ destinationPath: string;
161
+ sourceSha256: string;
162
+ overwrittenDestinationSha256?: string;
163
+ moved: true;
164
+ }
165
+
166
+ export interface ApplyPatchInput {
167
+ patch: string;
168
+ expectedSha256ByPath?: Record<string, string>;
169
+ }
170
+
171
+ export interface ApplyPatchFileChange {
172
+ path: string;
173
+ action: "create" | "edit" | "delete";
174
+ beforeSha256?: string;
175
+ afterSha256?: string;
176
+ bytesWritten?: number;
177
+ }
178
+
179
+ export interface ApplyPatchOutput {
180
+ changedFiles: ApplyPatchFileChange[];
181
+ }
182
+
183
+ function resolveToolPath(cwd: string, value: string | undefined): string {
184
+ const raw = value?.trim();
185
+ if (!raw) return resolve(cwd);
186
+ return isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
187
+ }
188
+
189
+ function toPositiveInt(value: number | undefined): number | undefined {
190
+ if (value === undefined) return undefined;
191
+ if (!Number.isFinite(value)) return undefined;
192
+ const rounded = Math.floor(value);
193
+ return rounded > 0 ? rounded : undefined;
194
+ }
195
+
196
+ function isBinaryBuffer(buffer: Buffer): boolean {
197
+ const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
198
+ return sample.includes(0);
199
+ }
200
+
201
+ async function pathExists(path: string): Promise<boolean> {
202
+ try {
203
+ await stat(path);
204
+ return true;
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ function sha256(buffer: Buffer | string): string {
211
+ return createHash("sha256").update(buffer).digest("hex");
212
+ }
213
+
214
+ async function sha256File(path: string): Promise<string> {
215
+ const hash = createHash("sha256");
216
+ for await (const chunk of createReadStream(path)) {
217
+ hash.update(chunk);
218
+ }
219
+ return hash.digest("hex");
220
+ }
221
+
222
+ function assertExpectedSha256(path: string, actual: string, expected: string | undefined): void {
223
+ if (expected && expected !== actual) {
224
+ throw new Error(`SHA-256 mismatch for ${path}: expected ${expected}, got ${actual}`);
225
+ }
226
+ }
227
+
228
+ function assertTextSize(path: string, text: string, maxBytes: number): void {
229
+ const bytes = Buffer.byteLength(text, "utf8");
230
+ if (bytes > maxBytes) {
231
+ throw new Error(`Content for ${path} is too large: ${bytes} bytes, max ${maxBytes}`);
232
+ }
233
+ }
234
+
235
+ async function readEditableTextFile(path: string): Promise<{ text: string; buffer: Buffer; sha: string }> {
236
+ const info = await stat(path);
237
+ if (info.isDirectory()) {
238
+ throw new Error(`Path is a directory: ${path}`);
239
+ }
240
+ if (info.size > MAX_EDIT_BYTES) {
241
+ throw new Error(`File is too large to edit: ${path} (${info.size} bytes, max ${MAX_EDIT_BYTES})`);
242
+ }
243
+
244
+ const buffer = await readFile(path);
245
+ if (isBinaryBuffer(buffer)) {
246
+ throw new Error(`Refusing to edit binary file: ${path}`);
247
+ }
248
+
249
+ return {
250
+ text: buffer.toString("utf8"),
251
+ buffer,
252
+ sha: sha256(buffer),
253
+ };
254
+ }
255
+
256
+ async function atomicWriteTextFile(path: string, text: string): Promise<void> {
257
+ await mkdir(dirname(path), { recursive: true });
258
+ const tempPath = resolve(
259
+ dirname(path),
260
+ `.chatccc-${basename(path)}-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`,
261
+ );
262
+ try {
263
+ await writeFile(tempPath, text, "utf8");
264
+ await rename(tempPath, path);
265
+ } catch (err) {
266
+ try {
267
+ await unlink(tempPath);
268
+ } catch {
269
+ // Best-effort cleanup.
270
+ }
271
+ throw err;
272
+ }
273
+ }
274
+
275
+ function countOccurrences(text: string, needle: string): number {
276
+ if (!needle) return 0;
277
+ let count = 0;
278
+ let index = 0;
279
+ while (true) {
280
+ const found = text.indexOf(needle, index);
281
+ if (found === -1) return count;
282
+ count++;
283
+ index = found + needle.length;
284
+ }
285
+ }
286
+
287
+ function replaceAllLiteral(text: string, oldText: string, newText: string): string {
288
+ return text.split(oldText).join(newText);
289
+ }
290
+
291
+ function detectEol(text: string): "\r\n" | "\n" {
292
+ return text.includes("\r\n") ? "\r\n" : "\n";
293
+ }
294
+
295
+ function normalizeCommandTimeoutMs(value: number | undefined): number {
296
+ if (value === undefined || !Number.isFinite(value)) return DEFAULT_COMMAND_TIMEOUT_MS;
297
+ return Math.min(Math.max(Math.floor(value), 1_000), MAX_COMMAND_TIMEOUT_MS);
298
+ }
299
+
300
+ function appendLimitedOutput(
301
+ target: { chunks: string[]; bytes: number; truncated: boolean },
302
+ chunk: Buffer,
303
+ ): void {
304
+ const remaining = MAX_COMMAND_OUTPUT_BYTES - target.bytes;
305
+ if (remaining <= 0) {
306
+ target.truncated = true;
307
+ return;
308
+ }
309
+
310
+ if (chunk.byteLength <= remaining) {
311
+ target.chunks.push(chunk.toString("utf8"));
312
+ target.bytes += chunk.byteLength;
313
+ return;
314
+ }
315
+
316
+ target.chunks.push(chunk.subarray(0, remaining).toString("utf8"));
317
+ target.bytes += remaining;
318
+ target.truncated = true;
319
+ }
320
+
321
+ function splitPatchPath(value: string): string | null {
322
+ const token = value.trim().split(/\s+/)[0];
323
+ if (!token || token === "/dev/null") return null;
324
+ if ((token.startsWith("a/") || token.startsWith("b/")) && token.length > 2) {
325
+ return token.slice(2);
326
+ }
327
+ return token;
328
+ }
329
+
330
+ interface ParsedPatchLine {
331
+ kind: "context" | "add" | "remove";
332
+ text: string;
333
+ }
334
+
335
+ interface ParsedPatchHunk {
336
+ oldStart: number;
337
+ lines: ParsedPatchLine[];
338
+ }
339
+
340
+ interface ParsedPatchFile {
341
+ oldPath: string | null;
342
+ newPath: string | null;
343
+ hunks: ParsedPatchHunk[];
344
+ }
345
+
346
+ function parseHunkHeader(line: string): number {
347
+ const match = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/.exec(line);
348
+ if (!match) {
349
+ throw new Error(`Invalid hunk header: ${line}`);
350
+ }
351
+ return Number(match[1]);
352
+ }
353
+
354
+ function parseUnifiedPatch(patch: string): ParsedPatchFile[] {
355
+ assertTextSize("patch", patch, MAX_PATCH_BYTES);
356
+ const normalizedPatch = patch.replace(/\r\n/g, "\n");
357
+ const lines = (normalizedPatch.endsWith("\n") ? normalizedPatch.slice(0, -1) : normalizedPatch).split("\n");
358
+ const files: ParsedPatchFile[] = [];
359
+ let current: ParsedPatchFile | null = null;
360
+ let currentHunk: ParsedPatchHunk | null = null;
361
+
362
+ const finishFile = () => {
363
+ if (!current) return;
364
+ if (!current.oldPath && !current.newPath) {
365
+ current = null;
366
+ currentHunk = null;
367
+ return;
368
+ }
369
+ files.push(current);
370
+ current = null;
371
+ currentHunk = null;
372
+ };
373
+
374
+ for (const line of lines) {
375
+ if (line.startsWith("diff --git ")) {
376
+ finishFile();
377
+ current = { oldPath: null, newPath: null, hunks: [] };
378
+ continue;
379
+ }
380
+
381
+ if (line.startsWith("--- ")) {
382
+ if (current?.hunks.length) finishFile();
383
+ current ??= { oldPath: null, newPath: null, hunks: [] };
384
+ current.oldPath = splitPatchPath(line.slice(4));
385
+ currentHunk = null;
386
+ continue;
387
+ }
388
+
389
+ if (line.startsWith("+++ ")) {
390
+ current ??= { oldPath: null, newPath: null, hunks: [] };
391
+ current.newPath = splitPatchPath(line.slice(4));
392
+ currentHunk = null;
393
+ continue;
394
+ }
395
+
396
+ if (line.startsWith("@@ ")) {
397
+ if (!current) throw new Error(`Hunk without file header: ${line}`);
398
+ currentHunk = { oldStart: parseHunkHeader(line), lines: [] };
399
+ current.hunks.push(currentHunk);
400
+ continue;
401
+ }
402
+
403
+ if (line === "\") continue;
404
+ if (!currentHunk) continue;
405
+
406
+ if (line.startsWith(" ")) {
407
+ currentHunk.lines.push({ kind: "context", text: line.slice(1) });
408
+ } else if (line.startsWith("+")) {
409
+ currentHunk.lines.push({ kind: "add", text: line.slice(1) });
410
+ } else if (line.startsWith("-")) {
411
+ currentHunk.lines.push({ kind: "remove", text: line.slice(1) });
412
+ } else {
413
+ throw new Error(`Invalid patch line: ${line}`);
414
+ }
415
+ }
416
+
417
+ finishFile();
418
+ if (files.length === 0) throw new Error("Patch does not contain any file changes");
419
+ return files;
420
+ }
421
+
422
+ function splitContentLines(text: string): string[] {
423
+ if (text.length === 0) return [];
424
+ return text.replace(/\r\n/g, "\n").split("\n");
425
+ }
426
+
427
+ function applyParsedHunks(path: string, text: string, hunks: ParsedPatchHunk[]): string {
428
+ const eol = detectEol(text);
429
+ const original = splitContentLines(text);
430
+ const output: string[] = [];
431
+ let oldIndex = 0;
432
+
433
+ for (const hunk of hunks) {
434
+ const targetIndex = Math.max(0, hunk.oldStart - 1);
435
+ if (targetIndex < oldIndex) {
436
+ throw new Error(`Overlapping hunk in patch for ${path}`);
437
+ }
438
+ output.push(...original.slice(oldIndex, targetIndex));
439
+ oldIndex = targetIndex;
440
+
441
+ for (const line of hunk.lines) {
442
+ if (line.kind === "add") {
443
+ output.push(line.text);
444
+ continue;
445
+ }
446
+
447
+ if (oldIndex >= original.length || original[oldIndex] !== line.text) {
448
+ throw new Error(`Patch context mismatch in ${path} near line ${oldIndex + 1}`);
449
+ }
450
+
451
+ if (line.kind === "context") {
452
+ output.push(original[oldIndex]);
453
+ }
454
+ oldIndex++;
455
+ }
456
+ }
457
+
458
+ output.push(...original.slice(oldIndex));
459
+ return output.join(eol);
460
+ }
461
+
462
+ export async function readFileForTool(cwd: string, input: ReadFileInput): Promise<ReadFileOutput> {
463
+ const filePath = resolveToolPath(cwd, input.path);
464
+ const info = await stat(filePath);
465
+ if (info.isDirectory()) {
466
+ throw new Error(`Path is a directory: ${filePath}`);
467
+ }
468
+
469
+ const bytesToRead = Math.min(info.size, MAX_READ_BYTES);
470
+ const buffer = Buffer.alloc(bytesToRead);
471
+ const handle = await open(filePath, "r");
472
+ try {
473
+ await handle.read(buffer, 0, bytesToRead, 0);
474
+ } finally {
475
+ await handle.close();
476
+ }
477
+ const fileSha256 = await sha256File(filePath);
478
+ const isBinary = isBinaryBuffer(buffer);
479
+ if (isBinary) {
480
+ return {
481
+ path: filePath,
482
+ size: info.size,
483
+ sha256: fileSha256,
484
+ isBinary: true,
485
+ truncated: info.size > bytesToRead,
486
+ content: "",
487
+ };
488
+ }
489
+
490
+ const text = buffer.toString("utf8");
491
+ const lines = text.split(/\r?\n/);
492
+ const totalLines = lines.length;
493
+ const startLine = toPositiveInt(input.startLine) ?? 1;
494
+ const endLine = toPositiveInt(input.endLine) ?? totalLines;
495
+ const normalizedEnd = Math.max(startLine, Math.min(endLine, totalLines));
496
+ const content = lines.slice(startLine - 1, normalizedEnd).join("\n");
497
+
498
+ return {
499
+ path: filePath,
500
+ size: info.size,
501
+ sha256: fileSha256,
502
+ isBinary: false,
503
+ truncated: info.size > bytesToRead || normalizedEnd < totalLines || startLine > 1,
504
+ startLine,
505
+ endLine: normalizedEnd,
506
+ totalLines,
507
+ content,
508
+ };
509
+ }
510
+
511
+ export async function listDirForTool(cwd: string, input: ListDirInput = {}): Promise<ListDirOutput> {
512
+ const dirPath = resolveToolPath(cwd, input.path);
513
+ const entries = await readdir(dirPath, { withFileTypes: true });
514
+ const selected = entries.slice(0, MAX_LIST_ENTRIES);
515
+ const result: ListDirEntry[] = [];
516
+
517
+ for (const entry of selected) {
518
+ const entryPath = resolve(dirPath, entry.name);
519
+ let size: number | undefined;
520
+ if (entry.isFile()) {
521
+ try {
522
+ size = (await stat(entryPath)).size;
523
+ } catch {
524
+ size = undefined;
525
+ }
526
+ }
527
+ result.push({
528
+ name: entry.name,
529
+ path: entryPath,
530
+ type: entry.isDirectory()
531
+ ? "directory"
532
+ : entry.isFile()
533
+ ? "file"
534
+ : entry.isSymbolicLink()
535
+ ? "symlink"
536
+ : "other",
537
+ ...(size !== undefined ? { size } : {}),
538
+ });
539
+ }
540
+
541
+ return {
542
+ path: dirPath,
543
+ entries: result,
544
+ truncated: entries.length > selected.length,
545
+ };
546
+ }
547
+
548
+ function parseRgLine(line: string): SearchCodeMatch | null {
549
+ const match = /^(.*?):(\d+):(\d+):(.*)$/.exec(line);
550
+ if (!match) return null;
551
+ return {
552
+ path: match[1],
553
+ line: Number(match[2]),
554
+ column: Number(match[3]),
555
+ text: match[4],
556
+ };
557
+ }
558
+
559
+ interface RipgrepOutput {
560
+ stdout: string;
561
+ stderr: string;
562
+ truncated: boolean;
563
+ }
564
+
565
+ function resolveBundledRipgrepPath(): string | undefined {
566
+ try {
567
+ const bundled = requireFromHere("@vscode/ripgrep") as { rgPath?: unknown };
568
+ return typeof bundled.rgPath === "string" && bundled.rgPath.trim()
569
+ ? bundled.rgPath
570
+ : undefined;
571
+ } catch {
572
+ // Unsupported platforms or damaged optional platform packages must not
573
+ // prevent ChatCCC itself from starting; system rg / Node fallback remain.
574
+ return undefined;
575
+ }
576
+ }
577
+
578
+ function defaultRipgrepCommands(): string[] {
579
+ const bundled = resolveBundledRipgrepPath();
580
+ return [...new Set([bundled, "rg"].filter((value): value is string => !!value))];
581
+ }
582
+
583
+ function isUnavailableExecutableError(err: unknown): boolean {
584
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
585
+ return code === "ENOENT" || code === "EACCES" || code === "EPERM";
586
+ }
587
+
588
+ async function runRipgrep(
589
+ command: string,
590
+ args: string[],
591
+ cwd: string,
592
+ signal?: AbortSignal,
593
+ ): Promise<RipgrepOutput> {
594
+ return new Promise<RipgrepOutput>((resolvePromise, reject) => {
595
+ let settled = false;
596
+ const child = spawn(command, args, {
597
+ cwd,
598
+ shell: false,
599
+ windowsHide: true,
600
+ stdio: ["ignore", "pipe", "pipe"],
601
+ });
602
+
603
+ let stdout = "";
604
+ let stderr = "";
605
+ let truncated = false;
606
+ const cleanup = () => {
607
+ clearTimeout(timeout);
608
+ signal?.removeEventListener("abort", abort);
609
+ };
610
+ const rejectOnce = (err: Error) => {
611
+ if (settled) return;
612
+ settled = true;
613
+ cleanup();
614
+ reject(err);
615
+ };
616
+ const timeout = setTimeout(() => {
617
+ child.kill();
618
+ rejectOnce(new Error(`search_code timed out after ${SEARCH_TIMEOUT_MS}ms`));
619
+ }, SEARCH_TIMEOUT_MS);
620
+ const abort = () => {
621
+ child.kill();
622
+ rejectOnce(new Error("search_code aborted"));
623
+ };
624
+ signal?.addEventListener("abort", abort, { once: true });
625
+
626
+ child.stdout?.on("data", (chunk: Buffer) => {
627
+ if (stdout.length >= MAX_SEARCH_BYTES) {
628
+ truncated = true;
629
+ return;
630
+ }
631
+ stdout += chunk.toString("utf8");
632
+ if (stdout.length > MAX_SEARCH_BYTES) {
633
+ stdout = stdout.slice(0, MAX_SEARCH_BYTES);
634
+ truncated = true;
635
+ }
636
+ });
637
+ child.stderr?.on("data", (chunk: Buffer) => {
638
+ stderr += chunk.toString("utf8");
639
+ });
640
+ child.on("error", (err) => rejectOnce(err));
641
+ child.on("close", (code) => {
642
+ if (settled) return;
643
+ settled = true;
644
+ cleanup();
645
+ if (code !== 0 && code !== 1) {
646
+ reject(new Error(stderr.trim() || `rg exited with code ${code}`));
647
+ return;
648
+ }
649
+ resolvePromise({ stdout, stderr, truncated });
650
+ });
651
+ });
652
+ }
653
+
654
+ function expandGlobBraces(pattern: string): string[] {
655
+ const openIndex = pattern.indexOf("{");
656
+ if (openIndex < 0) return [pattern];
657
+ const closeIndex = pattern.indexOf("}", openIndex + 1);
658
+ if (closeIndex < 0) return [pattern];
659
+ const alternatives = pattern.slice(openIndex + 1, closeIndex).split(",");
660
+ if (alternatives.length < 2) return [pattern];
661
+ return alternatives.flatMap((alternative) => expandGlobBraces(
662
+ pattern.slice(0, openIndex) + alternative + pattern.slice(closeIndex + 1),
663
+ ));
664
+ }
665
+
666
+ function globToRegExp(pattern: string): RegExp {
667
+ let source = "";
668
+ for (let index = 0; index < pattern.length; index += 1) {
669
+ const char = pattern[index];
670
+ if (char === "*") {
671
+ if (pattern[index + 1] === "*") {
672
+ index += 1;
673
+ if (pattern[index + 1] === "/") {
674
+ index += 1;
675
+ source += "(?:.*/)?";
676
+ } else {
677
+ source += ".*";
678
+ }
679
+ } else {
680
+ source += "[^/]*";
681
+ }
682
+ } else if (char === "?") {
683
+ source += "[^/]";
684
+ } else {
685
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
686
+ }
687
+ }
688
+ return new RegExp(`^${source}$`);
689
+ }
690
+
691
+ function createGlobMatchers(glob: string | undefined): Array<{ regex: RegExp; basenameOnly: boolean }> {
692
+ if (!glob?.trim()) return [];
693
+ return expandGlobBraces(glob.trim().replaceAll("\\", "/")).map((pattern) => ({
694
+ regex: globToRegExp(pattern),
695
+ basenameOnly: !pattern.includes("/"),
696
+ }));
697
+ }
698
+
699
+ function matchesFallbackGlob(
700
+ filePath: string,
701
+ searchRoot: string,
702
+ matchers: Array<{ regex: RegExp; basenameOnly: boolean }>,
703
+ ): boolean {
704
+ if (matchers.length === 0) return true;
705
+ const relativePath = relative(searchRoot, filePath).split(sep).join("/");
706
+ return matchers.some(({ regex, basenameOnly }) => regex.test(
707
+ basenameOnly ? basename(filePath) : relativePath,
708
+ ));
709
+ }
710
+
711
+ async function searchCodeWithNode(
712
+ query: string,
713
+ searchPath: string,
714
+ glob: string | undefined,
715
+ maxResults: number,
716
+ signal?: AbortSignal,
717
+ ): Promise<{ matches: SearchCodeMatch[]; truncated: boolean }> {
718
+ let queryRegex: RegExp;
719
+ try {
720
+ queryRegex = new RegExp(query);
721
+ } catch (err) {
722
+ throw new Error(`invalid search regex: ${(err as Error).message}`);
723
+ }
724
+
725
+ const startedAt = Date.now();
726
+ const matches: SearchCodeMatch[] = [];
727
+ const rootInfo = await stat(searchPath);
728
+ const searchRoot = rootInfo.isDirectory() ? searchPath : dirname(searchPath);
729
+ const globMatchers = createGlobMatchers(glob);
730
+ let truncated = false;
731
+ let outputBytes = 0;
732
+
733
+ const ensureActive = () => {
734
+ if (signal?.aborted) throw new Error("search_code aborted");
735
+ if (Date.now() - startedAt >= SEARCH_TIMEOUT_MS) {
736
+ throw new Error(`search_code timed out after ${SEARCH_TIMEOUT_MS}ms`);
737
+ }
738
+ };
739
+
740
+ const searchFile = async (filePath: string) => {
741
+ if (!matchesFallbackGlob(filePath, searchRoot, globMatchers)) return;
742
+ ensureActive();
743
+ const input = createReadStream(filePath, { encoding: "utf8" });
744
+ const lines = createInterface({ input, crlfDelay: Infinity });
745
+ let lineNumber = 0;
746
+ try {
747
+ for await (const line of lines) {
748
+ ensureActive();
749
+ lineNumber += 1;
750
+ if (line.includes("\0")) break;
751
+ const match = queryRegex.exec(line);
752
+ queryRegex.lastIndex = 0;
753
+ if (!match) continue;
754
+ const lineBytes = Buffer.byteLength(line, "utf8");
755
+ if (outputBytes + lineBytes > MAX_SEARCH_BYTES) {
756
+ truncated = true;
757
+ break;
758
+ }
759
+ outputBytes += lineBytes;
760
+ matches.push({
761
+ path: filePath,
762
+ line: lineNumber,
763
+ column: match.index + 1,
764
+ text: line,
765
+ });
766
+ if (matches.length >= maxResults) {
767
+ truncated = true;
768
+ break;
769
+ }
770
+ }
771
+ } catch (err) {
772
+ if (signal?.aborted) throw new Error("search_code aborted");
773
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
774
+ if (code !== "EACCES" && code !== "EPERM" && code !== "ENOENT") throw err;
775
+ } finally {
776
+ lines.close();
777
+ input.destroy();
778
+ }
779
+ };
780
+
781
+ const visit = async (currentPath: string): Promise<void> => {
782
+ ensureActive();
783
+ if (truncated) return;
784
+ let info;
785
+ try {
786
+ info = currentPath === searchPath ? rootInfo : await stat(currentPath);
787
+ } catch (err) {
788
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
789
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT") return;
790
+ throw err;
791
+ }
792
+ if (info.isFile()) {
793
+ await searchFile(currentPath);
794
+ return;
795
+ }
796
+ if (!info.isDirectory()) return;
797
+
798
+ let entries;
799
+ try {
800
+ entries = await readdir(currentPath, { withFileTypes: true });
801
+ } catch (err) {
802
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
803
+ if (code === "EACCES" || code === "EPERM" || code === "ENOENT") return;
804
+ throw err;
805
+ }
806
+ entries.sort((left, right) => left.name.localeCompare(right.name));
807
+ for (const entry of entries) {
808
+ if (truncated) break;
809
+ if (entry.name.startsWith(".")) continue;
810
+ if (entry.isDirectory() && FALLBACK_SKIPPED_DIRECTORIES.has(entry.name)) continue;
811
+ if (entry.isSymbolicLink()) continue;
812
+ await visit(resolve(currentPath, entry.name));
813
+ }
814
+ };
815
+
816
+ await visit(searchPath);
817
+ return { matches, truncated };
818
+ }
819
+
820
+ export async function searchCodeForTool(
821
+ cwd: string,
822
+ input: SearchCodeInput,
823
+ signal?: AbortSignal,
824
+ runtimeOptions: SearchCodeRuntimeOptions = {},
825
+ ): Promise<SearchCodeOutput> {
826
+ const query = input.query?.trim();
827
+ if (!query) throw new Error("query is required");
828
+ if (signal?.aborted) throw new Error("search_code aborted");
829
+
830
+ const searchPath = resolveToolPath(cwd, input.path);
831
+ const maxResults = Math.min(toPositiveInt(input.maxResults) ?? 50, MAX_SEARCH_RESULTS);
832
+ const args = [
833
+ "--line-number",
834
+ "--column",
835
+ "--no-heading",
836
+ "--color",
837
+ "never",
838
+ "--max-count",
839
+ String(maxResults),
840
+ ];
841
+ if (input.glob?.trim()) {
842
+ args.push("--glob", input.glob.trim());
843
+ }
844
+ args.push("--", query, searchPath);
845
+
846
+ const commands = runtimeOptions.ripgrepCommands ?? defaultRipgrepCommands();
847
+ let output: RipgrepOutput | undefined;
848
+ for (const command of commands) {
849
+ try {
850
+ output = await runRipgrep(command, args, cwd, signal);
851
+ break;
852
+ } catch (err) {
853
+ if (!isUnavailableExecutableError(err)) throw err;
854
+ }
855
+ }
856
+
857
+ const fallback = output
858
+ ? undefined
859
+ : await searchCodeWithNode(query, searchPath, input.glob?.trim(), maxResults, signal);
860
+ const matches = output
861
+ ? output.stdout
862
+ .split(/\r?\n/)
863
+ .filter(Boolean)
864
+ .map(parseRgLine)
865
+ .filter((match): match is SearchCodeMatch => !!match)
866
+ .slice(0, maxResults)
867
+ : fallback!.matches;
868
+
869
+ return {
870
+ query,
871
+ path: searchPath,
872
+ ...(input.glob?.trim() ? { glob: input.glob.trim() } : {}),
873
+ matches,
874
+ truncated: output
875
+ ? output.truncated || matches.length >= maxResults
876
+ : fallback!.truncated,
877
+ };
878
+ }
879
+
880
+ export async function runCommandForTool(
881
+ cwd: string,
882
+ input: RunCommandInput,
883
+ abortSignal?: AbortSignal,
884
+ ): Promise<RunCommandOutput> {
885
+ const command = input.command?.trim();
886
+ if (!command) throw new Error("command is required");
887
+
888
+ const commandCwd = resolveToolPath(cwd, input.cwd);
889
+ const cwdInfo = await stat(commandCwd);
890
+ if (!cwdInfo.isDirectory()) {
891
+ throw new Error(`cwd is not a directory: ${commandCwd}`);
892
+ }
893
+
894
+ const timeoutMs = normalizeCommandTimeoutMs(input.timeoutMs);
895
+ const startedAt = Date.now();
896
+ const stdout = { chunks: [] as string[], bytes: 0, truncated: false };
897
+ const stderr = { chunks: [] as string[], bytes: 0, truncated: false };
898
+
899
+ return new Promise<RunCommandOutput>((resolvePromise, reject) => {
900
+ let settled = false;
901
+ let timedOut = false;
902
+ let timeout: NodeJS.Timeout;
903
+ let fallbackTimer: NodeJS.Timeout | undefined;
904
+
905
+ const child = spawn(command, {
906
+ cwd: commandCwd,
907
+ shell: true,
908
+ windowsHide: true,
909
+ stdio: ["ignore", "pipe", "pipe"],
910
+ detached: process.platform !== "win32",
911
+ });
912
+
913
+ const cleanup = () => {
914
+ clearTimeout(timeout);
915
+ if (fallbackTimer) clearTimeout(fallbackTimer);
916
+ abortSignal?.removeEventListener("abort", abort);
917
+ };
918
+
919
+ const finish = (exitCode: number | null, signal: NodeJS.Signals | string | null) => {
920
+ if (settled) return;
921
+ settled = true;
922
+ cleanup();
923
+ resolvePromise({
924
+ command,
925
+ cwd: commandCwd,
926
+ exitCode,
927
+ signal,
928
+ stdout: stdout.chunks.join(""),
929
+ stderr: stderr.chunks.join(""),
930
+ timedOut,
931
+ truncated: stdout.truncated || stderr.truncated,
932
+ durationMs: Date.now() - startedAt,
933
+ });
934
+ };
935
+
936
+ const requestKill = (reason: NodeJS.Signals | "timeout" | "abort") => {
937
+ void killProcessTree(child.pid);
938
+ fallbackTimer = setTimeout(() => {
939
+ child.stdout?.destroy();
940
+ child.stderr?.destroy();
941
+ finish(null, reason === "timeout" ? "SIGTERM" : reason);
942
+ }, 5_000);
943
+ fallbackTimer.unref?.();
944
+ };
945
+
946
+ timeout = setTimeout(() => {
947
+ timedOut = true;
948
+ requestKill("timeout");
949
+ }, timeoutMs);
950
+
951
+ const abort = () => {
952
+ requestKill("abort");
953
+ };
954
+ abortSignal?.addEventListener("abort", abort, { once: true });
955
+
956
+ child.stdout?.on("data", (chunk: Buffer) => {
957
+ appendLimitedOutput(stdout, chunk);
958
+ });
959
+ child.stderr?.on("data", (chunk: Buffer) => {
960
+ appendLimitedOutput(stderr, chunk);
961
+ });
962
+ child.once("error", (err) => {
963
+ if (settled) return;
964
+ settled = true;
965
+ cleanup();
966
+ reject(err);
967
+ });
968
+ child.once("close", (code, signal) => {
969
+ finish(code, signal);
970
+ });
971
+ });
972
+ }
973
+
974
+ export async function editFileForTool(cwd: string, input: EditFileInput): Promise<EditFileOutput> {
975
+ if (!Array.isArray(input.edits) || input.edits.length === 0) {
976
+ throw new Error("edits must contain at least one replacement");
977
+ }
978
+
979
+ const filePath = resolveToolPath(cwd, input.path);
980
+ const before = await readEditableTextFile(filePath);
981
+ assertExpectedSha256(filePath, before.sha, input.expectedSha256);
982
+
983
+ // Normalize line endings before matching so that LF-based oldText/newText
984
+ // (which is what models typically emit) works against CRLF files checked
985
+ // out on Windows. The file's dominant EOL style is restored on write.
986
+ const eol = detectEol(before.text);
987
+ let text = eol === "\r\n" ? before.text.replace(/\r\n/g, "\n") : before.text;
988
+ let editsApplied = 0;
989
+ for (const [index, edit] of input.edits.entries()) {
990
+ if (!edit.oldText) {
991
+ throw new Error(`edit ${index + 1} oldText must not be empty`);
992
+ }
993
+ const oldText = edit.oldText.replace(/\r\n/g, "\n");
994
+ const newText = edit.newText.replace(/\r\n/g, "\n");
995
+ const count = countOccurrences(text, oldText);
996
+ if (count === 0) {
997
+ throw new Error(`edit ${index + 1} oldText was not found in ${filePath}`);
998
+ }
999
+ if (count > 1 && !edit.replaceAll) {
1000
+ throw new Error(`edit ${index + 1} oldText matched ${count} times in ${filePath}; set replaceAll=true or provide more context`);
1001
+ }
1002
+ text = edit.replaceAll
1003
+ ? replaceAllLiteral(text, oldText, newText)
1004
+ : text.replace(oldText, newText);
1005
+ editsApplied += edit.replaceAll ? count : 1;
1006
+ }
1007
+ if (eol === "\r\n") {
1008
+ // After normalization above the buffer contains only \n, so this is safe.
1009
+ text = text.replace(/\n/g, "\r\n");
1010
+ }
1011
+
1012
+ assertTextSize(filePath, text, MAX_EDIT_BYTES);
1013
+ const afterSha = sha256(text);
1014
+ const changed = afterSha !== before.sha;
1015
+ if (changed) {
1016
+ await atomicWriteTextFile(filePath, text);
1017
+ }
1018
+
1019
+ return {
1020
+ path: filePath,
1021
+ beforeSha256: before.sha,
1022
+ afterSha256: afterSha,
1023
+ bytesWritten: changed ? Buffer.byteLength(text, "utf8") : 0,
1024
+ changed,
1025
+ editsApplied,
1026
+ };
1027
+ }
1028
+
1029
+ export async function createFileForTool(cwd: string, input: CreateFileInput): Promise<FileWriteOutput> {
1030
+ const filePath = resolveToolPath(cwd, input.path);
1031
+ assertTextSize(filePath, input.content, MAX_CREATE_BYTES);
1032
+
1033
+ let beforeSha: string | undefined;
1034
+ if (await pathExists(filePath)) {
1035
+ const existing = await readEditableTextFile(filePath);
1036
+ beforeSha = existing.sha;
1037
+ assertExpectedSha256(filePath, existing.sha, input.expectedSha256);
1038
+ if (!input.overwrite) {
1039
+ throw new Error(`File already exists: ${filePath}`);
1040
+ }
1041
+ } else if (input.expectedSha256) {
1042
+ throw new Error(`Cannot check expectedSha256 because file does not exist: ${filePath}`);
1043
+ }
1044
+
1045
+ const afterSha = sha256(input.content);
1046
+ const changed = beforeSha !== afterSha;
1047
+ if (changed) {
1048
+ await atomicWriteTextFile(filePath, input.content);
1049
+ }
1050
+
1051
+ return {
1052
+ path: filePath,
1053
+ ...(beforeSha ? { beforeSha256: beforeSha } : {}),
1054
+ afterSha256: afterSha,
1055
+ bytesWritten: changed ? Buffer.byteLength(input.content, "utf8") : 0,
1056
+ changed,
1057
+ };
1058
+ }
1059
+
1060
+ export async function deleteFileForTool(cwd: string, input: DeleteFileInput): Promise<DeleteFileOutput> {
1061
+ const filePath = resolveToolPath(cwd, input.path);
1062
+ const before = await readEditableTextFile(filePath);
1063
+ assertExpectedSha256(filePath, before.sha, input.expectedSha256);
1064
+ await unlink(filePath);
1065
+ return {
1066
+ path: filePath,
1067
+ beforeSha256: before.sha,
1068
+ deleted: true,
1069
+ };
1070
+ }
1071
+
1072
+ export async function moveFileForTool(cwd: string, input: MoveFileInput): Promise<MoveFileOutput> {
1073
+ const sourcePath = resolveToolPath(cwd, input.sourcePath);
1074
+ const destinationPath = resolveToolPath(cwd, input.destinationPath);
1075
+ const source = await readEditableTextFile(sourcePath);
1076
+ assertExpectedSha256(sourcePath, source.sha, input.expectedSourceSha256);
1077
+
1078
+ let destinationSha: string | undefined;
1079
+ if (await pathExists(destinationPath)) {
1080
+ const destination = await readEditableTextFile(destinationPath);
1081
+ destinationSha = destination.sha;
1082
+ assertExpectedSha256(destinationPath, destination.sha, input.expectedDestinationSha256);
1083
+ if (!input.overwrite) {
1084
+ throw new Error(`Destination already exists: ${destinationPath}`);
1085
+ }
1086
+ } else if (input.expectedDestinationSha256) {
1087
+ throw new Error(`Cannot check expectedDestinationSha256 because destination does not exist: ${destinationPath}`);
1088
+ }
1089
+
1090
+ await mkdir(dirname(destinationPath), { recursive: true });
1091
+ try {
1092
+ await rename(sourcePath, destinationPath);
1093
+ } catch (err) {
1094
+ if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err;
1095
+ await copyFile(sourcePath, destinationPath);
1096
+ await unlink(sourcePath);
1097
+ }
1098
+
1099
+ return {
1100
+ sourcePath,
1101
+ destinationPath,
1102
+ sourceSha256: source.sha,
1103
+ ...(destinationSha ? { overwrittenDestinationSha256: destinationSha } : {}),
1104
+ moved: true,
1105
+ };
1106
+ }
1107
+
1108
+ function expectedPatchHash(
1109
+ input: ApplyPatchInput,
1110
+ absolutePath: string,
1111
+ patchPath: string,
1112
+ ): string | undefined {
1113
+ return input.expectedSha256ByPath?.[absolutePath] ?? input.expectedSha256ByPath?.[patchPath];
1114
+ }
1115
+
1116
+ export async function applyPatchForTool(cwd: string, input: ApplyPatchInput): Promise<ApplyPatchOutput> {
1117
+ const files = parseUnifiedPatch(input.patch);
1118
+ const changedFiles: ApplyPatchFileChange[] = [];
1119
+
1120
+ for (const file of files) {
1121
+ const patchPath = file.newPath ?? file.oldPath;
1122
+ if (!patchPath) throw new Error("Patch file is missing both old and new paths");
1123
+ const targetPath = resolveToolPath(cwd, patchPath);
1124
+ const action: ApplyPatchFileChange["action"] =
1125
+ file.oldPath === null ? "create" :
1126
+ file.newPath === null ? "delete" :
1127
+ "edit";
1128
+
1129
+ if (action === "create") {
1130
+ if (await pathExists(targetPath)) {
1131
+ throw new Error(`Patch target already exists: ${targetPath}`);
1132
+ }
1133
+ const text = applyParsedHunks(targetPath, "", file.hunks);
1134
+ assertTextSize(targetPath, text, MAX_CREATE_BYTES);
1135
+ await atomicWriteTextFile(targetPath, text);
1136
+ changedFiles.push({
1137
+ path: targetPath,
1138
+ action,
1139
+ afterSha256: sha256(text),
1140
+ bytesWritten: Buffer.byteLength(text, "utf8"),
1141
+ });
1142
+ continue;
1143
+ }
1144
+
1145
+ const before = await readEditableTextFile(targetPath);
1146
+ assertExpectedSha256(targetPath, before.sha, expectedPatchHash(input, targetPath, patchPath));
1147
+ const text = applyParsedHunks(targetPath, before.text, file.hunks);
1148
+
1149
+ if (action === "delete") {
1150
+ await unlink(targetPath);
1151
+ changedFiles.push({
1152
+ path: targetPath,
1153
+ action,
1154
+ beforeSha256: before.sha,
1155
+ });
1156
+ continue;
1157
+ }
1158
+
1159
+ assertTextSize(targetPath, text, MAX_EDIT_BYTES);
1160
+ const afterSha = sha256(text);
1161
+ if (afterSha !== before.sha) {
1162
+ await atomicWriteTextFile(targetPath, text);
1163
+ }
1164
+ changedFiles.push({
1165
+ path: targetPath,
1166
+ action,
1167
+ beforeSha256: before.sha,
1168
+ afterSha256: afterSha,
1169
+ bytesWritten: afterSha !== before.sha ? Buffer.byteLength(text, "utf8") : 0,
1170
+ });
1171
+ }
1172
+
1173
+ return { changedFiles };
1174
+ }
1175
+
1176
+ export interface BuiltinFileToolsOptions {
1177
+ /** 权限门控:副作用工具(run_command/文件写操作)执行前会先经过 gate.check */
1178
+ permissionGate?: PermissionGate;
1179
+ }
1180
+
1181
+ export function createBuiltinFileTools(
1182
+ cwd: string,
1183
+ options: BuiltinFileToolsOptions = {},
1184
+ ): ToolSet {
1185
+ const gate = options.permissionGate;
1186
+
1187
+ /** 文件路径的候选匹配键:原始路径 + 绝对路径 + 相对 cwd 路径(正/反斜杠双版本,规则可任选其一) */
1188
+ const pathKeys = (p: string): string[] => {
1189
+ const abs = resolveToolPath(cwd, p);
1190
+ const rel = relative(cwd, abs);
1191
+ return [
1192
+ p,
1193
+ abs,
1194
+ rel,
1195
+ rel.split(sep).join("/"),
1196
+ abs.split(sep).join("/"),
1197
+ ];
1198
+ };
1199
+
1200
+ /** 副作用工具执行前的权限守卫:gate 拒绝时抛错,工具不会真正执行 */
1201
+ const guard = async (request: PermissionRequest): Promise<void> => {
1202
+ if (!gate) return;
1203
+ const decision = await gate.check(request);
1204
+ if (decision === "deny") {
1205
+ throw new Error(
1206
+ `权限拒绝:${request.detail}(已按规则或用户选择拦截;如需放行请调整 ~/.deepccc/allow.json,或使用 --dangerously-bypass-permissions)`,
1207
+ );
1208
+ }
1209
+ };
1210
+
1211
+ return {
1212
+ read_file: tool<ReadFileInput, ReadFileOutput>({
1213
+ description: "Read a UTF-8 text file from the local filesystem. Use line ranges for large files.",
1214
+ inputSchema: jsonSchema<ReadFileInput>({
1215
+ type: "object",
1216
+ additionalProperties: false,
1217
+ properties: {
1218
+ path: { type: "string", description: "Absolute path or path relative to the session cwd." },
1219
+ startLine: { type: "number", description: "Optional 1-based first line to return." },
1220
+ endLine: { type: "number", description: "Optional 1-based last line to return." },
1221
+ },
1222
+ required: ["path"],
1223
+ }),
1224
+ execute: (input) => readFileForTool(cwd, input),
1225
+ }),
1226
+ list_dir: tool<ListDirInput, ListDirOutput>({
1227
+ description: "List files in a local directory.",
1228
+ inputSchema: jsonSchema<ListDirInput>({
1229
+ type: "object",
1230
+ additionalProperties: false,
1231
+ properties: {
1232
+ path: { type: "string", description: "Directory path. Defaults to the session cwd." },
1233
+ },
1234
+ }),
1235
+ execute: (input) => listDirForTool(cwd, input),
1236
+ }),
1237
+ search_code: tool<SearchCodeInput, SearchCodeOutput>({
1238
+ description: "Search local files with ripgrep without invoking a shell.",
1239
+ inputSchema: jsonSchema<SearchCodeInput>({
1240
+ type: "object",
1241
+ additionalProperties: false,
1242
+ properties: {
1243
+ query: { type: "string", description: "Text or regex query passed to ripgrep." },
1244
+ path: { type: "string", description: "File or directory to search. Defaults to the session cwd." },
1245
+ glob: { type: "string", description: "Optional ripgrep glob filter, for example **/*.ts." },
1246
+ maxResults: { type: "number", description: "Maximum result lines, capped internally." },
1247
+ },
1248
+ required: ["query"],
1249
+ }),
1250
+ execute: (input, options) => searchCodeForTool(cwd, input, options.abortSignal),
1251
+ }),
1252
+ run_command: tool<RunCommandInput, RunCommandOutput>({
1253
+ 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.",
1254
+ inputSchema: jsonSchema<RunCommandInput>({
1255
+ type: "object",
1256
+ additionalProperties: false,
1257
+ properties: {
1258
+ command: { type: "string", description: "Command line to run in the platform shell." },
1259
+ cwd: { type: "string", description: "Optional working directory. Defaults to the session cwd." },
1260
+ timeoutMs: { type: "number", description: `Optional timeout in milliseconds, capped at ${MAX_COMMAND_TIMEOUT_MS}.` },
1261
+ },
1262
+ required: ["command"],
1263
+ }),
1264
+ execute: async (input, options) => {
1265
+ await guard({
1266
+ tool: "run_command",
1267
+ action: input.command,
1268
+ reason: isDangerousCommand(input.command) ? "high-risk" : "rule",
1269
+ detail: `运行命令: ${input.command}`,
1270
+ });
1271
+ return runCommandForTool(cwd, input, options.abortSignal);
1272
+ },
1273
+ }),
1274
+ edit_file: tool<EditFileInput, EditFileOutput>({
1275
+ description: "Edit an existing UTF-8 text file by applying exact oldText -> newText replacements. Uses optional SHA-256 precondition to avoid overwriting concurrent edits.",
1276
+ inputSchema: jsonSchema<EditFileInput>({
1277
+ type: "object",
1278
+ additionalProperties: false,
1279
+ properties: {
1280
+ path: { type: "string", description: "Absolute path or path relative to the session cwd." },
1281
+ expectedSha256: { type: "string", description: "Optional SHA-256 hash of the current file content." },
1282
+ edits: {
1283
+ type: "array",
1284
+ minItems: 1,
1285
+ items: {
1286
+ type: "object",
1287
+ additionalProperties: false,
1288
+ properties: {
1289
+ oldText: { type: "string", description: "Exact text to replace. Include enough context to make it unique." },
1290
+ newText: { type: "string", description: "Replacement text." },
1291
+ replaceAll: { type: "boolean", description: "Replace every occurrence when oldText appears multiple times." },
1292
+ },
1293
+ required: ["oldText", "newText"],
1294
+ },
1295
+ },
1296
+ },
1297
+ required: ["path", "edits"],
1298
+ }),
1299
+ execute: async (input) => {
1300
+ await guard({
1301
+ tool: "edit_file",
1302
+ action: input.path,
1303
+ altKeys: pathKeys(input.path),
1304
+ reason: "rule",
1305
+ detail: `编辑文件: ${input.path}`,
1306
+ });
1307
+ return editFileForTool(cwd, input);
1308
+ },
1309
+ }),
1310
+ create_file: tool<CreateFileInput, FileWriteOutput>({
1311
+ description: "Create a UTF-8 text file, or overwrite an existing one when overwrite=true.",
1312
+ inputSchema: jsonSchema<CreateFileInput>({
1313
+ type: "object",
1314
+ additionalProperties: false,
1315
+ properties: {
1316
+ path: { type: "string", description: "Absolute path or path relative to the session cwd." },
1317
+ content: { type: "string", description: "Complete file content to write." },
1318
+ overwrite: { type: "boolean", description: "Allow replacing an existing file." },
1319
+ expectedSha256: { type: "string", description: "Optional SHA-256 hash required when overwriting an existing file." },
1320
+ },
1321
+ required: ["path", "content"],
1322
+ }),
1323
+ execute: async (input) => {
1324
+ await guard({
1325
+ tool: "create_file",
1326
+ action: input.path,
1327
+ altKeys: pathKeys(input.path),
1328
+ reason: "rule",
1329
+ detail: `创建/覆盖文件: ${input.path}`,
1330
+ });
1331
+ return createFileForTool(cwd, input);
1332
+ },
1333
+ }),
1334
+ delete_file: tool<DeleteFileInput, DeleteFileOutput>({
1335
+ description: "Delete an existing text file. Use expectedSha256 to avoid deleting a file that changed after reading.",
1336
+ inputSchema: jsonSchema<DeleteFileInput>({
1337
+ type: "object",
1338
+ additionalProperties: false,
1339
+ properties: {
1340
+ path: { type: "string", description: "Absolute path or path relative to the session cwd." },
1341
+ expectedSha256: { type: "string", description: "Optional SHA-256 hash of the file that must be deleted." },
1342
+ },
1343
+ required: ["path"],
1344
+ }),
1345
+ execute: async (input) => {
1346
+ await guard({
1347
+ tool: "delete_file",
1348
+ action: input.path,
1349
+ altKeys: pathKeys(input.path),
1350
+ reason: "rule",
1351
+ detail: `删除文件: ${input.path}`,
1352
+ });
1353
+ return deleteFileForTool(cwd, input);
1354
+ },
1355
+ }),
1356
+ move_file: tool<MoveFileInput, MoveFileOutput>({
1357
+ description: "Move or rename an existing text file. Can overwrite an existing destination only when overwrite=true.",
1358
+ inputSchema: jsonSchema<MoveFileInput>({
1359
+ type: "object",
1360
+ additionalProperties: false,
1361
+ properties: {
1362
+ sourcePath: { type: "string", description: "Existing source file path." },
1363
+ destinationPath: { type: "string", description: "Destination file path." },
1364
+ overwrite: { type: "boolean", description: "Allow replacing an existing destination file." },
1365
+ expectedSourceSha256: { type: "string", description: "Optional SHA-256 hash of the source file." },
1366
+ expectedDestinationSha256: { type: "string", description: "Optional SHA-256 hash of the destination file when overwriting." },
1367
+ },
1368
+ required: ["sourcePath", "destinationPath"],
1369
+ }),
1370
+ execute: async (input) => {
1371
+ await guard({
1372
+ tool: "move_file",
1373
+ action: input.sourcePath,
1374
+ altKeys: pathKeys(input.sourcePath),
1375
+ reason: "rule",
1376
+ detail: `移动/重命名文件: ${input.sourcePath} → ${input.destinationPath}`,
1377
+ });
1378
+ return moveFileForTool(cwd, input);
1379
+ },
1380
+ }),
1381
+ apply_patch: tool<ApplyPatchInput, ApplyPatchOutput>({
1382
+ description: "Apply a unified diff patch to one or more UTF-8 text files. Prefer edit_file for small targeted edits.",
1383
+ inputSchema: jsonSchema<ApplyPatchInput>({
1384
+ type: "object",
1385
+ additionalProperties: false,
1386
+ properties: {
1387
+ patch: { type: "string", description: "Unified diff patch text." },
1388
+ expectedSha256ByPath: {
1389
+ type: "object",
1390
+ description: "Optional map of patch path or absolute path to expected SHA-256 before applying.",
1391
+ additionalProperties: { type: "string" },
1392
+ },
1393
+ },
1394
+ required: ["patch"],
1395
+ }),
1396
+ execute: async (input) => {
1397
+ await guard({
1398
+ tool: "apply_patch",
1399
+ action: "patch",
1400
+ reason: "rule",
1401
+ detail: "应用 unified diff patch",
1402
+ });
1403
+ return applyPatchForTool(cwd, input);
1404
+ },
1405
+ }),
1406
+ };
1407
+ }