import z from "zod" import { Tool } from "./tool" import { Ripgrep } from "../file/ripgrep" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" import path from "path" import { assertExternalDirectory } from "./external-directory" const MAX_LINE_LENGTH = 2000 export const GrepTool = Tool.define("grep", { description: DESCRIPTION, parameters: z.object({ pattern: z.string().describe("The regex pattern to search for in file contents"), path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), }), async execute(params, ctx) { if (!params.pattern) { throw new Error("pattern is required") } await ctx.ask({ permission: "grep", patterns: [params.pattern], always: ["*"], metadata: { pattern: params.pattern, path: params.path, include: params.include, }, }) let searchPath = params.path ?? Instance.directory searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) const rgPath = await Ripgrep.filepath() const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern] if (params.include) { args.push("--glob", params.include) } args.push(searchPath) const proc = Bun.spawn([rgPath, ...args], { stdout: "pipe", stderr: "pipe", }) const output = await new Response(proc.stdout).text() const errorOutput = await new Response(proc.stderr).text() const exitCode = await proc.exited if (exitCode === 1) { return { title: params.pattern, metadata: { matches: 0, truncated: false }, output: "No files found", } } if (exitCode !== 0) { throw new Error(`ripgrep failed: ${errorOutput}`) } // Handle both Unix (\n) and Windows (\r\n) line endings const lines = output.trim().split(/\r?\n/) const matches = [] for (const line of lines) { if (!line) continue const [filePath, lineNumStr, ...lineTextParts] = line.split("|") if (!filePath || !lineNumStr || lineTextParts.length === 0) continue const lineNum = parseInt(lineNumStr, 10) const lineText = lineTextParts.join("|") const file = Bun.file(filePath) const stats = await file.stat().catch(() => null) if (!stats) continue matches.push({ path: filePath, modTime: stats.mtime.getTime(), lineNum, lineText, }) } matches.sort((a, b) => b.modTime - a.modTime) const limit = 100 const truncated = matches.length > limit const finalMatches = truncated ? matches.slice(0, limit) : matches if (finalMatches.length === 0) { return { title: params.pattern, metadata: { matches: 0, truncated: false }, output: "No files found", } } const outputLines = [`Found ${finalMatches.length} matches`] let currentFile = "" for (const match of finalMatches) { if (currentFile !== match.path) { if (currentFile !== "") { outputLines.push("") } currentFile = match.path outputLines.push(`${match.path}:`) } const truncatedLineText = match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) } if (truncated) { outputLines.push("") outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)") } return { title: params.pattern, metadata: { matches: finalMatches.length, truncated, }, output: outputLines.join("\n"), } }, })