Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/constants/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/constants/tool.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
106 changes: 104 additions & 2 deletions src/utils/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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,
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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);
Expand Down
65 changes: 65 additions & 0 deletions src/utils/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@
['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',
Expand Down Expand Up @@ -111,6 +128,7 @@
// for safe mode
export const TOOLS_REQUIRING_APPROVAL = new Set([
TOOL.NAME.WRITE_FILE,
TOOL.NAME.EDIT_FILE,
TOOL.NAME.RUN_SHELL,
]);

Expand All @@ -131,6 +149,12 @@
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:
Expand Down Expand Up @@ -181,6 +205,47 @@
}
}

/**
* 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');

Check failure

Code scanning / CodeQL

Potential file system race condition High

The file may have changed since it
was checked
.
Comment thread
remarkablemark marked this conversation as resolved.
Dismissed
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,
Expand Down
Loading