diff --git a/src/gadgets/EditFile.ts b/src/gadgets/EditFile.ts index 41f1b636..bc7005c3 100644 --- a/src/gadgets/EditFile.ts +++ b/src/gadgets/EditFile.ts @@ -53,21 +53,59 @@ function validatePath(inputPath: string): string { export class EditFile extends Gadget({ name: 'EditFile', - description: `Edit a file by searching for content and replacing it. + description: `Edit a file using one of three modes: -For multiple edits to the same file, call this gadget multiple times. -Each call provides immediate feedback, allowing you to adjust subsequent edits.`, +**search_replace**: Search for content and replace it. +- Uses layered matching: exact → whitespace → indentation → fuzzy +- Reduces edit errors by ~9x + +**insert_at_line**: Insert content at a specific line number. +- Line numbers are 1-based +- Content is inserted BEFORE the specified line +- Use line beyond EOF to append at end + +**remove_lines**: Remove a range of lines from the file. +- Line numbers are 1-based and inclusive + +For multiple edits, call this gadget multiple times.`, timeoutMs: 30000, maxConcurrent: 1, // Sequential execution to prevent race conditions on file writes - schema: z.object({ - comment: z.string().min(1).describe('Brief rationale for this gadget call'), - filePath: z.string().describe('Path to the file to edit (relative or absolute)'), - search: z.string().describe('The content to search for in the file'), - replace: z.string().describe('The content to replace it with (empty string to delete)'), - }), + schema: z.discriminatedUnion('mode', [ + // Mode 1: search_replace (current behavior) + z.object({ + mode: z.literal('search_replace'), + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + filePath: z.string().describe('Path to the file to edit'), + search: z.string().min(1).describe('The content to search for'), + replace: z.string().describe('The content to replace with (empty to delete)'), + }), + + // Mode 2: insert_at_line + z.object({ + mode: z.literal('insert_at_line'), + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + filePath: z.string().describe('Path to the file to edit'), + line: z + .number() + .int() + .min(1) + .describe('Line number to insert BEFORE (1-based). Use line beyond EOF to append.'), + content: z.string().describe('Content to insert (can be multiline)'), + }), + + // Mode 3: remove_lines + z.object({ + mode: z.literal('remove_lines'), + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + filePath: z.string().describe('Path to the file to edit'), + startLine: z.number().int().min(1).describe('First line to remove (1-based, inclusive)'), + endLine: z.number().int().min(1).describe('Last line to remove (1-based, inclusive)'), + }), + ]), examples: [ { params: { + mode: 'search_replace' as const, comment: 'Increasing timeout from 1s to 5s to fix test flakiness', filePath: 'src/config.ts', search: 'timeout: 1000', @@ -105,6 +143,7 @@ No lint issues found.`, }, { params: { + mode: 'search_replace' as const, comment: 'Updating retry constant per requirements', filePath: 'src/constants.ts', search: 'MAX_RETRIES = 3', @@ -153,6 +192,7 @@ No lint issues found.`, }, { params: { + mode: 'search_replace' as const, comment: 'Enabling feature flag for new functionality', filePath: 'src/data.json', search: '"enabled": false', @@ -178,24 +218,234 @@ Replaced 1 occurrence. 5 | }`, comment: 'Non-TypeScript file (no diagnostics appended)', }, + // insert_at_line examples + { + params: { + mode: 'insert_at_line' as const, + comment: 'Adding lodash import at top of file', + filePath: 'src/utils.ts', + line: 1, + content: "import _ from 'lodash';", + }, + output: `path=src/utils.ts mode=insert_at_line status=success + +Inserted 1 line at line 1. + +--- BEFORE (around line 1) --- +> 1 | import { foo } from 'bar'; + 2 | import { baz } from 'qux'; + 3 | + +--- AFTER --- +> 1 | import _ from 'lodash'; + 2 | import { foo } from 'bar'; + 3 | import { baz } from 'qux'; + 4 | + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Insert import at beginning of file', + }, + { + params: { + mode: 'insert_at_line' as const, + comment: 'Adding helper function in middle of file', + filePath: 'src/helpers.ts', + line: 10, + content: 'function validate(x: number): boolean {\n return x > 0;\n}', + }, + output: `path=src/helpers.ts mode=insert_at_line status=success + +Inserted 3 lines at line 10. + +--- BEFORE (around line 10) --- + 7 | } + 8 | + 9 | // Utils below +> 10 | function process(data: string) { + 11 | return data.trim(); + 12 | } + +--- AFTER --- + 7 | } + 8 | + 9 | // Utils below +> 10 | function validate(x: number): boolean { + 11 | return x > 0; + 12 | } + 13 | function process(data: string) { + 14 | return data.trim(); + 15 | } + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Insert multiline block in middle of file', + }, + { + params: { + mode: 'insert_at_line' as const, + comment: 'Appending export at end of file', + filePath: 'src/index.ts', + line: 999, + content: "export * from './newModule';", + }, + output: `path=src/index.ts mode=insert_at_line status=success + +Appended 1 line at end of file. + +--- BEFORE (end of file) --- + 3 | export * from './utils'; + 4 | export * from './helpers'; + 5 | + +--- AFTER --- + 3 | export * from './utils'; + 4 | export * from './helpers'; + 5 | +> 6 | export * from './newModule'; + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Append at end of file (line beyond EOF)', + }, + // remove_lines examples + { + params: { + mode: 'remove_lines' as const, + comment: 'Removing unused import', + filePath: 'src/app.ts', + startLine: 3, + endLine: 3, + }, + output: `path=src/app.ts mode=remove_lines status=success + +Removed 1 line (line 3). + +--- BEFORE --- + 1 | import { foo } from 'foo'; + 2 | import { bar } from 'bar'; +> 3 | import { unused } from 'unused'; + 4 | import { baz } from 'baz'; + 5 | + +--- AFTER --- + 1 | import { foo } from 'foo'; + 2 | import { bar } from 'bar'; + 3 | import { baz } from 'baz'; + 4 | + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Remove single line', + }, + { + params: { + mode: 'remove_lines' as const, + comment: 'Removing deprecated function', + filePath: 'src/legacy.ts', + startLine: 5, + endLine: 10, + }, + output: `path=src/legacy.ts mode=remove_lines status=success + +Removed 6 lines (lines 5-10). + +--- BEFORE --- + 2 | + 3 | export function keepThis() {} + 4 | +> 5 | /** @deprecated */ +> 6 | function oldFunc(x: number) { +> 7 | console.log('deprecated'); +> 8 | return x * 2; +> 9 | } +> 10 | + 11 | export function keepThisToo() {} + +--- AFTER --- + 2 | + 3 | export function keepThis() {} + 4 | + 5 | export function keepThisToo() {} + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Remove block of lines', + }, + { + params: { + mode: 'remove_lines' as const, + comment: 'Removing comment block from config', + filePath: 'config/settings.json', + startLine: 2, + endLine: 4, + }, + output: `path=config/settings.json mode=remove_lines status=success + +Removed 3 lines (lines 2-4). + +--- BEFORE --- + 1 | { +> 2 | "// NOTE": "Remove this later", +> 3 | "// TODO": "Clean up config", +> 4 | "// FIXME": "Legacy value", + 5 | "enabled": true, + 6 | "timeout": 5000 + 7 | } + +--- AFTER --- + 1 | { + 2 | "enabled": true, + 3 | "timeout": 5000 + 4 | }`, + comment: 'Remove from non-TS file (no diagnostics)', + }, ], }) { override execute(params: this['params']): string { - const { filePath, search, replace } = params; - - // Validate search is not empty - if (search.trim() === '') { - return `path=${filePath} status=error\n\nError: Search content cannot be empty.`; + // Validate and resolve path (shared across all modes) + const validatedPath = validatePath(params.filePath); + + // Dispatch to mode handler + switch (params.mode) { + case 'search_replace': + return this.handleSearchReplace( + params as Extract, + validatedPath, + ); + case 'insert_at_line': + return this.handleInsertAtLine( + params as Extract, + validatedPath, + ); + case 'remove_lines': + return this.handleRemoveLines( + params as Extract, + validatedPath, + ); } + } - // Validate and resolve path - let validatedPath: string; - try { - validatedPath = validatePath(filePath); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `path=${filePath} status=error\n\nError: ${message}`; - } + private handleSearchReplace( + params: Extract, + validatedPath: string, + ): string { + const { filePath, search, replace } = params; // Read file content let content: string; @@ -243,17 +493,11 @@ Replaced 1 occurrence. } // Write file - try { - writeFileSync(validatedPath, newContent, 'utf-8'); - // Invalidate read tracking so subsequent reads return fresh content - invalidateFileRead(validatedPath); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `path=${filePath} status=error\n\nError writing file: ${message}`; - } + writeFileSync(validatedPath, newContent, 'utf-8'); + invalidateFileRead(validatedPath); // Build and return success output - return this.buildSuccessOutput( + return this.buildSearchReplaceOutput( filePath, validatedPath, matches, @@ -263,7 +507,148 @@ Replaced 1 occurrence. ); } - private buildSuccessOutput( + private handleInsertAtLine( + params: Extract, + validatedPath: string, + ): string { + const { filePath, line, content: insertContent } = params; + + // Read file content + let content: string; + try { + content = readFileSync(validatedPath, 'utf-8'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + // For insert, create empty file + content = ''; + } else { + throw error; + } + } + + const lines = content.split('\n'); + const insertLines = insertContent.split('\n'); + const insertAtEnd = line > lines.length; + const effectiveLine = Math.min(line, lines.length + 1); + + // Store before context + const beforeContext = this.formatContext(lines, effectiveLine, effectiveLine, 3); + + // Insert lines + const newLines = [ + ...lines.slice(0, effectiveLine - 1), + ...insertLines, + ...lines.slice(effectiveLine - 1), + ]; + + const newContent = newLines.join('\n'); + writeFileSync(validatedPath, newContent, 'utf-8'); + invalidateFileRead(validatedPath); + + // Build output + const output: string[] = [`path=${filePath} mode=insert_at_line status=success`, '']; + + if (insertAtEnd) { + output.push( + `Appended ${insertLines.length} line${insertLines.length > 1 ? 's' : ''} at end of file.`, + ); + } else { + output.push( + `Inserted ${insertLines.length} line${insertLines.length > 1 ? 's' : ''} at line ${effectiveLine}.`, + ); + } + + output.push( + '', + insertAtEnd + ? '--- BEFORE (end of file) ---' + : `--- BEFORE (around line ${effectiveLine}) ---`, + beforeContext || '(empty file)', + '', + '--- AFTER ---', + this.formatContext(newLines, effectiveLine, effectiveLine + insertLines.length - 1, 3), + ); + + if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) { + output.push('', this.runDiagnostics(validatedPath)); + } + + return output.join('\n'); + } + + private handleRemoveLines( + params: Extract, + validatedPath: string, + ): string { + const { filePath, startLine, endLine } = params; + + // Validate line range + if (startLine > endLine) { + throw new Error(`Invalid line range: startLine (${startLine}) > endLine (${endLine})`); + } + + // Read file content + let content: string; + try { + content = readFileSync(validatedPath, 'utf-8'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + throw new Error(`File not found: ${filePath}`); + } + throw error; + } + + const lines = content.split('\n'); + + // Validate startLine + if (startLine > lines.length) { + throw new Error(`startLine (${startLine}) is beyond end of file (${lines.length} lines)`); + } + + const effectiveEndLine = Math.min(endLine, lines.length); + const removedCount = effectiveEndLine - startLine + 1; + + // Store before context with highlight on lines to remove + const beforeContext = this.formatContext(lines, startLine, effectiveEndLine, 3); + + // Remove lines + const newLines = [...lines.slice(0, startLine - 1), ...lines.slice(effectiveEndLine)]; + + const newContent = newLines.join('\n'); + writeFileSync(validatedPath, newContent, 'utf-8'); + invalidateFileRead(validatedPath); + + // Build output + const lineDesc = + removedCount === 1 ? `line ${startLine}` : `lines ${startLine}-${effectiveEndLine}`; + + const output: string[] = [ + `path=${filePath} mode=remove_lines status=success`, + '', + `Removed ${removedCount} line${removedCount > 1 ? 's' : ''} (${lineDesc}).`, + '', + '--- BEFORE ---', + beforeContext, + '', + '--- AFTER ---', + this.formatContext( + newLines, + Math.max(1, startLine - 1), + Math.min(newLines.length, startLine), + 3, + ) || '(empty file)', + ]; + + if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) { + output.push('', this.runDiagnostics(validatedPath)); + } + + return output.join('\n'); + } + + private buildSearchReplaceOutput( filePath: string, validatedPath: string, matches: Array<{ diff --git a/src/gadgets/ReadFile.ts b/src/gadgets/ReadFile.ts index a9309601..ec576b6e 100644 --- a/src/gadgets/ReadFile.ts +++ b/src/gadgets/ReadFile.ts @@ -63,25 +63,45 @@ Allowed paths: .describe( 'Path to the file to read (relative or absolute). ONLY VALID PATHS ALLOWED. Use ListDirectory to confirm full path.', ), + showLineNumbers: z + .boolean() + .optional() + .default(false) + .describe('If true, prefix each line with its 1-based line number (e.g., " 1 | content")'), }), examples: [ { params: { comment: 'Reading config to understand project structure', filePath: 'package.json', + showLineNumbers: false, }, output: 'path=package.json\n\n{\n "name": "my-project",\n "version": "1.0.0"\n ...\n}', comment: 'Read a JSON config file', }, { - params: { comment: 'Checking test output for failures', filePath: '/tmp/test.log' }, + params: { + comment: 'Checking test output for failures', + filePath: '/tmp/test.log', + showLineNumbers: false, + }, output: 'path=/tmp/test.log\n\n[Test output...]', comment: 'Read a test log from /tmp', }, + { + params: { + comment: 'Reading with line numbers for precise editing', + filePath: 'src/utils.ts', + showLineNumbers: true, + }, + output: + 'path=src/utils.ts\n\n 1 | export function add(a: number, b: number) {\n 2 | return a + b;\n 3 | }', + comment: 'Read with line numbers for insert_at_line or remove_lines operations', + }, ], }) { override execute(params: this['params']): string { - const { filePath } = params; + const { filePath, showLineNumbers = false } = params; const validatedPath = validatePath(filePath); // Check if already read in this session (content is in context) @@ -89,8 +109,19 @@ Allowed paths: return `path=${filePath}\n\n[Already read - refer to previous content in context]`; } - const content = readFileSync(validatedPath, 'utf-8'); + let content = readFileSync(validatedPath, 'utf-8'); markFileRead(validatedPath); + + if (showLineNumbers) { + content = this.addLineNumbers(content); + } + return `path=${filePath}\n\n${content}`; } + + private addLineNumbers(content: string): string { + const lines = content.split('\n'); + const width = String(lines.length).length; + return lines.map((line, i) => `${String(i + 1).padStart(width)} | ${line}`).join('\n'); + } }