@probelabs/probe
Version:
Node.js wrapper for the probe code search tool
409 lines (348 loc) • 14 kB
JavaScript
/**
* 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>`;