feat(tools): add file read/write/edit/list builtin tools

This commit is contained in:
William Valentin
2026-02-05 17:39:20 -08:00
parent b913941e4f
commit 32dd3ad728
7 changed files with 391 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import { readFileSync, writeFileSync } from 'fs';
import type { Tool, ToolResult } from '../types.js';
interface FileEditArgs {
path: string;
old_string: string;
new_string: string;
replace_all?: boolean;
}
export const fileEditTool: Tool = {
name: 'file.edit',
description: 'Edit a file by replacing an exact string match. Fails if old_string is not found or matches multiple times (unless replace_all is true).',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Absolute path to the file' },
old_string: { type: 'string', description: 'Exact string to find' },
new_string: { type: 'string', description: 'Replacement string' },
replace_all: { type: 'boolean', description: 'Replace all occurrences (default false)' },
},
required: ['path', 'old_string', 'new_string'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as FileEditArgs;
try {
const content = readFileSync(args.path, 'utf-8');
if (!content.includes(args.old_string)) {
return { success: false, output: '', error: `old_string not found in ${args.path}` };
}
const count = content.split(args.old_string).length - 1;
if (count > 1 && !args.replace_all) {
return { success: false, output: '', error: `old_string found multiple times (${count}). Use replace_all or provide more context.` };
}
const newContent = args.replace_all
? content.replaceAll(args.old_string, args.new_string)
: content.replace(args.old_string, args.new_string);
writeFileSync(args.path, newContent, 'utf-8');
return { success: true, output: `Edited ${args.path} (${count} replacement${count > 1 ? 's' : ''})` };
} catch (error) {
return { success: false, output: '', error: error instanceof Error ? error.message : String(error) };
}
},
};
+41
View File
@@ -0,0 +1,41 @@
import { readdirSync } from 'fs';
import type { Tool, ToolResult } from '../types.js';
interface FileListArgs {
path: string;
pattern?: string;
}
function matchGlob(name: string, pattern: string): boolean {
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
return regex.test(name);
}
export const fileListTool: Tool = {
name: 'file.list',
description: 'List files and directories in a given path. Optionally filter with a glob pattern.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Directory path to list' },
pattern: { type: 'string', description: 'Glob pattern to filter results (e.g. "*.ts")' },
},
required: ['path'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as FileListArgs;
try {
let entries = readdirSync(args.path, { withFileTypes: true });
if (args.pattern) {
entries = entries.filter(e => matchGlob(e.name, args.pattern!));
}
const output = entries
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
.sort()
.join('\n');
return { success: true, output };
} catch (error) {
return { success: false, output: '', error: error instanceof Error ? error.message : String(error) };
}
},
};
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from 'fs';
import type { Tool, ToolResult } from '../types.js';
interface FileReadArgs {
path: string;
offset?: number;
limit?: number;
}
export const fileReadTool: Tool = {
name: 'file.read',
description: 'Read the contents of a file. Optionally read specific lines with offset and limit.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Absolute path to the file' },
offset: { type: 'number', description: 'Line offset to start reading from (0-based)' },
limit: { type: 'number', description: 'Number of lines to read' },
},
required: ['path'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as FileReadArgs;
try {
const content = readFileSync(args.path, 'utf-8');
if (args.offset !== undefined || args.limit !== undefined) {
const lines = content.split('\n');
const start = args.offset ?? 0;
const end = args.limit !== undefined ? start + args.limit : lines.length;
return { success: true, output: lines.slice(start, end).join('\n') };
}
return { success: true, output: content };
} catch (error) {
return { success: false, output: '', error: error instanceof Error ? error.message : String(error) };
}
},
};
+31
View File
@@ -0,0 +1,31 @@
import { writeFileSync, mkdirSync } from 'fs';
import { dirname } from 'path';
import type { Tool, ToolResult } from '../types.js';
interface FileWriteArgs {
path: string;
content: string;
}
export const fileWriteTool: Tool = {
name: 'file.write',
description: 'Write content to a file. Creates the file and parent directories if they do not exist.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Absolute path to write to' },
content: { type: 'string', description: 'Content to write' },
},
required: ['path', 'content'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as FileWriteArgs;
try {
mkdirSync(dirname(args.path), { recursive: true });
writeFileSync(args.path, args.content, 'utf-8');
return { success: true, output: `Wrote ${args.content.length} bytes to ${args.path}` };
} catch (error) {
return { success: false, output: '', error: error instanceof Error ? error.message : String(error) };
}
},
};
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { fileReadTool } from './file-read.js';
import { fileWriteTool } from './file-write.js';
import { fileEditTool } from './file-edit.js';
import { fileListTool } from './file-list.js';
import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
let testDir: string;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), 'flynn-file-test-'));
});
afterEach(() => {
rmSync(testDir, { recursive: true });
});
describe('file.read', () => {
it('reads a file', async () => {
writeFileSync(join(testDir, 'hello.txt'), 'hello world');
const result = await fileReadTool.execute({ path: join(testDir, 'hello.txt') });
expect(result.success).toBe(true);
expect(result.output).toBe('hello world');
});
it('reads with offset and limit', async () => {
writeFileSync(join(testDir, 'lines.txt'), 'line1\nline2\nline3\nline4\n');
const result = await fileReadTool.execute({ path: join(testDir, 'lines.txt'), offset: 1, limit: 2 });
expect(result.success).toBe(true);
expect(result.output).toBe('line2\nline3');
});
it('returns error for missing file', async () => {
const result = await fileReadTool.execute({ path: join(testDir, 'nope.txt') });
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
});
describe('file.write', () => {
it('writes a new file', async () => {
const filePath = join(testDir, 'new.txt');
const result = await fileWriteTool.execute({ path: filePath, content: 'new content' });
expect(result.success).toBe(true);
expect(readFileSync(filePath, 'utf-8')).toBe('new content');
});
it('creates intermediate directories', async () => {
const filePath = join(testDir, 'sub', 'dir', 'file.txt');
const result = await fileWriteTool.execute({ path: filePath, content: 'deep' });
expect(result.success).toBe(true);
expect(readFileSync(filePath, 'utf-8')).toBe('deep');
});
});
describe('file.edit', () => {
it('replaces a string in a file', async () => {
const filePath = join(testDir, 'edit.txt');
writeFileSync(filePath, 'hello world');
const result = await fileEditTool.execute({
path: filePath,
old_string: 'world',
new_string: 'flynn',
});
expect(result.success).toBe(true);
expect(readFileSync(filePath, 'utf-8')).toBe('hello flynn');
});
it('fails if old_string not found', async () => {
const filePath = join(testDir, 'edit2.txt');
writeFileSync(filePath, 'hello world');
const result = await fileEditTool.execute({
path: filePath,
old_string: 'xyz',
new_string: 'abc',
});
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('fails if old_string matches multiple times without replace_all', async () => {
const filePath = join(testDir, 'edit3.txt');
writeFileSync(filePath, 'aaa bbb aaa');
const result = await fileEditTool.execute({
path: filePath,
old_string: 'aaa',
new_string: 'ccc',
});
expect(result.success).toBe(false);
expect(result.error).toContain('multiple');
});
it('replaces all when replace_all is true', async () => {
const filePath = join(testDir, 'edit4.txt');
writeFileSync(filePath, 'aaa bbb aaa');
const result = await fileEditTool.execute({
path: filePath,
old_string: 'aaa',
new_string: 'ccc',
replace_all: true,
});
expect(result.success).toBe(true);
expect(readFileSync(filePath, 'utf-8')).toBe('ccc bbb ccc');
});
});
describe('file.list', () => {
it('lists files in a directory', async () => {
writeFileSync(join(testDir, 'a.txt'), '');
writeFileSync(join(testDir, 'b.ts'), '');
mkdirSync(join(testDir, 'sub'));
writeFileSync(join(testDir, 'sub', 'c.txt'), '');
const result = await fileListTool.execute({ path: testDir });
expect(result.success).toBe(true);
expect(result.output).toContain('a.txt');
expect(result.output).toContain('b.ts');
expect(result.output).toContain('sub');
});
it('filters with glob pattern', async () => {
writeFileSync(join(testDir, 'a.txt'), '');
writeFileSync(join(testDir, 'b.ts'), '');
const result = await fileListTool.execute({ path: testDir, pattern: '*.ts' });
expect(result.success).toBe(true);
expect(result.output).toContain('b.ts');
expect(result.output).not.toContain('a.txt');
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { webFetchTool } from './web-fetch.js';
// Mock global fetch
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
beforeEach(() => {
mockFetch.mockReset();
});
describe('web.fetch', () => {
it('has correct metadata', () => {
expect(webFetchTool.name).toBe('web.fetch');
expect(webFetchTool.inputSchema.required).toContain('url');
});
it('fetches a URL and returns body text', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: async () => '<html><body><h1>Hello</h1><p>World</p></body></html>',
headers: new Headers({ 'content-type': 'text/html' }),
});
const result = await webFetchTool.execute({ url: 'https://example.com' });
expect(result.success).toBe(true);
expect(result.output).toBeTruthy();
expect(mockFetch).toHaveBeenCalledWith('https://example.com', expect.any(Object));
});
it('returns error on HTTP failure', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
text: async () => 'Not Found',
headers: new Headers(),
});
const result = await webFetchTool.execute({ url: 'https://example.com/nope' });
expect(result.success).toBe(false);
expect(result.error).toContain('404');
});
it('returns error on network failure', async () => {
mockFetch.mockRejectedValue(new Error('network error'));
const result = await webFetchTool.execute({ url: 'https://down.example.com' });
expect(result.success).toBe(false);
expect(result.error).toContain('network error');
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { Tool, ToolResult } from '../types.js';
interface WebFetchArgs {
url: string;
timeout?: number;
}
export const webFetchTool: Tool = {
name: 'web.fetch',
description: 'Fetch the content of a URL via HTTP GET. Returns the response body as text.',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string', description: 'The URL to fetch' },
timeout: { type: 'number', description: 'Timeout in milliseconds (default 15000)' },
},
required: ['url'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as WebFetchArgs;
const timeout = args.timeout ?? 15_000;
try {
const response = await fetch(args.url, {
signal: AbortSignal.timeout(timeout),
headers: {
'User-Agent': 'Flynn/0.1 (personal AI assistant)',
'Accept': 'text/html, application/json, text/plain, */*',
},
});
if (!response.ok) {
return {
success: false,
output: '',
error: `HTTP ${response.status}: ${await response.text()}`,
};
}
const body = await response.text();
return { success: true, output: body };
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};