import * as plugins from './plugins.js'; export interface IFilesystemToolOptions { /** Restrict file access to this directory. Default: process.cwd() */ rootDir?: string; } function validatePath(filePath: string, rootDir?: string): string { const resolved = plugins.path.resolve(filePath); if (rootDir) { const resolvedRoot = plugins.path.resolve(rootDir); if (!resolved.startsWith(resolvedRoot + plugins.path.sep) && resolved !== resolvedRoot) { throw new Error(`Access denied: "${filePath}" is outside allowed root "${rootDir}"`); } } return resolved; } export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSet { const rootDir = options?.rootDir; return { read_file: plugins.tool({ description: 'Read file contents. Returns the full text or a specified line range.', inputSchema: plugins.z.object({ path: plugins.z.string().describe('Absolute path to the file'), startLine: plugins.z .number() .optional() .describe('First line (1-indexed, inclusive)'), endLine: plugins.z .number() .optional() .describe('Last line (1-indexed, inclusive)'), }), execute: async ({ path: filePath, startLine, endLine, }: { path: string; startLine?: number; endLine?: number; }) => { const resolved = validatePath(filePath, rootDir); const content = plugins.fs.readFileSync(resolved, 'utf-8'); if (startLine !== undefined || endLine !== undefined) { const lines = content.split('\n'); const start = (startLine ?? 1) - 1; const end = endLine ?? lines.length; const sliced = lines.slice(start, end).join('\n'); return plugins.truncateOutput(sliced).content; } return plugins.truncateOutput(content).content; }, }), write_file: plugins.tool({ description: 'Write content to a file (creates parent dirs if needed, overwrites existing).', inputSchema: plugins.z.object({ path: plugins.z.string().describe('Absolute path to the file'), content: plugins.z.string().describe('Content to write'), }), execute: async ({ path: filePath, content }: { path: string; content: string }) => { const resolved = validatePath(filePath, rootDir); const dir = plugins.path.dirname(resolved); plugins.fs.mkdirSync(dir, { recursive: true }); plugins.fs.writeFileSync(resolved, content, 'utf-8'); return `Written ${content.length} characters to ${filePath}`; }, }), list_directory: plugins.tool({ description: 'List files and directories at the given path.', inputSchema: plugins.z.object({ path: plugins.z.string().describe('Directory path to list'), recursive: plugins.z .boolean() .optional() .describe('List recursively (default: false)'), }), execute: async ({ path: dirPath, recursive, }: { path: string; recursive?: boolean; }) => { const resolved = validatePath(dirPath, rootDir); function listDir(dir: string, prefix: string = ''): string[] { const entries = plugins.fs.readdirSync(dir, { withFileTypes: true }); const result: string[] = []; for (const entry of entries) { const rel = prefix ? `${prefix}/${entry.name}` : entry.name; const indicator = entry.isDirectory() ? '/' : ''; result.push(`${rel}${indicator}`); if (recursive && entry.isDirectory()) { result.push(...listDir(plugins.path.join(dir, entry.name), rel)); } } return result; } const entries = listDir(resolved); return plugins.truncateOutput(entries.join('\n')).content; }, }), delete_file: plugins.tool({ description: 'Delete a file or empty directory.', inputSchema: plugins.z.object({ path: plugins.z.string().describe('Path to delete'), }), execute: async ({ path: filePath }: { path: string }) => { const resolved = validatePath(filePath, rootDir); const stat = plugins.fs.statSync(resolved); if (stat.isDirectory()) { plugins.fs.rmdirSync(resolved); } else { plugins.fs.unlinkSync(resolved); } return `Deleted ${filePath}`; }, }), }; }