Last active
May 3, 2026 11:41
-
-
Save jahands/9585bac26038b51043d3110830b30321 to your computer and use it in GitHub Desktop.
slop-fork of opencode glob/grep tools to allow multiple patterns
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { realpathSync } from 'node:fs' | |
| import path from 'node:path' | |
| import { tool } from '@opencode-ai/plugin' | |
| import { Effect, Option } from 'effect' | |
| import * as Stream from 'effect/Stream' | |
| import type { ToolContext } from '@opencode-ai/plugin' | |
| const GLOB_DESCRIPTION = `- Fast file pattern matching tool that works with any codebase size | |
| - Supports glob patterns like "**/*.js", "src/**/*.ts", "!foo/**" | |
| - Returns matching file paths sorted by modification time | |
| - Use this tool when you need to find files by name patterns | |
| - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead | |
| - You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.` | |
| const GLOB_PATH_DESCRIPTION = | |
| '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.' | |
| type GlobParams = { | |
| patterns: string[] | |
| path?: string | |
| } | |
| type FileMatch = { | |
| path: string | |
| mtime: number | |
| } | |
| export const globTool = tool({ | |
| description: GLOB_DESCRIPTION, | |
| args: { | |
| patterns: tool.schema | |
| .array(tool.schema.string()) | |
| .describe('The glob patterns to match files against'), | |
| path: tool.schema.string().optional().describe(GLOB_PATH_DESCRIPTION), | |
| }, | |
| execute: (args, context) => Effect.runPromise(executeGlob(args, context)), | |
| }) | |
| function executeGlob(params: GlobParams, ctx: ToolContext) { | |
| return Effect.gen(function* () { | |
| yield* ctx.ask({ | |
| permission: 'glob', | |
| patterns: params.patterns, | |
| always: ['*'], | |
| metadata: { | |
| patterns: params.patterns, | |
| path: params.path, | |
| }, | |
| }) | |
| const requestedPath = params.path ?? ctx.directory | |
| const search = resolvePath( | |
| path.isAbsolute(requestedPath) ? requestedPath : path.resolve(ctx.directory, requestedPath) | |
| ) | |
| const info = yield* stat(search) | |
| if (info?.isFile()) { | |
| throw new Error(`glob path must be a directory: ${search}`) | |
| } | |
| yield* assertExternalDirectoryEffect(ctx, search) | |
| const limit = 100 | |
| let truncated = false | |
| const files = yield* rgFiles(search, params.patterns, ctx.abort).pipe( | |
| Stream.mapEffect((file) => | |
| Effect.gen(function* () { | |
| const full = resolvePath(path.resolve(search, file)) | |
| const info = yield* stat(full) | |
| const mtime = Option.fromUndefinedOr(info).pipe( | |
| Option.map((item) => item.mtime.getTime()), | |
| Option.getOrElse(() => 0) | |
| ) | |
| return { path: full, mtime } | |
| }) | |
| ), | |
| Stream.take(limit + 1), | |
| Stream.runCollect, | |
| Effect.map((chunk) => [...chunk]) | |
| ) | |
| 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((file: FileMatch) => file.path)) | |
| if (truncated) { | |
| output.push('') | |
| output.push( | |
| `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)` | |
| ) | |
| } | |
| } | |
| ctx.metadata({ | |
| title: path.relative(ctx.worktree, search), | |
| metadata: { | |
| count: files.length, | |
| truncated, | |
| }, | |
| }) | |
| return { | |
| metadata: { | |
| count: files.length, | |
| truncated, | |
| }, | |
| output: output.join('\n'), | |
| } | |
| }).pipe(Effect.orDie) | |
| } | |
| function stat(filepath: string) { | |
| return Effect.promise(() => Bun.file(filepath).stat()).pipe( | |
| Effect.catch(() => Effect.succeed(undefined)) | |
| ) | |
| } | |
| function rgFiles(cwd: string, patterns: string[], signal: AbortSignal) { | |
| return Stream.fromIterableEffect( | |
| Effect.promise(async () => { | |
| const globArgs = patterns.flatMap((pattern) => ['--glob', pattern]) | |
| const proc = Bun.spawn( | |
| ['rg', '--no-config', '--files', '--glob=!.git/*', '--hidden', ...globArgs, '.'], | |
| { | |
| cwd, | |
| stdout: 'pipe', | |
| stderr: 'pipe', | |
| signal, | |
| } | |
| ) | |
| const output = await new Response(proc.stdout).text() | |
| const exitCode = await proc.exited | |
| if (exitCode !== 0 && exitCode !== 1) { | |
| const stderr = await new Response(proc.stderr).text() | |
| throw new Error(stderr || `rg exited with code ${exitCode}`) | |
| } | |
| return output | |
| .split('\n') | |
| .filter(Boolean) | |
| .map((line) => path.normalize(line.replace(/^\.\//, ''))) | |
| }) | |
| ) | |
| } | |
| function resolvePath(filepath: string): string { | |
| const resolved = path.resolve(windowsPath(filepath)) | |
| try { | |
| return normalizePath(realpathSync(resolved)) | |
| } catch (error) { | |
| if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return normalizePath(resolved) | |
| throw error | |
| } | |
| } | |
| function normalizePath(filepath: string): string { | |
| if (process.platform !== 'win32') return filepath | |
| const resolved = path.resolve(windowsPath(filepath)) | |
| try { | |
| return realpathSync.native(resolved) | |
| } catch { | |
| return resolved | |
| } | |
| } | |
| function normalizePathPattern(pattern: string): string { | |
| if (process.platform !== 'win32') return pattern | |
| if (pattern === '*') return pattern | |
| const match = pattern.match(/^(.*)[\\/]\*$/) | |
| if (!match) return normalizePath(pattern) | |
| const dir = /^[A-Za-z]:$/.test(match[1]) ? `${match[1]}\\` : match[1] | |
| return path.join(normalizePath(dir), '*') | |
| } | |
| function windowsPath(filepath: string): string { | |
| if (process.platform !== 'win32') return filepath | |
| return filepath | |
| .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) | |
| .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) | |
| .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) | |
| .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) | |
| } | |
| function contains(parent: string, child: string): boolean { | |
| return !path.relative(parent, child).startsWith('..') | |
| } | |
| function containsPath(filepath: string, ctx: ToolContext): boolean { | |
| if (contains(resolvePath(ctx.directory), filepath)) return true | |
| if (ctx.worktree === '/') return false | |
| return contains(resolvePath(ctx.worktree), filepath) | |
| } | |
| function assertExternalDirectoryEffect(ctx: ToolContext, target: string) { | |
| return Effect.gen(function* () { | |
| const full = process.platform === 'win32' ? normalizePath(target) : target | |
| if (containsPath(full, ctx)) return | |
| const glob = | |
| process.platform === 'win32' | |
| ? normalizePathPattern(path.join(full, '*')) | |
| : path.join(full, '*').replaceAll('\\', '/') | |
| yield* ctx.ask({ | |
| permission: 'external_directory', | |
| patterns: [glob], | |
| always: [glob], | |
| metadata: { | |
| filepath: full, | |
| parentDir: full, | |
| }, | |
| }) | |
| }) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { realpathSync } from 'node:fs' | |
| import path from 'node:path' | |
| import { tool } from '@opencode-ai/plugin' | |
| import { Effect, Option, Schema } from 'effect' | |
| import type { ToolContext } from '@opencode-ai/plugin' | |
| const GREP_DESCRIPTION = `- Fast content search tool that works with any codebase size | |
| - Searches file contents using regular expressions | |
| - Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.) | |
| - Filter files by pattern with the include parameter (eg. ["*.js", "*.{ts,tsx}", "!foo/**"]) | |
| - Returns file paths and line numbers with at least one match sorted by modification time | |
| - Use this tool when you need to find files containing specific patterns | |
| - If you need to identify/count the number of matches within files, use the Bash tool with \`rg\` (ripgrep) directly. Do NOT use \`grep\`. | |
| - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead` | |
| const MAX_LINE_LENGTH = 2000 | |
| type GrepParams = { | |
| pattern: string | |
| path?: string | |
| include?: string[] | |
| } | |
| type SearchMatch = { | |
| path: { | |
| text: string | |
| } | |
| lines: { | |
| text: string | |
| } | |
| line_number: number | |
| } | |
| type SearchResult = { | |
| items: SearchMatch[] | |
| partial: boolean | |
| } | |
| type MatchRow = { | |
| path: string | |
| line: number | |
| text: string | |
| mtime: number | |
| } | |
| const PathText = Schema.Struct({ | |
| text: Schema.String, | |
| }) | |
| const SearchMatchSchema = Schema.Struct({ | |
| path: PathText, | |
| lines: Schema.Struct({ | |
| text: Schema.String, | |
| }), | |
| line_number: Schema.Number, | |
| absolute_offset: Schema.Number, | |
| submatches: Schema.Array( | |
| Schema.Struct({ | |
| match: Schema.Struct({ | |
| text: Schema.String, | |
| }), | |
| start: Schema.Number, | |
| end: Schema.Number, | |
| }) | |
| ), | |
| }) | |
| const RipgrepMatch = Schema.Struct({ | |
| type: Schema.Literal('match'), | |
| data: SearchMatchSchema, | |
| }) | |
| const RipgrepRow = Schema.Union([ | |
| Schema.Struct({ | |
| type: Schema.Literal('begin'), | |
| data: Schema.Unknown, | |
| }), | |
| RipgrepMatch, | |
| Schema.Struct({ | |
| type: Schema.Literal('end'), | |
| data: Schema.Unknown, | |
| }), | |
| Schema.Struct({ | |
| type: Schema.Literal('summary'), | |
| data: Schema.Unknown, | |
| }), | |
| ]) | |
| const decodeRipgrepRow = Schema.decodeUnknownPromise(Schema.fromJsonString(RipgrepRow)) | |
| export const grepTool = tool({ | |
| description: GREP_DESCRIPTION, | |
| args: { | |
| pattern: tool.schema.string().describe('The regex pattern to search for in file contents'), | |
| path: tool.schema | |
| .string() | |
| .optional() | |
| .describe('The directory to search in. Defaults to the current working directory.'), | |
| include: tool.schema | |
| .array(tool.schema.string()) | |
| .optional() | |
| .describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}", "!foo/**")'), | |
| }, | |
| execute: (args, context) => Effect.runPromise(executeGrep(args, context)), | |
| }) | |
| function executeGrep(params: GrepParams, ctx: ToolContext) { | |
| return Effect.gen(function* () { | |
| const empty = { | |
| 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 requestedPath = params.path ?? ctx.directory | |
| const search = resolvePath( | |
| path.isAbsolute(requestedPath) ? requestedPath : path.join(ctx.directory, params.path ?? '.') | |
| ) | |
| const info = yield* stat(search) | |
| const cwd = info?.isDirectory() ? search : path.dirname(search) | |
| const file = info?.isDirectory() ? undefined : [path.relative(cwd, search)] | |
| yield* assertExternalDirectoryEffect(ctx, search, { | |
| kind: info?.isDirectory() ? 'directory' : 'file', | |
| }) | |
| const result = yield* rgSearch({ | |
| cwd, | |
| pattern: params.pattern, | |
| glob: params.include, | |
| file, | |
| signal: ctx.abort, | |
| }) | |
| if (result.items.length === 0) { | |
| ctx.metadata({ title: params.pattern, metadata: empty.metadata }) | |
| return empty | |
| } | |
| const rows = result.items.map((item) => ({ | |
| path: resolvePath( | |
| path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text) | |
| ), | |
| line: item.line_number, | |
| text: item.lines.text, | |
| })) | |
| const times = new Map( | |
| (yield* Effect.forEach( | |
| [...new Set(rows.map((row) => row.path))], | |
| Effect.fnUntraced(function* (file) { | |
| const info = yield* stat(file) | |
| if (!info || info.isDirectory()) return undefined | |
| return [ | |
| file, | |
| Option.fromUndefinedOr(info.mtime).pipe( | |
| Option.map((time) => time.getTime()), | |
| Option.getOrElse(() => 0) | |
| ), | |
| ] as const | |
| }), | |
| { concurrency: 16 } | |
| )).filter((entry): entry is readonly [string, number] => Boolean(entry)) | |
| ) | |
| const matches = rows.flatMap((row) => { | |
| const mtime = times.get(row.path) | |
| if (mtime === undefined) return [] | |
| return [{ ...row, mtime }] | |
| }) | |
| matches.sort((a, b) => b.mtime - a.mtime) | |
| const limit = 100 | |
| const truncated = matches.length > limit | |
| const final = truncated ? matches.slice(0, limit) : matches | |
| if (final.length === 0) { | |
| ctx.metadata({ title: params.pattern, metadata: empty.metadata }) | |
| return empty | |
| } | |
| const total = matches.length | |
| const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ''}`] | |
| appendMatches(output, final) | |
| 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.partial) { | |
| output.push('') | |
| output.push('(Some paths were inaccessible and skipped)') | |
| } | |
| const metadata = { | |
| matches: total, | |
| truncated, | |
| } | |
| ctx.metadata({ title: params.pattern, metadata }) | |
| return { | |
| metadata, | |
| output: output.join('\n'), | |
| } | |
| }).pipe(Effect.orDie) | |
| } | |
| function appendMatches(output: string[], matches: MatchRow[]) { | |
| let current = '' | |
| for (const match of matches) { | |
| 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}`) | |
| } | |
| } | |
| function stat(filepath: string) { | |
| return Effect.promise(() => Bun.file(filepath).stat()).pipe( | |
| Effect.catch(() => Effect.succeed(undefined)) | |
| ) | |
| } | |
| function rgSearch(input: { | |
| cwd: string | |
| pattern: string | |
| glob?: string[] | |
| file?: string[] | |
| signal: AbortSignal | |
| }) { | |
| return Effect.promise(async () => { | |
| const globArgs = input.glob?.flatMap((glob) => [`--glob=${glob}`]) ?? [] | |
| const proc = Bun.spawn( | |
| [ | |
| 'rg', | |
| '--no-config', | |
| '--json', | |
| '--hidden', | |
| '--glob=!.git/*', | |
| '--no-messages', | |
| ...globArgs, | |
| '--', | |
| input.pattern, | |
| ...(input.file ?? ['.']), | |
| ], | |
| { | |
| cwd: input.cwd, | |
| stdout: 'pipe', | |
| stderr: 'pipe', | |
| signal: input.signal, | |
| } | |
| ) | |
| const stdout = await new Response(proc.stdout).text() | |
| const stderr = await new Response(proc.stderr).text() | |
| const exitCode = await proc.exited | |
| if (exitCode !== 0 && exitCode !== 1 && exitCode !== 2) { | |
| throw new Error(stderr.trim() || `ripgrep failed with code ${exitCode}`) | |
| } | |
| if (exitCode === 1) { | |
| return { items: [], partial: false } | |
| } | |
| const items: SearchMatch[] = [] | |
| for (const line of stdout.split('\n').filter(Boolean)) { | |
| const row = await decodeRipgrepRow(line) | |
| if (row.type === 'match') { | |
| items.push({ | |
| ...row.data, | |
| path: { | |
| ...row.data.path, | |
| text: clean(row.data.path.text), | |
| }, | |
| }) | |
| } | |
| } | |
| return { items, partial: exitCode === 2 } | |
| }) satisfies Effect.Effect<SearchResult, unknown, never> | |
| } | |
| function clean(file: string) { | |
| return path.normalize(file.replace(/^\.[\\/]/, '')) | |
| } | |
| function resolvePath(filepath: string): string { | |
| const resolved = path.resolve(windowsPath(filepath)) | |
| try { | |
| return normalizePath(realpathSync(resolved)) | |
| } catch (error) { | |
| if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return normalizePath(resolved) | |
| throw error | |
| } | |
| } | |
| function normalizePath(filepath: string): string { | |
| if (process.platform !== 'win32') return filepath | |
| const resolved = path.resolve(windowsPath(filepath)) | |
| try { | |
| return realpathSync.native(resolved) | |
| } catch { | |
| return resolved | |
| } | |
| } | |
| function normalizePathPattern(pattern: string): string { | |
| if (process.platform !== 'win32') return pattern | |
| if (pattern === '*') return pattern | |
| const match = pattern.match(/^(.*)[\\/]\*$/) | |
| if (!match) return normalizePath(pattern) | |
| const dir = /^[A-Za-z]:$/.test(match[1]) ? `${match[1]}\\` : match[1] | |
| return path.join(normalizePath(dir), '*') | |
| } | |
| function windowsPath(filepath: string): string { | |
| if (process.platform !== 'win32') return filepath | |
| return filepath | |
| .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) | |
| .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) | |
| .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) | |
| .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) | |
| } | |
| function contains(parent: string, child: string): boolean { | |
| return !path.relative(parent, child).startsWith('..') | |
| } | |
| function containsPath(filepath: string, ctx: ToolContext): boolean { | |
| if (contains(resolvePath(ctx.directory), filepath)) return true | |
| if (ctx.worktree === '/') return false | |
| return contains(resolvePath(ctx.worktree), filepath) | |
| } | |
| function assertExternalDirectoryEffect( | |
| ctx: ToolContext, | |
| target: string, | |
| options: { kind: 'file' | 'directory' } | |
| ) { | |
| return Effect.gen(function* () { | |
| const full = process.platform === 'win32' ? normalizePath(target) : target | |
| if (containsPath(full, ctx)) return | |
| const dir = options.kind === 'directory' ? full : path.dirname(full) | |
| const glob = | |
| process.platform === 'win32' | |
| ? normalizePathPattern(path.join(dir, '*')) | |
| : path.join(dir, '*').replaceAll('\\', '/') | |
| yield* ctx.ask({ | |
| permission: 'external_directory', | |
| patterns: [glob], | |
| always: [glob], | |
| metadata: { | |
| filepath: full, | |
| parentDir: dir, | |
| }, | |
| }) | |
| }) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { globTool } from './glob' | |
| import { grepTool } from './grep' | |
| import type { Plugin } from '@opencode-ai/plugin' | |
| export const ToolsPlugin: Plugin = async () => { | |
| return { | |
| tool: { | |
| glob: globTool, | |
| grep: grepTool, | |
| }, | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment