import path from "path" import { Effect, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { FSUtil } from "@opencode-ai/core/fs-util" import { Search } from "@opencode-ai/core/filesystem/search" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" import { Reference } from "@/reference/reference" const MAX_LINE_LENGTH = 2000 export const Parameters = Schema.Struct({ pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }), path: Schema.optional(Schema.String).annotate({ description: "The directory to search in. Defaults to the current working directory.", }), include: Schema.optional(Schema.String).annotate({ description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")', }), }) export const GrepTool = Tool.define( "grep", Effect.gen(function* () { const fs = yield* FSUtil.Service const searchSvc = yield* Search.Service const reference = yield* Reference.Service return { description: DESCRIPTION, parameters: Parameters, execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => Effect.gen(function* () { const empty = { title: params.pattern, metadata: { matches: 0, truncated: false }, output: "No files found", } if (!params.pattern) { throw new Error("pattern is required") } yield* ctx.ask({ permission: "grep", patterns: [params.pattern], always: ["*"], metadata: { pattern: params.pattern, path: params.path, include: params.include, }, }) const ins = yield* InstanceState.context const requested = path.isAbsolute(params.path ?? ins.directory) ? (params.path ?? ins.directory) : path.join(ins.directory, params.path ?? ".") yield* reference.ensure(requested) const requestedInfo = yield* fs.stat(requested).pipe(Effect.catch(() => Effect.succeed(undefined))) yield* assertExternalDirectoryEffect(ctx, requested, { bypass: yield* reference.contains(requested), kind: requestedInfo?.type === "Directory" ? "directory" : "file", }) const search = FSUtil.resolve(requested) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) const cwd = info?.type === "Directory" ? search : path.dirname(search) const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)] const result = yield* searchSvc.search({ cwd, pattern: params.pattern, glob: params.include ? [params.include] : undefined, file, signal: ctx.abort, }) if (result.items.length === 0) return empty const rows = result.items.map((item) => ({ path: FSUtil.resolve(path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text)), line: item.line_number, text: item.lines.text, })) const limit = 100 const truncated = rows.length > limit const final = truncated ? rows.slice(0, limit) : rows if (final.length === 0) return empty const total = rows.length const hasMore = truncated || result.hasNextPage const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`] let current = "" for (const match of final) { if (current !== match.path) { if (current !== "") output.push("") current = match.path output.push(`${match.path}:`) } const text = match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text output.push(` Line ${match.line}: ${text}`) } if (truncated) { output.push("") output.push( `(Results truncated: showing ${limit} of ${total} matches (${total - limit} hidden). Consider using a more specific path or pattern.)`, ) } if (result.hasNextPage) { output.push("") output.push(`(Results truncated. Consider using a more specific path or pattern.)`) } if (result.partial) { output.push("") output.push("(Some paths were inaccessible and skipped)") } if (result.regexFallbackError) { output.push("") output.push(`(Regex fallback: ${result.regexFallbackError})`) } return { title: params.pattern, metadata: { matches: total, truncated, }, output: output.join("\n"), } }).pipe(Effect.orDie), } }), )