diff options
Diffstat (limited to 'packages/tool-read-file/src/read-file.ts')
| -rw-r--r-- | packages/tool-read-file/src/read-file.ts | 330 |
1 files changed, 165 insertions, 165 deletions
diff --git a/packages/tool-read-file/src/read-file.ts b/packages/tool-read-file/src/read-file.ts index b88c241..b77f63c 100644 --- a/packages/tool-read-file/src/read-file.ts +++ b/packages/tool-read-file/src/read-file.ts @@ -6,68 +6,68 @@ const DEFAULT_LIMIT = 500; const HARD_CAP = 5000; interface ValidatedArgs { - readonly path: string; - readonly offset: number; - readonly limit: number; + readonly path: string; + readonly offset: number; + readonly limit: number; } /** Pure: validate and coerce args from the model. */ export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object." }; - } - const obj = args as Record<string, unknown>; - - const rawPath = obj.path; - if (typeof rawPath !== "string" || rawPath.length === 0) { - return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; - } - - let offset = 1; - if (obj.offset !== undefined) { - const n = Number(obj.offset); - if (!Number.isFinite(n) || n < 1) { - return { error: 'Error: Invalid "offset" parameter (must be a positive integer).' }; - } - offset = Math.floor(n); - } - - let limit = DEFAULT_LIMIT; - if (obj.limit !== undefined) { - const n = Number(obj.limit); - if (!Number.isFinite(n) || n < 1) { - return { error: 'Error: Invalid "limit" parameter (must be a positive integer).' }; - } - limit = Math.min(Math.floor(n), HARD_CAP); - } else { - limit = Math.min(limit, HARD_CAP); - } - - return { path: rawPath, offset, limit }; + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record<string, unknown>; + + const rawPath = obj.path; + if (typeof rawPath !== "string" || rawPath.length === 0) { + return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; + } + + let offset = 1; + if (obj.offset !== undefined) { + const n = Number(obj.offset); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "offset" parameter (must be a positive integer).' }; + } + offset = Math.floor(n); + } + + let limit = DEFAULT_LIMIT; + if (obj.limit !== undefined) { + const n = Number(obj.limit); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "limit" parameter (must be a positive integer).' }; + } + limit = Math.min(Math.floor(n), HARD_CAP); + } else { + limit = Math.min(limit, HARD_CAP); + } + + return { path: rawPath, offset, limit }; } /** Pure: slice lines from content (1-indexed offset). */ export function sliceLines( - content: string, - offset: number, - limit: number, + content: string, + offset: number, + limit: number, ): { readonly lines: readonly string[]; readonly totalLines: number } { - const allLines = content.split("\n"); - const totalLines = allLines.length; - const start = offset - 1; // convert to 0-indexed - const sliced = allLines.slice(start, start + limit); - return { lines: sliced, totalLines }; + const allLines = content.split("\n"); + const totalLines = allLines.length; + const start = offset - 1; // convert to 0-indexed + const sliced = allLines.slice(start, start + limit); + return { lines: sliced, totalLines }; } /** Pure: render lines into a string with line numbers. */ export function renderLines(lines: readonly string[], offset: number): string { - return lines.map((line, i) => `${offset + i}: ${line}`).join("\n"); + return lines.map((line, i) => `${offset + i}: ${line}`).join("\n"); } /** A directory entry with its type. */ export interface DirEntry { - readonly name: string; - readonly isDirectory: boolean; + readonly name: string; + readonly isDirectory: boolean; } /** @@ -75,11 +75,11 @@ export interface DirEntry { * Subdirectories get a trailing `/`. Empty input returns an empty-directory message. */ export function formatDirectoryEntries(entries: readonly DirEntry[], dirPath: string): string { - if (entries.length === 0) { - return `(empty directory: ${dirPath})`; - } - const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); - return sorted.map((e) => (e.isDirectory ? `${e.name}/` : e.name)).join("\n"); + if (entries.length === 0) { + return `(empty directory: ${dirPath})`; + } + const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); + return sorted.map((e) => (e.isDirectory ? `${e.name}/` : e.name)).join("\n"); } /** @@ -94,120 +94,120 @@ export function formatDirectoryEntries(entries: readonly DirEntry[], dirPath: st * injected so the tool is testable; `execute` prefers `ctx.cwd` when present. */ export function createReadFileTool(deps: { - readonly resolveBackend: ExecBackendResolver; - readonly workdir?: string; + readonly resolveBackend: ExecBackendResolver; + readonly workdir?: string; }): ToolContract { - const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; - - return { - name: "read_file", - description: - "Read the contents of a file or list a directory's contents. " + - "For files, returns lines with 1-indexed line numbers. " + - "Supports offset/limit for reading specific sections of large files. " + - "For directories, returns sorted entries with subdirectories suffixed by /.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Path to the file, relative to the working directory.", - }, - offset: { - type: "number", - description: "1-indexed start line number (default: 1).", - default: 1, - }, - limit: { - type: "number", - description: "Maximum number of lines to return (default: 500, hard cap: 5000).", - default: 500, - }, - }, - required: ["path"], - }, - concurrencySafe: true, - async execute(args: unknown, ctx): Promise<ToolResult> { - const validated = validateArgs(args); - if ("error" in validated) { - return { content: validated.error, isError: true }; - } - - const { path: relPath, offset, limit } = validated; - - const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; - if (effectiveBase === undefined) { - return { - content: - "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", - isError: true, - }; - } - const resolvedPath = resolve(effectiveBase, relPath); - - const backend: ExecBackend = deps.resolveBackend(ctx.computerId); - - // Stat to determine if this is a file or directory. - let pathStat: StatResult; - try { - pathStat = await backend.stat(resolvedPath); - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return { content: `Error: File "${relPath}" not found.`, isError: true }; - } - return { - content: `Error reading path: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - // Directory listing branch. backend.readdir already returns - // {name, isDirectory}[] entries, so no per-entry collapse is needed. - if (pathStat.isDirectory) { - let entries: readonly DirEntry[]; - try { - entries = await backend.readdir(resolvedPath); - } catch (err: unknown) { - return { - content: `Error reading directory: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - return { content: formatDirectoryEntries(entries, relPath) }; - } - - // File branch — read the file. - let content: string; - try { - content = await backend.readFile(resolvedPath); - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return { content: `Error: File "${relPath}" not found.`, isError: true }; - } - return { - content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - // Handle empty file. - if (content.length === 0) { - return { content: `(empty file: ${relPath})` }; - } - - // Apply offset/limit line slicing. - const { lines, totalLines } = sliceLines(content, offset, limit); - - if (offset > totalLines) { - return { - content: `Error: offset ${offset} exceeds total lines (${totalLines}) in "${relPath}".`, - isError: true, - }; - } - - return { content: renderLines(lines, offset) }; - }, - }; + const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; + + return { + name: "read_file", + description: + "Read the contents of a file or list a directory's contents. " + + "For files, returns lines with 1-indexed line numbers. " + + "Supports offset/limit for reading specific sections of large files. " + + "For directories, returns sorted entries with subdirectories suffixed by /.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file, relative to the working directory.", + }, + offset: { + type: "number", + description: "1-indexed start line number (default: 1).", + default: 1, + }, + limit: { + type: "number", + description: "Maximum number of lines to return (default: 500, hard cap: 5000).", + default: 500, + }, + }, + required: ["path"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx): Promise<ToolResult> { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + + const { path: relPath, offset, limit } = validated; + + const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; + if (effectiveBase === undefined) { + return { + content: + "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", + isError: true, + }; + } + const resolvedPath = resolve(effectiveBase, relPath); + + const backend: ExecBackend = deps.resolveBackend(ctx.computerId); + + // Stat to determine if this is a file or directory. + let pathStat: StatResult; + try { + pathStat = await backend.stat(resolvedPath); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { content: `Error: File "${relPath}" not found.`, isError: true }; + } + return { + content: `Error reading path: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + // Directory listing branch. backend.readdir already returns + // {name, isDirectory}[] entries, so no per-entry collapse is needed. + if (pathStat.isDirectory) { + let entries: readonly DirEntry[]; + try { + entries = await backend.readdir(resolvedPath); + } catch (err: unknown) { + return { + content: `Error reading directory: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + return { content: formatDirectoryEntries(entries, relPath) }; + } + + // File branch — read the file. + let content: string; + try { + content = await backend.readFile(resolvedPath); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { content: `Error: File "${relPath}" not found.`, isError: true }; + } + return { + content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + // Handle empty file. + if (content.length === 0) { + return { content: `(empty file: ${relPath})` }; + } + + // Apply offset/limit line slicing. + const { lines, totalLines } = sliceLines(content, offset, limit); + + if (offset > totalLines) { + return { + content: `Error: offset ${offset} exceeds total lines (${totalLines}) in "${relPath}".`, + isError: true, + }; + } + + return { content: renderLines(lines, offset) }; + }, + }; } |
