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]
### 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
- **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,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>**
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
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
### 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 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**
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
**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 { SettingsManager } from "./settings-manager.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 { SessionSelectorComponent } from "./tui/session-selector.js";
import { TuiRenderer } from "./tui/tui-renderer.js";
@ -48,6 +48,7 @@ interface Args {
noSession?: boolean;
session?: string;
models?: string[];
tools?: ToolName[];
print?: boolean;
messages: string[];
fileArgs: string[];
@ -87,6 +88,19 @@ function parseArgs(args: string[]): Args {
result.session = args[++i];
} else if (arg === "--models" && i + 1 < args.length) {
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) {
const level = args[++i];
if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high") {
@ -220,6 +234,8 @@ ${chalk.bold("Options:")}
--session <path> Use specific session file
--no-session Don't save session (ephemeral)
--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
--help, -h Show this help
@ -254,6 +270,9 @@ ${chalk.bold("Examples:")}
# Start with a specific thinking level
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:")}
ANTHROPIC_API_KEY - Anthropic Claude 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
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
bash - Execute bash commands
edit - Edit files with find/replace
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
if (customPrompt && existsSync(customPrompt)) {
try {
@ -333,22 +366,75 @@ function buildSystemPrompt(customPrompt?: string): string {
// Get absolute path to 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.
Available tools:
- 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
${toolsList}
Guidelines:
- Always use bash tool for file operations like ls, grep, find
- 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
${guidelines}
Documentation:
- 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
// This may update initialModel if restoring from session
@ -996,13 +1082,16 @@ export async function main(args: string[]) {
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)
const agent = new Agent({
initialState: {
systemPrompt,
model: initialModel as any, // Can be null
thinkingLevel: initialThinking,
tools: codingTools,
tools: selectedTools,
},
queueMode: settingsManager.getQueueMode(),
transport: new ProviderTransport({

View file

@ -12,6 +12,7 @@ interface ToolConfig {
name: string;
repo: string; // GitHub repo (e.g., "sharkdp/fd")
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;
}
@ -20,6 +21,7 @@ const TOOLS: Record<string, ToolConfig> = {
name: "fd",
repo: "sharkdp/fd",
binaryName: "fd",
tagPrefix: "v",
getAssetName: (version, plat, architecture) => {
if (plat === "darwin") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
@ -34,20 +36,42 @@ const TOOLS: Record<string, ToolConfig> = {
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
function commandExists(cmd: string): boolean {
try {
spawnSync(cmd, ["--version"], { stdio: "pipe" });
return true;
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
// Check for ENOENT error (command not found)
return result.error === undefined || result.error === null;
} catch {
return false;
}
}
// 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];
if (!config) return null;
@ -75,7 +99,7 @@ async function getLatestVersion(repo: string): Promise<string> {
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/, "");
}
@ -96,7 +120,7 @@ async function downloadFile(url: string, dest: string): Promise<void> {
}
// Download and install a tool
async function downloadTool(tool: "fd"): Promise<string> {
async function downloadTool(tool: "fd" | "rg"): Promise<string> {
const config = TOOLS[tool];
if (!config) throw new Error(`Unknown tool: ${tool}`);
@ -115,7 +139,7 @@ async function downloadTool(tool: "fd"): Promise<string> {
// Create tools directory
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 binaryExt = plat === "win32" ? ".exe" : "";
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
// 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);
if (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 { 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 { writeTool } from "./write.js";
import { bashTool } from "./bash.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 { writeTool } from "./write.js";
// Default tools for full access mode
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");
}
}
} 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 {
// Generic tool
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 { bashTool } from "../src/tools/bash.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 { writeTool } from "../src/tools/write.js";
@ -247,4 +250,90 @@ describe("Coding Agent Tools", () => {
expect(getTextOutput(result)).toContain("Command failed");
}, 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/");
});
});
});