UNPKG

@probelabs/probe

Version:

Node.js wrapper for the probe code search tool

409 lines (348 loc) 14 kB
/** * Edit and Create tools for file modification * @module tools/edit */ import { tool } from 'ai'; import { promises as fs } from 'fs'; import { dirname, resolve, isAbsolute, sep } from 'path'; import { existsSync } from 'fs'; /** * Validates that a path is within allowed directories * @param {string} filePath - Path to validate * @param {string[]} allowedFolders - List of allowed folders * @returns {boolean} True if path is allowed */ function isPathAllowed(filePath, allowedFolders) { if (!allowedFolders || allowedFolders.length === 0) { // If no restrictions, allow current directory and below const resolvedPath = resolve(filePath); const cwd = resolve(process.cwd()); // Ensure proper path separator to prevent path traversal return resolvedPath === cwd || resolvedPath.startsWith(cwd + sep); } const resolvedPath = resolve(filePath); return allowedFolders.some(folder => { const allowedPath = resolve(folder); // Ensure proper path separator to prevent path traversal return resolvedPath === allowedPath || resolvedPath.startsWith(allowedPath + sep); }); } /** * Common configuration for file tools * @param {Object} options - Configuration options * @returns {Object} Parsed configuration */ function parseFileToolOptions(options = {}) { return { debug: options.debug || false, allowedFolders: options.allowedFolders || [], defaultPath: options.defaultPath }; } /** * Edit tool generator - Claude Code style string replacement * * @param {Object} [options] - Configuration options * @param {boolean} [options.debug=false] - Enable debug logging * @param {string[]} [options.allowedFolders] - Allowed directories for file operations * @param {string} [options.defaultPath] - Default working directory * @returns {Object} Configured edit tool */ export const editTool = (options = {}) => { const { debug, allowedFolders, defaultPath } = parseFileToolOptions(options); return tool({ name: 'edit', description: `Edit files using exact string replacement (Claude Code style). This tool performs exact string replacements in files. It requires the old_string to match exactly what's in the file, including all whitespace and indentation. Parameters: - file_path: Path to the file to edit (absolute or relative) - old_string: Exact text to find and replace (must be unique in the file unless replace_all is true) - new_string: Text to replace with - replace_all: (optional) Replace all occurrences instead of requiring uniqueness Important: - The old_string must match EXACTLY including whitespace - If old_string appears multiple times and replace_all is false, the edit will fail - Use larger context around the string to ensure uniqueness when needed`, inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'Path to the file to edit' }, old_string: { type: 'string', description: 'Exact text to find and replace' }, new_string: { type: 'string', description: 'Text to replace with' }, replace_all: { type: 'boolean', description: 'Replace all occurrences (default: false)', default: false } }, required: ['file_path', 'old_string', 'new_string'] }, execute: async ({ file_path, old_string, new_string, replace_all = false }) => { try { // Validate input parameters if (!file_path || typeof file_path !== 'string' || file_path.trim() === '') { return `Error editing file: Invalid file_path - must be a non-empty string`; } if (old_string === undefined || old_string === null || typeof old_string !== 'string') { return `Error editing file: Invalid old_string - must be a string`; } if (new_string === undefined || new_string === null || typeof new_string !== 'string') { return `Error editing file: Invalid new_string - must be a string`; } // Resolve the file path const resolvedPath = isAbsolute(file_path) ? file_path : resolve(defaultPath || process.cwd(), file_path); if (debug) { console.error(`[Edit] Attempting to edit file: ${resolvedPath}`); } // Check if path is allowed if (!isPathAllowed(resolvedPath, allowedFolders)) { return `Error editing file: Permission denied - ${file_path} is outside allowed directories`; } // Check if file exists if (!existsSync(resolvedPath)) { return `Error editing file: File not found - ${file_path}`; } // Read the file const content = await fs.readFile(resolvedPath, 'utf-8'); // Check if old_string exists in the file if (!content.includes(old_string)) { return `Error editing file: String not found - the specified old_string was not found in ${file_path}`; } // Count occurrences const occurrences = content.split(old_string).length - 1; // Check uniqueness if not replacing all if (!replace_all && occurrences > 1) { return `Error editing file: Multiple occurrences found - the old_string appears ${occurrences} times. Use replace_all: true to replace all occurrences, or provide more context to make the string unique.`; } // Perform the replacement let newContent; if (replace_all) { newContent = content.replaceAll(old_string, new_string); } else { newContent = content.replace(old_string, new_string); } // Check if replacement was made if (newContent === content) { return `Error editing file: No changes made - old_string and new_string might be the same`; } // Write the file back await fs.writeFile(resolvedPath, newContent, 'utf-8'); const replacedCount = replace_all ? occurrences : 1; if (debug) { console.error(`[Edit] Successfully edited ${resolvedPath}, replaced ${replacedCount} occurrence(s)`); } // Return success message as a string (matching other tools pattern) return `Successfully edited ${file_path} (${replacedCount} replacement${replacedCount !== 1 ? 's' : ''})`; } catch (error) { console.error('[Edit] Error:', error); return `Error editing file: ${error.message}`; } } }); }; /** * Create tool generator - Create new files * * @param {Object} [options] - Configuration options * @param {boolean} [options.debug=false] - Enable debug logging * @param {string[]} [options.allowedFolders] - Allowed directories for file operations * @param {string} [options.defaultPath] - Default working directory * @returns {Object} Configured create tool */ export const createTool = (options = {}) => { const { debug, allowedFolders, defaultPath } = parseFileToolOptions(options); return tool({ name: 'create', description: `Create new files with specified content. This tool creates new files in the filesystem. It will create parent directories if they don't exist. Parameters: - file_path: Path where the file should be created (absolute or relative) - content: Content to write to the file - overwrite: (optional) Whether to overwrite if file exists (default: false) Important: - By default, will fail if the file already exists - Set overwrite: true to replace existing files - Parent directories will be created automatically if needed`, inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'Path where the file should be created' }, content: { type: 'string', description: 'Content to write to the file' }, overwrite: { type: 'boolean', description: 'Overwrite if file exists (default: false)', default: false } }, required: ['file_path', 'content'] }, execute: async ({ file_path, content, overwrite = false }) => { try { // Validate input parameters if (!file_path || typeof file_path !== 'string' || file_path.trim() === '') { return `Error creating file: Invalid file_path - must be a non-empty string`; } if (content === undefined || content === null || typeof content !== 'string') { return `Error creating file: Invalid content - must be a string`; } // Resolve the file path const resolvedPath = isAbsolute(file_path) ? file_path : resolve(defaultPath || process.cwd(), file_path); if (debug) { console.error(`[Create] Attempting to create file: ${resolvedPath}`); } // Check if path is allowed if (!isPathAllowed(resolvedPath, allowedFolders)) { return `Error creating file: Permission denied - ${file_path} is outside allowed directories`; } // Check if file exists if (existsSync(resolvedPath) && !overwrite) { return `Error creating file: File already exists - ${file_path}. Use overwrite: true to replace it.`; } // Ensure parent directory exists const dir = dirname(resolvedPath); await fs.mkdir(dir, { recursive: true }); // Write the file await fs.writeFile(resolvedPath, content, 'utf-8'); const action = existsSync(resolvedPath) && overwrite ? 'overwrote' : 'created'; const bytes = Buffer.byteLength(content, 'utf-8'); if (debug) { console.error(`[Create] Successfully ${action} ${resolvedPath}`); } // Return success message as a string (matching other tools pattern) return `Successfully ${action} ${file_path} (${bytes} bytes)`; } catch (error) { console.error('[Create] Error:', error); return `Error creating file: ${error.message}`; } } }); }; // Export schemas for tool definitions export const editSchema = { type: 'object', properties: { file_path: { type: 'string', description: 'Path to the file to edit' }, old_string: { type: 'string', description: 'Exact text to find and replace' }, new_string: { type: 'string', description: 'Text to replace with' }, replace_all: { type: 'boolean', description: 'Replace all occurrences (default: false)' } }, required: ['file_path', 'old_string', 'new_string'] }; export const createSchema = { type: 'object', properties: { file_path: { type: 'string', description: 'Path where the file should be created' }, content: { type: 'string', description: 'Content to write to the file' }, overwrite: { type: 'boolean', description: 'Overwrite if file exists (default: false)' } }, required: ['file_path', 'content'] }; // Tool descriptions for XML definitions export const editDescription = 'Edit files using exact string replacement. Requires exact match including whitespace.'; export const createDescription = 'Create new files with specified content. Will create parent directories if needed.'; // XML tool definitions export const editToolDefinition = ` ## edit Description: ${editDescription} When to use: - For precise, surgical edits to existing files - When you need to change specific lines or blocks of code - For renaming functions, variables, or updating configuration values - When the exact text to replace is known and unique (or use replace_all for multiple occurrences) When NOT to use: - For creating new files (use 'create' tool instead) - When you cannot determine the exact text to replace - When changes span multiple locations that would be better handled together Parameters: - file_path: (required) Path to the file to edit - old_string: (required) Exact text to find and replace (must match including whitespace, newlines, and indentation) - new_string: (required) Text to replace with - replace_all: (optional, default: false) Replace all occurrences if the string appears multiple times Important notes: - The old_string MUST match EXACTLY, including all whitespace, indentation, and line breaks - If old_string appears multiple times and replace_all is false, the tool will fail - Always verify the exact formatting of the text you want to replace Examples: <edit> <file_path>src/main.js</file_path> <old_string>function oldName() { return 42; }</old_string> <new_string>function newName() { return 42; }</new_string> </edit> <edit> <file_path>config.json</file_path> <old_string>"debug": false</old_string> <new_string>"debug": true</new_string> <replace_all>true</replace_all> </edit>`; export const createToolDefinition = ` ## create Description: ${createDescription} When to use: - For creating brand new files from scratch - When you need to add configuration files, documentation, or new modules - For generating boilerplate code or templates - When you have the complete content ready to write When NOT to use: - For editing existing files (use 'edit' tool instead) - When a file already exists unless you explicitly want to overwrite it Parameters: - file_path: (required) Path where the file should be created - content: (required) Complete content to write to the file - overwrite: (optional, default: false) Whether to overwrite if file already exists Important notes: - Parent directories will be created automatically if they don't exist - The tool will fail if the file already exists and overwrite is false - Be careful with the overwrite option as it completely replaces existing files Examples: <create> <file_path>src/newFile.js</file_path> <content>export function hello() { return "Hello, world!"; }</content> </create> <create> <file_path>README.md</file_path> <content># My Project This is a new project.</content> <overwrite>true</overwrite> </create>`;