summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-read-file
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-10 16:01:33 +0900
committerAdam Malczewski <[email protected]>2026-06-10 16:01:33 +0900
commitbf862168f0fd7b10d02ae04a9d82f7c37b9d85e5 (patch)
tree073048a5775c605d8c28862d0f8c83e63327a17e /packages/tool-read-file
parent9e7554cde98f45df30dad1f9d356b6954138685b (diff)
downloaddispatch-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.ts2
-rw-r--r--packages/tool-read-file/src/read-file.test.ts78
-rw-r--r--packages/tool-read-file/src/read-file.ts61
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");