|
/** |
|
* search-tools.ts — override pi's built-in `grep` and `find` tools so they |
|
* shell out to ripgrep (`rg`) and `fd`. |
|
* |
|
* Why: the model naturally reaches for the built-in `grep`/`find` tools. Rather |
|
* than fight that instinct in the prompt, we redefine those tools to run the |
|
* faster, gitignore-aware equivalents underneath. The agent keeps calling |
|
* `grep`/`find`; it transparently gets `rg`/`fd` behavior. |
|
* |
|
* Requires `rg` and `fd` on PATH. Structural code search (ast-grep) stays in the |
|
* `bash` tool — it needs a language + pattern and isn't a drop-in for grep. |
|
*/ |
|
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; |
|
import { Type } from "typebox"; |
|
import { StringEnum } from "@earendil-works/pi-ai"; |
|
|
|
const EXEC_TIMEOUT_MS = 120_000; |
|
|
|
export default function (pi: ExtensionAPI) { |
|
// ---- grep → ripgrep ----------------------------------------------------- |
|
pi.registerTool({ |
|
name: "grep", |
|
label: "grep (ripgrep)", |
|
description: |
|
"Search file contents by regex using ripgrep (rg). Fast and respects " + |
|
".gitignore by default. Set includeIgnored to also search ignored/hidden files.", |
|
promptSnippet: "Search file contents by regex (ripgrep)", |
|
promptGuidelines: [ |
|
"Use grep for text/regex search across files; it runs ripgrep under the hood.", |
|
"Set includeIgnored=true when a match might live in a gitignored or hidden file.", |
|
"For structural code search (a specific call, import, or declaration) prefer ast-grep via the bash tool instead of grep.", |
|
], |
|
parameters: Type.Object({ |
|
pattern: Type.String({ description: "Regex to search for" }), |
|
path: Type.Optional( |
|
Type.String({ description: "File or directory to search (default: current directory)" }), |
|
), |
|
glob: Type.Optional( |
|
Type.String({ |
|
description: "Only search files matching this glob, e.g. '*.ts' or '!*.test.ts'", |
|
}), |
|
), |
|
ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive match" })), |
|
wholeWord: Type.Optional(Type.Boolean({ description: "Match whole words only" })), |
|
fixedString: Type.Optional( |
|
Type.Boolean({ description: "Treat pattern as a literal string, not a regex" }), |
|
), |
|
context: Type.Optional( |
|
Type.Number({ description: "Lines of context to show around each match" }), |
|
), |
|
filesWithMatches: Type.Optional( |
|
Type.Boolean({ description: "List only the file paths that contain matches" }), |
|
), |
|
includeIgnored: Type.Optional( |
|
Type.Boolean({ description: "Also search gitignored and hidden files" }), |
|
), |
|
}), |
|
|
|
async execute(_toolCallId, params, signal) { |
|
const args: string[] = ["--line-number", "--color=never"]; |
|
if (params.ignoreCase) args.push("-i"); |
|
if (params.wholeWord) args.push("-w"); |
|
if (params.fixedString) args.push("-F"); |
|
if (params.filesWithMatches) args.push("-l"); |
|
if (params.includeIgnored) args.push("-uu"); |
|
if (typeof params.context === "number") args.push("-C", String(params.context)); |
|
if (params.glob) args.push("-g", params.glob); |
|
// `--` terminates flags so a pattern beginning with `-` is safe. |
|
args.push("--", params.pattern); |
|
if (params.path) args.push(params.path); |
|
|
|
const result = await pi.exec("rg", args, { signal, timeout: EXEC_TIMEOUT_MS }); |
|
|
|
// rg exit codes: 0 = matches, 1 = no matches, >=2 = real error. |
|
if (result.code >= 2) { |
|
throw new Error(result.stderr.trim() || `rg exited with code ${result.code}`); |
|
} |
|
const out = result.stdout.trim(); |
|
if (!out) { |
|
return { content: [{ type: "text", text: "No matches." }], details: { matched: false } }; |
|
} |
|
const text = result.truncated ? `${out}\n… (output truncated)` : out; |
|
return { |
|
content: [{ type: "text", text }], |
|
details: { exitCode: result.code, truncated: result.truncated }, |
|
}; |
|
}, |
|
}); |
|
|
|
// ---- find → fd ---------------------------------------------------------- |
|
pi.registerTool({ |
|
name: "find", |
|
label: "find (fd)", |
|
description: |
|
"Find files and directories by name using fd. Respects .gitignore by " + |
|
"default. Pattern is a regex unless glob=true; omit it to list everything.", |
|
promptSnippet: "Find files/dirs by name (fd)", |
|
promptGuidelines: [ |
|
"Use find to locate files or directories by name; it runs fd under the hood.", |
|
"Set hidden=true to include hidden files, or type to restrict to files vs directories.", |
|
], |
|
parameters: Type.Object({ |
|
pattern: Type.Optional( |
|
Type.String({ |
|
description: "Name pattern (regex by default, or glob if glob=true). Omit to list all.", |
|
}), |
|
), |
|
path: Type.Optional( |
|
Type.String({ description: "Directory to search in (default: current directory)" }), |
|
), |
|
glob: Type.Optional( |
|
Type.Boolean({ description: "Treat pattern as a glob instead of a regex" }), |
|
), |
|
type: Type.Optional(StringEnum(["file", "dir"] as const)), |
|
extension: Type.Optional( |
|
Type.String({ description: "Filter by file extension, e.g. 'ts' (no leading dot)" }), |
|
), |
|
hidden: Type.Optional(Type.Boolean({ description: "Include hidden files and directories" })), |
|
}), |
|
|
|
async execute(_toolCallId, params, signal) { |
|
const args: string[] = ["--color=never"]; |
|
if (params.type) args.push("--type", params.type === "dir" ? "d" : "f"); |
|
if (params.hidden) args.push("--hidden"); |
|
if (params.extension) args.push("--extension", params.extension); |
|
if (params.glob) args.push("--glob"); |
|
// fd needs a pattern positionally; default matches everything. |
|
const pattern = params.pattern ?? (params.glob ? "*" : "."); |
|
args.push("--", pattern); |
|
if (params.path) args.push(params.path); |
|
|
|
const result = await pi.exec("fd", args, { signal, timeout: EXEC_TIMEOUT_MS }); |
|
|
|
// fd exits 0 on success (even with no results), non-zero on real error. |
|
if (result.code !== 0) { |
|
throw new Error(result.stderr.trim() || `fd exited with code ${result.code}`); |
|
} |
|
const out = result.stdout.trim(); |
|
if (!out) { |
|
return { content: [{ type: "text", text: "No files found." }], details: { count: 0 } }; |
|
} |
|
const text = result.truncated ? `${out}\n… (output truncated)` : out; |
|
return { |
|
content: [{ type: "text", text }], |
|
details: { count: out.split("\n").length, truncated: result.truncated }, |
|
}; |
|
}, |
|
}); |
|
} |