summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-edit-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-edit-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-edit-file')
-rw-r--r--packages/tool-edit-file/package.json11
-rw-r--r--packages/tool-edit-file/src/edit-file.test.ts361
-rw-r--r--packages/tool-edit-file/src/edit-file.ts251
-rw-r--r--packages/tool-edit-file/src/extension.ts18
-rw-r--r--packages/tool-edit-file/src/index.ts2
-rw-r--r--packages/tool-edit-file/tsconfig.json6
6 files changed, 649 insertions, 0 deletions
diff --git a/packages/tool-edit-file/package.json b/packages/tool-edit-file/package.json
new file mode 100644
index 0000000..f71ad80
--- /dev/null
+++ b/packages/tool-edit-file/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@dispatch/tool-edit-file",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*"
+ }
+}
diff --git a/packages/tool-edit-file/src/edit-file.test.ts b/packages/tool-edit-file/src/edit-file.test.ts
new file mode 100644
index 0000000..dba3d9e
--- /dev/null
+++ b/packages/tool-edit-file/src/edit-file.test.ts
@@ -0,0 +1,361 @@
+import { mkdtemp, readFile, 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 {
+ computeReplacement,
+ createEditFileTool,
+ isPathWithinWorkdir,
+ validateArgs,
+} from "./edit-file.js";
+
+function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
+ return {
+ toolCallId: "test-call-1",
+ onOutput: () => {},
+ signal: AbortSignal.timeout(5000),
+ log: createLogger(
+ { extensionId: "test" },
+ { emit: () => {} },
+ { now: () => 0, newId: () => "id" },
+ ),
+ ...overrides,
+ };
+}
+
+let workdir: string;
+
+beforeEach(async () => {
+ workdir = await mkdtemp(join(tmpdir(), "tool-edit-file-test-"));
+});
+
+afterEach(async () => {
+ await rm(workdir, { recursive: true, force: true });
+});
+
+describe("validateArgs", () => {
+ it("returns validated args for valid input", () => {
+ const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" });
+ expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: false });
+ });
+
+ it("parses replaceAll true", () => {
+ const result = validateArgs({
+ path: "f.txt",
+ oldString: "a",
+ newString: "b",
+ replaceAll: true,
+ });
+ expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: true });
+ });
+
+ it("defaults replaceAll to false when omitted", () => {
+ const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" });
+ expect(result).toHaveProperty("replaceAll", false);
+ });
+
+ it("returns error for null args", () => {
+ const result = validateArgs(null);
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for missing path", () => {
+ const result = validateArgs({ oldString: "a", newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for missing oldString", () => {
+ const result = validateArgs({ path: "f.txt", newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for missing newString", () => {
+ const result = validateArgs({ path: "f.txt", oldString: "a" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for non-string path", () => {
+ const result = validateArgs({ path: 123, oldString: "a", newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for non-string oldString", () => {
+ const result = validateArgs({ path: "f.txt", oldString: 123, newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
+});
+
+describe("computeReplacement", () => {
+ it("replaces a single occurrence", () => {
+ const result = computeReplacement("hello world", "world", "there", false);
+ expect(result).toEqual({ content: "hello there", count: 1 });
+ });
+
+ it("replaces all occurrences when replaceAll is true", () => {
+ const result = computeReplacement("aaa", "a", "b", true);
+ expect(result).toEqual({ content: "bbb", count: 3 });
+ });
+
+ it("returns identical error when newString equals oldString", () => {
+ const result = computeReplacement("hello", "hello", "hello", false);
+ expect(result).toEqual({ kind: "identical" });
+ });
+
+ it("returns notFound error when oldString is not in content", () => {
+ const result = computeReplacement("hello", "xyz", "abc", false);
+ expect(result).toEqual({ kind: "notFound" });
+ });
+
+ it("returns notUnique error when oldString occurs multiple times and replaceAll is false", () => {
+ const result = computeReplacement("abc abc abc", "abc", "xyz", false);
+ expect(result).toEqual({ kind: "notUnique", count: 3 });
+ });
+
+ it("replaces only the single match when unique", () => {
+ const result = computeReplacement("foo bar baz", "bar", "qux", false);
+ expect(result).toEqual({ content: "foo qux baz", count: 1 });
+ });
+
+ it("handles replaceAll with multiple occurrences", () => {
+ const result = computeReplacement("one two one two", "two", "three", true);
+ expect(result).toEqual({ content: "one three one three", count: 2 });
+ });
+
+ it("handles empty oldString as notFound (empty string not searched)", () => {
+ // empty oldString would cause infinite loop in split, so we treat it as not-found
+ const result = computeReplacement("hello", "", "x", false);
+ expect(result).toEqual({ kind: "notFound" });
+ });
+
+ it("handles oldString at start of content", () => {
+ const result = computeReplacement("hello world", "hello", "goodbye", false);
+ expect(result).toEqual({ content: "goodbye world", count: 1 });
+ });
+
+ it("handles oldString at end of content", () => {
+ const result = computeReplacement("hello world", "world", "there", false);
+ expect(result).toEqual({ content: "hello there", count: 1 });
+ });
+
+ it("handles multiline oldString and newString", () => {
+ const content = "line1\nold line\nline3";
+ const result = computeReplacement(content, "old line", "new line", false);
+ expect(result).toEqual({ content: "line1\nnew line\nline3", count: 1 });
+ });
+});
+
+describe("isPathWithinWorkdir", () => {
+ it("accepts a path within workdir", () => {
+ expect(isPathWithinWorkdir("/tmp/workdir/file.txt", "/tmp/workdir")).toBe(true);
+ });
+
+ it("accepts the workdir itself", () => {
+ expect(isPathWithinWorkdir("/tmp/workdir", "/tmp/workdir")).toBe(true);
+ });
+
+ it("rejects a path outside workdir", () => {
+ expect(isPathWithinWorkdir("/tmp/other/file.txt", "/tmp/workdir")).toBe(false);
+ });
+
+ it("rejects a prefix attack (workdir prefix but different dir)", () => {
+ expect(isPathWithinWorkdir("/tmp/workdir-evil/file.txt", "/tmp/workdir")).toBe(false);
+ });
+});
+
+describe("createEditFileTool", () => {
+ it("replaces a single occurrence", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "hello world\n", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "world", newString: "there" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 1 occurrence");
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("hello there\n");
+ });
+
+ it("replaces all occurrences when replaceAll is true", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "aaa\n", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "a", newString: "b", replaceAll: true },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 3 occurrences");
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("bbb\n");
+ });
+
+ it("errors when oldString is not found", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "hello\n", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "xyz", newString: "abc" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("oldString not found");
+ });
+
+ it("errors when oldString is non-unique and replaceAll is false", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "abc abc abc\n", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "abc", newString: "xyz" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("Found 3 matches");
+ });
+
+ it("errors when newString equals oldString", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "hello\n", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "hello", newString: "hello" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("newString must differ from oldString");
+ });
+
+ it("errors / not-found for a nonexistent file", async () => {
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "nonexistent.txt", oldString: "a", newString: "b" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("not found");
+ });
+
+ it("rejects a path outside the working directory", async () => {
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "../escape.txt", oldString: "a", newString: "b" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ });
+
+ it("rejects an absolute path outside workdir", async () => {
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "/etc/passwd", oldString: "a", newString: "b" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ });
+
+ it("handles symlink escape attempt", async () => {
+ const outsideDir = await mkdtemp(join(tmpdir(), "outside-"));
+ const outsideFile = join(outsideDir, "secret.txt");
+ await writeFile(outsideFile, "secret data", "utf8");
+
+ const symlinkPath = join(workdir, "link.txt");
+ const { symlink } = await import("node:fs/promises");
+ await symlink(outsideFile, symlinkPath);
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "link.txt", oldString: "secret", newString: "leaked" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+
+ await rm(outsideDir, { recursive: true, force: true });
+ });
+
+ it("reads file under ctx.cwd when set", async () => {
+ const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-"));
+ try {
+ const filePath = join(ctxDir, "ctx-file.txt");
+ await writeFile(filePath, "hello world", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const result = await tool.execute(
+ { path: "ctx-file.txt", oldString: "world", newString: "there" },
+ stubCtx({ cwd: ctxDir }),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 1 occurrence");
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("hello there");
+ } finally {
+ await rm(ctxDir, { recursive: true, force: true });
+ }
+ });
+
+ it("falls back to baked workdir when ctx.cwd is omitted", async () => {
+ const filePath = join(workdir, "baked-file.txt");
+ await writeFile(filePath, "hello world", "utf8");
+
+ const tool = createEditFileTool(workdir);
+ const ctx = stubCtx();
+ expect(ctx.cwd).toBeUndefined();
+ const result = await tool.execute(
+ { path: "baked-file.txt", oldString: "world", newString: "there" },
+ ctx,
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 1 occurrence");
+ });
+
+ it("never throws on bad input (always returns ToolResult)", async () => {
+ const tool = createEditFileTool(workdir);
+
+ const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }];
+ for (const input of inputs) {
+ const result = await tool.execute(input, stubCtx());
+ expect(result).toHaveProperty("content");
+ expect(typeof result.content).toBe("string");
+ }
+ });
+
+ it("concurrencySafe is false", () => {
+ const tool = createEditFileTool(workdir);
+ expect(tool.concurrencySafe).toBe(false);
+ });
+
+ it("has correct name and parameters shape", () => {
+ const tool = createEditFileTool(workdir);
+ expect(tool.name).toBe("edit_file");
+ expect(tool.parameters.type).toBe("object");
+ expect(tool.parameters.required).toEqual(["path", "oldString", "newString"]);
+ expect(tool.parameters.properties?.path?.type).toBe("string");
+ expect(tool.parameters.properties?.oldString?.type).toBe("string");
+ expect(tool.parameters.properties?.newString?.type).toBe("string");
+ expect(tool.parameters.properties?.replaceAll?.type).toBe("boolean");
+ });
+});
diff --git a/packages/tool-edit-file/src/edit-file.ts b/packages/tool-edit-file/src/edit-file.ts
new file mode 100644
index 0000000..af630aa
--- /dev/null
+++ b/packages/tool-edit-file/src/edit-file.ts
@@ -0,0 +1,251 @@
+import { readFile, realpath, writeFile } from "node:fs/promises";
+import { resolve, sep } from "node:path";
+import type { ToolContract, ToolResult } from "@dispatch/kernel";
+
+// --- Pure types ---
+
+interface ValidatedArgs {
+ readonly path: string;
+ readonly oldString: string;
+ readonly newString: string;
+ readonly replaceAll: boolean;
+}
+
+export type ReplacementError =
+ | { readonly kind: "identical" }
+ | { readonly kind: "notFound" }
+ | { readonly kind: "notUnique"; readonly count: number };
+
+export interface ReplacementSuccess {
+ readonly content: string;
+ readonly count: number;
+}
+
+// --- Pure functions ---
+
+/** 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).' };
+ }
+
+ const rawOld = obj.oldString;
+ if (typeof rawOld !== "string" || rawOld.length === 0) {
+ return {
+ error: 'Error: Missing or invalid "oldString" parameter (must be a non-empty string).',
+ };
+ }
+
+ const rawNew = obj.newString;
+ if (typeof rawNew !== "string") {
+ return {
+ error: 'Error: Missing or invalid "newString" parameter (must be a string).',
+ };
+ }
+
+ const rawReplaceAll = obj.replaceAll;
+ const replaceAll = rawReplaceAll === true;
+
+ return { path: rawPath, oldString: rawOld, newString: rawNew, replaceAll };
+}
+
+/** Pure: compute the replacement result given file content + params. */
+export function computeReplacement(
+ content: string,
+ oldString: string,
+ newString: string,
+ replaceAll: boolean,
+): ReplacementSuccess | ReplacementError {
+ if (oldString === newString) {
+ return { kind: "identical" };
+ }
+
+ if (oldString === "") {
+ return { kind: "notFound" };
+ }
+
+ if (!content.includes(oldString)) {
+ return { kind: "notFound" };
+ }
+
+ if (replaceAll) {
+ const parts = content.split(oldString);
+ const count = parts.length - 1;
+ return { content: parts.join(newString), count };
+ }
+
+ // Single replacement — check uniqueness.
+ const firstIndex = content.indexOf(oldString);
+ const secondIndex = content.indexOf(oldString, firstIndex + oldString.length);
+ if (secondIndex !== -1) {
+ // Count total occurrences.
+ let count = 0;
+ let idx = 0;
+ while (true) {
+ idx = content.indexOf(oldString, idx);
+ if (idx === -1) break;
+ count++;
+ idx += oldString.length;
+ }
+ return { kind: "notUnique", count };
+ }
+
+ return {
+ content:
+ content.slice(0, firstIndex) + newString + content.slice(firstIndex + oldString.length),
+ count: 1,
+ };
+}
+
+/** Pure: check that a resolved absolute path is within the workdir (prefix check). */
+export function isPathWithinWorkdir(resolvedPath: string, workdir: string): boolean {
+ const normalizedWorkdir = workdir.endsWith(sep) ? workdir : workdir + sep;
+ return resolvedPath === workdir || resolvedPath.startsWith(normalizedWorkdir);
+}
+
+// --- Shell / edge ---
+
+/**
+ * Factory: create an edit_file ToolContract bound to a working directory.
+ * The working directory is injected so the tool is testable.
+ */
+export function createEditFileTool(workingDirectory: string): ToolContract {
+ const workdir = resolve(workingDirectory);
+
+ return {
+ name: "edit_file",
+ description:
+ "Perform an exact string replacement in an existing file. " +
+ "Provide oldString (the text to find) and newString (the replacement). " +
+ "By default replaces a single occurrence; set replaceAll to replace every match.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description: "Path to the file, relative to the working directory.",
+ },
+ oldString: {
+ type: "string",
+ description: "The exact string to find and replace.",
+ },
+ newString: {
+ type: "string",
+ description: "The string to replace oldString with.",
+ },
+ replaceAll: {
+ type: "boolean",
+ description: "Replace all occurrences (default: false).",
+ default: false,
+ },
+ },
+ required: ["path", "oldString", "newString"],
+ },
+ concurrencySafe: false,
+ async execute(args: unknown, ctx): Promise<ToolResult> {
+ const validated = validateArgs(args);
+ if ("error" in validated) {
+ return { content: validated.error, isError: true };
+ }
+
+ const { path: relPath, oldString, newString, replaceAll } = validated;
+
+ // Effective base: per-turn ctx.cwd overrides the baked workdir.
+ const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir;
+
+ // Resolve the requested path against the effective base.
+ const resolvedPath = resolve(effectiveBase, relPath);
+
+ // Basic prefix check.
+ if (!isPathWithinWorkdir(resolvedPath, effectiveBase)) {
+ return {
+ content: `Error: Path "${relPath}" is outside the working directory.`,
+ isError: true,
+ };
+ }
+
+ // Symlink hardening: realpath both and re-check containment.
+ let realResolved: string;
+ let realBase: string;
+ try {
+ [realResolved, realBase] = await Promise.all([
+ realpath(resolvedPath),
+ realpath(effectiveBase),
+ ]);
+ } 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 accessing file: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ if (!isPathWithinWorkdir(realResolved, realBase)) {
+ return {
+ content: `Error: Path "${relPath}" is outside the working directory.`,
+ isError: true,
+ };
+ }
+
+ // Read the file.
+ let content: string;
+ try {
+ content = await readFile(resolvedPath, "utf8");
+ } 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,
+ };
+ }
+
+ // Pure replacement decision.
+ const result = computeReplacement(content, oldString, newString, replaceAll);
+
+ if ("kind" in result) {
+ switch (result.kind) {
+ case "identical":
+ return {
+ content: "Error: newString must differ from oldString.",
+ isError: true,
+ };
+ case "notFound":
+ return {
+ content: `Error: oldString not found in content of "${relPath}".`,
+ isError: true,
+ };
+ case "notUnique":
+ return {
+ content: `Error: Found ${result.count} matches for oldString in "${relPath}"; provide more surrounding context to make it unique, or set replaceAll.`,
+ isError: true,
+ };
+ }
+ }
+
+ // Write the modified content back.
+ try {
+ await writeFile(resolvedPath, result.content, "utf8");
+ } catch (err: unknown) {
+ return {
+ content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ const plural = result.count === 1 ? "" : "s";
+ return { content: `Replaced ${result.count} occurrence${plural} in "${relPath}".` };
+ },
+ };
+}
diff --git a/packages/tool-edit-file/src/extension.ts b/packages/tool-edit-file/src/extension.ts
new file mode 100644
index 0000000..a4bb19e
--- /dev/null
+++ b/packages/tool-edit-file/src/extension.ts
@@ -0,0 +1,18 @@
+import type { Extension } from "@dispatch/kernel";
+import { createEditFileTool } from "./edit-file.js";
+
+export const extension: Extension = {
+ manifest: {
+ id: "tool-edit-file",
+ name: "Edit File Tool",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ capabilities: { fs: true },
+ contributes: { tools: ["edit_file"] },
+ },
+ activate(host) {
+ host.defineTool(createEditFileTool(process.cwd()));
+ },
+};
diff --git a/packages/tool-edit-file/src/index.ts b/packages/tool-edit-file/src/index.ts
new file mode 100644
index 0000000..49baf93
--- /dev/null
+++ b/packages/tool-edit-file/src/index.ts
@@ -0,0 +1,2 @@
+export { createEditFileTool } from "./edit-file.js";
+export { extension } from "./extension.js";
diff --git a/packages/tool-edit-file/tsconfig.json b/packages/tool-edit-file/tsconfig.json
new file mode 100644
index 0000000..ff99a43
--- /dev/null
+++ b/packages/tool-edit-file/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }]
+}