1import { spawnSync } from "node:child_process";
2import { relative } from "node:path";
3import { Type } from "@sinclair/typebox";
4import type { AgentTool } from "@mariozechner/pi-ai";
5import { resolveToCwd } from "./path-utils.js";
6import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js";
7import { ToolInputError } from "../../util/errors.js";
8
9const DEFAULT_LIMIT = 1000;
10
11const FindSchema = Type.Object({
12 pattern: Type.String({ description: "Glob pattern to match files, e.g. '*.ts', '**/*.json'" }),
13 path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
14 limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })),
15});
16
17export const createFindTool = (workspacePath: string): AgentTool => {
18 const fdResult = spawnSync("which", ["fd"], { encoding: "utf-8" });
19 const fdPath = fdResult.stdout?.trim();
20 if (!fdPath) throw new ToolInputError("fd is not available");
21
22 return {
23 name: "find",
24 label: "Find Files",
25 description: `Search for files by glob pattern using fd. Returns up to ${DEFAULT_LIMIT} results and ${DEFAULT_MAX_BYTES / 1024} KB of output. Respects .gitignore.`,
26 parameters: FindSchema as any,
27 execute: async (_toolCallId: string, params: any) => {
28 const searchDir: string = params.path || ".";
29 const effectiveLimit = params.limit ?? DEFAULT_LIMIT;
30 const searchPath = resolveToCwd(searchDir, workspacePath);
31
32 const args = [
33 "--glob",
34 "--color=never",
35 "--hidden",
36 "--max-results",
37 String(effectiveLimit),
38 params.pattern,
39 searchPath,
40 ];
41
42 const result = spawnSync(fdPath, args, {
43 encoding: "utf-8",
44 maxBuffer: 10 * 1024 * 1024,
45 });
46
47 const rawOutput = result.stdout?.trim() ?? "";
48 if (!rawOutput) {
49 return {
50 content: [{ type: "text", text: "No files found matching pattern" }],
51 details: { pattern: params.pattern, path: searchDir, matches: 0 },
52 };
53 }
54
55 const lines = rawOutput.split("\n").map((line) => {
56 const isDir = line.endsWith("/");
57 const rel = relative(searchPath, line);
58 return isDir && !rel.endsWith("/") ? `${rel}/` : rel;
59 });
60
61 const relativized = lines.join("\n");
62 const truncated = truncateHead(relativized, { maxLines: Number.MAX_SAFE_INTEGER });
63
64 const notices: string[] = [];
65 if (lines.length >= effectiveLimit) {
66 notices.push(`Result limit reached (${effectiveLimit}). Narrow your pattern for complete results.`);
67 }
68 if (truncated.truncatedBy === "bytes") {
69 const droppedBytes = truncated.totalBytes - truncated.outputBytes;
70 notices.push(`Output truncated by ${formatSize(droppedBytes)} to fit ${formatSize(DEFAULT_MAX_BYTES)} limit.`);
71 }
72
73 const output = notices.length > 0
74 ? `${truncated.content}\n\n${notices.join("\n")}`
75 : truncated.content;
76
77 return {
78 content: [{ type: "text", text: output }],
79 details: {
80 pattern: params.pattern,
81 path: searchDir,
82 matches: lines.length,
83 ...(truncated.truncated && { truncatedBy: truncated.truncatedBy }),
84 },
85 };
86 },
87 };
88};