Files
SuperCharged-Claude-Code-Up…/dexto/packages/tools-filesystem/src/edit-file-tool.ts
admin b52318eeae feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 00:27:56 +04:00

268 lines
11 KiB
TypeScript

/**
* Edit File Tool
*
* Internal tool for editing files by replacing text (requires approval)
*/
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { z } from 'zod';
import { createPatch } from 'diff';
import { InternalTool, ToolExecutionContext, ApprovalType } from '@dexto/core';
import type { DiffDisplayData, ApprovalRequestDetails, ApprovalResponse } from '@dexto/core';
import { ToolError } from '@dexto/core';
import { ToolErrorCode } from '@dexto/core';
import { DextoRuntimeError } from '@dexto/core';
import type { FileToolOptions } from './file-tool-types.js';
import { FileSystemErrorCode } from './error-codes.js';
/**
* Cache for content hashes between preview and execute phases.
* Keyed by toolCallId to ensure proper cleanup after execution.
* This prevents file corruption when user modifies file between preview and execute.
*/
const previewContentHashCache = new Map<string, string>();
/**
* Compute SHA-256 hash of content for change detection
*/
function computeContentHash(content: string): string {
return createHash('sha256').update(content, 'utf8').digest('hex');
}
const EditFileInputSchema = z
.object({
file_path: z.string().describe('Absolute path to the file to edit'),
old_string: z
.string()
.describe('Text to replace (must be unique unless replace_all is true)'),
new_string: z.string().describe('Replacement text'),
replace_all: z
.boolean()
.optional()
.default(false)
.describe('Replace all occurrences (default: false, requires unique match)'),
})
.strict();
type EditFileInput = z.input<typeof EditFileInputSchema>;
/**
* Generate diff preview without modifying the file
*/
function generateDiffPreview(
filePath: string,
originalContent: string,
newContent: string
): DiffDisplayData {
const unified = createPatch(filePath, originalContent, newContent, 'before', 'after', {
context: 3,
});
const additions = (unified.match(/^\+[^+]/gm) || []).length;
const deletions = (unified.match(/^-[^-]/gm) || []).length;
return {
type: 'diff',
unified,
filename: filePath,
additions,
deletions,
};
}
/**
* Create the edit_file internal tool with directory approval support
*/
export function createEditFileTool(options: FileToolOptions): InternalTool {
const { fileSystemService, directoryApproval } = options;
// Store parent directory for use in onApprovalGranted callback
let pendingApprovalParentDir: string | undefined;
return {
id: 'edit_file',
description:
'Edit a file by replacing text. By default, old_string must be unique in the file (will error if found multiple times). Set replace_all=true to replace all occurrences. Automatically creates backup before editing. Requires approval. Returns success status, path, number of changes made, and backup path.',
inputSchema: EditFileInputSchema,
/**
* Check if this edit operation needs directory access approval.
* Returns custom approval request if the file is outside allowed paths.
*/
getApprovalOverride: async (args: unknown): Promise<ApprovalRequestDetails | null> => {
const { file_path } = args as EditFileInput;
if (!file_path) return null;
// Check if path is within config-allowed paths (async for non-blocking symlink resolution)
const isAllowed = await fileSystemService.isPathWithinConfigAllowed(file_path);
if (isAllowed) {
return null; // Use normal tool confirmation
}
// Check if directory is already session-approved
if (directoryApproval?.isSessionApproved(file_path)) {
return null; // Already approved, use normal flow
}
// Need directory access approval
const absolutePath = path.resolve(file_path);
const parentDir = path.dirname(absolutePath);
pendingApprovalParentDir = parentDir;
return {
type: ApprovalType.DIRECTORY_ACCESS,
metadata: {
path: absolutePath,
parentDir,
operation: 'edit',
toolName: 'edit_file',
},
};
},
/**
* Handle approved directory access - remember the directory for session
*/
onApprovalGranted: (response: ApprovalResponse): void => {
if (!directoryApproval || !pendingApprovalParentDir) return;
// Check if user wants to remember the directory
// Use type assertion to access rememberDirectory since response.data is a union type
const data = response.data as { rememberDirectory?: boolean } | undefined;
const rememberDirectory = data?.rememberDirectory ?? false;
directoryApproval.addApproved(
pendingApprovalParentDir,
rememberDirectory ? 'session' : 'once'
);
// Clear pending state
pendingApprovalParentDir = undefined;
},
/**
* Generate preview for approval UI - shows diff without modifying file
* Throws ToolError.validationFailed() for validation errors (file not found, string not found)
* Stores content hash for change detection in execute phase.
*/
generatePreview: async (input: unknown, context?: ToolExecutionContext) => {
const { file_path, old_string, new_string, replace_all } = input as EditFileInput;
try {
// Read current file content
const originalFile = await fileSystemService.readFile(file_path);
const originalContent = originalFile.content;
// Store content hash for change detection in execute phase
if (context?.toolCallId) {
previewContentHashCache.set(
context.toolCallId,
computeContentHash(originalContent)
);
}
// Validate uniqueness constraint when replace_all is false
if (!replace_all) {
const occurrences = originalContent.split(old_string).length - 1;
if (occurrences > 1) {
throw ToolError.validationFailed(
'edit_file',
`String found ${occurrences} times in file. Set replace_all=true to replace all, or provide more context to make old_string unique.`,
{ file_path, occurrences }
);
}
}
// Compute what the new content would be
const newContent = replace_all
? originalContent.split(old_string).join(new_string)
: originalContent.replace(old_string, new_string);
// If no change, old_string was not found - throw validation error
if (originalContent === newContent) {
throw ToolError.validationFailed(
'edit_file',
`String not found in file: "${old_string.slice(0, 50)}${old_string.length > 50 ? '...' : ''}"`,
{ file_path, old_string_preview: old_string.slice(0, 100) }
);
}
return generateDiffPreview(file_path, originalContent, newContent);
} catch (error) {
// Re-throw validation errors as-is
if (
error instanceof DextoRuntimeError &&
error.code === ToolErrorCode.VALIDATION_FAILED
) {
throw error;
}
// Convert filesystem errors (file not found, etc.) to validation errors
if (error instanceof DextoRuntimeError) {
throw ToolError.validationFailed('edit_file', error.message, {
file_path,
originalErrorCode: error.code,
});
}
// Unexpected errors - return null to allow approval to proceed
return null;
}
},
execute: async (input: unknown, context?: ToolExecutionContext) => {
// Input is validated by provider before reaching here
const { file_path, old_string, new_string, replace_all } = input as EditFileInput;
// Check if file was modified since preview (safety check)
// This prevents corrupting user edits made between preview approval and execution
if (context?.toolCallId && previewContentHashCache.has(context.toolCallId)) {
const expectedHash = previewContentHashCache.get(context.toolCallId)!;
previewContentHashCache.delete(context.toolCallId); // Clean up regardless of outcome
// Read current content to verify it hasn't changed
let currentContent: string;
try {
const currentFile = await fileSystemService.readFile(file_path);
currentContent = currentFile.content;
} catch (error) {
// File was deleted between preview and execute - treat as modified
if (
error instanceof DextoRuntimeError &&
error.code === FileSystemErrorCode.FILE_NOT_FOUND
) {
throw ToolError.fileModifiedSincePreview('edit_file', file_path);
}
throw error;
}
const currentHash = computeContentHash(currentContent);
if (expectedHash !== currentHash) {
throw ToolError.fileModifiedSincePreview('edit_file', file_path);
}
}
// Edit file using FileSystemService
// Backup behavior is controlled by config.enableBackups (default: false)
// editFile returns originalContent and newContent, eliminating extra file reads
const result = await fileSystemService.editFile(file_path, {
oldString: old_string,
newString: new_string,
replaceAll: replace_all,
});
// Generate display data using content returned from editFile
const _display = generateDiffPreview(
file_path,
result.originalContent,
result.newContent
);
return {
success: result.success,
path: result.path,
changes_count: result.changesCount,
...(result.backupPath && { backup_path: result.backupPath }),
_display,
};
},
};
}