// Copyright (C) 2026 Rob Colbert // Licensed under the Apache License, Version 2.0 import { exec } from "node:child_process"; import path from "node:path"; import { promisify } from "node:util"; import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; import { DroneTool } from "../tool.ts"; import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts"; const execAsync = promisify(exec); const DEFAULT_TIMEOUT = 30_000; const MAX_TIMEOUT = 120_000; export class ShellExecTool extends DroneTool { get name(): string { return "shell_exec"; } get category(): string { return "system"; } public definition: IToolDefinition = { type: "function", function: { name: "shell_exec", description: "Execute a shell command in the project directory using Bash. Returns stdout, stderr, and exit code. For temporary/scratch work, create and use a tmp directory inside the .gadget workspace directory.", parameters: { type: "object", properties: { command: { type: "string", description: "The shell command to execute with Bash.", }, cwd: { type: "string", description: "Working directory for the command, relative to the project root. Defaults to the project root.", }, timeout: { type: "number", description: `Timeout in milliseconds. Default: ${DEFAULT_TIMEOUT / 1000}s, max: ${MAX_TIMEOUT / 1000}s.`, }, }, required: ["command"], }, }, }; public async execute(args: IToolArguments, logger: IAiLogger): Promise { const command = args.command; if (typeof command !== "string" || command.trim().length === 0) { return toolError({ code: "MISSING_PARAMETER", message: "Command must not be empty.", parameter: "command", recoveryHint: "Provide a valid shell command to execute with Bash.", }); } const projectRoot = getProjectRoot(this.toolbox); if (!projectRoot) { return toolError({ code: "OPERATION_NOT_ALLOWED", message: "No active project workspace is configured for shell execution.", recoveryHint: "Run this tool during an active Agent work order.", }); } let resolvedCwd: string; if (args.cwd !== undefined && typeof args.cwd === "string") { const resolved = resolveProjectPath(this.toolbox, args.cwd); if (typeof resolved === "string") return resolved; resolvedCwd = resolved.absolutePath; } else { resolvedCwd = path.resolve(projectRoot); } const timeout = Math.min( typeof args.timeout === "number" ? args.timeout : DEFAULT_TIMEOUT, MAX_TIMEOUT, ); try { const { stdout, stderr } = await execAsync(command, { cwd: resolvedCwd, shell: "/bin/bash", timeout, maxBuffer: 1024 * 1024 * 10, }); return this.formatOutput(command, stdout, stderr, 0); } catch (error) { if (error instanceof Error) { const nodeError = error as NodeJS.ErrnoException & { code?: string; stdout?: string; stderr?: string; }; if (nodeError.code === "ETIMEDOUT" || nodeError.message.includes("timeout")) { return toolError({ code: "TIMEOUT", message: `Command timed out after ${timeout / 1000}s: ${command}`, recoveryHint: `Increase the timeout parameter (max ${MAX_TIMEOUT / 1000}s) or break the command into smaller steps.`, }); } const stdout = nodeError.stdout ?? ""; const stderr = nodeError.stderr ?? nodeError.message; const exitCode = typeof nodeError.code === "string" ? parseInt(nodeError.code.replace("ERR_", ""), 10) : 1; return this.formatOutput( command, stdout, stderr, isNaN(exitCode) ? 1 : exitCode, ); } logger.error("failed to execute command", { command, error: String(error) }); return toolError({ code: "OPERATION_FAILED", message: `Failed to execute command: ${String(error)}`, }); } } private formatOutput( command: string, stdout: string, stderr: string, exitCode: number, ): string { return [ `COMMAND: ${command}`, `EXIT CODE: ${exitCode}`, "---[stdout]", stdout || "N/A", "---[stderr]", stderr || "N/A", ].join("\n"); } }