diff options
| author | Adam Malczewski <[email protected]> | 2026-06-10 16:01:33 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-10 16:01:33 +0900 |
| commit | bf862168f0fd7b10d02ae04a9d82f7c37b9d85e5 (patch) | |
| tree | 073048a5775c605d8c28862d0f8c83e63327a17e /packages/tool-read-file | |
| parent | 9e7554cde98f45df30dad1f9d356b6954138685b (diff) | |
| download | dispatch-bf862168f0fd7b10d02ae04a9d82f7c37b9d85e5.tar.gz dispatch-bf862168f0fd7b10d02ae04a9d82f7c37b9d85e5.zip | |
feat(tools): add run_shell, edit_file, write_file + read_file directory listing
Four standard-tier tool extensions (one tool per extension, zero ABI change):
- tool-read-file: read_file now lists directory contents (sorted, /-suffixed subdirs)
- tool-shell: run_shell (foreground, streamed, cancellable, cwd, timeout + output cap)
- tool-edit-file: edit_file (oldString/newString/replaceAll; errors on absent/non-unique)
- tool-write-file: write_file (explicit overwrite flag)
Registered in host-bin CORE_EXTENSIONS. Live boot clean (shell capability accepted).
686 vitest + 89 bun = 775 tests; tsc -b EXIT 0; biome clean.
Diffstat (limited to 'packages/tool-read-file')
| -rw-r--r-- | packages/tool-read-file/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/tool-read-file/src/read-file.test.ts | 78 | ||||
| -rw-r--r-- | packages/tool-read-file/src/read-file.ts | 61 |
3 files changed, 135 insertions, 6 deletions
diff --git a/packages/tool-read-file/src/index.ts b/packages/tool-read-file/src/index.ts index 2903efc..1846972 100644 --- a/packages/tool-read-file/src/index.ts +++ b/packages/tool-read-file/src/index.ts @@ -1,2 +1,2 @@ export { extension } from "./extension.js"; -export { createReadFileTool } from "./read-file.js"; +export { createReadFileTool, type DirEntry, formatDirectoryEntries } from "./read-file.js"; diff --git a/packages/tool-read-file/src/read-file.test.ts b/packages/tool-read-file/src/read-file.test.ts index 2725a05..25b29ff 100644 --- a/packages/tool-read-file/src/read-file.test.ts +++ b/packages/tool-read-file/src/read-file.test.ts @@ -1,10 +1,11 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createReadFileTool, + formatDirectoryEntries, isPathWithinWorkdir, renderLines, sliceLines, @@ -137,6 +138,33 @@ describe("renderLines", () => { }); }); +describe("formatDirectoryEntries", () => { + it("lists directory entries sorted with trailing slash on subdirectories", () => { + const entries = [ + { name: "zebra.txt", isDirectory: false }, + { name: "alpha", isDirectory: true }, + { name: "readme.md", isDirectory: false }, + { name: "beta", isDirectory: true }, + ]; + const result = formatDirectoryEntries(entries, "mydir"); + expect(result).toBe("alpha/\nbeta/\nreadme.md\nzebra.txt"); + }); + + it("returns empty-directory message for an empty dir", () => { + const result = formatDirectoryEntries([], "empty-dir"); + expect(result).toBe("(empty directory: empty-dir)"); + }); + + it("handles mixed files and directories with same name sorting", () => { + const entries = [ + { name: "b", isDirectory: false }, + { name: "a", isDirectory: true }, + ]; + const result = formatDirectoryEntries(entries, "."); + expect(result).toBe("a/\nb"); + }); +}); + describe("createReadFileTool", () => { it("reads a real temp file", async () => { const filePath = join(workdir, "hello.txt"); @@ -316,4 +344,52 @@ describe("createReadFileTool", () => { expect(result.isError).toBeUndefined(); expect(result.content).toContain("1: from baked workdir"); }); + + it("lists directory entries sorted with trailing slash on subdirectories", async () => { + await mkdir(join(workdir, "subdir")); + await writeFile(join(workdir, "zebra.txt"), "z", "utf8"); + await writeFile(join(workdir, "alpha.txt"), "a", "utf8"); + + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "." }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("alpha.txt\nsubdir/\nzebra.txt"); + }); + + it("returns empty-directory message for an empty dir", async () => { + await mkdir(join(workdir, "empty-dir")); + + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "empty-dir" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("(empty directory: empty-dir)"); + }); + + it("reads a file unchanged (regression: line numbers + offset/limit)", async () => { + await writeFile(join(workdir, "regression.txt"), "a\nb\nc\nd\ne\n", "utf8"); + + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "regression.txt", offset: 2, limit: 3 }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("2: b\n3: c\n4: d"); + }); + + it("rejects a directory path outside the working directory (containment still enforced)", async () => { + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "../outside-dir" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("outside the working directory"); + }); + + it("returns not-found for a nonexistent path", async () => { + const tool = createReadFileTool(workdir); + const result = await tool.execute({ path: "nonexistent-path" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("not found"); + }); }); diff --git a/packages/tool-read-file/src/read-file.ts b/packages/tool-read-file/src/read-file.ts index d4a4de8..99b396e 100644 --- a/packages/tool-read-file/src/read-file.ts +++ b/packages/tool-read-file/src/read-file.ts @@ -1,4 +1,4 @@ -import { readFile, realpath } from "node:fs/promises"; +import { readdir, readFile, realpath, stat } from "node:fs/promises"; import { resolve, sep } from "node:path"; import type { ToolContract, ToolResult } from "@dispatch/kernel"; @@ -70,6 +70,24 @@ export function renderLines(lines: readonly string[], offset: number): string { 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; +} + +/** + * Pure: format directory entries into a sorted listing string. + * 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"); +} + /** * Factory: create a read_file ToolContract bound to a working directory. * The working directory is injected so the tool is testable. @@ -80,8 +98,10 @@ export function createReadFileTool(workingDirectory: string): ToolContract { return { name: "read_file", description: - "Read the contents of a file. Returns lines with 1-indexed line numbers. " + - "Supports offset/limit for reading specific sections of large files.", + "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: { @@ -151,7 +171,40 @@ export function createReadFileTool(workingDirectory: string): ToolContract { }; } - // Read the file. + // Stat to determine if this is a file or directory. + let pathStat: import("node:fs").Stats; + try { + pathStat = await 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. + if (pathStat.isDirectory()) { + let rawEntries: import("node:fs").Dirent<string>[]; + try { + rawEntries = await readdir(resolvedPath, { encoding: "utf8", withFileTypes: true }); + } catch (err: unknown) { + return { + content: `Error reading directory: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + const dirEntries = rawEntries.map((e) => ({ + name: e.name, + isDirectory: e.isDirectory(), + })); + return { content: formatDirectoryEntries(dirEntries, relPath) }; + } + + // File branch — read the file. let content: string; try { content = await readFile(resolvedPath, "utf8"); |
