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>
This commit is contained in:
267
dexto/packages/tools-filesystem/src/edit-file-tool.ts
Normal file
267
dexto/packages/tools-filesystem/src/edit-file-tool.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user