summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/tool/grep.ts305
-rw-r--r--packages/opencode/src/tool/registry.ts3
-rw-r--r--packages/opencode/test/tool/grep.test.ts14
3 files changed, 176 insertions, 146 deletions
diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts
index 82e7ac166..8f53c2e21 100644
--- a/packages/opencode/src/tool/grep.ts
+++ b/packages/opencode/src/tool/grep.ts
@@ -1,156 +1,177 @@
import z from "zod"
-import { text } from "node:stream/consumers"
+import { Effect } from "effect"
+import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep"
-import { Process } from "../util/process"
+import { ChildProcess } from "effect/unstable/process"
+import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
-import { assertExternalDirectory } from "./external-directory"
+import { assertExternalDirectoryEffect } 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", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
- if (params.include) {
- args.push("--glob", params.include)
- }
- args.push(searchPath)
-
- const proc = Process.spawn([rgPath, ...args], {
- stdout: "pipe",
- stderr: "pipe",
- abort: ctx.abort,
- })
-
- if (!proc.stdout || !proc.stderr) {
- throw new Error("Process output not available")
- }
-
- const output = await text(proc.stdout)
- const errorOutput = await text(proc.stderr)
- 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 stats = Filesystem.stat(filePath)
- 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 totalMatches = matches.length
- const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
-
- 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 truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
- )
- }
-
- if (hasErrors) {
- outputLines.push("")
- outputLines.push("(Some paths were inaccessible and skipped)")
- }
+export const GrepTool = Tool.defineEffect(
+ "grep",
+ Effect.gen(function* () {
+ const spawner = yield* ChildProcessSpawner
return {
- title: params.pattern,
- metadata: {
- matches: totalMatches,
- truncated,
- },
- output: outputLines.join("\n"),
+ 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}")'),
+ }),
+ execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
+ Effect.gen(function* () {
+ if (!params.pattern) {
+ throw new Error("pattern is required")
+ }
+
+ yield* Effect.promise(() =>
+ 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)
+ yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
+
+ const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
+ const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
+ if (params.include) {
+ args.push("--glob", params.include)
+ }
+ args.push(searchPath)
+
+ const result = yield* Effect.scoped(
+ Effect.gen(function* () {
+ const handle = yield* spawner.spawn(
+ ChildProcess.make(rgPath, args, {
+ stdin: "ignore",
+ }),
+ )
+
+ const [output, errorOutput] = yield* Effect.all(
+ [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
+ { concurrency: 2 },
+ )
+
+ const exitCode = yield* handle.exitCode
+
+ return { output, errorOutput, exitCode }
+ }),
+ )
+
+ const { output, errorOutput, exitCode } = result
+
+ // 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 stats = Filesystem.stat(filePath)
+ 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 totalMatches = matches.length
+ const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
+
+ 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 truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). 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: totalMatches,
+ truncated,
+ },
+ output: outputLines.join("\n"),
+ }
+ }).pipe(Effect.orDie, Effect.runPromise),
}
- },
-})
+ }),
+)
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index bbc154371..84dd7b79a 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -112,6 +112,7 @@ export namespace ToolRegistry {
const globtool = yield* GlobTool
const writetool = yield* WriteTool
const edit = yield* EditTool
+ const greptool = yield* GrepTool
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -173,7 +174,7 @@ export namespace ToolRegistry {
bash: Tool.init(bash),
read: Tool.init(read),
glob: Tool.init(globtool),
- grep: Tool.init(GrepTool),
+ grep: Tool.init(greptool),
edit: Tool.init(edit),
write: Tool.init(writetool),
task: Tool.init(task),
diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts
index e03b1752e..a0cfb61c4 100644
--- a/packages/opencode/test/tool/grep.test.ts
+++ b/packages/opencode/test/tool/grep.test.ts
@@ -1,9 +1,17 @@
import { describe, expect, test } from "bun:test"
import path from "path"
+import { Effect, Layer, ManagedRuntime } from "effect"
import { GrepTool } from "../../src/tool/grep"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+
+const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
+
+function initGrep() {
+ return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
+}
const ctx = {
sessionID: SessionID.make("ses_test"),
@@ -23,7 +31,7 @@ describe("tool.grep", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
- const grep = await GrepTool.init()
+ const grep = await initGrep()
const result = await grep.execute(
{
pattern: "export",
@@ -47,7 +55,7 @@ describe("tool.grep", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const grep = await GrepTool.init()
+ const grep = await initGrep()
const result = await grep.execute(
{
pattern: "xyznonexistentpatternxyz123",
@@ -72,7 +80,7 @@ describe("tool.grep", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const grep = await GrepTool.init()
+ const grep = await initGrep()
const result = await grep.execute(
{
pattern: "line",