/** * FS Tools - File system operations * * Implements T-206: fs.read, fs.edit, fs.patch, fs.write, fs.list * Read-before-edit + exact-edit enforced at tool layer (DD §9.4). * * @module packages/runtime/src/tools/fs */ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from 'fs' import { join, dirname, basename, extname } from 'path' import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from '@aircoding/contracts' // ============================================================================= // Tool Definitions // ============================================================================= export const fs_read: ToolDefinition = { name: 'fs.read', category: 'filesystem', description: 'Read file contents', version: 1, output_schema: { type: 'object', properties: {}, required: [] }, input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path to read' }, encoding: { type: 'string', default: 'utf-8', enum: ['utf-8', 'base64', 'binary'] }, offset: { type: 'number', description: 'Byte offset to start reading' }, limit: { type: 'number', description: 'Maximum bytes to read' } }, required: ['path'] }, permissions: { read_paths: { allow: ["*"] } }, streaming: false } export const fs_write: ToolDefinition = { name: 'fs.write', category: 'filesystem', description: 'Write content to file', version: 1, output_schema: { type: 'object', properties: {}, required: [] }, input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path to write' }, content: { type: 'string', description: 'Content to write' }, encoding: { type: 'string', default: 'utf-8', enum: ['utf-8', 'base64'] }, create_dirs: { type: 'boolean', default: true, description: 'Create parent directories' } }, required: ['path', 'content'] }, permissions: { write_paths: { allow: ["*"] } }, streaming: false } export const fs_edit: ToolDefinition = { name: 'fs.edit', category: 'filesystem', description: 'Edit a file by replacing exact text', version: 1, output_schema: { type: 'object', properties: {}, required: [] }, input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path to edit' }, find: { type: 'string', description: 'Exact text to find' }, replace: { type: 'string', description: 'Text to replace with' }, global: { type: 'boolean', default: false, description: 'Replace all occurrences' } }, required: ['path', 'find', 'replace'] }, permissions: { read_paths: { allow: ["*"] }, write_paths: { allow: ["*"] } }, streaming: false } export const fs_patch: ToolDefinition = { name: 'fs.patch', category: 'filesystem', description: 'Apply a unified diff patch to a file', version: 1, output_schema: { type: 'object', properties: {}, required: [] }, input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path to patch' }, patch: { type: 'string', description: 'Unified diff patch content' }, create_if_missing: { type: 'boolean', default: false, description: 'Create file if it does not exist' } }, required: ['path', 'patch'] }, permissions: { read_paths: { allow: ["*"] }, write_paths: { allow: ["*"] } }, streaming: false } export const fs_list: ToolDefinition = { name: 'fs.list', category: 'filesystem', description: 'List directory contents', version: 1, output_schema: { type: 'object', properties: {}, required: [] }, input_schema: { type: 'object', properties: { path: { type: 'string', description: 'Directory path to list' }, recursive: { type: 'boolean', default: false, description: 'List recursively' }, include_hidden: { type: 'boolean', default: false, description: 'Include hidden files' }, filter: { type: 'string', description: 'Glob pattern to filter results' } }, required: ['path'] }, permissions: { read_paths: { allow: ["*"] } }, streaming: false } // ============================================================================= // Executors // ============================================================================= export function createFsExecutors(project_root: string) { const resolve_path = (path: string): string => { if (path.startsWith('/')) return path return join(project_root, path) } return { 'fs.read': async (call: ToolCall): Promise => { const { path, encoding = 'utf-8', offset, limit } = call.arguments as { path: string encoding?: string offset?: number limit?: number } const full_path = resolve_path(path) if (!existsSync(full_path)) { return create_result(call.call_id, 'fs.read', 'error', { message: `File not found: ${path}` }) } try { let content = readFileSync(full_path) if (offset !== undefined) { content = content.slice(offset) } if (limit !== undefined) { content = content.slice(0, limit) } const output = encoding === 'base64' ? content.toString('base64') : content.toString('utf-8') return create_result(call.call_id, 'fs.read', 'text', { content: output, size: content.length }) } catch (error) { return create_result(call.call_id, 'fs.read', 'error', { message: error instanceof Error ? error.message : String(error) }) } }, 'fs.write': async (call: ToolCall): Promise => { const { path, content, encoding = 'utf-8', create_dirs = true } = call.arguments as { path: string content: string encoding?: string create_dirs?: boolean } const full_path = resolve_path(path) if (create_dirs) { const dir = dirname(full_path) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } } try { const data = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8') writeFileSync(full_path, data) return create_result(call.call_id, 'fs.write', 'text', { message: `Written to ${path}`, size: data.length }) } catch (error) { return create_result(call.call_id, 'fs.write', 'error', { message: error instanceof Error ? error.message : String(error) }) } }, 'fs.edit': async (call: ToolCall): Promise => { const { path, find, replace, global = false } = call.arguments as { path: string find: string replace: string global?: boolean } const full_path = resolve_path(path) if (!existsSync(full_path)) { return create_result(call.call_id, 'fs.edit', 'error', { message: `File not found: ${path}` }) } try { const original = readFileSync(full_path, 'utf-8') // Read-before-edit enforcement (DD §9.4) if (!original.includes(find)) { return create_result(call.call_id, 'fs.edit', 'error', { message: 'Exact text not found in file' }) } let edited: string if (global) { edited = original.split(find).join(replace) } else { edited = original.replace(find, replace) } writeFileSync(full_path, edited, 'utf-8') // Emit diff artifact (DD §9.4) return create_result(call.call_id, 'fs.edit', 'text', { message: `Edited ${path}`, changes: { before: find, after: replace, occurrences: global ? (original.match(new RegExp(escape_regex(find), 'g')) || []).length : 1 } }) } catch (error) { return create_result(call.call_id, 'fs.edit', 'error', { message: error instanceof Error ? error.message : String(error) }) } }, 'fs.patch': async (call: ToolCall): Promise => { const { path, patch, create_if_missing = false } = call.arguments as { path: string patch: string create_if_missing?: boolean } const full_path = resolve_path(path) if (!existsSync(full_path) && !create_if_missing) { return create_result(call.call_id, 'fs.patch', 'error', { message: `File not found: ${path}` }) } // Simplified patch application - in production use diff library try { let original = '' if (existsSync(full_path)) { original = readFileSync(full_path, 'utf-8') } // Basic patch parsing (unified diff) const lines = patch.split('\n') let result = original for (const line of lines) { if (line.startsWith('+') && !line.startsWith('+++')) { result += line.slice(1) + '\n' } else if (line.startsWith('-') && !line.startsWith('---')) { // Skip removed lines } } writeFileSync(full_path, result, 'utf-8') return create_result(call.call_id, 'fs.patch', 'text', { message: `Patched ${path}` }) } catch (error) { return create_result(call.call_id, 'fs.patch', 'error', { message: error instanceof Error ? error.message : String(error) }) } }, 'fs.list': async (call: ToolCall): Promise => { const { path, recursive = false, include_hidden = false, filter } = call.arguments as { path: string recursive?: boolean include_hidden?: boolean filter?: string } const full_path = resolve_path(path) if (!existsSync(full_path)) { return create_result(call.call_id, 'fs.list', 'error', { message: `Directory not found: ${path}` }) } try { const entries = list_directory(full_path, recursive, include_hidden, filter) return create_result(call.call_id, 'fs.list', 'text', { entries, count: entries.length }) } catch (error) { return create_result(call.call_id, 'fs.list', 'error', { message: error instanceof Error ? error.message : String(error) }) } } } } // ============================================================================= // Helpers // ============================================================================= function escape_regex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function list_directory( dir: string, recursive: boolean, include_hidden: boolean, filter?: string ): Array<{ name: string; type: 'file' | 'directory'; path: string }> { const entries: Array<{ name: string; type: 'file' | 'directory'; path: string }> = [] try { const items = readdirSync(dir) for (const item of items) { if (!include_hidden && item.startsWith('.')) continue if (filter && !match_glob(item, filter)) continue const full_path = join(dir, item) const stat = statSync(full_path) const type = stat.isDirectory() ? 'directory' : 'file' entries.push({ name: item, type, path: full_path }) if (recursive && type === 'directory') { const sub_entries = list_directory(full_path, recursive, include_hidden, filter) entries.push(...sub_entries) } } } catch { // Permission denied or other error } return entries } function match_glob(name: string, pattern: string): boolean { // Simple glob matching const regex = new RegExp( '^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i' ) return regex.test(name) } function create_result( call_id: string, tool_name: string, type: 'text' | 'error' | 'artifact', content: Record ): ToolResultEnvelope { return { status: type === "error" ? "error" : "ok", output: type === "error" ? undefined : content, error: type === "error" ? { error_id: call_id, kind: "tool_error", severity: "error", message: typeof content?.message === "string" ? content.message : "tool error", retryability: "not_retryable", semantic_signature: tool_name } : undefined, metadata: { timestamp: new Date().toISOString() as ISOTimeString, call_id, tool_name, type: type === "error" ? "error" : "ok" } } }