feat(coding-agent): add read-only exploration tools (grep, find, ls) and --tools flag

Add grep, find, and ls tools for safe code exploration without modification risk.
These tools are available via the new --tools CLI flag.

- grep: Uses ripgrep (auto-downloaded) for fast regex searching. Respects .gitignore,
  supports glob filtering, context lines, and hidden files.
- find: Uses fd (auto-downloaded) for fast file finding. Respects .gitignore, supports
  glob patterns, and hidden files.
- ls: Lists directory contents with proper sorting and directory indicators.
- --tools flag: Specify available tools (e.g., --tools read,grep,find,ls for read-only mode)
- Dynamic system prompt adapts to selected tools with relevant guidelines

Closes #74
This commit is contained in:
Mario Zechner 2025-11-29 00:59:23 +01:00
parent a61eca5dee
commit 186169a820
10 changed files with 928 additions and 25 deletions

View file

@ -2,6 +2,15 @@
## [Unreleased] ## [Unreleased]
### Added
- **Read-Only Exploration Tools**: Added `grep`, `find`, and `ls` tools for safe code exploration without modification risk. These tools are available via the new `--tools` flag.
- `grep`: Uses `ripgrep` (auto-downloaded) for fast regex searching. Respects `.gitignore` (including nested), supports glob filtering, context lines, and hidden files.
- `find`: Uses `fd` (auto-downloaded) for fast file finding. Respects `.gitignore`, supports glob patterns, and hidden files.
- `ls`: Lists directory contents with proper sorting and indicators.
- **`--tools` Flag**: New CLI flag to specify available tools (e.g., `--tools read,grep,find,ls` for read-only mode). Default behavior remains unchanged (`read,bash,edit,write`).
- **Dynamic System Prompt**: The system prompt now adapts to the selected tools, showing relevant guidelines and warnings (e.g., "READ-ONLY mode" when write tools are disabled).
### Fixed ### Fixed
- **Prompt Restoration on API Key Error**: When submitting a message fails due to missing API key, the prompt is now restored to the editor instead of being lost. ([#77](https://github.com/badlogic/pi-mono/issues/77)) - **Prompt Restoration on API Key Error**: When submitting a message fails due to missing API key, the prompt is now restored to the editor instead of being lost. ([#77](https://github.com/badlogic/pi-mono/issues/77))

View file

@ -757,6 +757,22 @@ Examples:
- `--models sonnet:high,haiku:low` - Sonnet with high thinking, Haiku with low thinking - `--models sonnet:high,haiku:low` - Sonnet with high thinking, Haiku with low thinking
- `--models sonnet,haiku` - Partial match for any model containing "sonnet" or "haiku" - `--models sonnet,haiku` - Partial match for any model containing "sonnet" or "haiku"
**--tools <tools>**
Comma-separated list of tools to enable. By default, pi uses `read,bash,edit,write`. This flag allows restricting or changing the available tools.
Available tools:
- `read` - Read file contents
- `bash` - Execute bash commands
- `edit` - Make surgical edits to files
- `write` - Create or overwrite files
- `grep` - Search file contents for patterns (read-only, off by default)
- `find` - Find files by glob pattern (read-only, off by default)
- `ls` - List directory contents (read-only, off by default)
Examples:
- `--tools read,grep,find,ls` - Read-only mode for code review/exploration
- `--tools read,bash` - Only allow reading and bash commands
**--thinking <level>** **--thinking <level>**
Set thinking level for reasoning-capable models. Valid values: `off`, `minimal`, `low`, `medium`, `high`. Takes highest priority over all other thinking level sources (saved settings, `--models` pattern levels, session restore). Set thinking level for reasoning-capable models. Valid values: `off`, `minimal`, `low`, `medium`, `high`. Takes highest priority over all other thinking level sources (saved settings, `--models` pattern levels, session restore).
@ -810,13 +826,21 @@ pi --models sonnet:high,haiku:low
# Start with specific thinking level # Start with specific thinking level
pi --thinking high "Solve this complex algorithm problem" pi --thinking high "Solve this complex algorithm problem"
# Read-only mode (no file modifications possible)
pi --tools read,grep,find,ls -p "Review the architecture in src/"
# Oracle-style subagent (bash for git/gh, no file modifications)
pi --tools read,bash,grep,find,ls \
--no-session \
-p "Use bash only for read-only operations. Read issue #74 with gh, then review the implementation"
``` ```
## Tools ## Tools
### Built-in Tools ### Default Tools
The agent has access to four core tools for working with your codebase: By default, the agent has access to four core tools:
**read** **read**
Read file contents. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit parameters for large files. Lines longer than 2000 characters are truncated. Read file contents. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit parameters for large files. Lines longer than 2000 characters are truncated.
@ -830,6 +854,19 @@ Edit a file by replacing exact text. The oldText must match exactly (including w
**bash** **bash**
Execute a bash command in the current working directory. Returns stdout and stderr. Optionally accepts a `timeout` parameter (in seconds) - no default timeout. Execute a bash command in the current working directory. Returns stdout and stderr. Optionally accepts a `timeout` parameter (in seconds) - no default timeout.
### Read-Only Exploration Tools
These tools are available via `--tools` flag for read-only code exploration:
**grep**
Search file contents for a pattern (regex or literal). Returns matching lines with file paths and line numbers. Respects `.gitignore`. Parameters: `pattern` (required), `path`, `glob`, `ignoreCase`, `literal`, `context`, `limit`.
**find**
Search for files by glob pattern (e.g., `**/*.ts`). Returns matching file paths relative to the search directory. Respects `.gitignore`. Parameters: `pattern` (required), `path`, `limit`.
**ls**
List directory contents. Returns entries sorted alphabetically with `/` suffix for directories. Includes dotfiles. Parameters: `path`, `limit`.
### MCP & Adding Your Own Tools ### MCP & Adding Your Own Tools
**pi does and will not support MCP.** Instead, it relies on the four built-in tools above and assumes the agent can invoke pre-existing CLI tools or write them on the fly as needed. **pi does and will not support MCP.** Instead, it relies on the four built-in tools above and assumes the agent can invoke pre-existing CLI tools or write them on the fly as needed.

View file

@ -11,7 +11,7 @@ import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config
import { SessionManager } from "./session-manager.js"; import { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js"; import { SettingsManager } from "./settings-manager.js";
import { initTheme } from "./theme/theme.js"; import { initTheme } from "./theme/theme.js";
import { codingTools } from "./tools/index.js"; import { allTools, codingTools, type ToolName } from "./tools/index.js";
import { ensureTool } from "./tools-manager.js"; import { ensureTool } from "./tools-manager.js";
import { SessionSelectorComponent } from "./tui/session-selector.js"; import { SessionSelectorComponent } from "./tui/session-selector.js";
import { TuiRenderer } from "./tui/tui-renderer.js"; import { TuiRenderer } from "./tui/tui-renderer.js";
@ -48,6 +48,7 @@ interface Args {
noSession?: boolean; noSession?: boolean;
session?: string; session?: string;
models?: string[]; models?: string[];
tools?: ToolName[];
print?: boolean; print?: boolean;
messages: string[]; messages: string[];
fileArgs: string[]; fileArgs: string[];
@ -87,6 +88,19 @@ function parseArgs(args: string[]): Args {
result.session = args[++i]; result.session = args[++i];
} else if (arg === "--models" && i + 1 < args.length) { } else if (arg === "--models" && i + 1 < args.length) {
result.models = args[++i].split(",").map((s) => s.trim()); result.models = args[++i].split(",").map((s) => s.trim());
} else if (arg === "--tools" && i + 1 < args.length) {
const toolNames = args[++i].split(",").map((s) => s.trim());
const validTools: ToolName[] = [];
for (const name of toolNames) {
if (name in allTools) {
validTools.push(name as ToolName);
} else {
console.error(
chalk.yellow(`Warning: Unknown tool "${name}". Valid tools: ${Object.keys(allTools).join(", ")}`),
);
}
}
result.tools = validTools;
} else if (arg === "--thinking" && i + 1 < args.length) { } else if (arg === "--thinking" && i + 1 < args.length) {
const level = args[++i]; const level = args[++i];
if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high") { if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high") {
@ -220,6 +234,8 @@ ${chalk.bold("Options:")}
--session <path> Use specific session file --session <path> Use specific session file
--no-session Don't save session (ephemeral) --no-session Don't save session (ephemeral)
--models <patterns> Comma-separated model patterns for quick cycling with Ctrl+P --models <patterns> Comma-separated model patterns for quick cycling with Ctrl+P
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
Available: read, bash, edit, write, grep, find, ls
--thinking <level> Set thinking level: off, minimal, low, medium, high --thinking <level> Set thinking level: off, minimal, low, medium, high
--help, -h Show this help --help, -h Show this help
@ -254,6 +270,9 @@ ${chalk.bold("Examples:")}
# Start with a specific thinking level # Start with a specific thinking level
pi --thinking high "Solve this complex problem" pi --thinking high "Solve this complex problem"
# Read-only mode (no file modifications possible)
pi --tools read,grep,find,ls -p "Review the code in src/"
${chalk.bold("Environment Variables:")} ${chalk.bold("Environment Variables:")}
ANTHROPIC_API_KEY - Anthropic Claude API key ANTHROPIC_API_KEY - Anthropic Claude API key
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key) ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)
@ -266,15 +285,29 @@ ${chalk.bold("Environment Variables:")}
ZAI_API_KEY - ZAI API key ZAI_API_KEY - ZAI API key
PI_CODING_AGENT_DIR - Session storage directory (default: ~/.pi/agent) PI_CODING_AGENT_DIR - Session storage directory (default: ~/.pi/agent)
${chalk.bold("Available Tools:")} ${chalk.bold("Available Tools (default: read, bash, edit, write):")}
read - Read file contents read - Read file contents
bash - Execute bash commands bash - Execute bash commands
edit - Edit files with find/replace edit - Edit files with find/replace
write - Write files (creates/overwrites) write - Write files (creates/overwrites)
grep - Search file contents (read-only, off by default)
find - Find files by glob pattern (read-only, off by default)
ls - List directory contents (read-only, off by default)
`); `);
} }
function buildSystemPrompt(customPrompt?: string): string { // Tool descriptions for system prompt
const toolDescriptions: Record<ToolName, string> = {
read: "Read file contents",
bash: "Execute bash commands (ls, grep, find, etc.)",
edit: "Make surgical edits to files (find exact text and replace)",
write: "Create or overwrite files",
grep: "Search file contents for patterns (respects .gitignore)",
find: "Find files by glob pattern (respects .gitignore)",
ls: "List directory contents",
};
function buildSystemPrompt(customPrompt?: string, selectedTools?: ToolName[]): string {
// Check if customPrompt is a file path that exists // Check if customPrompt is a file path that exists
if (customPrompt && existsSync(customPrompt)) { if (customPrompt && existsSync(customPrompt)) {
try { try {
@ -333,22 +366,75 @@ function buildSystemPrompt(customPrompt?: string): string {
// Get absolute path to README.md // Get absolute path to README.md
const readmePath = resolve(join(__dirname, "../README.md")); const readmePath = resolve(join(__dirname, "../README.md"));
// Build tools list based on selected tools
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
const toolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n");
// Build guidelines based on which tools are actually available
const guidelinesList: string[] = [];
const hasBash = tools.includes("bash");
const hasEdit = tools.includes("edit");
const hasWrite = tools.includes("write");
const hasGrep = tools.includes("grep");
const hasFind = tools.includes("find");
const hasLs = tools.includes("ls");
const hasRead = tools.includes("read");
// Read-only mode notice (no bash, edit, or write)
if (!hasBash && !hasEdit && !hasWrite) {
guidelinesList.push("You are in READ-ONLY mode - you cannot modify files or execute arbitrary commands");
}
// Bash without edit/write = read-only bash mode
if (hasBash && !hasEdit && !hasWrite) {
guidelinesList.push(
"Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files",
);
}
// File exploration guidelines
if (hasBash && !hasGrep && !hasFind && !hasLs) {
guidelinesList.push("Use bash for file operations like ls, grep, find");
} else if (hasBash && (hasGrep || hasFind || hasLs)) {
guidelinesList.push("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
}
// Read before edit guideline
if (hasRead && hasEdit) {
guidelinesList.push("Use read to examine files before editing");
}
// Edit guideline
if (hasEdit) {
guidelinesList.push("Use edit for precise changes (old text must match exactly)");
}
// Write guideline
if (hasWrite) {
guidelinesList.push("Use write only for new files or complete rewrites");
}
// Output guideline (only when actually writing/executing)
if (hasEdit || hasWrite) {
guidelinesList.push(
"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
);
}
// Always include these
guidelinesList.push("Be concise in your responses");
guidelinesList.push("Show file paths clearly when working with files");
const guidelines = guidelinesList.map((g) => `- ${g}`).join("\n");
let prompt = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files. let prompt = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
Available tools: Available tools:
- read: Read file contents ${toolsList}
- bash: Execute bash commands (ls, grep, find, etc.)
- edit: Make surgical edits to files (find exact text and replace)
- write: Create or overwrite files
Guidelines: Guidelines:
- Always use bash tool for file operations like ls, grep, find ${guidelines}
- Use read to examine files before editing
- Use edit for precise changes (old text must match exactly)
- Use write only for new files or complete rewrites
- Be concise in your responses
- Show file paths clearly when working with files
- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
Documentation: Documentation:
- Your own documentation (including custom model setup and theme creation) is at: ${readmePath} - Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
@ -913,7 +999,7 @@ export async function main(args: string[]) {
} }
} }
const systemPrompt = buildSystemPrompt(parsed.systemPrompt); const systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools);
// Load previous messages if continuing or resuming // Load previous messages if continuing or resuming
// This may update initialModel if restoring from session // This may update initialModel if restoring from session
@ -996,13 +1082,16 @@ export async function main(args: string[]) {
initialThinking = parsed.thinking; initialThinking = parsed.thinking;
} }
// Determine which tools to use
const selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools;
// Create agent (initialModel can be null in interactive mode) // Create agent (initialModel can be null in interactive mode)
const agent = new Agent({ const agent = new Agent({
initialState: { initialState: {
systemPrompt, systemPrompt,
model: initialModel as any, // Can be null model: initialModel as any, // Can be null
thinkingLevel: initialThinking, thinkingLevel: initialThinking,
tools: codingTools, tools: selectedTools,
}, },
queueMode: settingsManager.getQueueMode(), queueMode: settingsManager.getQueueMode(),
transport: new ProviderTransport({ transport: new ProviderTransport({

View file

@ -12,6 +12,7 @@ interface ToolConfig {
name: string; name: string;
repo: string; // GitHub repo (e.g., "sharkdp/fd") repo: string; // GitHub repo (e.g., "sharkdp/fd")
binaryName: string; // Name of the binary inside the archive binaryName: string; // Name of the binary inside the archive
tagPrefix: string; // Prefix for tags (e.g., "v" for v1.0.0, "" for 1.0.0)
getAssetName: (version: string, plat: string, architecture: string) => string | null; getAssetName: (version: string, plat: string, architecture: string) => string | null;
} }
@ -20,6 +21,7 @@ const TOOLS: Record<string, ToolConfig> = {
name: "fd", name: "fd",
repo: "sharkdp/fd", repo: "sharkdp/fd",
binaryName: "fd", binaryName: "fd",
tagPrefix: "v",
getAssetName: (version, plat, architecture) => { getAssetName: (version, plat, architecture) => {
if (plat === "darwin") { if (plat === "darwin") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
@ -34,20 +36,42 @@ const TOOLS: Record<string, ToolConfig> = {
return null; return null;
}, },
}, },
rg: {
name: "ripgrep",
repo: "BurntSushi/ripgrep",
binaryName: "rg",
tagPrefix: "",
getAssetName: (version, plat, architecture) => {
if (plat === "darwin") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;
} else if (plat === "linux") {
if (architecture === "arm64") {
return `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;
}
return `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;
} else if (plat === "win32") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;
}
return null;
},
},
}; };
// Check if a command exists in PATH by trying to run it // Check if a command exists in PATH by trying to run it
function commandExists(cmd: string): boolean { function commandExists(cmd: string): boolean {
try { try {
spawnSync(cmd, ["--version"], { stdio: "pipe" }); const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
return true; // Check for ENOENT error (command not found)
return result.error === undefined || result.error === null;
} catch { } catch {
return false; return false;
} }
} }
// Get the path to a tool (system-wide or in our tools dir) // Get the path to a tool (system-wide or in our tools dir)
export function getToolPath(tool: "fd"): string | null { export function getToolPath(tool: "fd" | "rg"): string | null {
const config = TOOLS[tool]; const config = TOOLS[tool];
if (!config) return null; if (!config) return null;
@ -75,7 +99,7 @@ async function getLatestVersion(repo: string): Promise<string> {
throw new Error(`GitHub API error: ${response.status}`); throw new Error(`GitHub API error: ${response.status}`);
} }
const data = await response.json(); const data = (await response.json()) as { tag_name: string };
return data.tag_name.replace(/^v/, ""); return data.tag_name.replace(/^v/, "");
} }
@ -96,7 +120,7 @@ async function downloadFile(url: string, dest: string): Promise<void> {
} }
// Download and install a tool // Download and install a tool
async function downloadTool(tool: "fd"): Promise<string> { async function downloadTool(tool: "fd" | "rg"): Promise<string> {
const config = TOOLS[tool]; const config = TOOLS[tool];
if (!config) throw new Error(`Unknown tool: ${tool}`); if (!config) throw new Error(`Unknown tool: ${tool}`);
@ -115,7 +139,7 @@ async function downloadTool(tool: "fd"): Promise<string> {
// Create tools directory // Create tools directory
mkdirSync(TOOLS_DIR, { recursive: true }); mkdirSync(TOOLS_DIR, { recursive: true });
const downloadUrl = `https://github.com/${config.repo}/releases/download/v${version}/${assetName}`; const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;
const archivePath = join(TOOLS_DIR, assetName); const archivePath = join(TOOLS_DIR, assetName);
const binaryExt = plat === "win32" ? ".exe" : ""; const binaryExt = plat === "win32" ? ".exe" : "";
const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt); const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);
@ -159,7 +183,7 @@ async function downloadTool(tool: "fd"): Promise<string> {
// Ensure a tool is available, downloading if necessary // Ensure a tool is available, downloading if necessary
// Returns the path to the tool, or null if unavailable // Returns the path to the tool, or null if unavailable
export async function ensureTool(tool: "fd", silent: boolean = false): Promise<string | null> { export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise<string | null> {
const existingPath = getToolPath(tool); const existingPath = getToolPath(tool);
if (existingPath) { if (existingPath) {
return existingPath; return existingPath;

View file

@ -0,0 +1,169 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { spawnSync } from "child_process";
import { existsSync } from "fs";
import { globSync } from "glob";
import { homedir } from "os";
import path from "path";
import { ensureTool } from "../tools-manager.js";
/**
* Expand ~ to home directory
*/
function expandPath(filePath: string): string {
if (filePath === "~") {
return homedir();
}
if (filePath.startsWith("~/")) {
return homedir() + filePath.slice(1);
}
return filePath;
}
const findSchema = Type.Object({
pattern: Type.String({
description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
}),
path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })),
});
const DEFAULT_LIMIT = 1000;
export const findTool: AgentTool<typeof findSchema> = {
name: "find",
label: "find",
description:
"Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore.",
parameters: findSchema,
execute: async (
_toolCallId: string,
{ pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number },
signal?: AbortSignal,
) => {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
const onAbort = () => reject(new Error("Operation aborted"));
signal?.addEventListener("abort", onAbort, { once: true });
(async () => {
try {
// Ensure fd is available
const fdPath = await ensureTool("fd", true);
if (!fdPath) {
reject(new Error("fd is not available and could not be downloaded"));
return;
}
const searchPath = path.resolve(expandPath(searchDir || "."));
const effectiveLimit = limit ?? DEFAULT_LIMIT;
// Build fd arguments
const args: string[] = [
"--glob", // Use glob pattern
"--color=never", // No ANSI colors
"--hidden", // Search hidden files (but still respect .gitignore)
"--max-results",
String(effectiveLimit),
];
// Include .gitignore files (root + nested) so fd respects them even outside git repos
const gitignoreFiles = new Set<string>();
const rootGitignore = path.join(searchPath, ".gitignore");
if (existsSync(rootGitignore)) {
gitignoreFiles.add(rootGitignore);
}
try {
const nestedGitignores = globSync("**/.gitignore", {
cwd: searchPath,
dot: true,
absolute: true,
ignore: ["**/node_modules/**", "**/.git/**"],
});
for (const file of nestedGitignores) {
gitignoreFiles.add(file);
}
} catch {
// Ignore glob errors
}
for (const gitignorePath of gitignoreFiles) {
args.push("--ignore-file", gitignorePath);
}
// Pattern and path
args.push(pattern, searchPath);
// Run fd
const result = spawnSync(fdPath, args, {
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024, // 10MB
});
signal?.removeEventListener("abort", onAbort);
if (result.error) {
reject(new Error(`Failed to run fd: ${result.error.message}`));
return;
}
let output = result.stdout?.trim() || "";
if (result.status !== 0) {
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
// fd returns non-zero for some errors but may still have partial output
if (!output) {
reject(new Error(errorMsg));
return;
}
}
if (!output) {
output = "No files found matching pattern";
} else {
const lines = output.split("\n");
const relativized: string[] = [];
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "").trim();
if (!line) {
continue;
}
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
let relativePath = line;
if (line.startsWith(searchPath)) {
relativePath = line.slice(searchPath.length + 1); // +1 for the /
} else {
relativePath = path.relative(searchPath, line);
}
if (hadTrailingSlash && !relativePath.endsWith("/")) {
relativePath += "/";
}
relativized.push(relativePath);
}
output = relativized.join("\n");
const count = relativized.length;
if (count >= effectiveLimit) {
output += `\n\n(truncated, ${effectiveLimit} results shown)`;
}
}
resolve({ content: [{ type: "text", text: output }], details: undefined });
} catch (e: any) {
signal?.removeEventListener("abort", onAbort);
reject(e);
}
})();
});
},
};

View file

@ -0,0 +1,267 @@
import { createInterface } from "node:readline";
import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { readFileSync, type Stats, statSync } from "fs";
import { homedir } from "os";
import path from "path";
import { ensureTool } from "../tools-manager.js";
/**
* Expand ~ to home directory
*/
function expandPath(filePath: string): string {
if (filePath === "~") {
return homedir();
}
if (filePath.startsWith("~/")) {
return homedir() + filePath.slice(1);
}
return filePath;
}
const grepSchema = Type.Object({
pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })),
glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })),
ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
literal: Type.Optional(
Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }),
),
context: Type.Optional(
Type.Number({ description: "Number of lines to show before and after each match (default: 0)" }),
),
limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 100)" })),
});
const DEFAULT_LIMIT = 100;
export const grepTool: AgentTool<typeof grepSchema> = {
name: "grep",
label: "grep",
description:
"Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore.",
parameters: grepSchema,
execute: async (
_toolCallId: string,
{
pattern,
path: searchDir,
glob,
ignoreCase,
literal,
context,
limit,
}: {
pattern: string;
path?: string;
glob?: string;
ignoreCase?: boolean;
literal?: boolean;
context?: number;
limit?: number;
},
signal?: AbortSignal,
) => {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let settled = false;
const settle = (fn: () => void) => {
if (!settled) {
settled = true;
fn();
}
};
(async () => {
try {
const rgPath = await ensureTool("rg", true);
if (!rgPath) {
settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded")));
return;
}
const searchPath = path.resolve(expandPath(searchDir || "."));
let searchStat: Stats;
try {
searchStat = statSync(searchPath);
} catch (err) {
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
return;
}
const isDirectory = searchStat.isDirectory();
const contextValue = context && context > 0 ? context : 0;
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
const formatPath = (filePath: string): string => {
if (isDirectory) {
const relative = path.relative(searchPath, filePath);
if (relative && !relative.startsWith("..")) {
return relative.replace(/\\/g, "/");
}
}
return path.basename(filePath);
};
const fileCache = new Map<string, string[]>();
const getFileLines = (filePath: string): string[] => {
let lines = fileCache.get(filePath);
if (!lines) {
try {
const content = readFileSync(filePath, "utf-8");
lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
} catch {
lines = [];
}
fileCache.set(filePath, lines);
}
return lines;
};
const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
if (ignoreCase) {
args.push("--ignore-case");
}
if (literal) {
args.push("--fixed-strings");
}
if (glob) {
args.push("--glob", glob);
}
args.push(pattern, searchPath);
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
const rl = createInterface({ input: child.stdout });
let stderr = "";
let matchCount = 0;
let truncated = false;
let aborted = false;
let killedDueToLimit = false;
const outputLines: string[] = [];
const cleanup = () => {
rl.close();
signal?.removeEventListener("abort", onAbort);
};
const stopChild = (dueToLimit: boolean = false) => {
if (!child.killed) {
killedDueToLimit = dueToLimit;
child.kill();
}
};
const onAbort = () => {
aborted = true;
stopChild();
};
signal?.addEventListener("abort", onAbort, { once: true });
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
const formatBlock = (filePath: string, lineNumber: number) => {
const relativePath = formatPath(filePath);
const lines = getFileLines(filePath);
if (!lines.length) {
return [`${relativePath}:${lineNumber}: (unable to read file)`];
}
const block: string[] = [];
const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
for (let current = start; current <= end; current++) {
const lineText = lines[current - 1] ?? "";
const sanitized = lineText.replace(/\r/g, "");
const isMatchLine = current === lineNumber;
if (isMatchLine) {
block.push(`${relativePath}:${current}: ${sanitized}`);
} else {
block.push(`${relativePath}-${current}- ${sanitized}`);
}
}
return block;
};
rl.on("line", (line) => {
if (!line.trim() || matchCount >= effectiveLimit) {
return;
}
let event: any;
try {
event = JSON.parse(line);
} catch {
return;
}
if (event.type === "match") {
matchCount++;
const filePath = event.data?.path?.text;
const lineNumber = event.data?.line_number;
if (filePath && typeof lineNumber === "number") {
outputLines.push(...formatBlock(filePath, lineNumber));
}
if (matchCount >= effectiveLimit) {
truncated = true;
stopChild(true);
}
}
});
child.on("error", (error) => {
cleanup();
settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
});
child.on("close", (code) => {
cleanup();
if (aborted) {
settle(() => reject(new Error("Operation aborted")));
return;
}
if (!killedDueToLimit && code !== 0 && code !== 1) {
const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`;
settle(() => reject(new Error(errorMsg)));
return;
}
if (matchCount === 0) {
settle(() =>
resolve({ content: [{ type: "text", text: "No matches found" }], details: undefined }),
);
return;
}
let output = outputLines.join("\n");
if (truncated) {
output += `\n\n(truncated, limit of ${effectiveLimit} matches reached)`;
}
settle(() => resolve({ content: [{ type: "text", text: output }], details: undefined }));
});
} catch (err) {
settle(() => reject(err as Error));
}
})();
});
},
};

View file

@ -1,11 +1,31 @@
export { bashTool } from "./bash.js"; export { bashTool } from "./bash.js";
export { editTool } from "./edit.js"; export { editTool } from "./edit.js";
export { findTool } from "./find.js";
export { grepTool } from "./grep.js";
export { lsTool } from "./ls.js";
export { readTool } from "./read.js"; export { readTool } from "./read.js";
export { writeTool } from "./write.js"; export { writeTool } from "./write.js";
import { bashTool } from "./bash.js"; import { bashTool } from "./bash.js";
import { editTool } from "./edit.js"; import { editTool } from "./edit.js";
import { findTool } from "./find.js";
import { grepTool } from "./grep.js";
import { lsTool } from "./ls.js";
import { readTool } from "./read.js"; import { readTool } from "./read.js";
import { writeTool } from "./write.js"; import { writeTool } from "./write.js";
// Default tools for full access mode
export const codingTools = [readTool, bashTool, editTool, writeTool]; export const codingTools = [readTool, bashTool, editTool, writeTool];
// All available tools (including read-only exploration tools)
export const allTools = {
read: readTool,
bash: bashTool,
edit: editTool,
write: writeTool,
grep: grepTool,
find: findTool,
ls: lsTool,
};
export type ToolName = keyof typeof allTools;

View file

@ -0,0 +1,116 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { existsSync, readdirSync, statSync } from "fs";
import { homedir } from "os";
import nodePath from "path";
/**
* Expand ~ to home directory
*/
function expandPath(filePath: string): string {
if (filePath === "~") {
return homedir();
}
if (filePath.startsWith("~/")) {
return homedir() + filePath.slice(1);
}
return filePath;
}
const lsSchema = Type.Object({
path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })),
});
const DEFAULT_LIMIT = 500;
export const lsTool: AgentTool<typeof lsSchema> = {
name: "ls",
label: "ls",
description:
"List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles.",
parameters: lsSchema,
execute: async (_toolCallId: string, { path, limit }: { path?: string; limit?: number }, signal?: AbortSignal) => {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
const onAbort = () => reject(new Error("Operation aborted"));
signal?.addEventListener("abort", onAbort, { once: true });
try {
const dirPath = nodePath.resolve(expandPath(path || "."));
const effectiveLimit = limit ?? DEFAULT_LIMIT;
// Check if path exists
if (!existsSync(dirPath)) {
reject(new Error(`Path not found: ${dirPath}`));
return;
}
// Check if path is a directory
const stat = statSync(dirPath);
if (!stat.isDirectory()) {
reject(new Error(`Not a directory: ${dirPath}`));
return;
}
// Read directory entries
let entries: string[];
try {
entries = readdirSync(dirPath);
} catch (e: any) {
reject(new Error(`Cannot read directory: ${e.message}`));
return;
}
// Sort alphabetically (case-insensitive)
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
// Format entries with directory indicators
const results: string[] = [];
let truncated = false;
for (const entry of entries) {
if (results.length >= effectiveLimit) {
truncated = true;
break;
}
const fullPath = nodePath.join(dirPath, entry);
let suffix = "";
try {
const entryStat = statSync(fullPath);
if (entryStat.isDirectory()) {
suffix = "/";
}
} catch {
// Skip entries we can't stat
continue;
}
results.push(entry + suffix);
}
signal?.removeEventListener("abort", onAbort);
let output = results.join("\n");
if (truncated) {
const remaining = entries.length - effectiveLimit;
output += `\n\n(truncated, ${remaining} more entries)`;
}
if (results.length === 0) {
output = "(empty directory)";
}
resolve({ content: [{ type: "text", text: output }], details: undefined });
} catch (e: any) {
signal?.removeEventListener("abort", onAbort);
reject(e);
}
});
},
};

View file

@ -198,6 +198,89 @@ export class ToolExecutionComponent extends Container {
text += "\n\n" + coloredLines.join("\n"); text += "\n\n" + coloredLines.join("\n");
} }
} }
} else if (this.toolName === "ls") {
const path = shortenPath(this.args?.path || ".");
const limit = this.args?.limit;
text = theme.fg("toolTitle", theme.bold("ls")) + " " + theme.fg("accent", path);
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
if (this.result) {
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
}
} else if (this.toolName === "find") {
const pattern = this.args?.pattern || "";
const path = shortenPath(this.args?.path || ".");
const limit = this.args?.limit;
text =
theme.fg("toolTitle", theme.bold("find")) +
" " +
theme.fg("accent", pattern) +
theme.fg("toolOutput", ` in ${path}`);
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
if (this.result) {
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
}
} else if (this.toolName === "grep") {
const pattern = this.args?.pattern || "";
const path = shortenPath(this.args?.path || ".");
const glob = this.args?.glob;
const limit = this.args?.limit;
text =
theme.fg("toolTitle", theme.bold("grep")) +
" " +
theme.fg("accent", `/${pattern}/`) +
theme.fg("toolOutput", ` in ${path}`);
if (glob) {
text += theme.fg("toolOutput", ` (${glob})`);
}
if (limit !== undefined) {
text += theme.fg("toolOutput", ` limit ${limit}`);
}
if (this.result) {
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 15;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
}
} else { } else {
// Generic tool // Generic tool
text = theme.fg("toolTitle", theme.bold(this.toolName)); text = theme.fg("toolTitle", theme.bold(this.toolName));

View file

@ -4,6 +4,9 @@ import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { bashTool } from "../src/tools/bash.js"; import { bashTool } from "../src/tools/bash.js";
import { editTool } from "../src/tools/edit.js"; import { editTool } from "../src/tools/edit.js";
import { findTool } from "../src/tools/find.js";
import { grepTool } from "../src/tools/grep.js";
import { lsTool } from "../src/tools/ls.js";
import { readTool } from "../src/tools/read.js"; import { readTool } from "../src/tools/read.js";
import { writeTool } from "../src/tools/write.js"; import { writeTool } from "../src/tools/write.js";
@ -247,4 +250,90 @@ describe("Coding Agent Tools", () => {
expect(getTextOutput(result)).toContain("Command failed"); expect(getTextOutput(result)).toContain("Command failed");
}, 35000); }, 35000);
}); });
describe("grep tool", () => {
it("should include filename when searching a single file", async () => {
const testFile = join(testDir, "example.txt");
writeFileSync(testFile, "first line\nmatch line\nlast line");
const result = await grepTool.execute("test-call-11", {
pattern: "match",
path: testFile,
});
const output = getTextOutput(result);
expect(output).toContain("example.txt:2: match line");
});
it("should respect global limit and include context lines", async () => {
const testFile = join(testDir, "context.txt");
const content = ["before", "match one", "after", "middle", "match two", "after two"].join("\n");
writeFileSync(testFile, content);
const result = await grepTool.execute("test-call-12", {
pattern: "match",
path: testFile,
limit: 1,
context: 1,
});
const output = getTextOutput(result);
expect(output).toContain("context.txt-1- before");
expect(output).toContain("context.txt:2: match one");
expect(output).toContain("context.txt-3- after");
expect(output).toContain("(truncated, limit of 1 matches reached)");
// Ensure second match is not present
expect(output).not.toContain("match two");
});
});
describe("find tool", () => {
it("should include hidden files that are not gitignored", async () => {
const hiddenDir = join(testDir, ".secret");
mkdirSync(hiddenDir);
writeFileSync(join(hiddenDir, "hidden.txt"), "hidden");
writeFileSync(join(testDir, "visible.txt"), "visible");
const result = await findTool.execute("test-call-13", {
pattern: "**/*.txt",
path: testDir,
});
const outputLines = getTextOutput(result)
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
expect(outputLines).toContain("visible.txt");
expect(outputLines).toContain(".secret/hidden.txt");
});
it("should respect .gitignore", async () => {
writeFileSync(join(testDir, ".gitignore"), "ignored.txt\n");
writeFileSync(join(testDir, "ignored.txt"), "ignored");
writeFileSync(join(testDir, "kept.txt"), "kept");
const result = await findTool.execute("test-call-14", {
pattern: "**/*.txt",
path: testDir,
});
const output = getTextOutput(result);
expect(output).toContain("kept.txt");
expect(output).not.toContain("ignored.txt");
});
});
describe("ls tool", () => {
it("should list dotfiles and directories", async () => {
writeFileSync(join(testDir, ".hidden-file"), "secret");
mkdirSync(join(testDir, ".hidden-dir"));
const result = await lsTool.execute("test-call-15", { path: testDir });
const output = getTextOutput(result);
expect(output).toContain(".hidden-file");
expect(output).toContain(".hidden-dir/");
});
});
}); });