import { readFile, readdir, stat } from 'node:fs/promises'; import { resolve, basename, relative } from 'node:path'; import { z } from 'zod'; import { pathGuard, PathScopeError } from './path_guard.js'; import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js'; const MAX_FILE_BYTES = 5 * 1024 * 1024; const DEFAULT_VIEW_LINES = 200; const MAX_GREP_RESULTS = 200; const DEFAULT_GREP_RESULTS = 100; const MAX_FIND_RESULTS = 200; const DEFAULT_FIND_RESULTS = 100; const MAX_DIR_ENTRIES = 500; export interface ToolJsonSchema { type: 'function'; function: { name: string; description: string; parameters: Record; }; } export interface ToolDef { name: string; description: string; inputSchema: z.ZodType; jsonSchema: ToolJsonSchema; execute(input: TInput, projectRoot: string): Promise; } const ViewFileInput = z.object({ path: z.string().min(1), start_line: z.number().int().positive().optional(), end_line: z.number().int().positive().optional(), }); type ViewFileInputT = z.infer; export const viewFile: ToolDef = { name: 'view_file', description: "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused. Output is truncated if longer than the slice; the response indicates truncation.", inputSchema: ViewFileInput, jsonSchema: { type: 'function', function: { name: 'view_file', description: "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused.", parameters: { type: 'object', properties: { path: { type: 'string', description: 'absolute or project-relative path' }, start_line: { type: 'integer', description: 'first line (1-indexed)' }, end_line: { type: 'integer', description: 'last line (1-indexed, inclusive)' }, }, required: ['path'], additionalProperties: false, }, }, }, async execute(input, projectRoot) { const real = await pathGuard(projectRoot, input.path); const s = await stat(real); if (!s.isFile()) { throw new PathScopeError(`not a file: ${input.path}`); } if (s.size > MAX_FILE_BYTES) { throw new Error(`file too large (${s.size} bytes, max ${MAX_FILE_BYTES})`); } const raw = await readFile(real, 'utf8'); const lines = raw.split('\n'); const total = lines.length; let start = input.start_line ?? 1; let end = input.end_line ?? Math.min(total, start + DEFAULT_VIEW_LINES - 1); if (input.start_line == null && input.end_line == null) { end = Math.min(total, DEFAULT_VIEW_LINES); } if (start < 1) start = 1; if (end > total) end = total; if (end < start) end = start; const slice = lines.slice(start - 1, end); const content = slice.join('\n'); const truncated = total > end || start > 1; return { path: relative(projectRoot, real) || basename(real), content, total_lines: total, returned_lines: [start, end], truncated, }; }, }; const ListDirInput = z.object({ path: z.string().min(1), show_hidden: z.boolean().optional(), }); type ListDirInputT = z.infer; export const listDir: ToolDef = { name: 'list_dir', description: 'List entries in a directory (up to 500). Hidden files excluded unless show_hidden=true.', inputSchema: ListDirInput, jsonSchema: { type: 'function', function: { name: 'list_dir', description: 'List entries in a directory (up to 500). Hidden files (dot-prefixed) excluded unless show_hidden=true.', parameters: { type: 'object', properties: { path: { type: 'string' }, show_hidden: { type: 'boolean' }, }, required: ['path'], additionalProperties: false, }, }, }, async execute(input, projectRoot) { const real = await pathGuard(projectRoot, input.path); const s = await stat(real); if (!s.isDirectory()) { throw new PathScopeError(`not a directory: ${input.path}`); } const entries = await readdir(real, { withFileTypes: true }); const filtered = input.show_hidden ? entries : entries.filter((e) => !e.name.startsWith('.')); const total = filtered.length; const slice = filtered.slice(0, MAX_DIR_ENTRIES); const out = await Promise.all( slice.map(async (e) => { const child = resolve(real, e.name); let size: number | undefined; if (e.isFile()) { try { const cs = await stat(child); size = cs.size; } catch { /* ignore */ } } return { name: e.name, type: e.isDirectory() ? ('dir' as const) : ('file' as const), ...(size != null ? { size } : {}), }; }) ); return { path: relative(projectRoot, real) || '.', entries: out, total, truncated: total > MAX_DIR_ENTRIES, }; }, }; const GrepInput = z.object({ pattern: z.string().min(1), path: z.string().optional(), case_sensitive: z.boolean().optional(), max_results: z.number().int().positive().optional(), hidden: z.boolean().optional(), }); type GrepInputT = z.infer; export const grep: ToolDef = { name: 'grep', description: 'Search file contents with ripgrep. Default path is project root. Max 100 results (200 cap).', inputSchema: GrepInput, jsonSchema: { type: 'function', function: { name: 'grep', description: 'Search file contents with ripgrep. Returns up to 100 matches (cap 200). Set hidden=true to include dot-prefixed files.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, case_sensitive: { type: 'boolean' }, max_results: { type: 'integer' }, hidden: { type: 'boolean' }, }, required: ['pattern'], additionalProperties: false, }, }, }, async execute(input, projectRoot) { const limit = Math.min( Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1), MAX_GREP_RESULTS ); // Delegate to file_ops.grep; reshape match objects to preserve LLM output format // (file_ops uses {path, line, text}; tool output uses {path, line, content}) const result = await fileOpsGrep(projectRoot, input.pattern, { path: input.path, max_matches: limit, case_sensitive: input.case_sensitive, hidden: input.hidden, }); return { matches: result.matches.map((m) => ({ path: m.path, line: m.line, content: m.text, })), total: result.matches.length, truncated: result.truncated, }; }, }; const FindFilesInput = z.object({ pattern: z.string().min(1), path: z.string().optional(), max_results: z.number().int().positive().optional(), }); type FindFilesInputT = z.infer; export const findFiles: ToolDef = { name: 'find_files', description: 'Glob for filenames. Default path is project root. Max 100 results (200 cap).', inputSchema: FindFilesInput, jsonSchema: { type: 'function', function: { name: 'find_files', description: 'Glob for filenames under a directory. Default path is project root. Max 100 results (cap 200). Pattern uses standard glob (e.g. "**/*.ts").', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, max_results: { type: 'integer' }, }, required: ['pattern'], additionalProperties: false, }, }, }, async execute(input, projectRoot) { const limit = Math.min( Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1), MAX_FIND_RESULTS ); // Delegate to file_ops.findFiles; reshape { files, total, truncated } to // preserve the LLM-visible output format { paths, total, truncated } const result = await fileOpsFindFiles(projectRoot, input.pattern, { path: input.path, max_results: limit, }); return { paths: result.files, total: result.total, truncated: result.truncated, }; }, }; export const ALL_TOOLS: ReadonlyArray> = [ viewFile as ToolDef, listDir as ToolDef, grep as ToolDef, findFiles as ToolDef, ]; export const TOOLS_BY_NAME: Record> = Object.fromEntries( ALL_TOOLS.map((t) => [t.name, t]) ); export function toolJsonSchemas(): ToolJsonSchema[] { return ALL_TOOLS.map((t) => t.jsonSchema); }