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", "--no-messages", "--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", signal: ctx.abort, }) const output = await new Response(proc.stdout).text() const errorOutput = await new Response(proc.stderr).text() const exitCode = await proc.exited // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc. // Only fail if exit code is 2 AND no output was produced if (exitCode === 1 || (exitCode === 2 && !output.trim())) { return { title: params.pattern, metadata: { matches: 0, truncated: false }, output: "No files found", } } if (exitCode !== 0 && exitCode !== 2) { throw new Error(`ripgrep failed: ${errorOutput}`) } const hasErrors = exitCode === 2 // 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.)") } if (hasErrors) { outputLines.push("") outputLines.push("(Some paths were inaccessible and skipped)") } return { title: params.pattern, metadata: { matches: finalMatches.length, truncated, }, output: outputLines.join("\n"), } },})
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",
"--no-messages",
"--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",
signal: ctx.abort,
})
const output = await new Response(proc.stdout).text()
const errorOutput = await new Response(proc.stderr).text()
const exitCode = await proc.exited
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`)
}
const hasErrors = exitCode === 2
// 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.)")
}
if (hasErrors) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}
return {
title: params.pattern,
metadata: {
matches: finalMatches.length,
truncated,
},
output: outputLines.join("\n"),
}
},
})