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-shell/src | |
| 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-shell/src')
| -rw-r--r-- | packages/tool-shell/src/extension.ts | 19 | ||||
| -rw-r--r-- | packages/tool-shell/src/index.ts | 3 | ||||
| -rw-r--r-- | packages/tool-shell/src/shell.test.ts | 357 | ||||
| -rw-r--r-- | packages/tool-shell/src/shell.ts | 181 | ||||
| -rw-r--r-- | packages/tool-shell/src/spawn.ts | 46 |
5 files changed, 606 insertions, 0 deletions
diff --git a/packages/tool-shell/src/extension.ts b/packages/tool-shell/src/extension.ts new file mode 100644 index 0000000..1a89de0 --- /dev/null +++ b/packages/tool-shell/src/extension.ts @@ -0,0 +1,19 @@ +import type { Extension } from "@dispatch/kernel"; +import { createRunShellTool } from "./shell.js"; +import { realSpawn } from "./spawn.js"; + +export const extension: Extension = { + manifest: { + id: "tool-shell", + name: "Shell Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { shell: true }, + contributes: { tools: ["run_shell"] }, + }, + activate(host) { + host.defineTool(createRunShellTool({ workdir: process.cwd(), spawn: realSpawn })); + }, +}; diff --git a/packages/tool-shell/src/index.ts b/packages/tool-shell/src/index.ts new file mode 100644 index 0000000..efd36fc --- /dev/null +++ b/packages/tool-shell/src/index.ts @@ -0,0 +1,3 @@ +export { extension } from "./extension.js"; +export type { SpawnResult, SpawnShell, ValidatedArgs } from "./shell.js"; +export { createRunShellTool } from "./shell.js"; diff --git a/packages/tool-shell/src/shell.test.ts b/packages/tool-shell/src/shell.test.ts new file mode 100644 index 0000000..a70693b --- /dev/null +++ b/packages/tool-shell/src/shell.test.ts @@ -0,0 +1,357 @@ +import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { + buildResult, + createRunShellTool, + type SpawnShell, + truncateOutput, + validateArgs, +} from "./shell.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, + }; +} + +function fakeSpawn(result: { exitCode: number | null; timedOut: boolean }): SpawnShell { + return async () => result; +} + +describe("validateArgs", () => { + it("returns validated args for valid input", () => { + const result = validateArgs({ command: "echo hello" }); + expect(result).toEqual({ command: "echo hello", timeout: 120_000 }); + }); + + it("parses custom timeout", () => { + const result = validateArgs({ command: "echo hello", timeout: 5000 }); + expect(result).toEqual({ command: "echo hello", timeout: 5000 }); + }); + + it("floors fractional timeout", () => { + const result = validateArgs({ command: "echo hello", timeout: 5000.7 }); + expect(result).toEqual({ command: "echo hello", timeout: 5000 }); + }); + + it("returns error for null args", () => { + const result = validateArgs(null); + expect(result).toHaveProperty("error"); + }); + + it("returns error for non-object args", () => { + const result = validateArgs("string"); + expect(result).toHaveProperty("error"); + }); + + it("returns error for missing command", () => { + const result = validateArgs({}); + expect(result).toHaveProperty("error"); + }); + + it("rejects missing or empty command", () => { + const empty = validateArgs({ command: "" }); + expect(empty).toHaveProperty("error"); + const whitespace = validateArgs({ command: " " }); + expect(whitespace).toHaveProperty("error"); + const missing = validateArgs({ timeout: 5000 }); + expect(missing).toHaveProperty("error"); + }); + + it("returns error for non-string command", () => { + const result = validateArgs({ command: 123 }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for invalid timeout", () => { + const negative = validateArgs({ command: "echo", timeout: -1 }); + expect(negative).toHaveProperty("error"); + const zero = validateArgs({ command: "echo", timeout: 0 }); + expect(zero).toHaveProperty("error"); + const nan = validateArgs({ command: "echo", timeout: Number.NaN }); + expect(nan).toHaveProperty("error"); + }); +}); + +describe("truncateOutput", () => { + it("returns output unchanged when under cap", () => { + const output = "short output"; + expect(truncateOutput(output, 100)).toBe("short output"); + }); + + it("returns output unchanged when exactly at cap", () => { + const output = "exact"; + expect(truncateOutput(output, 5)).toBe("exact"); + }); + + it("truncates output beyond the cap and appends a notice", () => { + const output = "a".repeat(100); + const result = truncateOutput(output, 50); + expect(result).toContain("a".repeat(50)); + expect(result).toContain("[Output truncated: exceeded 50 characters]"); + expect(result.length).toBeLessThan(output.length + 100); + }); +}); + +describe("buildResult", () => { + it("maps a zero exit code to a success result", () => { + const result = buildResult({ + exitCode: 0, + timedOut: false, + aborted: false, + output: "all good", + cap: 50_000, + }); + expect(result.content).toBe("all good"); + expect(result.isError).toBeUndefined(); + }); + + it("maps a non-zero exit code to an isError result", () => { + const result = buildResult({ + exitCode: 1, + timedOut: false, + aborted: false, + output: "some error", + cap: 50_000, + }); + expect(result.content).toBe("some error"); + expect(result.isError).toBe(true); + }); + + it("reports a timeout as an isError result", () => { + const result = buildResult({ + exitCode: null, + timedOut: true, + aborted: false, + output: "partial", + cap: 50_000, + }); + expect(result.content).toContain("partial"); + expect(result.content).toContain("[Command timed out]"); + expect(result.isError).toBe(true); + }); + + it("reports abort as an isError result", () => { + const result = buildResult({ + exitCode: null, + timedOut: false, + aborted: true, + output: "interrupted", + cap: 50_000, + }); + expect(result.content).toBe("interrupted"); + expect(result.isError).toBe(true); + }); + + it("truncates output in result when over cap", () => { + const output = "x".repeat(60_000); + const result = buildResult({ + exitCode: 0, + timedOut: false, + aborted: false, + output, + cap: 50_000, + }); + expect(result.content).toContain("[Output truncated"); + expect(result.content.length).toBeLessThan(60_000); + }); +}); + +describe("createRunShellTool", () => { + it("has correct name and parameters shape", () => { + const tool = createRunShellTool({ + workdir: "/tmp", + spawn: fakeSpawn({ exitCode: 0, timedOut: false }), + }); + expect(tool.name).toBe("run_shell"); + expect(tool.parameters.type).toBe("object"); + expect(tool.parameters.required).toEqual(["command"]); + expect(tool.parameters.properties?.command?.type).toBe("string"); + expect(tool.parameters.properties?.timeout?.type).toBe("number"); + }); + + it("concurrencySafe is false", () => { + const tool = createRunShellTool({ + workdir: "/tmp", + spawn: fakeSpawn({ exitCode: 0, timedOut: false }), + }); + expect(tool.concurrencySafe).toBe(false); + }); + + it("rejects missing or empty command", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + spawn: fakeSpawn({ exitCode: 0, timedOut: false }), + }); + const result = await tool.execute({}, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("Missing or empty"); + }); + + it("maps a zero exit code to a success result", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + spawn: async (_params) => { + _params.onOutput("hello\n", "stdout"); + return { exitCode: 0, timedOut: false }; + }, + }); + const result = await tool.execute({ command: "echo hello" }, stubCtx()); + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("hello"); + }); + + it("maps a non-zero exit code to an isError result", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + spawn: async (_params) => { + _params.onOutput("error output\n", "stderr"); + return { exitCode: 1, timedOut: false }; + }, + }); + const result = await tool.execute({ command: "false" }, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("error output"); + }); + + it("reports a timeout as an isError result", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + spawn: async (_params) => { + _params.onOutput("partial\n", "stdout"); + return { exitCode: null, timedOut: true }; + }, + }); + const result = await tool.execute({ command: "sleep 999" }, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("[Command timed out]"); + }); + + it("truncates output beyond the cap and appends a notice", async () => { + const cap = 100; + const tool = createRunShellTool({ + workdir: "/tmp", + outputCap: cap, + spawn: async (_params) => { + _params.onOutput("a".repeat(200), "stdout"); + return { exitCode: 0, timedOut: false }; + }, + }); + const result = await tool.execute({ command: "gen" }, stubCtx()); + expect(result.content).toContain("[Output truncated"); + expect(result.content.length).toBeLessThan(200); + }); + + it("streams output to ctx.onOutput", async () => { + const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = []; + const tool = createRunShellTool({ + workdir: "/tmp", + spawn: async (params) => { + params.onOutput("line1\n", "stdout"); + params.onOutput("err1\n", "stderr"); + params.onOutput("line2\n", "stdout"); + return { exitCode: 0, timedOut: false }; + }, + }); + await tool.execute( + { command: "test" }, + stubCtx({ + onOutput: (data, stream) => chunks.push({ data, stream }), + }), + ); + expect(chunks).toEqual([ + { data: "line1\n", stream: "stdout" }, + { data: "err1\n", stream: "stderr" }, + { data: "line2\n", stream: "stdout" }, + ]); + }); + + it("uses ctx.cwd when present over baked workdir", async () => { + let receivedCwd = ""; + const tool = createRunShellTool({ + workdir: "/baked", + spawn: async (params) => { + receivedCwd = params.cwd; + return { exitCode: 0, timedOut: false }; + }, + }); + await tool.execute({ command: "pwd" }, stubCtx({ cwd: "/custom" })); + expect(receivedCwd).toBe("/custom"); + }); + + it("falls back to baked workdir when ctx.cwd is omitted", async () => { + let receivedCwd = ""; + const tool = createRunShellTool({ + workdir: "/baked", + spawn: async (params) => { + receivedCwd = params.cwd; + return { exitCode: 0, timedOut: false }; + }, + }); + await tool.execute({ command: "pwd" }, stubCtx()); + expect(receivedCwd).toBe("/baked"); + }); + + it("returns error for spawn failure", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + spawn: async () => { + throw new Error("spawn failed"); + }, + }); + const result = await tool.execute({ command: "bad" }, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("Error spawning command"); + }); + + it("reports abort as isError when signal fires before spawn completes", async () => { + const controller = new AbortController(); + controller.abort(); + const tool = createRunShellTool({ + workdir: "/tmp", + spawn: async () => ({ exitCode: 0, timedOut: false }), + }); + const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal })); + expect(result.isError).toBe(true); + }); + + it("passes timeout to spawn", async () => { + let receivedTimeout = 0; + const tool = createRunShellTool({ + workdir: "/tmp", + spawn: async (params) => { + receivedTimeout = params.timeout; + return { exitCode: 0, timedOut: false }; + }, + }); + await tool.execute({ command: "test", timeout: 5000 }, stubCtx()); + expect(receivedTimeout).toBe(5000); + }); +}); + +describe("createRunShellTool (integration)", () => { + it("runs a real echo command and captures stdout + cwd", async () => { + const { realSpawn } = await import("./spawn.js"); + const tool = createRunShellTool({ workdir: "/tmp", spawn: realSpawn }); + let streamed = ""; + const result = await tool.execute( + { command: "echo hello-from-shell" }, + stubCtx({ + onOutput: (data) => { + streamed += data; + }, + }), + ); + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("hello-from-shell"); + expect(streamed).toContain("hello-from-shell"); + }); +}); diff --git a/packages/tool-shell/src/shell.ts b/packages/tool-shell/src/shell.ts new file mode 100644 index 0000000..d96d73e --- /dev/null +++ b/packages/tool-shell/src/shell.ts @@ -0,0 +1,181 @@ +import { resolve } from "node:path"; +import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; + +const DEFAULT_TIMEOUT = 120_000; +const OUTPUT_CAP = 50_000; + +export interface ValidatedArgs { + readonly command: string; + readonly timeout: number; +} + +export interface SpawnResult { + readonly exitCode: number | null; + readonly timedOut: boolean; +} + +export type SpawnShell = (params: { + readonly command: string; + readonly cwd: string; + readonly signal: AbortSignal; + readonly timeout: number; + readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; +}) => Promise<SpawnResult>; + +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 rawCommand = obj.command; + if (typeof rawCommand !== "string" || rawCommand.trim().length === 0) { + return { error: 'Error: Missing or empty "command" parameter (must be a non-empty string).' }; + } + + let timeout = DEFAULT_TIMEOUT; + if (obj.timeout !== undefined) { + const n = Number(obj.timeout); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "timeout" parameter (must be a positive number).' }; + } + timeout = Math.floor(n); + } + + return { command: rawCommand, timeout }; +} + +export function truncateOutput(output: string, cap: number): string { + if (output.length <= cap) { + return output; + } + const truncated = output.slice(0, cap); + return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`; +} + +export function buildResult(params: { + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly aborted: boolean; + readonly output: string; + readonly cap: number; +}): ToolResult { + if (params.aborted) { + return { + content: truncateOutput(params.output, params.cap), + isError: true, + }; + } + if (params.timedOut) { + const content = truncateOutput(params.output, params.cap); + return { + content: `${content}\n\n[Command timed out]`, + isError: true, + }; + } + const exitCode = params.exitCode; + if (exitCode !== null && exitCode !== 0) { + return { + content: truncateOutput(params.output, params.cap), + isError: true, + }; + } + return { + content: truncateOutput(params.output, params.cap), + }; +} + +export function createRunShellTool(deps: { + readonly workdir: string; + readonly spawn: SpawnShell; + readonly outputCap?: number; +}): ToolContract { + const workdir = resolve(deps.workdir); + const cap = deps.outputCap ?? OUTPUT_CAP; + + return { + name: "run_shell", + description: + "Execute a shell command and return its output. " + + "Use for running CLI tools, scripts, or system commands.", + parameters: { + type: "object", + properties: { + command: { + type: "string", + description: "The shell command to execute.", + }, + timeout: { + type: "number", + description: `Timeout in milliseconds (default: ${DEFAULT_TIMEOUT}).`, + default: DEFAULT_TIMEOUT, + }, + }, + required: ["command"], + }, + concurrencySafe: false, + async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + + const { command, timeout } = validated; + const effectiveCwd = ctx.cwd ? resolve(ctx.cwd) : workdir; + + if (ctx.signal.aborted) { + return buildResult({ + exitCode: null, + timedOut: false, + aborted: true, + output: "", + cap, + }); + } + + let output = ""; + const appendOutput = (data: string, _stream: "stdout" | "stderr") => { + output += data; + }; + + let spawnResult: SpawnResult; + let aborted = false; + + try { + spawnResult = await deps.spawn({ + command, + cwd: effectiveCwd, + signal: ctx.signal, + timeout, + onOutput: (data, stream) => { + ctx.onOutput(data, stream); + appendOutput(data, stream); + }, + }); + } catch (err: unknown) { + if (ctx.signal.aborted) { + aborted = true; + return buildResult({ + exitCode: null, + timedOut: false, + aborted: true, + output, + cap, + }); + } + return { + content: `Error spawning command: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + return buildResult({ + exitCode: spawnResult.exitCode, + timedOut: spawnResult.timedOut, + aborted, + output, + cap, + }); + }, + }; +} diff --git a/packages/tool-shell/src/spawn.ts b/packages/tool-shell/src/spawn.ts new file mode 100644 index 0000000..9025c26 --- /dev/null +++ b/packages/tool-shell/src/spawn.ts @@ -0,0 +1,46 @@ +import { spawn as nodeSpawn } from "node:child_process"; +import type { SpawnResult, SpawnShell } from "./shell.js"; + +export const realSpawn: SpawnShell = (params): Promise<SpawnResult> => { + return new Promise<SpawnResult>((resolve) => { + const child = nodeSpawn("sh", ["-c", params.command], { + cwd: params.cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + + let timedOut = false; + let killed = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, params.timeout); + + const onAbort = () => { + killed = true; + child.kill("SIGKILL"); + }; + params.signal.addEventListener("abort", onAbort, { once: true }); + + child.stdout.on("data", (chunk: Buffer) => { + params.onOutput(chunk.toString(), "stdout"); + }); + + child.stderr.on("data", (chunk: Buffer) => { + params.onOutput(chunk.toString(), "stderr"); + }); + + child.on("close", (code) => { + clearTimeout(timer); + params.signal.removeEventListener("abort", onAbort); + resolve({ exitCode: code, timedOut }); + }); + + child.on("error", () => { + clearTimeout(timer); + params.signal.removeEventListener("abort", onAbort); + if (!killed && !timedOut) { + resolve({ exitCode: 1, timedOut: false }); + } + }); + }); +}; |
