diff options
Diffstat (limited to 'packages/tool-write-file/src')
| -rw-r--r-- | packages/tool-write-file/src/extension.ts | 32 | ||||
| -rw-r--r-- | packages/tool-write-file/src/index.ts | 6 | ||||
| -rw-r--r-- | packages/tool-write-file/src/write-file.test.ts | 420 | ||||
| -rw-r--r-- | packages/tool-write-file/src/write-file.ts | 258 |
4 files changed, 358 insertions, 358 deletions
diff --git a/packages/tool-write-file/src/extension.ts b/packages/tool-write-file/src/extension.ts index 0a9a10f..36f7148 100644 --- a/packages/tool-write-file/src/extension.ts +++ b/packages/tool-write-file/src/extension.ts @@ -3,20 +3,20 @@ import type { Extension } from "@dispatch/kernel"; import { createWriteFileTool } from "./write-file.js"; export const extension: Extension = { - manifest: { - id: "tool-write-file", - name: "Write File Tool", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { fs: true }, - contributes: { tools: ["write_file"] }, - // Host activates exec-backend first → host.getService at activation is safe. - dependsOn: ["exec-backend"], - }, - activate(host) { - const resolveBackend = host.getService(execBackendHandle); - host.defineTool(createWriteFileTool({ resolveBackend, workdir: process.cwd() })); - }, + manifest: { + id: "tool-write-file", + name: "Write File Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { fs: true }, + contributes: { tools: ["write_file"] }, + // Host activates exec-backend first → host.getService at activation is safe. + dependsOn: ["exec-backend"], + }, + activate(host) { + const resolveBackend = host.getService(execBackendHandle); + host.defineTool(createWriteFileTool({ resolveBackend, workdir: process.cwd() })); + }, }; diff --git a/packages/tool-write-file/src/index.ts b/packages/tool-write-file/src/index.ts index 8a2cb36..d81c90a 100644 --- a/packages/tool-write-file/src/index.ts +++ b/packages/tool-write-file/src/index.ts @@ -1,6 +1,6 @@ export { extension } from "./extension.js"; export { - createWriteFileTool, - decideOverwrite, - validateArgs, + createWriteFileTool, + decideOverwrite, + validateArgs, } from "./write-file.js"; diff --git a/packages/tool-write-file/src/write-file.test.ts b/packages/tool-write-file/src/write-file.test.ts index d157eb2..5999291 100644 --- a/packages/tool-write-file/src/write-file.test.ts +++ b/packages/tool-write-file/src/write-file.test.ts @@ -7,17 +7,17 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createWriteFileTool, decideOverwrite, validateArgs } from "./write-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, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } /** @@ -26,220 +26,220 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { * real fs edge is exercised, matching the constitution's strict-core rule. */ function makeTool(workdir: string) { - return createWriteFileTool({ resolveBackend: () => localExecBackend, workdir }); + return createWriteFileTool({ resolveBackend: () => localExecBackend, workdir }); } let workdir: string; beforeEach(async () => { - workdir = await mkdtemp(join(tmpdir(), "tool-write-file-test-")); + workdir = await mkdtemp(join(tmpdir(), "tool-write-file-test-")); }); afterEach(async () => { - await rm(workdir, { recursive: true, force: true }); + await rm(workdir, { recursive: true, force: true }); }); describe("decideOverwrite", () => { - it("returns create when file absent and overwrite is false", () => { - expect(decideOverwrite(false, false)).toBe("create"); - }); - - it("returns create when file absent and overwrite is false (default)", () => { - expect(decideOverwrite(false, false)).toBe("create"); - }); - - it("returns error when file exists and overwrite is false", () => { - const result = decideOverwrite(true, false); - expect(typeof result).toBe("object"); - if (typeof result === "object") { - expect(result.error).toContain("already exists"); - } - }); - - it("returns overwrite when file exists and overwrite is true", () => { - expect(decideOverwrite(true, true)).toBe("overwrite"); - }); - - it("returns error when file absent and overwrite is true", () => { - const result = decideOverwrite(false, true); - expect(typeof result).toBe("object"); - if (typeof result === "object") { - expect(result.error).toContain("does not exist"); - } - }); - - it("covers all four rows of the truth table", () => { - expect(decideOverwrite(false, false)).toBe("create"); - expect(decideOverwrite(true, false)).toEqual( - expect.objectContaining({ error: expect.any(String) }), - ); - expect(decideOverwrite(true, true)).toBe("overwrite"); - expect(decideOverwrite(false, true)).toEqual( - expect.objectContaining({ error: expect.any(String) }), - ); - }); + it("returns create when file absent and overwrite is false", () => { + expect(decideOverwrite(false, false)).toBe("create"); + }); + + it("returns create when file absent and overwrite is false (default)", () => { + expect(decideOverwrite(false, false)).toBe("create"); + }); + + it("returns error when file exists and overwrite is false", () => { + const result = decideOverwrite(true, false); + expect(typeof result).toBe("object"); + if (typeof result === "object") { + expect(result.error).toContain("already exists"); + } + }); + + it("returns overwrite when file exists and overwrite is true", () => { + expect(decideOverwrite(true, true)).toBe("overwrite"); + }); + + it("returns error when file absent and overwrite is true", () => { + const result = decideOverwrite(false, true); + expect(typeof result).toBe("object"); + if (typeof result === "object") { + expect(result.error).toContain("does not exist"); + } + }); + + it("covers all four rows of the truth table", () => { + expect(decideOverwrite(false, false)).toBe("create"); + expect(decideOverwrite(true, false)).toEqual( + expect.objectContaining({ error: expect.any(String) }), + ); + expect(decideOverwrite(true, true)).toBe("overwrite"); + expect(decideOverwrite(false, true)).toEqual( + expect.objectContaining({ error: expect.any(String) }), + ); + }); }); describe("validateArgs", () => { - it("returns validated args for valid input", () => { - const result = validateArgs({ path: "foo.txt", content: "hello" }); - expect(result).toEqual({ path: "foo.txt", content: "hello", overwrite: false }); - }); - - it("parses overwrite as true", () => { - const result = validateArgs({ path: "foo.txt", content: "x", overwrite: true }); - expect(result).toEqual({ path: "foo.txt", content: "x", overwrite: true }); - }); - - it("defaults overwrite to false", () => { - const result = validateArgs({ path: "foo.txt", content: "x" }); - expect(result).toEqual({ path: "foo.txt", content: "x", overwrite: false }); - }); - - it("accepts empty string content", () => { - const result = validateArgs({ path: "foo.txt", content: "" }); - expect(result).toEqual({ path: "foo.txt", content: "", overwrite: false }); - }); - - it("returns error for null args", () => { - expect(validateArgs(null)).toHaveProperty("error"); - }); - - it("returns error for missing path", () => { - expect(validateArgs({ content: "x" })).toHaveProperty("error"); - }); - - it("returns error for missing content", () => { - expect(validateArgs({ path: "foo.txt" })).toHaveProperty("error"); - }); - - it("returns error for non-string content", () => { - expect(validateArgs({ path: "foo.txt", content: 123 })).toHaveProperty("error"); - }); - - it("returns error for non-boolean overwrite", () => { - expect(validateArgs({ path: "foo.txt", content: "x", overwrite: "yes" })).toHaveProperty( - "error", - ); - }); + it("returns validated args for valid input", () => { + const result = validateArgs({ path: "foo.txt", content: "hello" }); + expect(result).toEqual({ path: "foo.txt", content: "hello", overwrite: false }); + }); + + it("parses overwrite as true", () => { + const result = validateArgs({ path: "foo.txt", content: "x", overwrite: true }); + expect(result).toEqual({ path: "foo.txt", content: "x", overwrite: true }); + }); + + it("defaults overwrite to false", () => { + const result = validateArgs({ path: "foo.txt", content: "x" }); + expect(result).toEqual({ path: "foo.txt", content: "x", overwrite: false }); + }); + + it("accepts empty string content", () => { + const result = validateArgs({ path: "foo.txt", content: "" }); + expect(result).toEqual({ path: "foo.txt", content: "", overwrite: false }); + }); + + it("returns error for null args", () => { + expect(validateArgs(null)).toHaveProperty("error"); + }); + + it("returns error for missing path", () => { + expect(validateArgs({ content: "x" })).toHaveProperty("error"); + }); + + it("returns error for missing content", () => { + expect(validateArgs({ path: "foo.txt" })).toHaveProperty("error"); + }); + + it("returns error for non-string content", () => { + expect(validateArgs({ path: "foo.txt", content: 123 })).toHaveProperty("error"); + }); + + it("returns error for non-boolean overwrite", () => { + expect(validateArgs({ path: "foo.txt", content: "x", overwrite: "yes" })).toHaveProperty( + "error", + ); + }); }); describe("createWriteFileTool", () => { - it("creates a new file when overwrite is unset and the file is absent", async () => { - const tool = makeTool(workdir); - const result = await tool.execute({ path: "new-file.txt", content: "hello world" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("Created"); - const written = await readFile(join(workdir, "new-file.txt"), "utf8"); - expect(written).toBe("hello world"); - }); - - it("errors when the file exists and overwrite is unset", async () => { - await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "existing.txt", content: "new content" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("already exists"); - expect(result.content).toContain("overwrite"); - const unchanged = await readFile(join(workdir, "existing.txt"), "utf8"); - expect(unchanged).toBe("old content"); - }); - - it("overwrites an existing file when overwrite is true", async () => { - await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute( - { path: "existing.txt", content: "new content", overwrite: true }, - stubCtx(), - ); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("Overwrote"); - const written = await readFile(join(workdir, "existing.txt"), "utf8"); - expect(written).toBe("new content"); - }); - - it("errors when overwrite is true but the file is absent", async () => { - const tool = makeTool(workdir); - const result = await tool.execute( - { path: "nonexistent.txt", content: "data", overwrite: true }, - stubCtx(), - ); - - expect(result.isError).toBe(true); - expect(result.content).toContain("does not exist"); - }); - - it("errors when the parent directory does not exist", async () => { - const tool = makeTool(workdir); - const result = await tool.execute({ path: "no/such/dir/file.txt", content: "data" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("Error"); - }); - - it("concurrencySafe is false", () => { - const tool = makeTool(workdir); - expect(tool.concurrencySafe).toBe(false); - }); - - it("has correct name and parameters shape", () => { - const tool = makeTool(workdir); - expect(tool.name).toBe("write_file"); - expect(tool.parameters.type).toBe("object"); - expect(tool.parameters.required).toEqual(["path", "content"]); - expect(tool.parameters.properties?.path?.type).toBe("string"); - expect(tool.parameters.properties?.content?.type).toBe("string"); - expect(tool.parameters.properties?.overwrite?.type).toBe("boolean"); - }); - - it("never throws on bad input (always returns ToolResult)", async () => { - const tool = makeTool(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("respects ctx.cwd over baked workdir", async () => { - const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); - try { - const tool = makeTool(workdir); - const result = await tool.execute( - { path: "ctx-file.txt", content: "from ctx" }, - stubCtx({ cwd: ctxDir }), - ); - - expect(result.isError).toBeUndefined(); - const written = await readFile(join(ctxDir, "ctx-file.txt"), "utf8"); - expect(written).toBe("from ctx"); - } finally { - await rm(ctxDir, { recursive: true, force: true }); - } - }); - - it("writes empty content", async () => { - const tool = makeTool(workdir); - const result = await tool.execute({ path: "empty.txt", content: "" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - const written = await readFile(join(workdir, "empty.txt"), "utf8"); - expect(written).toBe(""); - }); - - it("writes content in subdirectory that exists", async () => { - await mkdir(join(workdir, "sub")); - const tool = makeTool(workdir); - const result = await tool.execute({ path: "sub/file.txt", content: "nested" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - const written = await readFile(join(workdir, "sub", "file.txt"), "utf8"); - expect(written).toBe("nested"); - }); + it("creates a new file when overwrite is unset and the file is absent", async () => { + const tool = makeTool(workdir); + const result = await tool.execute({ path: "new-file.txt", content: "hello world" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Created"); + const written = await readFile(join(workdir, "new-file.txt"), "utf8"); + expect(written).toBe("hello world"); + }); + + it("errors when the file exists and overwrite is unset", async () => { + await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "existing.txt", content: "new content" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("already exists"); + expect(result.content).toContain("overwrite"); + const unchanged = await readFile(join(workdir, "existing.txt"), "utf8"); + expect(unchanged).toBe("old content"); + }); + + it("overwrites an existing file when overwrite is true", async () => { + await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute( + { path: "existing.txt", content: "new content", overwrite: true }, + stubCtx(), + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Overwrote"); + const written = await readFile(join(workdir, "existing.txt"), "utf8"); + expect(written).toBe("new content"); + }); + + it("errors when overwrite is true but the file is absent", async () => { + const tool = makeTool(workdir); + const result = await tool.execute( + { path: "nonexistent.txt", content: "data", overwrite: true }, + stubCtx(), + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("does not exist"); + }); + + it("errors when the parent directory does not exist", async () => { + const tool = makeTool(workdir); + const result = await tool.execute({ path: "no/such/dir/file.txt", content: "data" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("Error"); + }); + + it("concurrencySafe is false", () => { + const tool = makeTool(workdir); + expect(tool.concurrencySafe).toBe(false); + }); + + it("has correct name and parameters shape", () => { + const tool = makeTool(workdir); + expect(tool.name).toBe("write_file"); + expect(tool.parameters.type).toBe("object"); + expect(tool.parameters.required).toEqual(["path", "content"]); + expect(tool.parameters.properties?.path?.type).toBe("string"); + expect(tool.parameters.properties?.content?.type).toBe("string"); + expect(tool.parameters.properties?.overwrite?.type).toBe("boolean"); + }); + + it("never throws on bad input (always returns ToolResult)", async () => { + const tool = makeTool(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("respects ctx.cwd over baked workdir", async () => { + const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); + try { + const tool = makeTool(workdir); + const result = await tool.execute( + { path: "ctx-file.txt", content: "from ctx" }, + stubCtx({ cwd: ctxDir }), + ); + + expect(result.isError).toBeUndefined(); + const written = await readFile(join(ctxDir, "ctx-file.txt"), "utf8"); + expect(written).toBe("from ctx"); + } finally { + await rm(ctxDir, { recursive: true, force: true }); + } + }); + + it("writes empty content", async () => { + const tool = makeTool(workdir); + const result = await tool.execute({ path: "empty.txt", content: "" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + const written = await readFile(join(workdir, "empty.txt"), "utf8"); + expect(written).toBe(""); + }); + + it("writes content in subdirectory that exists", async () => { + await mkdir(join(workdir, "sub")); + const tool = makeTool(workdir); + const result = await tool.execute({ path: "sub/file.txt", content: "nested" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + const written = await readFile(join(workdir, "sub", "file.txt"), "utf8"); + expect(written).toBe("nested"); + }); }); diff --git a/packages/tool-write-file/src/write-file.ts b/packages/tool-write-file/src/write-file.ts index cf761b6..14a5ec4 100644 --- a/packages/tool-write-file/src/write-file.ts +++ b/packages/tool-write-file/src/write-file.ts @@ -3,51 +3,51 @@ import type { ExecBackend, ExecBackendResolver } from "@dispatch/exec-backend"; import type { ToolContract, ToolResult } from "@dispatch/kernel"; interface ValidatedArgs { - readonly path: string; - readonly content: string; - readonly overwrite: boolean; + readonly path: string; + readonly content: string; + readonly overwrite: boolean; } export type OverwriteDecision = "create" | "overwrite" | { readonly error: string }; /** Pure: decide the action based on file existence and the overwrite flag. */ export function decideOverwrite(fileExists: boolean, overwrite: boolean): OverwriteDecision { - if (!fileExists && !overwrite) return "create"; - if (fileExists && !overwrite) { - return { error: "Error: File already exists; set overwrite: true to replace it." }; - } - if (fileExists && overwrite) return "overwrite"; - return { error: "Error: overwrite: true but the file does not exist." }; + if (!fileExists && !overwrite) return "create"; + if (fileExists && !overwrite) { + return { error: "Error: File already exists; set overwrite: true to replace it." }; + } + if (fileExists && overwrite) return "overwrite"; + return { error: "Error: overwrite: true but the file does not exist." }; } /** 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 rawContent = obj.content; - if (typeof rawContent !== "string") { - return { - error: 'Error: Missing or invalid "content" parameter (must be a string).', - }; - } - - let overwrite = false; - if (obj.overwrite !== undefined) { - if (typeof obj.overwrite !== "boolean") { - return { error: 'Error: Invalid "overwrite" parameter (must be a boolean).' }; - } - overwrite = obj.overwrite; - } - - return { path: rawPath, content: rawContent, overwrite }; + 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 rawContent = obj.content; + if (typeof rawContent !== "string") { + return { + error: 'Error: Missing or invalid "content" parameter (must be a string).', + }; + } + + let overwrite = false; + if (obj.overwrite !== undefined) { + if (typeof obj.overwrite !== "boolean") { + return { error: 'Error: Invalid "overwrite" parameter (must be a boolean).' }; + } + overwrite = obj.overwrite; + } + + return { path: rawPath, content: rawContent, overwrite }; } /** @@ -62,99 +62,99 @@ export function validateArgs(args: unknown): ValidatedArgs | { readonly error: s * injected so the tool is testable; `execute` prefers `ctx.cwd` when present. */ export function createWriteFileTool(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: "write_file", - description: - "Write a whole file to disk. " + - "By default, creates a new file; errors if it already exists. " + - "Set overwrite: true to replace an existing file (errors if the file does not exist). " + - "Parent directories are NOT auto-created — the parent must already exist.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Path to the file, relative to the working directory.", - }, - content: { - type: "string", - description: "The full content to write to the file.", - }, - overwrite: { - type: "boolean", - description: - "When false/unset: creates a new file (errors if it already exists). " + - "When true: replaces an existing file (errors if it does not exist).", - default: false, - }, - }, - required: ["path", "content"], - }, - 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, content, overwrite } = 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); - - // Check existence. `backend.exists` never throws — it returns false - // when the path is missing — so the old try/catch around `access` - // collapses to a single boolean read. - const fileExists = await backend.exists(resolvedPath); - - // Pure decision. - const decision = decideOverwrite(fileExists, overwrite); - if (typeof decision === "object") { - return { content: decision.error, isError: true }; - } - - // Verify it's not a directory. `backend.stat` returns a - // `{ isFile, isDirectory }` result; only reached when the file - // exists, so an ENOENT here is a lost race left to propagate - // (same as the prior uncaught `stat` call). - if (fileExists) { - const pathStat = await backend.stat(resolvedPath); - if (pathStat.isDirectory) { - return { - content: `Error: "${relPath}" is a directory, not a file.`, - isError: true, - }; - } - } - - // Write the file. LocalExecBackend throws node:fs-style errors - // carrying a `.code` (e.g. ENOENT when the parent dir is missing); - // the catch surfaces the message verbatim. - try { - await backend.writeFile(resolvedPath, content); - } catch (err: unknown) { - return { - content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - const action = decision === "create" ? "Created" : "Overwrote"; - return { content: `${action} "${relPath}" (${content.length} bytes).` }; - }, - }; + const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; + + return { + name: "write_file", + description: + "Write a whole file to disk. " + + "By default, creates a new file; errors if it already exists. " + + "Set overwrite: true to replace an existing file (errors if the file does not exist). " + + "Parent directories are NOT auto-created — the parent must already exist.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file, relative to the working directory.", + }, + content: { + type: "string", + description: "The full content to write to the file.", + }, + overwrite: { + type: "boolean", + description: + "When false/unset: creates a new file (errors if it already exists). " + + "When true: replaces an existing file (errors if it does not exist).", + default: false, + }, + }, + required: ["path", "content"], + }, + 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, content, overwrite } = 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); + + // Check existence. `backend.exists` never throws — it returns false + // when the path is missing — so the old try/catch around `access` + // collapses to a single boolean read. + const fileExists = await backend.exists(resolvedPath); + + // Pure decision. + const decision = decideOverwrite(fileExists, overwrite); + if (typeof decision === "object") { + return { content: decision.error, isError: true }; + } + + // Verify it's not a directory. `backend.stat` returns a + // `{ isFile, isDirectory }` result; only reached when the file + // exists, so an ENOENT here is a lost race left to propagate + // (same as the prior uncaught `stat` call). + if (fileExists) { + const pathStat = await backend.stat(resolvedPath); + if (pathStat.isDirectory) { + return { + content: `Error: "${relPath}" is a directory, not a file.`, + isError: true, + }; + } + } + + // Write the file. LocalExecBackend throws node:fs-style errors + // carrying a `.code` (e.g. ENOENT when the parent dir is missing); + // the catch surfaces the message verbatim. + try { + await backend.writeFile(resolvedPath, content); + } catch (err: unknown) { + return { + content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + const action = decision === "create" ? "Created" : "Overwrote"; + return { content: `${action} "${relPath}" (${content.length} bytes).` }; + }, + }; } |
