import type { ToolRegistry } from "./core/registry.js"; import { createLogger } from "../utils/logger.js"; export const WRITE_TOOL_TARGETED_EDIT_CONTRACT = "Use only for new files or intentional full-file replacement. For targeted edits to existing files, use edit with old_string and new_string."; /** * Register default OpenCode tools in the registry */ export function registerDefaultTools(registry: ToolRegistry): void { // 1. Bash tool - Execute shell commands registry.register({ id: "bash", name: "bash", description: "Execute a shell command. Use this to run programs/tests; prefer write/edit for creating or modifying files.", parameters: { type: "object", properties: { command: { type: "string", description: "The shell command to execute" }, timeout: { type: "number", description: "Timeout in seconds (default: 30)" }, cwd: { type: "string", description: "Working directory for the command" } }, required: ["command"] }, source: "local" as const }, async (args) => { const { spawn } = await import("child_process"); const command = resolveBashCommand(args); if (!command) { throw new Error("bash: missing required argument 'command'"); } const timeoutMs = resolveTimeoutMs(args.timeout); const cwd = resolveWorkingDirectory(args); return new Promise((resolve, reject) => { const proc = spawn(command, { shell: resolveShellOption(), cwd, }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let timedOut = false; const timer = setTimeout(() => { timedOut = true; proc.kill("SIGTERM"); }, timeoutMs); proc.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); proc.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); proc.on("close", (code) => { clearTimeout(timer); const stdout = Buffer.concat(stdoutChunks).toString("utf8"); const stderr = Buffer.concat(stderrChunks).toString("utf8"); const output = stdout || stderr || "Command executed successfully"; if (timedOut) { resolve(`Command timed out after ${timeoutMs / 1000}s\n${output}`); } else if (code !== 0) { resolve(`${output}\n[Exit code: ${code}]`); } else { resolve(output); } }); proc.on("error", reject); }); }); // 2. Read tool - Read file contents registry.register({ id: "read", name: "read", description: "Read the contents of a file", parameters: { type: "object", properties: { path: { type: "string", description: "Absolute path to the file to read" }, offset: { type: "number", description: "Line number to start reading from" }, limit: { type: "number", description: "Maximum number of lines to read" } }, required: ["path"] }, source: "local" as const }, async (args) => { const fs = await import("fs"); try { const path = args.path as string; const offset = args.offset as number | undefined; const limit = args.limit as number | undefined; let content = fs.readFileSync(path, "utf-8"); if (offset !== undefined || limit !== undefined) { const lines = content.split("\n"); const start = offset || 0; const end = limit ? start + limit : lines.length; content = lines.slice(start, end).join("\n"); } return content; } catch (error: any) { throw error; } }); // 3. Write tool - Write file contents registry.register({ id: "write", name: "write", description: `Write content to a file. ${WRITE_TOOL_TARGETED_EDIT_CONTRACT}`, parameters: { type: "object", properties: { path: { type: "string", description: "Absolute path to the file to write" }, content: { type: "string", description: "Content to write to the file" }, force: { type: "boolean", description: "Set true only when intentionally replacing an existing file with complete content" } }, required: ["path", "content"] }, source: "local" as const }, async (args) => { const fs = await import("fs"); const path = await import("path"); try { const filePath = args.path as string; const content = args.content as string; const force = args.force === true; return writeFullFileWithOverwriteGuard(fs, path, filePath, content, force, "write"); } catch (error: any) { throw error; } }); // 4. Edit tool - Edit file contents registry.register({ id: "edit", name: "edit", description: "Edit a file by replacing old text with new text. Use for targeted replacements; use write to overwrite an entire file.", parameters: { type: "object", properties: { path: { type: "string", description: "Absolute path to the file to edit" }, old_string: { type: "string", description: "The text to replace" }, new_string: { type: "string", description: "The replacement text" }, content: { type: "string", description: "Compatibility field for full-file content emitted by cursor-agent" }, streamContent: { type: "string", description: "Compatibility field for full-file content emitted by cursor-agent" } }, required: ["path", "old_string", "new_string"] }, source: "local" as const }, async (args) => { const fs = await import("fs"); const path = await import("path"); try { const resolvedArgs = resolveEditArguments(args); const filePath = resolvedArgs.path; const oldString = resolvedArgs.old_string; const newString = resolvedArgs.new_string; if (!filePath) { throw new Error("edit: missing required argument 'path'"); } if (typeof oldString !== "string") { const fullFileContent = coerceToString(args.streamContent ?? args.content); if (fullFileContent !== null) { return writeFullFileWithOverwriteGuard(fs, path, filePath, fullFileContent, args.force === true, "edit", 2); } throw new Error("edit: missing required argument 'old_string'"); } if (oldString.length === 0) { throw new Error("edit: old_string must not be empty; use write to overwrite an entire file"); } if (typeof newString !== "string") { throw new Error("edit: missing required argument 'new_string'"); } let content = ""; try { content = fs.readFileSync(filePath, "utf-8"); } catch (error: any) { if (error?.code === "ENOENT") { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(filePath, newString, "utf-8"); return `File did not exist. Created and wrote content: ${filePath}`; } throw error; } if (!content.includes(oldString)) { return `Error: Could not find the text to replace in ${filePath}`; } content = content.replaceAll(oldString, newString); fs.writeFileSync(filePath, content, "utf-8"); return `File edited successfully: ${filePath}`; } catch (error: any) { throw error; } }); // 5. Grep tool - Search file contents registry.register({ id: "grep", name: "grep", description: "Search for a pattern in files", parameters: { type: "object", properties: { pattern: { type: "string", description: "The search pattern (regex supported)" }, path: { type: "string", description: "Directory or file to search in" }, include: { type: "string", description: "File pattern to include (e.g., '*.ts')" } }, required: ["pattern", "path"] }, source: "local" as const }, async (args) => { const { execFile } = await import("child_process"); const { promisify } = await import("util"); const execFileAsync = promisify(execFile); const pattern = args.pattern as string; const path = args.path as string; const include = args.include as string | undefined; if (process.platform === "win32") { return nodeFallbackGrep(pattern, path, include); } const grepArgs = ["-r", "-n"]; if (include) { grepArgs.push(`--include=${include}`); } grepArgs.push("-e", pattern, path); const runGrep = async (extraArgs: string[] = []) => { return execFileAsync("grep", [...extraArgs, ...grepArgs], { timeout: 30000 }); }; try { const { stdout } = await runGrep(); return stdout || "No matches found"; } catch (error: any) { // grep exits with code 1 when no matches found — not an error if (error.code === 1) { return "No matches found"; } const stderr = typeof error?.stderr === "string" ? error.stderr : ""; const isRegexSyntaxError = error.code === 2 && /(invalid regular expression|invalid repetition count|braces not balanced|repetition-operator operand invalid|unmatched(\s*\\?\{)?)/i.test(stderr); // BSD grep uses basic regex by default and can reject patterns that work in ERE. // Retry with -E so patterns like \$\{[A-Z_][A-Z0-9_]*:- are handled. if (isRegexSyntaxError) { try { const { stdout } = await runGrep(["-E"]); return stdout || "No matches found"; } catch (extendedError: any) { if (extendedError.code === 1) { return "No matches found"; } throw extendedError; } } throw error; } }); // 6. LS tool - List directory contents registry.register({ id: "ls", name: "ls", description: "List directory contents", parameters: { type: "object", properties: { path: { type: "string", description: "Absolute path to the directory" } }, required: ["path"] }, source: "local" as const }, async (args) => { const fs = await import("fs"); const path = await import("path"); try { const dirPath = args.path as string; const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const result = entries.map(entry => { const type = entry.isDirectory() ? "d" : entry.isSymbolicLink() ? "l" : entry.isFile() ? "f" : "?"; return `[${type}] ${entry.name}`; }); return result.join("\n") || "Empty directory"; } catch (error: any) { throw error; } }); // 7. Glob tool - Find files matching pattern registry.register({ id: "glob", name: "glob", description: "Find files matching a glob pattern", parameters: { type: "object", properties: { pattern: { type: "string", description: "Glob pattern (e.g., '**/*.ts')" }, path: { type: "string", description: "Directory to search in (default: current directory)" } }, required: ["pattern"] }, source: "local" as const }, async (args) => { const { execFile } = await import("child_process"); const { promisify } = await import("util"); const execFileAsync = promisify(execFile); const pattern = resolveGlobPattern(args); if (!pattern) { throw new Error("glob: missing required argument 'pattern'"); } const path = resolvePathArg(args, "glob"); const cwd = path || "."; const normalizedPattern = pattern.replace(/\\/g, "/"); if (process.platform === "win32") { return nodeFallbackGlob(normalizedPattern, cwd); } const isPathPattern = normalizedPattern.includes("/"); const findArgs = [cwd, "-type", "f"]; if (isPathPattern) { if (cwd === "." || cwd === "./") { const dotPattern = normalizedPattern.startsWith("./") ? normalizedPattern : `./${normalizedPattern}`; findArgs.push("(", "-path", normalizedPattern, "-o", "-path", dotPattern, ")"); } else { findArgs.push("-path", normalizedPattern); } } else { findArgs.push("-name", normalizedPattern); } try { const { stdout } = await execFileAsync("find", findArgs, { timeout: 30000 }); // Limit output to 50 lines (replaces piped `| head -50`) const lines = (stdout || "").split("\n").filter(Boolean); return lines.slice(0, 50).join("\n") || "No files found"; } catch (error: any) { const stdout = typeof error?.stdout === "string" ? error.stdout : ""; const stderr = typeof error?.stderr === "string" ? error.stderr : ""; // Permission-denied and "no results" scenarios from find should not be fatal. if (error?.code === 1 || stderr.includes("Permission denied")) { const lines = stdout.split("\n").filter(Boolean); return lines.slice(0, 50).join("\n") || "No files found"; } throw error; } }); // 8. Mkdir tool - Create directories registry.register({ id: "mkdir", name: "mkdir", description: "Create a directory, including parent directories if needed", parameters: { type: "object", properties: { path: { type: "string", description: "Directory path to create" } }, required: ["path"] }, source: "local" as const }, async (args) => { const { mkdir } = await import("fs/promises"); const { resolve } = await import("path"); const rawPath = resolvePathArg(args, "mkdir"); if (!rawPath) { throw new Error("mkdir: missing required argument 'path'"); } const target = resolve(rawPath); await mkdir(target, { recursive: true }); return `Created directory: ${target}`; }); // 9. Rm tool - Delete files/directories registry.register({ id: "rm", name: "rm", description: "Delete a file or directory. Use force: true for non-empty directories.", parameters: { type: "object", properties: { path: { type: "string", description: "Path to delete" }, force: { type: "boolean", description: "If true, recursively delete non-empty directories" } }, required: ["path"] }, source: "local" as const }, async (args) => { const { rm, stat } = await import("fs/promises"); const { resolve } = await import("path"); const rawPath = resolvePathArg(args, "rm"); if (!rawPath) { throw new Error("rm: missing required argument 'path'"); } const target = resolve(rawPath); const force = resolveBoolean(args.force, false); const info = await stat(target); if (info.isDirectory() && !force) { throw new Error("Directory not empty. Use force: true to delete recursively."); } await rm(target, { recursive: force }); return `Deleted: ${target}`; }); // 10. Stat tool - Get file/directory metadata registry.register({ id: "stat", name: "stat", description: "Get file or directory information: size, type, permissions, timestamps", parameters: { type: "object", properties: { path: { type: "string", description: "Path to inspect" } }, required: ["path"] }, source: "local" as const }, async (args) => { const { stat } = await import("fs/promises"); const { resolve } = await import("path"); const rawPath = resolvePathArg(args, "stat"); if (!rawPath) { throw new Error("stat: missing required argument 'path'"); } const target = resolve(rawPath); const info = await stat(target); return JSON.stringify({ path: target, type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other", size: info.size, mode: info.mode.toString(8), modified: info.mtime.toISOString(), created: info.birthtime.toISOString(), }, null, 2); }); } function writeFullFileWithOverwriteGuard( fs: typeof import("fs"), path: typeof import("path"), filePath: string, content: string, force: boolean, toolName: string, minimumExistingLines = 5, ): string { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } if (!force && fs.existsSync(filePath)) { const existing = fs.readFileSync(filePath, "utf-8"); const suspicious = detectSuspiciousPartialOverwrite(existing, content, minimumExistingLines); if (suspicious) { throw new Error( `${toolName}: refusing suspicious partial overwrite of existing file ${filePath} ` + `(${suspicious.existingLines} lines -> ${suspicious.nextLines} lines). ` + `${WRITE_TOOL_TARGETED_EDIT_CONTRACT} ` + "Do not retry write for this targeted change; call edit with path, old_string, and new_string. " + "Pass force: true only when intentionally replacing the full file.", ); } } fs.writeFileSync(filePath, content, "utf-8"); return `File written successfully: ${filePath}`; } function resolveEditArguments(args: Record): { path: string; old_string: string | undefined; new_string: string | undefined; } { const path = typeof args.path === "string" ? args.path : ""; let oldString = typeof args.old_string === "string" ? args.old_string : undefined; let newString = typeof args.new_string === "string" ? args.new_string : undefined; if (newString === undefined) { const fallbackContent = coerceToString(args.content); if (fallbackContent !== null) { newString = fallbackContent; } } return { path, old_string: oldString, new_string: newString, }; } function detectSuspiciousPartialOverwrite( existing: string, next: string, minimumExistingLines = 5, ): { existingLines: number; nextLines: number } | null { if (process.env.CURSOR_ACP_WRITE_OVERWRITE_GUARD === "false") { return null; } if (existing.length === 0) { return null; } const existingLines = countLogicalLines(existing); const nextLines = countLogicalLines(next); if (existingLines < minimumExistingLines) { return null; } const lineShrink = nextLines <= Math.max(3, Math.floor(existingLines * 0.1)); const byteShrink = next.length <= Math.max(120, Math.floor(existing.length * 0.1)); return lineShrink && byteShrink ? { existingLines, nextLines } : null; } function countLogicalLines(value: string): number { if (value.length === 0) { return 0; } const withoutTrailingNewline = value.endsWith("\n") ? value.slice(0, -1) : value; if (withoutTrailingNewline.length === 0) { return 1; } return withoutTrailingNewline.split("\n").length; } function resolveBashCommand(args: Record): string | null { const direct = coerceToString(args.command ?? args.cmd ?? args.script ?? args.input); if (direct !== null && direct.trim().length > 0) { return direct; } if (Array.isArray(args.command)) { const parts = args.command .map((part) => coerceToString(part)) .filter((part): part is string => typeof part === "string" && part.trim().length > 0); if (parts.length > 0) { return parts.join(" "); } } const commandObject = args.command; if (typeof commandObject === "object" && commandObject !== null && !Array.isArray(commandObject)) { const record = commandObject as Record; const base = coerceToString(record.command ?? record.cmd); if (base !== null && base.trim().length > 0) { if (Array.isArray(record.args)) { const argParts = record.args .map((entry) => coerceToString(entry)) .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); return argParts.length > 0 ? `${base} ${argParts.join(" ")}` : base; } return base; } } return null; } function resolveWorkingDirectory(args: Record): string | undefined { const cwd = coerceToString(args.cwd ?? args.workdir ?? args.path); if (cwd !== null && cwd.trim().length > 0) { return cwd; } return undefined; } function resolveGlobPattern(args: Record): string | null { const direct = coerceToString( args.pattern ?? args.globPattern ?? args.filePattern ?? args.searchPattern ?? args.includePattern, ); if (direct !== null && direct.trim().length > 0) { return direct; } return null; } function resolvePathArg(args: Record, toolName: string): string | null { const value = coerceToString( args.path ?? args.filePath ?? args.targetPath ?? args.directory ?? args.dir ?? args.folder ?? args.targetDirectory ?? args.targetFile, ); if (value !== null && value.trim().length > 0) { return value; } if (toolName === "glob") { return "."; } return null; } function resolveTimeout(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value) && value > 0) { return value; } if (typeof value === "string") { const parsed = Number(value.trim()); if (Number.isFinite(parsed) && parsed > 0) { return parsed; } } return undefined; } // Convert model-supplied timeout (seconds) to milliseconds. Falls back to 30s. function resolveTimeoutMs(value: unknown): number { const raw = resolveTimeout(value); if (raw === undefined) return 30_000; // Values ≤ 600 are treated as seconds (no real use case for a <600ms shell timeout). return raw <= 600 ? raw * 1000 : raw; } export function resolveShellOption(deps: { platform?: NodeJS.Platform; env?: Record; } = {}): string | boolean { const platform = deps.platform ?? process.platform; const env = deps.env ?? process.env; if (platform === "win32") { return env.ComSpec || env.COMSPEC || true; } return env.SHELL || "/bin/bash"; } function resolveBoolean(value: unknown, defaultValue: boolean): boolean { if (typeof value === "boolean") { return value; } if (typeof value === "number") { return value !== 0; } if (typeof value === "string") { const normalized = value.trim().toLowerCase(); if (normalized === "true" || normalized === "1" || normalized === "yes") { return true; } if (normalized === "false" || normalized === "0" || normalized === "no") { return false; } } return defaultValue; } function coerceToString(value: unknown): string | null { if (typeof value === "string") { return value; } if (value === null || value === undefined) { return null; } if (Array.isArray(value)) { const parts: string[] = []; for (const item of value) { if (typeof item === "string") { parts.push(item); } else if (typeof item === "object" && item !== null) { const record = item as Record; if (typeof record.text === "string") { parts.push(record.text); } else if (typeof record.content === "string") { parts.push(record.content); } else if (typeof record.value === "string") { parts.push(record.value); } else { parts.push(JSON.stringify(record)); } } else { parts.push(String(item)); } } return parts.length > 0 ? parts.join("") : null; } if (typeof value === "object") { const record = value as Record; if (typeof record.text === "string") { return record.text; } if (typeof record.content === "string") { return record.content; } if (typeof record.value === "string") { return record.value; } return JSON.stringify(record); } if (typeof value === "number" || typeof value === "boolean") { return String(value); } return null; } /** * Get the names of all default tools */ export function getDefaultToolNames(): string[] { return ["bash", "read", "write", "edit", "grep", "ls", "glob", "mkdir", "rm", "stat"]; } const FALLBACK_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build"]); const fallbackLog = createLogger("tools:fallback"); export async function nodeFallbackGrep( pattern: string, searchPath: string, include?: string, ): Promise { const fs = await import("fs/promises"); const path = await import("path"); let regex: RegExp; try { regex = new RegExp(pattern); } catch { return "Invalid regex pattern"; } let includeRegex: RegExp | undefined; if (include) { const incPattern = include.replace(/\./g, "\\.").replace(/\?/g, ".").replace(/\*/g, ".*"); includeRegex = new RegExp(`^${incPattern}$`); } const results: string[] = []; async function walk(dir: string): Promise { if (results.length >= 100) return; let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch (err: any) { if (err?.code !== "ENOENT" && err?.code !== "EACCES") { fallbackLog.error("Unexpected error reading directory", { dir, code: err?.code, message: err?.message }); } return; } for (const entry of entries) { if (results.length >= 100) return; const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (!FALLBACK_SKIP_DIRS.has(entry.name)) { await walk(fullPath); } } else if (entry.isFile()) { if (includeRegex && !includeRegex.test(entry.name)) continue; let content: string; try { content = await fs.readFile(fullPath, "utf-8"); } catch (err: any) { if (err?.code !== "ENOENT" && err?.code !== "EACCES") { fallbackLog.error("Unexpected error reading file", { path: fullPath, code: err?.code, message: err?.message }); } continue; } const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i])) { results.push(`${fullPath}:${i + 1}:${lines[i]}`); if (results.length >= 100) break; } } } } } let stat; try { stat = await fs.stat(searchPath); } catch { return "Path not found"; } if (stat.isFile()) { let content: string; try { content = await fs.readFile(searchPath, "utf-8"); } catch (err: any) { if (err?.code !== "ENOENT" && err?.code !== "EACCES") { fallbackLog.error("Unexpected error reading file", { path: searchPath, code: err?.code, message: err?.message }); } return "Path not found"; } const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i])) { results.push(`${searchPath}:${i + 1}:${lines[i]}`); if (results.length >= 100) break; } } } else { await walk(searchPath); } return results.join("\n") || "No matches found"; } export async function nodeFallbackGlob( pattern: string, searchPath: string, ): Promise { const fs = await import("fs/promises"); const path = await import("path"); const results: string[] = []; const isPathPattern = pattern.includes("/"); // Handle ** before * so double-star → .* and single-star → [^/]* let regexPattern = pattern .replace(/\./g, "\\.") .replace(/\*\*/g, "\x00") // placeholder for ** .replace(/\*/g, "[^/]*") .replace(/\x00/g, ".*"); // restore ** as .* let regex: RegExp; try { regex = isPathPattern ? new RegExp(`${regexPattern}$`) : new RegExp(`^${regexPattern}$`); } catch { return "No files found"; } async function walk(dir: string): Promise { if (results.length >= 50) return; let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch (err: any) { if (err?.code !== "ENOENT" && err?.code !== "EACCES") { fallbackLog.error("Unexpected error reading directory", { dir, code: err?.code, message: err?.message }); } return; } for (const entry of entries) { if (results.length >= 50) return; const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (!FALLBACK_SKIP_DIRS.has(entry.name)) { await walk(fullPath); } } else if (entry.isFile()) { const matchTarget = isPathPattern ? fullPath.replace(/\\/g, "/") : entry.name; if (regex.test(matchTarget)) { results.push(fullPath); } } } } await walk(searchPath); return results.join("\n") || "No files found"; }