diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3c565c19..cf7a7e78 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 28e32990..04218dee 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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 ** +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 ** 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. diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index ac6075c9..04017bc6 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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 Use specific session file --no-session Don't save session (ephemeral) --models Comma-separated model patterns for quick cycling with Ctrl+P + --tools Comma-separated list of tools to enable (default: read,bash,edit,write) + Available: read, bash, edit, write, grep, find, ls --thinking 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 = { + 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({ diff --git a/packages/coding-agent/src/tools-manager.ts b/packages/coding-agent/src/tools-manager.ts index a7531796..a93762ac 100644 --- a/packages/coding-agent/src/tools-manager.ts +++ b/packages/coding-agent/src/tools-manager.ts @@ -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 = { 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 = { 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 { 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 { } // Download and install a tool -async function downloadTool(tool: "fd"): Promise { +async function downloadTool(tool: "fd" | "rg"): Promise { const config = TOOLS[tool]; if (!config) throw new Error(`Unknown tool: ${tool}`); @@ -115,7 +139,7 @@ async function downloadTool(tool: "fd"): Promise { // 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 { // 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 { +export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise { const existingPath = getToolPath(tool); if (existingPath) { return existingPath; diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts new file mode 100644 index 00000000..36838890 --- /dev/null +++ b/packages/coding-agent/src/tools/find.ts @@ -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 = { + 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(); + 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); + } + })(); + }); + }, +}; diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts new file mode 100644 index 00000000..38b8d9df --- /dev/null +++ b/packages/coding-agent/src/tools/grep.ts @@ -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 = { + 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(); + 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)); + } + })(); + }); + }, +}; diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 0c650efa..94af0286 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -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; diff --git a/packages/coding-agent/src/tools/ls.ts b/packages/coding-agent/src/tools/ls.ts new file mode 100644 index 00000000..18c88ebe --- /dev/null +++ b/packages/coding-agent/src/tools/ls.ts @@ -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 = { + 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); + } + }); + }, +}; diff --git a/packages/coding-agent/src/tui/tool-execution.ts b/packages/coding-agent/src/tui/tool-execution.ts index 419f6b84..390f4f3f 100644 --- a/packages/coding-agent/src/tui/tool-execution.ts +++ b/packages/coding-agent/src/tui/tool-execution.ts @@ -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)); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index a516478b..521f599d 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -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/"); + }); + }); });