/** * Read tool — fetches a file's contents, optionally with line-range slicing. * * Output mirrors what Claude SDK's Read tool produces so the model — which * was trained against that format — uses it correctly. Line numbers are * prefixed with 1-based indices padded for alignment. */ import fs from 'fs'; import path from 'path'; import type { PiTool } from './types.js'; import { safeResolve, displayPath } from './path-safety.js'; const MAX_BYTES = 256 * 1024; // 256 KB cap per read const DEFAULT_LIMIT = 2000; // default line cap function formatWithLineNumbers(text: string, startLine: number): string { const lines = text.split('\n'); return lines.map((line, i) => { const n = String(startLine + i).padStart(6, ' '); return `${n}\t${line}`; }).join('\n'); } export const readTool: PiTool = { name: 'Read', description: 'Read a file from the workspace. Use this to inspect existing code, configuration, or data files.', inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'Path to the file. Relative paths resolve against the workspace root.' }, offset: { type: 'integer', description: '1-based line number to start at (default 1).', minimum: 1 }, limit: { type: 'integer', description: 'How many lines to return (default 2000, max 2000).', minimum: 1 }, }, required: ['file_path'], }, async run(input, ctx) { const filePath = input?.file_path; let abs: string; try { abs = safeResolve(ctx.cwd, filePath); } catch (err: any) { return { output: err.message, isError: true }; } if (!fs.existsSync(abs)) { return { output: `File not found: ${displayPath(ctx.cwd, abs)}`, isError: true }; } const stat = fs.statSync(abs); if (stat.isDirectory()) { return { output: `Path is a directory, not a file: ${displayPath(ctx.cwd, abs)}`, isError: true }; } if (stat.size > MAX_BYTES) { return { output: `File too large (${stat.size} bytes; max ${MAX_BYTES}). Use a smaller range with offset/limit.`, isError: true, }; } const raw = fs.readFileSync(abs, 'utf-8'); const allLines = raw.split('\n'); const offset = Math.max(1, Number(input?.offset) || 1); const limit = Math.min(DEFAULT_LIMIT, Math.max(1, Number(input?.limit) || DEFAULT_LIMIT)); const slice = allLines.slice(offset - 1, offset - 1 + limit).join('\n'); const truncatedNote = (offset - 1 + limit) < allLines.length ? `\n\n[Truncated — file has ${allLines.length} lines; showed ${offset}–${offset + limit - 1}.]` : ''; if (!slice.trim()) { return { output: `(file ${displayPath(ctx.cwd, abs)} is empty${truncatedNote ? ` past line ${offset}` : ''})` }; } return { output: formatWithLineNumbers(slice, offset) + truncatedNote }; }, };