diff --git a/packages/opencode/src/tool/shell/bash.ts b/packages/opencode/src/tool/shell/bash.ts index 98e05c82d6..3539f18482 100644 --- a/packages/opencode/src/tool/shell/bash.ts +++ b/packages/opencode/src/tool/shell/bash.ts @@ -1,76 +1,7 @@ -import z from "zod" -import { Tool } from "../tool" -import DESCRIPTION from "./shell.txt" -import { Log } from "@/util/log" -import { Instance } from "@/project/instance" -import { Flag } from "@/flag/flag" -import { Shell } from "@/shell/shell" -import { resolvePath, formatShellDescription, askPermission } from "./util" -import { ShellParser } from "./parser" -import { ShellRunner } from "./runner" +import { createShellTool } from "./util" -export const log = Log.create({ service: "bash-tool" }) - -const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 - -const NAME = "bash" - -export const BashTool = Tool.define(NAME, async () => { - const shell = Shell.acceptable() - const name = Shell.name(shell) - log.info("bash tool using shell", { shell, name }) - - return { - description: formatShellDescription(DESCRIPTION, { - name, - shellName: "Bash", - chaining: - "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", - }), - parameters: z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .describe( - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), - }), - async execute(params, ctx) { - const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory - if (params.timeout !== undefined && params.timeout < 0) { - throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) - } - const timeout = params.timeout ?? DEFAULT_TIMEOUT - - const scan = await ShellParser.collect({ - command: params.command, - cwd, - shell, - shellType: NAME, - }) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - - await askPermission(ctx, scan, NAME) - - return ShellRunner.run( - { - shell, - name, - command: params.command, - cwd, - env: await ShellRunner.shellEnv(ctx, cwd), - timeout, - description: params.description, - }, - ctx, - ) - }, - } -}) +export const BashTool = createShellTool( + "bash", + "Bash", + "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", +) diff --git a/packages/opencode/src/tool/shell/powershell.ts b/packages/opencode/src/tool/shell/powershell.ts index cc63ac2c02..0910ec1347 100644 --- a/packages/opencode/src/tool/shell/powershell.ts +++ b/packages/opencode/src/tool/shell/powershell.ts @@ -1,76 +1,7 @@ -import z from "zod" -import { Tool } from "../tool" -import DESCRIPTION from "./shell.txt" -import { Log } from "@/util/log" -import { Instance } from "@/project/instance" -import { Flag } from "@/flag/flag" -import { Shell } from "@/shell/shell" -import { resolvePath, formatShellDescription, askPermission } from "./util" -import { ShellParser } from "./parser" -import { ShellRunner } from "./runner" +import { createShellTool } from "./util" -export const log = Log.create({ service: "powershell-tool" }) - -const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 - -const NAME = "powershell" - -export const PowershellTool = Tool.define(NAME, async () => { - const shell = Shell.acceptable() - const name = Shell.name(shell) - log.info("powershell tool using shell", { shell, name }) - - return { - description: formatShellDescription(DESCRIPTION, { - name, - shellName: "Windows PowerShell", - chaining: - "avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }`", - }), - parameters: z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .describe( - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), - }), - async execute(params, ctx) { - const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory - if (params.timeout !== undefined && params.timeout < 0) { - throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) - } - const timeout = params.timeout ?? DEFAULT_TIMEOUT - - const scan = await ShellParser.collect({ - command: params.command, - cwd, - shell, - shellType: NAME, - }) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - - await askPermission(ctx, scan, NAME) - - return ShellRunner.run( - { - shell, - name, - command: params.command, - cwd, - env: await ShellRunner.shellEnv(ctx, cwd), - timeout, - description: params.description, - }, - ctx, - ) - }, - } -}) +export const PowershellTool = createShellTool( + "powershell", + "Windows PowerShell", + "avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }`", +) diff --git a/packages/opencode/src/tool/shell/pwsh.ts b/packages/opencode/src/tool/shell/pwsh.ts index 525c9d4be3..d22dc2bd70 100644 --- a/packages/opencode/src/tool/shell/pwsh.ts +++ b/packages/opencode/src/tool/shell/pwsh.ts @@ -1,76 +1,7 @@ -import z from "zod" -import { Tool } from "../tool" -import DESCRIPTION from "./shell.txt" -import { Log } from "@/util/log" -import { Instance } from "@/project/instance" -import { Flag } from "@/flag/flag" -import { Shell } from "@/shell/shell" -import { resolvePath, formatShellDescription, askPermission } from "./util" -import { ShellParser } from "./parser" -import { ShellRunner } from "./runner" +import { createShellTool } from "./util" -export const log = Log.create({ service: "pwsh-tool" }) - -const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 - -const NAME = "pwsh" - -export const PwshTool = Tool.define(NAME, async () => { - const shell = Shell.acceptable() - const name = Shell.name(shell) - log.info("pwsh tool using shell", { shell, name }) - - return { - description: formatShellDescription(DESCRIPTION, { - name, - shellName: "PowerShell Core", - chaining: - "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", - }), - parameters: z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .describe( - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), - }), - async execute(params, ctx) { - const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory - if (params.timeout !== undefined && params.timeout < 0) { - throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) - } - const timeout = params.timeout ?? DEFAULT_TIMEOUT - - const scan = await ShellParser.collect({ - command: params.command, - cwd, - shell, - shellType: NAME, - }) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - - await askPermission(ctx, scan, NAME) - - return ShellRunner.run( - { - shell, - name, - command: params.command, - cwd, - env: await ShellRunner.shellEnv(ctx, cwd), - timeout, - description: params.description, - }, - ctx, - ) - }, - } -}) +export const PwshTool = createShellTool( + "pwsh", + "PowerShell Core", + "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", +) diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts index 0091ff8e8c..335fe3432f 100644 --- a/packages/opencode/src/tool/shell/util.ts +++ b/packages/opencode/src/tool/shell/util.ts @@ -91,6 +91,76 @@ export function formatShellDescription(template: string, opts: { name: string; s .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)) } +import z from "zod" +import DESCRIPTION from "./shell.txt" +import { Log } from "@/util/log" +import { Flag } from "@/flag/flag" +import { ShellParser } from "./parser" +import { ShellRunner } from "./runner" + +export type ShellType = "bash" | "pwsh" | "powershell" + +const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 + +export function createShellTool(id: ShellType, shellName: string, chaining: string) { + const log = Log.create({ service: `${id}-tool` }) + + return Tool.define(id, async () => { + const shell = Shell.acceptable() + const name = Shell.name(shell) + log.info(`${id} tool using shell`, { shell, name }) + + return { + description: formatShellDescription(DESCRIPTION, { name, shellName, chaining }), + parameters: z.object({ + command: z.string().describe("The command to execute"), + timeout: z.number().describe("Optional timeout in milliseconds").optional(), + workdir: z + .string() + .describe( + `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, + ) + .optional(), + description: z + .string() + .describe( + "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + ), + }), + async execute(params, ctx) { + const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory + if (params.timeout !== undefined && params.timeout < 0) { + throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) + } + const timeout = params.timeout ?? DEFAULT_TIMEOUT + + const scan = await ShellParser.collect({ + command: params.command, + cwd, + shell, + shellType: id, + }) + if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + + await askPermission(ctx, scan, id) + + return ShellRunner.run( + { + shell, + name, + command: params.command, + cwd, + env: await ShellRunner.shellEnv(ctx, cwd), + timeout, + description: params.description, + }, + ctx, + ) + }, + } + }) +} + export async function askPermission(ctx: Tool.Context, scan: Scan, permissionName: string = "bash") { if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => {