refactor(tools): rename all tools to snake_case for consistency
This commit is contained in:
61
notepad.md
Normal file
61
notepad.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# MCP Loader Plugin - Orchestration Notepad
|
||||||
|
|
||||||
|
## Task Started
|
||||||
|
All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Orchestration Overview
|
||||||
|
|
||||||
|
**Todo List File**: ./tool-search-tool-plan.md
|
||||||
|
**Total Tasks**: 5 (Phase 1-5)
|
||||||
|
**Target Files**:
|
||||||
|
- `~/.config/opencode/plugin/mcp-loader.ts` - Main plugin
|
||||||
|
- `~/.config/opencode/mcp-loader.json` - Global config example
|
||||||
|
- `~/.config/opencode/plugin/mcp-loader.test.ts` - Unit tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accumulated Wisdom
|
||||||
|
|
||||||
|
(To be populated by executors)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Progress
|
||||||
|
|
||||||
|
| Task | Description | Status |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| 1 | Plugin skeleton + config loader | pending |
|
||||||
|
| 2 | MCP server registry + lifecycle | pending |
|
||||||
|
| 3 | mcp_search + mcp_status tools | pending |
|
||||||
|
| 4 | mcp_call tool | pending |
|
||||||
|
| 5 | Documentation | pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 2025-12-04 16:58 - Task 1 Completed
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
- Created `~/.config/opencode/plugin/mcp-loader.ts` - Plugin skeleton with config loader
|
||||||
|
- Created `~/.config/opencode/plugin/mcp-loader.test.ts` - 14 unit tests
|
||||||
|
|
||||||
|
### Key Implementation Details
|
||||||
|
- Config merge: project overrides global for same server names, merges different
|
||||||
|
- Env var substitution: `{env:VAR}` → `process.env.VAR`
|
||||||
|
- Validation: type required, local needs command, remote needs url
|
||||||
|
- Empty config returns `{ servers: {} }` (not error)
|
||||||
|
|
||||||
|
### Test Results
|
||||||
|
- 14 tests passed
|
||||||
|
- substituteEnvVars: 4 tests
|
||||||
|
- substituteHeaderEnvVars: 1 test
|
||||||
|
- loadConfig: 9 tests
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
- `~/.config/opencode/plugin/mcp-loader.ts`
|
||||||
|
- `~/.config/opencode/plugin/mcp-loader.test.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
ast_grep_transform,
|
ast_grep_transform,
|
||||||
} from "./ast-grep"
|
} from "./ast-grep"
|
||||||
|
|
||||||
|
import { safe_grep } from "./safe-grep"
|
||||||
|
|
||||||
export const builtinTools = {
|
export const builtinTools = {
|
||||||
lsp_hover,
|
lsp_hover,
|
||||||
lsp_goto_definition,
|
lsp_goto_definition,
|
||||||
@@ -37,4 +39,5 @@ export const builtinTools = {
|
|||||||
ast_grep_languages,
|
ast_grep_languages,
|
||||||
ast_grep_analyze,
|
ast_grep_analyze,
|
||||||
ast_grep_transform,
|
ast_grep_transform,
|
||||||
|
safe_grep,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ interface ManagedClient {
|
|||||||
client: LSPClient
|
client: LSPClient
|
||||||
lastUsedAt: number
|
lastUsedAt: number
|
||||||
refCount: number
|
refCount: number
|
||||||
|
initPromise?: Promise<void>
|
||||||
|
isInitializing: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
class LSPServerManager {
|
class LSPServerManager {
|
||||||
@@ -53,6 +55,9 @@ class LSPServerManager {
|
|||||||
|
|
||||||
let managed = this.clients.get(key)
|
let managed = this.clients.get(key)
|
||||||
if (managed) {
|
if (managed) {
|
||||||
|
if (managed.initPromise) {
|
||||||
|
await managed.initPromise
|
||||||
|
}
|
||||||
if (managed.client.isAlive()) {
|
if (managed.client.isAlive()) {
|
||||||
managed.refCount++
|
managed.refCount++
|
||||||
managed.lastUsedAt = Date.now()
|
managed.lastUsedAt = Date.now()
|
||||||
@@ -63,18 +68,56 @@ class LSPServerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new LSPClient(root, server)
|
const client = new LSPClient(root, server)
|
||||||
|
const initPromise = (async () => {
|
||||||
await client.start()
|
await client.start()
|
||||||
await client.initialize()
|
await client.initialize()
|
||||||
|
})()
|
||||||
|
|
||||||
this.clients.set(key, {
|
this.clients.set(key, {
|
||||||
client,
|
client,
|
||||||
lastUsedAt: Date.now(),
|
lastUsedAt: Date.now(),
|
||||||
refCount: 1,
|
refCount: 1,
|
||||||
|
initPromise,
|
||||||
|
isInitializing: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await initPromise
|
||||||
|
const m = this.clients.get(key)
|
||||||
|
if (m) {
|
||||||
|
m.initPromise = undefined
|
||||||
|
m.isInitializing = false
|
||||||
|
}
|
||||||
|
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
warmupClient(root: string, server: ResolvedServer): void {
|
||||||
|
const key = this.getKey(root, server.id)
|
||||||
|
if (this.clients.has(key)) return
|
||||||
|
|
||||||
|
const client = new LSPClient(root, server)
|
||||||
|
const initPromise = (async () => {
|
||||||
|
await client.start()
|
||||||
|
await client.initialize()
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.clients.set(key, {
|
||||||
|
client,
|
||||||
|
lastUsedAt: Date.now(),
|
||||||
|
refCount: 0,
|
||||||
|
initPromise,
|
||||||
|
isInitializing: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
initPromise.then(() => {
|
||||||
|
const m = this.clients.get(key)
|
||||||
|
if (m) {
|
||||||
|
m.initPromise = undefined
|
||||||
|
m.isInitializing = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
releaseClient(root: string, serverId: string): void {
|
releaseClient(root: string, serverId: string): void {
|
||||||
const key = this.getKey(root, serverId)
|
const key = this.getKey(root, serverId)
|
||||||
const managed = this.clients.get(key)
|
const managed = this.clients.get(key)
|
||||||
@@ -84,6 +127,12 @@ class LSPServerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isServerInitializing(root: string, serverId: string): boolean {
|
||||||
|
const key = this.getKey(root, serverId)
|
||||||
|
const managed = this.clients.get(key)
|
||||||
|
return managed?.isInitializing ?? false
|
||||||
|
}
|
||||||
|
|
||||||
async stopAll(): Promise<void> {
|
async stopAll(): Promise<void> {
|
||||||
for (const [, managed] of this.clients) {
|
for (const [, managed] of this.clients) {
|
||||||
await managed.client.stop()
|
await managed.client.stop()
|
||||||
@@ -278,7 +327,7 @@ export class LSPClient {
|
|||||||
const stderr = this.stderrBuffer.slice(-5).join("\n")
|
const stderr = this.stderrBuffer.slice(-5).join("\n")
|
||||||
reject(new Error(`LSP request timeout (method: ${method})` + (stderr ? `\nrecent stderr: ${stderr}` : "")))
|
reject(new Error(`LSP request timeout (method: ${method})` + (stderr ? `\nrecent stderr: ${stderr}` : "")))
|
||||||
}
|
}
|
||||||
}, 30000)
|
}, 15000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ export async function withLspClient<T>(filePath: string, fn: (client: LSPClient)
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn(client)
|
return await fn(client)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message.includes("timeout")) {
|
||||||
|
const isInitializing = lspManager.isServerInitializing(root, server.id)
|
||||||
|
if (isInitializing) {
|
||||||
|
throw new Error(
|
||||||
|
`LSP server is still initializing. Please retry in a few seconds. ` +
|
||||||
|
`Original error: ${e.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
lspManager.releaseClient(root, server.id)
|
lspManager.releaseClient(root, server.id)
|
||||||
}
|
}
|
||||||
|
|||||||
229
src/tools/safe-grep/cli.ts
Normal file
229
src/tools/safe-grep/cli.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { spawn } from "bun"
|
||||||
|
import {
|
||||||
|
resolveGrepCli,
|
||||||
|
type GrepBackend,
|
||||||
|
DEFAULT_MAX_DEPTH,
|
||||||
|
DEFAULT_MAX_FILESIZE,
|
||||||
|
DEFAULT_MAX_COUNT,
|
||||||
|
DEFAULT_MAX_COLUMNS,
|
||||||
|
DEFAULT_TIMEOUT_MS,
|
||||||
|
DEFAULT_MAX_OUTPUT_BYTES,
|
||||||
|
RG_SAFETY_FLAGS,
|
||||||
|
GREP_SAFETY_FLAGS,
|
||||||
|
} from "./constants"
|
||||||
|
import type { GrepOptions, GrepMatch, GrepResult, CountResult } from "./types"
|
||||||
|
|
||||||
|
function buildRgArgs(options: GrepOptions): string[] {
|
||||||
|
const args: string[] = [
|
||||||
|
...RG_SAFETY_FLAGS,
|
||||||
|
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
|
||||||
|
`--max-filesize=${options.maxFilesize ?? DEFAULT_MAX_FILESIZE}`,
|
||||||
|
`--max-count=${Math.min(options.maxCount ?? DEFAULT_MAX_COUNT, DEFAULT_MAX_COUNT)}`,
|
||||||
|
`--max-columns=${Math.min(options.maxColumns ?? DEFAULT_MAX_COLUMNS, DEFAULT_MAX_COLUMNS)}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (options.context !== undefined && options.context > 0) {
|
||||||
|
args.push(`-C${Math.min(options.context, 10)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.caseSensitive) args.push("--case-sensitive")
|
||||||
|
if (options.wholeWord) args.push("-w")
|
||||||
|
if (options.fixedStrings) args.push("-F")
|
||||||
|
if (options.multiline) args.push("-U")
|
||||||
|
if (options.hidden) args.push("--hidden")
|
||||||
|
if (options.noIgnore) args.push("--no-ignore")
|
||||||
|
|
||||||
|
if (options.fileType?.length) {
|
||||||
|
for (const type of options.fileType) {
|
||||||
|
args.push(`--type=${type}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.globs) {
|
||||||
|
for (const glob of options.globs) {
|
||||||
|
args.push(`--glob=${glob}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.excludeGlobs) {
|
||||||
|
for (const glob of options.excludeGlobs) {
|
||||||
|
args.push(`--glob=!${glob}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGrepArgs(options: GrepOptions): string[] {
|
||||||
|
const args: string[] = [...GREP_SAFETY_FLAGS, "-r"]
|
||||||
|
|
||||||
|
if (options.context !== undefined && options.context > 0) {
|
||||||
|
args.push(`-C${Math.min(options.context, 10)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.caseSensitive) args.push("-i")
|
||||||
|
if (options.wholeWord) args.push("-w")
|
||||||
|
if (options.fixedStrings) args.push("-F")
|
||||||
|
|
||||||
|
if (options.globs?.length) {
|
||||||
|
for (const glob of options.globs) {
|
||||||
|
args.push(`--include=${glob}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.excludeGlobs?.length) {
|
||||||
|
for (const glob of options.excludeGlobs) {
|
||||||
|
args.push(`--exclude=${glob}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push("--exclude-dir=.git", "--exclude-dir=node_modules")
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArgs(options: GrepOptions, backend: GrepBackend): string[] {
|
||||||
|
return backend === "rg" ? buildRgArgs(options) : buildGrepArgs(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOutput(output: string): GrepMatch[] {
|
||||||
|
if (!output.trim()) return []
|
||||||
|
|
||||||
|
const matches: GrepMatch[] = []
|
||||||
|
const lines = output.split("\n")
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
|
||||||
|
const match = line.match(/^(.+?):(\d+):(.*)$/)
|
||||||
|
if (match) {
|
||||||
|
matches.push({
|
||||||
|
file: match[1],
|
||||||
|
line: parseInt(match[2], 10),
|
||||||
|
text: match[3],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCountOutput(output: string): CountResult[] {
|
||||||
|
if (!output.trim()) return []
|
||||||
|
|
||||||
|
const results: CountResult[] = []
|
||||||
|
const lines = output.split("\n")
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
|
||||||
|
const match = line.match(/^(.+?):(\d+)$/)
|
||||||
|
if (match) {
|
||||||
|
results.push({
|
||||||
|
file: match[1],
|
||||||
|
count: parseInt(match[2], 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRg(options: GrepOptions): Promise<GrepResult> {
|
||||||
|
const cli = resolveGrepCli()
|
||||||
|
const args = buildArgs(options, cli.backend)
|
||||||
|
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||||
|
|
||||||
|
if (cli.backend === "rg") {
|
||||||
|
args.push("--", options.pattern)
|
||||||
|
} else {
|
||||||
|
args.push("-e", options.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = options.paths?.length ? options.paths : ["."]
|
||||||
|
args.push(...paths)
|
||||||
|
const proc = spawn([cli.path, ...args], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
proc.kill()
|
||||||
|
reject(new Error(`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
|
||||||
|
|
||||||
|
const truncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||||
|
const outputToProcess = truncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||||
|
|
||||||
|
if (exitCode > 1 && stderr.trim()) {
|
||||||
|
return {
|
||||||
|
matches: [],
|
||||||
|
totalMatches: 0,
|
||||||
|
filesSearched: 0,
|
||||||
|
truncated: false,
|
||||||
|
error: stderr.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = parseOutput(outputToProcess)
|
||||||
|
const filesSearched = new Set(matches.map((m) => m.file)).size
|
||||||
|
|
||||||
|
return {
|
||||||
|
matches,
|
||||||
|
totalMatches: matches.length,
|
||||||
|
filesSearched,
|
||||||
|
truncated,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
matches: [],
|
||||||
|
totalMatches: 0,
|
||||||
|
filesSearched: 0,
|
||||||
|
truncated: false,
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRgCount(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
|
||||||
|
const cli = resolveGrepCli()
|
||||||
|
const args = buildArgs({ ...options, context: 0 }, cli.backend)
|
||||||
|
|
||||||
|
if (cli.backend === "rg") {
|
||||||
|
args.push("--count", "--", options.pattern)
|
||||||
|
} else {
|
||||||
|
args.push("-c", "-e", options.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = options.paths?.length ? options.paths : ["."]
|
||||||
|
args.push(...paths)
|
||||||
|
|
||||||
|
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||||
|
const proc = spawn([cli.path, ...args], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
proc.kill()
|
||||||
|
reject(new Error(`Search timeout after ${timeout}ms`))
|
||||||
|
}, timeout)
|
||||||
|
proc.exited.then(() => clearTimeout(id))
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
||||||
|
return parseCountOutput(stdout)
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Count search failed: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/tools/safe-grep/constants.ts
Normal file
99
src/tools/safe-grep/constants.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { existsSync } from "node:fs"
|
||||||
|
import { join, dirname } from "node:path"
|
||||||
|
import { spawnSync } from "node:child_process"
|
||||||
|
|
||||||
|
export type GrepBackend = "rg" | "grep"
|
||||||
|
|
||||||
|
interface ResolvedCli {
|
||||||
|
path: string
|
||||||
|
backend: GrepBackend
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedCli: ResolvedCli | null = null
|
||||||
|
|
||||||
|
function findExecutable(name: string): string | null {
|
||||||
|
const isWindows = process.platform === "win32"
|
||||||
|
const cmd = isWindows ? "where" : "which"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 })
|
||||||
|
if (result.status === 0 && result.stdout.trim()) {
|
||||||
|
return result.stdout.trim().split("\n")[0]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpenCodeBundledRg(): string | null {
|
||||||
|
// OpenCode binary directory (where opencode executable lives)
|
||||||
|
const execPath = process.execPath
|
||||||
|
const execDir = dirname(execPath)
|
||||||
|
|
||||||
|
const isWindows = process.platform === "win32"
|
||||||
|
const rgName = isWindows ? "rg.exe" : "rg"
|
||||||
|
|
||||||
|
// Check common bundled locations
|
||||||
|
const candidates = [
|
||||||
|
join(execDir, rgName),
|
||||||
|
join(execDir, "bin", rgName),
|
||||||
|
join(execDir, "..", "bin", rgName),
|
||||||
|
join(execDir, "..", "libexec", rgName),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGrepCli(): ResolvedCli {
|
||||||
|
if (cachedCli) return cachedCli
|
||||||
|
|
||||||
|
// Priority 1: OpenCode bundled rg
|
||||||
|
const bundledRg = getOpenCodeBundledRg()
|
||||||
|
if (bundledRg) {
|
||||||
|
cachedCli = { path: bundledRg, backend: "rg" }
|
||||||
|
return cachedCli
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: System rg
|
||||||
|
const systemRg = findExecutable("rg")
|
||||||
|
if (systemRg) {
|
||||||
|
cachedCli = { path: systemRg, backend: "rg" }
|
||||||
|
return cachedCli
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: grep (fallback)
|
||||||
|
const grep = findExecutable("grep")
|
||||||
|
if (grep) {
|
||||||
|
cachedCli = { path: grep, backend: "grep" }
|
||||||
|
return cachedCli
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: assume rg is in PATH
|
||||||
|
cachedCli = { path: "rg", backend: "rg" }
|
||||||
|
return cachedCli
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MAX_DEPTH = 20
|
||||||
|
export const DEFAULT_MAX_FILESIZE = "10M"
|
||||||
|
export const DEFAULT_MAX_COUNT = 500
|
||||||
|
export const DEFAULT_MAX_COLUMNS = 1000
|
||||||
|
export const DEFAULT_CONTEXT = 2
|
||||||
|
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||||
|
export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
export const RG_SAFETY_FLAGS = [
|
||||||
|
"--no-follow",
|
||||||
|
"--color=never",
|
||||||
|
"--no-heading",
|
||||||
|
"--line-number",
|
||||||
|
"--with-filename",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const GREP_SAFETY_FLAGS = ["-n", "-H", "--color=never"] as const
|
||||||
3
src/tools/safe-grep/index.ts
Normal file
3
src/tools/safe-grep/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { safe_grep } from "./tools"
|
||||||
|
|
||||||
|
export { safe_grep }
|
||||||
40
src/tools/safe-grep/tools.ts
Normal file
40
src/tools/safe-grep/tools.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { tool } from "@opencode-ai/plugin/tool"
|
||||||
|
import { runRg } from "./cli"
|
||||||
|
import { formatGrepResult } from "./utils"
|
||||||
|
|
||||||
|
export const safe_grep = tool({
|
||||||
|
description:
|
||||||
|
"Fast content search tool with safety limits (60s timeout, 10MB output). " +
|
||||||
|
"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}\"). " +
|
||||||
|
"Returns file paths with matches sorted by modification time.",
|
||||||
|
args: {
|
||||||
|
pattern: tool.schema.string().describe("The regex pattern to search for in file contents"),
|
||||||
|
include: tool.schema
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"),
|
||||||
|
path: tool.schema
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("The directory to search in. Defaults to the current working directory."),
|
||||||
|
},
|
||||||
|
execute: async (args) => {
|
||||||
|
try {
|
||||||
|
const globs = args.include ? [args.include] : undefined
|
||||||
|
const paths = args.path ? [args.path] : undefined
|
||||||
|
|
||||||
|
const result = await runRg({
|
||||||
|
pattern: args.pattern,
|
||||||
|
paths,
|
||||||
|
globs,
|
||||||
|
context: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return formatGrepResult(result)
|
||||||
|
} catch (e) {
|
||||||
|
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
39
src/tools/safe-grep/types.ts
Normal file
39
src/tools/safe-grep/types.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export interface GrepMatch {
|
||||||
|
file: string
|
||||||
|
line: number
|
||||||
|
column?: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrepResult {
|
||||||
|
matches: GrepMatch[]
|
||||||
|
totalMatches: number
|
||||||
|
filesSearched: number
|
||||||
|
truncated: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrepOptions {
|
||||||
|
pattern: string
|
||||||
|
paths?: string[]
|
||||||
|
globs?: string[]
|
||||||
|
excludeGlobs?: string[]
|
||||||
|
context?: number
|
||||||
|
maxDepth?: number
|
||||||
|
maxFilesize?: string
|
||||||
|
maxCount?: number
|
||||||
|
maxColumns?: number
|
||||||
|
caseSensitive?: boolean
|
||||||
|
wholeWord?: boolean
|
||||||
|
fixedStrings?: boolean
|
||||||
|
multiline?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
noIgnore?: boolean
|
||||||
|
fileType?: string[]
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CountResult {
|
||||||
|
file: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
53
src/tools/safe-grep/utils.ts
Normal file
53
src/tools/safe-grep/utils.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { GrepResult, GrepMatch, CountResult } from "./types"
|
||||||
|
|
||||||
|
export function formatGrepResult(result: GrepResult): string {
|
||||||
|
if (result.error) {
|
||||||
|
return `Error: ${result.error}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.matches.length === 0) {
|
||||||
|
return "No matches found"
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
lines.push(`Found ${result.totalMatches} match(es) in ${result.filesSearched} file(s)`)
|
||||||
|
if (result.truncated) {
|
||||||
|
lines.push("[Output truncated due to size limit]")
|
||||||
|
}
|
||||||
|
lines.push("")
|
||||||
|
|
||||||
|
const byFile = new Map<string, GrepMatch[]>()
|
||||||
|
for (const match of result.matches) {
|
||||||
|
const existing = byFile.get(match.file) || []
|
||||||
|
existing.push(match)
|
||||||
|
byFile.set(match.file, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [file, matches] of byFile) {
|
||||||
|
lines.push(file)
|
||||||
|
for (const match of matches) {
|
||||||
|
lines.push(` ${match.line}: ${match.text.trim()}`)
|
||||||
|
}
|
||||||
|
lines.push("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCountResult(results: CountResult[]): string {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return "No matches found"
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = results.reduce((sum, r) => sum + r.count, 0)
|
||||||
|
const lines: string[] = [`Found ${total} match(es) in ${results.length} file(s):`, ""]
|
||||||
|
|
||||||
|
const sorted = [...results].sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
for (const { file, count } of sorted) {
|
||||||
|
lines.push(` ${count.toString().padStart(6)}: ${file}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
6
test-rule.yml
Normal file
6
test-rule.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
id: test-rule
|
||||||
|
message: Test rule
|
||||||
|
severity: info
|
||||||
|
language: JavaScript
|
||||||
|
rule:
|
||||||
|
pattern: console
|
||||||
Reference in New Issue
Block a user