feat(tools): add file read/write/edit/list builtin tools
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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) };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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) };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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) };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user