diff --git a/src/constants/prompt.ts b/src/constants/prompt.ts index 0f92264d..28527d6d 100644 --- a/src/constants/prompt.ts +++ b/src/constants/prompt.ts @@ -13,7 +13,7 @@ When tools return results, incorporate them into your response naturally`; export const TOOL_INSTRUCTIONS = `Available tools: - read_file: Read file contents at a path - write_file: Write content to a file (requires approval) -- edit_file: Make precise edits to a file +- edit_file: Replace one exact text match in a file (requires approval) - list_dir: List files in a directory - grep_search: Search code with regex - run_shell: Execute shell commands (requires approval) diff --git a/src/constants/tool.ts b/src/constants/tool.ts index ae97019a..abf19c74 100644 --- a/src/constants/tool.ts +++ b/src/constants/tool.ts @@ -1,6 +1,7 @@ export const NAME = { READ_FILE: 'read_file', WRITE_FILE: 'write_file', + EDIT_FILE: 'edit_file', RUN_SHELL: 'run_shell', LIST_DIR: 'list_dir', GREP_SEARCH: 'grep_search', diff --git a/src/utils/tools.test.ts b/src/utils/tools.test.ts index e13d7864..d5719865 100644 --- a/src/utils/tools.test.ts +++ b/src/utils/tools.test.ts @@ -18,9 +18,10 @@ describe('tools', () => { describe('TOOLS', () => { it('exports tool definitions', () => { - expect(TOOLS).toHaveLength(6); + expect(TOOLS).toHaveLength(7); expect(TOOLS.map((t) => t.function.name)).toContain('read_file'); expect(TOOLS.map((t) => t.function.name)).toContain('write_file'); + expect(TOOLS.map((t) => t.function.name)).toContain('edit_file'); expect(TOOLS.map((t) => t.function.name)).toContain('run_shell'); expect(TOOLS.map((t) => t.function.name)).toContain('list_dir'); expect(TOOLS.map((t) => t.function.name)).toContain('grep_search'); @@ -29,8 +30,9 @@ describe('tools', () => { }); describe('TOOLS_REQUIRING_APPROVAL', () => { - it('contains write_file and run_shell', () => { + it('contains write_file, edit_file, and run_shell', () => { expect(TOOLS_REQUIRING_APPROVAL.has('write_file')).toBe(true); + expect(TOOLS_REQUIRING_APPROVAL.has('edit_file')).toBe(true); expect(TOOLS_REQUIRING_APPROVAL.has('run_shell')).toBe(true); expect(TOOLS_REQUIRING_APPROVAL.has('read_file' as 'write_file')).toBe( false, @@ -66,6 +68,24 @@ describe('tools', () => { ); }); + it('executes edit_file tool', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('before target after'); + + const result = await executeTool('edit_file', { + path: '/test.txt', + oldText: 'target', + newText: 'updated', + }); + + expect(result.content).toContain('File edited successfully'); + expect(vi.mocked(writeFileSync)).toHaveBeenCalledWith( + '/test.txt', + 'before updated after', + 'utf8', + ); + }); + it('executes list_dir tool', async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readdirSync).mockReturnValue([ @@ -205,6 +225,88 @@ describe('tools', () => { }); }); + describe('editFile error handling', () => { + it('returns error when file does not exist', async () => { + vi.mocked(existsSync).mockReturnValue(false); + + const result = await executeTool('edit_file', { + path: '/missing.txt', + oldText: 'before', + newText: 'after', + }); + expect(result.error).toContain('File not found'); + }); + + it('returns error when text is not found', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('content'); + + const result = await executeTool('edit_file', { + path: '/test.txt', + oldText: 'missing', + newText: 'after', + }); + expect(result.error).toContain('Exact text not found'); + }); + + it('returns error when text matches multiple locations', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('repeat repeat'); + + const result = await executeTool('edit_file', { + path: '/test.txt', + oldText: 'repeat', + newText: 'after', + }); + expect(result.error).toContain('matched multiple locations'); + }); + + it('returns error when read fails', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const result = await executeTool('edit_file', { + path: '/test.txt', + oldText: 'before', + newText: 'after', + }); + expect(result.error).toContain('Failed to edit file'); + }); + + it('returns error when write fails', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('before'); + vi.mocked(writeFileSync).mockImplementation(() => { + throw new Error('Disk full'); + }); + + const result = await executeTool('edit_file', { + path: '/test.txt', + oldText: 'before', + newText: 'after', + }); + expect(result.error).toContain('Failed to edit file'); + }); + + it('handles non-Error exceptions in editFile', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'edit failed'; + }); + + const result = await executeTool('edit_file', { + path: '/test.txt', + oldText: 'before', + newText: 'after', + }); + expect(result.error).toContain('Failed to edit file'); + expect(result.error).toContain('edit failed'); + }); + }); + describe('listDir error handling', () => { it('returns error when directory does not exist', async () => { vi.mocked(existsSync).mockReturnValue(false); diff --git a/src/utils/tools.ts b/src/utils/tools.ts index dc865b5e..036aeefb 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -56,6 +56,23 @@ export const TOOLS = [ ['path', 'content'], ), + defineTool( + TOOL.NAME.EDIT_FILE, + 'Replace one exact text match in an existing file at the specified path', + { + path: { type: 'string', description: 'The path to the file to edit' }, + oldText: { + type: 'string', + description: 'The exact existing text to replace', + }, + newText: { + type: 'string', + description: 'The replacement text to write in place of oldText', + }, + }, + ['path', 'oldText', 'newText'], + ), + defineTool( TOOL.NAME.RUN_SHELL, 'Execute a shell command', @@ -111,6 +128,7 @@ export const TOOLS = [ // for safe mode export const TOOLS_REQUIRING_APPROVAL = new Set([ TOOL.NAME.WRITE_FILE, + TOOL.NAME.EDIT_FILE, TOOL.NAME.RUN_SHELL, ]); @@ -131,6 +149,12 @@ export async function executeTool( return readFile(args.path as string); case TOOL.NAME.WRITE_FILE: return writeFile(args.path as string, args.content as string); + case TOOL.NAME.EDIT_FILE: + return editFile( + args.path as string, + args.oldText as string, + args.newText as string, + ); case TOOL.NAME.RUN_SHELL: return runShell(args.command as string); case TOOL.NAME.LIST_DIR: @@ -181,6 +205,47 @@ function writeFile(filePath: string, content: string): ToolExecutionResult { } } +/** + * Replace one exact text match in an existing file + */ +function editFile( + filePath: string, + oldText: string, + newText: string, +): ToolExecutionResult { + try { + if (!existsSync(filePath)) { + return { content: '', error: `File not found: ${filePath}` }; + } + + const content = readFileSync(filePath, 'utf8'); + + if (!content.includes(oldText)) { + return { + content: '', + error: `Exact text not found in file: ${filePath}`, + }; + } + + const matchCount = content.split(oldText).length - 1; + if (matchCount > 1) { + return { + content: '', + error: `Exact text matched multiple locations in file: ${filePath}`, + }; + } + + const updatedContent = content.replace(oldText, newText); + writeFileSync(filePath, updatedContent, 'utf8'); + return { content: `File edited successfully: ${filePath}` }; + } catch (error) { + return { + content: '', + error: `Failed to edit file: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + // Shared shell execution options const SHELL_EXEC_OPTIONS = { timeout: 30000,