summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-10 13:56:42 -0400
committerGitHub <[email protected]>2026-04-10 13:56:42 -0400
commit847f1d99c9d4f8a55cff59d1d895ccd6895f927b (patch)
treeea6fa7a564cfdfdb69856239a3a28dc149dd7720
parent59d08683eaa7dce988641c53dbaea3c64f24f58e (diff)
downloadopencode-847f1d99c9d4f8a55cff59d1d895ccd6895f927b.tar.gz
opencode-847f1d99c9d4f8a55cff59d1d895ccd6895f927b.zip
convert glob tool to Tool.defineEffect (#21897)
-rw-r--r--packages/opencode/src/file/ripgrep.ts70
-rw-r--r--packages/opencode/src/tool/glob.ts146
-rw-r--r--packages/opencode/src/tool/registry.ts6
-rw-r--r--packages/opencode/test/file/ripgrep.test.ts45
-rw-r--r--packages/opencode/test/session/prompt-effect.test.ts2
-rw-r--r--packages/opencode/test/session/snapshot-tool-race.test.ts2
6 files changed, 204 insertions, 67 deletions
diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts
index 601c82e94..8a2d9407a 100644
--- a/packages/opencode/src/file/ripgrep.ts
+++ b/packages/opencode/src/file/ripgrep.ts
@@ -3,10 +3,17 @@ import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import z from "zod"
+import { Effect, Layer, ServiceMap } from "effect"
+import * as Stream from "effect/Stream"
+import { ChildProcess } from "effect/unstable/process"
+import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import type { PlatformError } from "effect/PlatformError"
import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { Filesystem } from "../util/filesystem"
+import { AppFileSystem } from "../filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { text } from "node:stream/consumers"
@@ -274,6 +281,69 @@ export namespace Ripgrep {
input.signal?.throwIfAborted()
}
+ export interface Interface {
+ readonly files: (input: {
+ cwd: string
+ glob?: string[]
+ hidden?: boolean
+ follow?: boolean
+ maxDepth?: number
+ }) => Stream.Stream<string, PlatformError>
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Ripgrep") {}
+
+ export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const spawner = yield* ChildProcessSpawner
+ const afs = yield* AppFileSystem.Service
+
+ const files = Effect.fn("Ripgrep.files")(function* (input: {
+ cwd: string
+ glob?: string[]
+ hidden?: boolean
+ follow?: boolean
+ maxDepth?: number
+ }) {
+ const rgPath = yield* Effect.promise(() => filepath())
+ const isDir = yield* afs.isDir(input.cwd)
+ if (!isDir) {
+ return yield* Effect.die(
+ Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
+ code: "ENOENT" as const,
+ errno: -2,
+ path: input.cwd,
+ }),
+ )
+ }
+
+ const args = [rgPath, "--files", "--glob=!.git/*"]
+ if (input.follow) args.push("--follow")
+ if (input.hidden !== false) args.push("--hidden")
+ if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
+ if (input.glob) {
+ for (const g of input.glob) {
+ args.push(`--glob=${g}`)
+ }
+ }
+
+ return spawner.streamLines(
+ ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }),
+ ).pipe(Stream.filter((line: string) => line.length > 0))
+ })
+
+ return Service.of({
+ files: (input) => Stream.unwrap(files(input)),
+ })
+ }),
+ )
+
+ export const defaultLayer = layer.pipe(
+ Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(CrossSpawnSpawner.defaultLayer),
+ )
+
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
log.info("tree", input)
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts
index a2611246c..180e20f60 100644
--- a/packages/opencode/src/tool/glob.ts
+++ b/packages/opencode/src/tool/glob.ts
@@ -1,78 +1,92 @@
import z from "zod"
import path from "path"
+import { Effect, Option } from "effect"
+import * as Stream from "effect/Stream"
import { Tool } from "./tool"
-import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
-import { assertExternalDirectory } from "./external-directory"
+import { assertExternalDirectoryEffect } from "./external-directory"
+import { AppFileSystem } from "../filesystem"
-export const GlobTool = Tool.define("glob", {
- description: DESCRIPTION,
- parameters: z.object({
- pattern: z.string().describe("The glob pattern to match files against"),
- path: z
- .string()
- .optional()
- .describe(
- `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
- ),
- }),
- async execute(params, ctx) {
- await ctx.ask({
- permission: "glob",
- patterns: [params.pattern],
- always: ["*"],
- metadata: {
- pattern: params.pattern,
- path: params.path,
- },
- })
+export const GlobTool = Tool.defineEffect(
+ "glob",
+ Effect.gen(function* () {
+ const rg = yield* Ripgrep.Service
+ const fs = yield* AppFileSystem.Service
- let search = params.path ?? Instance.directory
- search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
- await assertExternalDirectory(ctx, search, { kind: "directory" })
+ return {
+ description: DESCRIPTION,
+ parameters: z.object({
+ pattern: z.string().describe("The glob pattern to match files against"),
+ path: z
+ .string()
+ .optional()
+ .describe(
+ `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
+ ),
+ }),
+ execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
+ Effect.gen(function* () {
+ yield* Effect.promise(() =>
+ ctx.ask({
+ permission: "glob",
+ patterns: [params.pattern],
+ always: ["*"],
+ metadata: {
+ pattern: params.pattern,
+ path: params.path,
+ },
+ }),
+ )
- const limit = 100
- const files = []
- let truncated = false
- for await (const file of Ripgrep.files({
- cwd: search,
- glob: [params.pattern],
- signal: ctx.abort,
- })) {
- if (files.length >= limit) {
- truncated = true
- break
- }
- const full = path.resolve(search, file)
- const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
- files.push({
- path: full,
- mtime: stats,
- })
- }
- files.sort((a, b) => b.mtime - a.mtime)
+ let search = params.path ?? Instance.directory
+ search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
+ yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
- const output = []
- if (files.length === 0) output.push("No files found")
- if (files.length > 0) {
- output.push(...files.map((f) => f.path))
- if (truncated) {
- output.push("")
- output.push(
- `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
- )
- }
- }
+ const limit = 100
+ let truncated = false
+ const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
+ Stream.mapEffect((file) =>
+ Effect.gen(function* () {
+ const full = path.resolve(search, file)
+ const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
+ const mtime = info?.mtime.pipe(Option.map((d) => d.getTime()), Option.getOrElse(() => 0)) ?? 0
+ return { path: full, mtime }
+ }),
+ ),
+ Stream.take(limit + 1),
+ Stream.runCollect,
+ Effect.map((chunk) => [...chunk]),
+ )
- return {
- title: path.relative(Instance.worktree, search),
- metadata: {
- count: files.length,
- truncated,
- },
- output: output.join("\n"),
+ if (files.length > limit) {
+ truncated = true
+ files.length = limit
+ }
+ files.sort((a, b) => b.mtime - a.mtime)
+
+ const output = []
+ if (files.length === 0) output.push("No files found")
+ if (files.length > 0) {
+ output.push(...files.map((f) => f.path))
+ if (truncated) {
+ output.push("")
+ output.push(
+ `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
+ )
+ }
+ }
+
+ return {
+ title: path.relative(Instance.worktree, search),
+ metadata: {
+ count: files.length,
+ truncated,
+ },
+ output: output.join("\n"),
+ }
+ }).pipe(Effect.orDie, Effect.runPromise),
}
- },
-})
+ }),
+)
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index e47eb744e..7f566ecd0 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -33,6 +33,7 @@ import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { Ripgrep } from "../file/ripgrep"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
@@ -89,6 +90,7 @@ export namespace ToolRegistry {
| AppFileSystem.Service
| HttpClient.HttpClient
| ChildProcessSpawner
+ | Ripgrep.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -107,6 +109,7 @@ export namespace ToolRegistry {
const websearch = yield* WebSearchTool
const bash = yield* BashTool
const codesearch = yield* CodeSearchTool
+ const globtool = yield* GlobTool
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -167,7 +170,7 @@ export namespace ToolRegistry {
invalid: Tool.init(InvalidTool),
bash: Tool.init(bash),
read: Tool.init(read),
- glob: Tool.init(GlobTool),
+ glob: Tool.init(globtool),
grep: Tool.init(GrepTool),
edit: Tool.init(EditTool),
write: Tool.init(WriteTool),
@@ -320,6 +323,7 @@ export namespace ToolRegistry {
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
+ Layer.provide(Ripgrep.defaultLayer),
),
)
diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts
index 5eb56e53d..03c529f18 100644
--- a/packages/opencode/test/file/ripgrep.test.ts
+++ b/packages/opencode/test/file/ripgrep.test.ts
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test"
+import { Effect } from "effect"
+import * as Stream from "effect/Stream"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../fixture/fixture"
@@ -52,3 +54,46 @@ describe("file.ripgrep", () => {
expect(hits).toEqual([])
})
})
+
+describe("Ripgrep.Service", () => {
+ test("files returns stream of filenames", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "a.txt"), "hello")
+ await Bun.write(path.join(dir, "b.txt"), "world")
+ },
+ })
+
+ const files = await Effect.gen(function* () {
+ const rg = yield* Ripgrep.Service
+ return yield* rg.files({ cwd: tmp.path }).pipe(Stream.runCollect, Effect.map((chunk) => [...chunk].sort()))
+ }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
+
+ expect(files).toEqual(["a.txt", "b.txt"])
+ })
+
+ test("files respects glob filter", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "keep.ts"), "yes")
+ await Bun.write(path.join(dir, "skip.txt"), "no")
+ },
+ })
+
+ const files = await Effect.gen(function* () {
+ const rg = yield* Ripgrep.Service
+ return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(Stream.runCollect, Effect.map((chunk) => [...chunk]))
+ }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
+
+ expect(files).toEqual(["keep.ts"])
+ })
+
+ test("files dies on nonexistent directory", async () => {
+ const exit = await Effect.gen(function* () {
+ const rg = yield* Ripgrep.Service
+ return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect)
+ }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
+
+ expect(exit._tag).toBe("Failure")
+ })
+})
diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts
index c26a5fd03..aef88b233 100644
--- a/packages/opencode/test/session/prompt-effect.test.ts
+++ b/packages/opencode/test/session/prompt-effect.test.ts
@@ -37,6 +37,7 @@ import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { Ripgrep } from "../../src/file/ripgrep"
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"
@@ -172,6 +173,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
+ Layer.provide(Ripgrep.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts
index 911acba31..10d4d8f6f 100644
--- a/packages/opencode/test/session/snapshot-tool-race.test.ts
+++ b/packages/opencode/test/session/snapshot-tool-race.test.ts
@@ -53,6 +53,7 @@ import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { Ripgrep } from "../../src/file/ripgrep"
Log.init({ print: false })
@@ -136,6 +137,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
+ Layer.provide(Ripgrep.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),