Skip to content

Instantly share code, notes, and snippets.

@jahands
Last active May 3, 2026 11:41
Show Gist options
  • Select an option

  • Save jahands/9585bac26038b51043d3110830b30321 to your computer and use it in GitHub Desktop.

Select an option

Save jahands/9585bac26038b51043d3110830b30321 to your computer and use it in GitHub Desktop.
slop-fork of opencode glob/grep tools to allow multiple patterns
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,
},
})
})
}
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,
},
})
})
}
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