feat(tools): add glob tool with timeout protection
- Override OpenCode's built-in glob with 60s timeout - Kill process on expiration to prevent indefinite hanging - Reuse grep's CLI resolver for ripgrep detection Generated by [OpenCode](https://opencode.ai/)
This commit is contained in:
@@ -190,6 +190,12 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
|||||||
- 기본 grep 도구는 시간제한이 걸려있지 않습니다. 대형 코드베이스에서 광범위한 패턴을 검색하면 CPU가 폭발하고 무한히 멈출 수 있습니다.
|
- 기본 grep 도구는 시간제한이 걸려있지 않습니다. 대형 코드베이스에서 광범위한 패턴을 검색하면 CPU가 폭발하고 무한히 멈출 수 있습니다.
|
||||||
- 이 도구는 엄격한 제한을 적용하며, 내장 `grep`을 완전히 대체합니다.
|
- 이 도구는 엄격한 제한을 적용하며, 내장 `grep`을 완전히 대체합니다.
|
||||||
|
|
||||||
|
#### Glob
|
||||||
|
|
||||||
|
- **glob**: 타임아웃 보호가 있는 파일 패턴 매칭 (60초). OpenCode 내장 `glob` 도구를 대체합니다.
|
||||||
|
- 기본 `glob`은 타임아웃이 없습니다. ripgrep이 멈추면 무한정 대기합니다.
|
||||||
|
- 이 도구는 타임아웃을 강제하고 만료 시 프로세스를 종료합니다.
|
||||||
|
|
||||||
#### 내장 MCPs
|
#### 내장 MCPs
|
||||||
|
|
||||||
- **websearch_exa**: Exa AI 웹 검색. 실시간 웹 검색과 콘텐츠 스크래핑을 수행합니다. 관련 웹사이트에서 LLM에 최적화된 컨텍스트를 반환합니다.
|
- **websearch_exa**: Exa AI 웹 검색. 실시간 웹 검색과 콘텐츠 스크래핑을 수행합니다. 관련 웹사이트에서 LLM에 최적화된 컨텍스트를 반환합니다.
|
||||||
|
|||||||
@@ -187,6 +187,12 @@ The features you use in your editor—other agents cannot access them. Oh My Ope
|
|||||||
- The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging.
|
- The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging.
|
||||||
- This tool enforces strict limits and completely replaces the built-in `grep`.
|
- This tool enforces strict limits and completely replaces the built-in `grep`.
|
||||||
|
|
||||||
|
#### Glob
|
||||||
|
|
||||||
|
- **glob**: File pattern matching with timeout protection (60s). Overrides OpenCode's built-in `glob` tool.
|
||||||
|
- The default `glob` lacks timeout. If ripgrep hangs, it waits indefinitely.
|
||||||
|
- This tool enforces timeouts and kills the process on expiration.
|
||||||
|
|
||||||
#### Built-in MCPs
|
#### Built-in MCPs
|
||||||
|
|
||||||
- **websearch_exa**: Exa AI web search. Performs real-time web searches and can scrape content from specific URLs. Returns LLM-optimized context from relevant websites.
|
- **websearch_exa**: Exa AI web search. Performs real-time web searches and can scrape content from specific URLs. Returns LLM-optimized context from relevant websites.
|
||||||
|
|||||||
129
src/tools/glob/cli.ts
Normal file
129
src/tools/glob/cli.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { spawn } from "bun"
|
||||||
|
import {
|
||||||
|
resolveGrepCli,
|
||||||
|
DEFAULT_TIMEOUT_MS,
|
||||||
|
DEFAULT_LIMIT,
|
||||||
|
DEFAULT_MAX_DEPTH,
|
||||||
|
DEFAULT_MAX_OUTPUT_BYTES,
|
||||||
|
RG_FILES_FLAGS,
|
||||||
|
} from "./constants"
|
||||||
|
import type { GlobOptions, GlobResult, FileMatch } from "./types"
|
||||||
|
import { stat } from "node:fs/promises"
|
||||||
|
|
||||||
|
function buildRgArgs(options: GlobOptions): string[] {
|
||||||
|
const args: string[] = [
|
||||||
|
...RG_FILES_FLAGS,
|
||||||
|
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (options.hidden) args.push("--hidden")
|
||||||
|
if (options.noIgnore) args.push("--no-ignore")
|
||||||
|
|
||||||
|
args.push(`--glob=${options.pattern}`)
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFindArgs(options: GlobOptions): string[] {
|
||||||
|
const args: string[] = ["."]
|
||||||
|
|
||||||
|
const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)
|
||||||
|
args.push("-maxdepth", String(maxDepth))
|
||||||
|
|
||||||
|
args.push("-type", "f")
|
||||||
|
args.push("-name", options.pattern)
|
||||||
|
|
||||||
|
if (!options.hidden) {
|
||||||
|
args.push("-not", "-path", "*/.*")
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFileMtime(filePath: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const stats = await stat(filePath)
|
||||||
|
return stats.mtime.getTime()
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRgFiles(options: GlobOptions): Promise<GlobResult> {
|
||||||
|
const cli = resolveGrepCli()
|
||||||
|
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||||
|
const limit = Math.min(options.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT)
|
||||||
|
|
||||||
|
const isRg = cli.backend === "rg"
|
||||||
|
const args = isRg ? buildRgArgs(options) : buildFindArgs(options)
|
||||||
|
|
||||||
|
const paths = options.paths?.length ? options.paths : ["."]
|
||||||
|
if (isRg) {
|
||||||
|
args.push(...paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = paths[0] || "."
|
||||||
|
|
||||||
|
const proc = spawn([cli.path, ...args], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
cwd: isRg ? undefined : cwd,
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
proc.kill()
|
||||||
|
reject(new Error(`Glob search timeout after ${timeout}ms`))
|
||||||
|
}, timeout)
|
||||||
|
proc.exited.then(() => clearTimeout(id))
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
||||||
|
const stderr = await new Response(proc.stderr).text()
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
|
||||||
|
if (exitCode > 1 && stderr.trim()) {
|
||||||
|
return {
|
||||||
|
files: [],
|
||||||
|
totalFiles: 0,
|
||||||
|
truncated: false,
|
||||||
|
error: stderr.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncatedOutput = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||||
|
const outputToProcess = truncatedOutput ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||||
|
|
||||||
|
const lines = outputToProcess.trim().split("\n").filter(Boolean)
|
||||||
|
|
||||||
|
const files: FileMatch[] = []
|
||||||
|
let truncated = false
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (files.length >= limit) {
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = isRg ? line : `${cwd}/${line}`
|
||||||
|
const mtime = await getFileMtime(filePath)
|
||||||
|
files.push({ path: filePath, mtime })
|
||||||
|
}
|
||||||
|
|
||||||
|
files.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
totalFiles: files.length,
|
||||||
|
truncated: truncated || truncatedOutput,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
files: [],
|
||||||
|
totalFiles: 0,
|
||||||
|
truncated: false,
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/tools/glob/constants.ts
Normal file
12
src/tools/glob/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export { resolveGrepCli, type GrepBackend } from "../grep/constants"
|
||||||
|
|
||||||
|
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||||
|
export const DEFAULT_LIMIT = 100
|
||||||
|
export const DEFAULT_MAX_DEPTH = 20
|
||||||
|
export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
export const RG_FILES_FLAGS = [
|
||||||
|
"--files",
|
||||||
|
"--color=never",
|
||||||
|
"--glob=!.git/*",
|
||||||
|
] as const
|
||||||
3
src/tools/glob/index.ts
Normal file
3
src/tools/glob/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { glob } from "./tools"
|
||||||
|
|
||||||
|
export { glob }
|
||||||
36
src/tools/glob/tools.ts
Normal file
36
src/tools/glob/tools.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { tool } from "@opencode-ai/plugin/tool"
|
||||||
|
import { runRgFiles } from "./cli"
|
||||||
|
import { formatGlobResult } from "./utils"
|
||||||
|
|
||||||
|
export const glob = tool({
|
||||||
|
description:
|
||||||
|
"Fast file pattern matching tool with safety limits (60s timeout, 100 file limit). " +
|
||||||
|
"Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " +
|
||||||
|
"Returns matching file paths sorted by modification time. " +
|
||||||
|
"Use this tool when you need to find files by name patterns.",
|
||||||
|
args: {
|
||||||
|
pattern: tool.schema.string().describe("The glob pattern to match files against"),
|
||||||
|
path: tool.schema
|
||||||
|
.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: async (args) => {
|
||||||
|
try {
|
||||||
|
const paths = args.path ? [args.path] : undefined
|
||||||
|
|
||||||
|
const result = await runRgFiles({
|
||||||
|
pattern: args.pattern,
|
||||||
|
paths,
|
||||||
|
})
|
||||||
|
|
||||||
|
return formatGlobResult(result)
|
||||||
|
} catch (e) {
|
||||||
|
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
21
src/tools/glob/types.ts
Normal file
21
src/tools/glob/types.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface FileMatch {
|
||||||
|
path: string
|
||||||
|
mtime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobResult {
|
||||||
|
files: FileMatch[]
|
||||||
|
totalFiles: number
|
||||||
|
truncated: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobOptions {
|
||||||
|
pattern: string
|
||||||
|
paths?: string[]
|
||||||
|
hidden?: boolean
|
||||||
|
noIgnore?: boolean
|
||||||
|
maxDepth?: number
|
||||||
|
timeout?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
26
src/tools/glob/utils.ts
Normal file
26
src/tools/glob/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { GlobResult } from "./types"
|
||||||
|
|
||||||
|
export function formatGlobResult(result: GlobResult): string {
|
||||||
|
if (result.error) {
|
||||||
|
return `Error: ${result.error}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.files.length === 0) {
|
||||||
|
return "No files found"
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
lines.push(`Found ${result.totalFiles} file(s)`)
|
||||||
|
lines.push("")
|
||||||
|
|
||||||
|
for (const file of result.files) {
|
||||||
|
lines.push(file.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.truncated) {
|
||||||
|
lines.push("")
|
||||||
|
lines.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "./ast-grep"
|
} from "./ast-grep"
|
||||||
|
|
||||||
import { grep } from "./grep"
|
import { grep } from "./grep"
|
||||||
|
import { glob } from "./glob"
|
||||||
|
|
||||||
export const builtinTools = {
|
export const builtinTools = {
|
||||||
lsp_hover,
|
lsp_hover,
|
||||||
@@ -34,4 +35,5 @@ export const builtinTools = {
|
|||||||
ast_grep_search,
|
ast_grep_search,
|
||||||
ast_grep_replace,
|
ast_grep_replace,
|
||||||
grep,
|
grep,
|
||||||
|
glob,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user