import * as plugins from './plugins.js'; import * as interfaces from './smartagent.interfaces.js'; import { BaseToolWrapper } from './smartagent.tools.base.js'; /** * Options for FilesystemTool */ export interface IFilesystemToolOptions { /** Base path to scope all operations to. If set, all paths must be within this directory. */ basePath?: string; } /** * Filesystem tool for file and directory operations * Wraps @push.rocks/smartfs */ export class FilesystemTool extends BaseToolWrapper { public name = 'filesystem'; public description = 'Read, write, list, and delete files and directories'; /** Base path to scope all operations to */ private basePath?: string; constructor(options?: IFilesystemToolOptions) { super(); if (options?.basePath) { this.basePath = plugins.path.resolve(options.basePath); } } /** * Validate that a path is within the allowed base path * @throws Error if path is outside allowed directory */ private validatePath(pathArg: string): string { const resolved = plugins.path.resolve(pathArg); if (this.basePath) { // Ensure the resolved path starts with the base path if (!resolved.startsWith(this.basePath + plugins.path.sep) && resolved !== this.basePath) { throw new Error(`Access denied: path "${pathArg}" is outside allowed directory "${this.basePath}"`); } } return resolved; } public actions: interfaces.IToolAction[] = [ { name: 'read', description: 'Read file contents (full or specific line range)', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to the file' }, encoding: { type: 'string', enum: ['utf8', 'binary', 'base64'], default: 'utf8', description: 'File encoding', }, startLine: { type: 'number', description: 'First line to read (1-indexed, inclusive). If omitted, reads from beginning.', }, endLine: { type: 'number', description: 'Last line to read (1-indexed, inclusive). If omitted, reads to end.', }, }, required: ['path'], }, }, { name: 'write', description: 'Write content to a file (creates or overwrites)', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Absolute path to the file' }, content: { type: 'string', description: 'Content to write' }, encoding: { type: 'string', enum: ['utf8', 'binary', 'base64'], default: 'utf8', description: 'File encoding', }, }, required: ['path', 'content'], }, }, { name: 'append', description: 'Append content to a file', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Absolute path to the file' }, content: { type: 'string', description: 'Content to append' }, }, required: ['path', 'content'], }, }, { name: 'list', description: 'List files and directories in a path', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Directory path to list' }, recursive: { type: 'boolean', default: false, description: 'List recursively' }, filter: { type: 'string', description: 'Glob pattern to filter results (e.g., "*.ts")' }, }, required: ['path'], }, }, { name: 'delete', description: 'Delete a file or directory', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to delete' }, recursive: { type: 'boolean', default: false, description: 'For directories, delete recursively', }, }, required: ['path'], }, }, { name: 'exists', description: 'Check if a file or directory exists', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to check' }, }, required: ['path'], }, }, { name: 'stat', description: 'Get file or directory statistics (size, dates, etc.)', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to get stats for' }, }, required: ['path'], }, }, { name: 'copy', description: 'Copy a file to a new location', parameters: { type: 'object', properties: { source: { type: 'string', description: 'Source file path' }, destination: { type: 'string', description: 'Destination file path' }, }, required: ['source', 'destination'], }, }, { name: 'move', description: 'Move a file to a new location', parameters: { type: 'object', properties: { source: { type: 'string', description: 'Source file path' }, destination: { type: 'string', description: 'Destination file path' }, }, required: ['source', 'destination'], }, }, { name: 'mkdir', description: 'Create a directory', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Directory path to create' }, recursive: { type: 'boolean', default: true, description: 'Create parent directories if needed', }, }, required: ['path'], }, }, { name: 'tree', description: 'Show directory structure as a tree (no file contents)', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Root directory path' }, maxDepth: { type: 'number', default: 3, description: 'Maximum depth to traverse (default: 3)', }, filter: { type: 'string', description: 'Glob pattern to filter files (e.g., "*.ts")', }, showSizes: { type: 'boolean', default: false, description: 'Include file sizes in output', }, format: { type: 'string', enum: ['string', 'json'], default: 'string', description: 'Output format: "string" for human-readable tree, "json" for structured array', }, }, required: ['path'], }, }, { name: 'glob', description: 'Find files matching a glob pattern', parameters: { type: 'object', properties: { pattern: { type: 'string', description: 'Glob pattern (e.g., "**/*.ts", "src/**/*.js")', }, path: { type: 'string', description: 'Base path to search from (defaults to current directory)', }, }, required: ['pattern'], }, }, ]; private smartfs!: plugins.smartfs.SmartFs; public async initialize(): Promise { this.smartfs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode()); this.isInitialized = true; } public async cleanup(): Promise { this.isInitialized = false; } public async execute( action: string, params: Record ): Promise { this.validateAction(action); this.ensureInitialized(); try { switch (action) { case 'read': { const validatedPath = this.validatePath(params.path as string); const encoding = (params.encoding as string) || 'utf8'; const startLine = params.startLine as number | undefined; const endLine = params.endLine as number | undefined; const fullContent = await this.smartfs .file(validatedPath) .encoding(encoding as 'utf8' | 'binary' | 'base64') .read(); const contentStr = fullContent.toString(); const lines = contentStr.split('\n'); const totalLines = lines.length; // Apply line range if specified let resultContent: string; let resultStartLine = 1; let resultEndLine = totalLines; if (startLine !== undefined || endLine !== undefined) { const start = Math.max(1, startLine ?? 1); const end = Math.min(totalLines, endLine ?? totalLines); resultStartLine = start; resultEndLine = end; // Convert to 0-indexed for array slicing const selectedLines = lines.slice(start - 1, end); // Add line numbers to output for context resultContent = selectedLines .map((line, idx) => `${String(start + idx).padStart(5)}│ ${line}`) .join('\n'); } else { // No range specified - return full content but warn if large const MAX_LINES_WITHOUT_RANGE = 500; if (totalLines > MAX_LINES_WITHOUT_RANGE) { // Return first portion with warning const selectedLines = lines.slice(0, MAX_LINES_WITHOUT_RANGE); resultContent = selectedLines .map((line, idx) => `${String(idx + 1).padStart(5)}│ ${line}`) .join('\n'); resultContent += `\n\n[... ${totalLines - MAX_LINES_WITHOUT_RANGE} more lines. Use startLine/endLine to read specific ranges.]`; resultEndLine = MAX_LINES_WITHOUT_RANGE; } else { resultContent = contentStr; } } return { success: true, result: { path: params.path, content: resultContent, encoding, totalLines, startLine: resultStartLine, endLine: resultEndLine, }, }; } case 'write': { const validatedPath = this.validatePath(params.path as string); const encoding = (params.encoding as string) || 'utf8'; await this.smartfs .file(validatedPath) .encoding(encoding as 'utf8' | 'binary' | 'base64') .write(params.content as string); return { success: true, result: { path: params.path, written: true, bytesWritten: (params.content as string).length, }, }; } case 'append': { const validatedPath = this.validatePath(params.path as string); await this.smartfs.file(validatedPath).append(params.content as string); return { success: true, result: { path: params.path, appended: true, }, }; } case 'list': { const validatedPath = this.validatePath(params.path as string); let dir = this.smartfs.directory(validatedPath); if (params.recursive) { dir = dir.recursive(); } if (params.filter) { dir = dir.filter(params.filter as string); } const entries = await dir.list(); return { success: true, result: { path: params.path, entries, count: entries.length, }, }; } case 'delete': { const validatedPath = this.validatePath(params.path as string); // Check if it's a directory or file const exists = await this.smartfs.file(validatedPath).exists(); if (exists) { // Try to get stats to check if it's a directory try { const stats = await this.smartfs.file(validatedPath).stat(); if (stats.isDirectory && params.recursive) { await this.smartfs.directory(validatedPath).recursive().delete(); } else { await this.smartfs.file(validatedPath).delete(); } } catch { await this.smartfs.file(validatedPath).delete(); } } return { success: true, result: { path: params.path, deleted: true, }, }; } case 'exists': { const validatedPath = this.validatePath(params.path as string); const exists = await this.smartfs.file(validatedPath).exists(); return { success: true, result: { path: params.path, exists, }, }; } case 'stat': { const validatedPath = this.validatePath(params.path as string); const stats = await this.smartfs.file(validatedPath).stat(); return { success: true, result: { path: params.path, stats, }, }; } case 'copy': { const validatedSource = this.validatePath(params.source as string); const validatedDest = this.validatePath(params.destination as string); await this.smartfs.file(validatedSource).copy(validatedDest); return { success: true, result: { source: params.source, destination: params.destination, copied: true, }, }; } case 'move': { const validatedSource = this.validatePath(params.source as string); const validatedDest = this.validatePath(params.destination as string); await this.smartfs.file(validatedSource).move(validatedDest); return { success: true, result: { source: params.source, destination: params.destination, moved: true, }, }; } case 'mkdir': { const validatedPath = this.validatePath(params.path as string); let dir = this.smartfs.directory(validatedPath); if (params.recursive !== false) { dir = dir.recursive(); } await dir.create(); return { success: true, result: { path: params.path, created: true, }, }; } case 'tree': { const validatedPath = this.validatePath(params.path as string); const maxDepth = (params.maxDepth as number) ?? 3; const filter = params.filter as string | undefined; const showSizes = (params.showSizes as boolean) ?? false; const format = (params.format as 'string' | 'json') ?? 'string'; // Collect all entries recursively up to maxDepth interface ITreeEntry { path: string; relativePath: string; isDir: boolean; depth: number; size?: number; } const entries: ITreeEntry[] = []; const collectEntries = async (dirPath: string, depth: number, relativePath: string) => { if (depth > maxDepth) return; let dir = this.smartfs.directory(dirPath); if (filter) { dir = dir.filter(filter); } const items = await dir.list(); for (const item of items) { // item is IDirectoryEntry with name, path, isFile, isDirectory properties const itemPath = item.path; const itemRelPath = relativePath ? `${relativePath}/${item.name}` : item.name; const isDir = item.isDirectory; const entry: ITreeEntry = { path: itemPath, relativePath: itemRelPath, isDir, depth, }; if (showSizes && !isDir && item.stats) { entry.size = item.stats.size; } entries.push(entry); // Recurse into directories if (isDir && depth < maxDepth) { await collectEntries(itemPath, depth + 1, itemRelPath); } } }; await collectEntries(validatedPath, 0, ''); // Sort entries by path for consistent output entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); if (format === 'json') { return { success: true, result: { path: params.path, entries: entries.map((e) => ({ path: e.relativePath, isDir: e.isDir, depth: e.depth, ...(e.size !== undefined ? { size: e.size } : {}), })), count: entries.length, }, }; } // Format as string tree const formatSize = (bytes: number): string => { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; }; // Build tree string with proper indentation let treeStr = `${params.path}/\n`; const pathParts = new Map(); // Track which paths are last in their parent // Group by parent to determine last child const parentChildCount = new Map(); const parentCurrentChild = new Map(); for (const entry of entries) { const parentPath = entry.relativePath.includes('/') ? entry.relativePath.substring(0, entry.relativePath.lastIndexOf('/')) : ''; parentChildCount.set(parentPath, (parentChildCount.get(parentPath) || 0) + 1); } for (const entry of entries) { const parentPath = entry.relativePath.includes('/') ? entry.relativePath.substring(0, entry.relativePath.lastIndexOf('/')) : ''; parentCurrentChild.set(parentPath, (parentCurrentChild.get(parentPath) || 0) + 1); const isLast = parentCurrentChild.get(parentPath) === parentChildCount.get(parentPath); // Build prefix based on depth let prefix = ''; const parts = entry.relativePath.split('/'); for (let i = 0; i < parts.length - 1; i++) { prefix += '│ '; } prefix += isLast ? '└── ' : '├── '; const name = parts[parts.length - 1]; const suffix = entry.isDir ? '/' : ''; const sizeStr = showSizes && entry.size !== undefined ? ` (${formatSize(entry.size)})` : ''; treeStr += `${prefix}${name}${suffix}${sizeStr}\n`; } return { success: true, result: { path: params.path, tree: treeStr, count: entries.length, }, }; } case 'glob': { const pattern = params.pattern as string; const basePath = params.path ? this.validatePath(params.path as string) : (this.basePath || process.cwd()); // Use smartfs to list with filter const dir = this.smartfs.directory(basePath).recursive().filter(pattern); const matches = await dir.list(); // Return file paths relative to base path for readability const files = matches.map((entry) => ({ path: entry.path, relativePath: plugins.path.relative(basePath, entry.path), isDirectory: entry.isDirectory, })); return { success: true, result: { pattern, basePath, files, count: files.length, }, }; } default: return { success: false, error: `Unknown action: ${action}`, }; } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), }; } } public getCallSummary(action: string, params: Record): string { switch (action) { case 'read': { const lineRange = params.startLine || params.endLine ? ` lines ${params.startLine || 1}-${params.endLine || 'end'}` : ''; return `Read file "${params.path}"${lineRange}`; } case 'write': { const content = params.content as string; const preview = content.length > 100 ? content.substring(0, 100) + '...' : content; return `Write ${content.length} bytes to "${params.path}". Content preview: "${preview}"`; } case 'append': { const content = params.content as string; const preview = content.length > 100 ? content.substring(0, 100) + '...' : content; return `Append ${content.length} bytes to "${params.path}". Content preview: "${preview}"`; } case 'list': return `List directory "${params.path}"${params.recursive ? ' recursively' : ''}${params.filter ? ` with filter "${params.filter}"` : ''}`; case 'delete': return `Delete "${params.path}"${params.recursive ? ' recursively' : ''}`; case 'exists': return `Check if "${params.path}" exists`; case 'stat': return `Get statistics for "${params.path}"`; case 'copy': return `Copy "${params.source}" to "${params.destination}"`; case 'move': return `Move "${params.source}" to "${params.destination}"`; case 'mkdir': return `Create directory "${params.path}"${params.recursive !== false ? ' (with parents)' : ''}`; case 'tree': return `Show tree of "${params.path}" (depth: ${params.maxDepth ?? 3}, format: ${params.format ?? 'string'})`; case 'glob': return `Find files matching "${params.pattern}"${params.path ? ` in "${params.path}"` : ''}`; default: return `Unknown action: ${action}`; } } }