From 32dd3ad7283b9404f07e20362988dc2a9da23d31 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 5 Feb 2026 17:39:20 -0800 Subject: [PATCH] feat(tools): add file read/write/edit/list builtin tools --- src/tools/builtin/file-edit.ts | 48 ++++++++++ src/tools/builtin/file-list.ts | 41 +++++++++ src/tools/builtin/file-read.ts | 37 ++++++++ src/tools/builtin/file-write.ts | 31 +++++++ src/tools/builtin/file.test.ts | 132 ++++++++++++++++++++++++++++ src/tools/builtin/web-fetch.test.ts | 52 +++++++++++ src/tools/builtin/web-fetch.ts | 50 +++++++++++ 7 files changed, 391 insertions(+) create mode 100644 src/tools/builtin/file-edit.ts create mode 100644 src/tools/builtin/file-list.ts create mode 100644 src/tools/builtin/file-read.ts create mode 100644 src/tools/builtin/file-write.ts create mode 100644 src/tools/builtin/file.test.ts create mode 100644 src/tools/builtin/web-fetch.test.ts create mode 100644 src/tools/builtin/web-fetch.ts diff --git a/src/tools/builtin/file-edit.ts b/src/tools/builtin/file-edit.ts new file mode 100644 index 0000000..6b6f91f --- /dev/null +++ b/src/tools/builtin/file-edit.ts @@ -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 => { + 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) }; + } + }, +}; diff --git a/src/tools/builtin/file-list.ts b/src/tools/builtin/file-list.ts new file mode 100644 index 0000000..c3cce88 --- /dev/null +++ b/src/tools/builtin/file-list.ts @@ -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 => { + 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) }; + } + }, +}; diff --git a/src/tools/builtin/file-read.ts b/src/tools/builtin/file-read.ts new file mode 100644 index 0000000..40b33ce --- /dev/null +++ b/src/tools/builtin/file-read.ts @@ -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 => { + 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) }; + } + }, +}; diff --git a/src/tools/builtin/file-write.ts b/src/tools/builtin/file-write.ts new file mode 100644 index 0000000..1928a6b --- /dev/null +++ b/src/tools/builtin/file-write.ts @@ -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 => { + 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) }; + } + }, +}; diff --git a/src/tools/builtin/file.test.ts b/src/tools/builtin/file.test.ts new file mode 100644 index 0000000..7232a5b --- /dev/null +++ b/src/tools/builtin/file.test.ts @@ -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'); + }); +}); diff --git a/src/tools/builtin/web-fetch.test.ts b/src/tools/builtin/web-fetch.test.ts new file mode 100644 index 0000000..9705e84 --- /dev/null +++ b/src/tools/builtin/web-fetch.test.ts @@ -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 () => '

Hello

World

', + 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'); + }); +}); diff --git a/src/tools/builtin/web-fetch.ts b/src/tools/builtin/web-fetch.ts new file mode 100644 index 0000000..9c9f04c --- /dev/null +++ b/src/tools/builtin/web-fetch.ts @@ -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 => { + 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), + }; + } + }, +};