refactor: extract shell tool factory to eliminate duplication

This commit is contained in:
LukeParkerDev 2026-03-30 20:15:58 +10:00
parent 67dfbcbcfd
commit 3e26c3ae83
4 changed files with 88 additions and 225 deletions

View file

@ -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`).",
)

View file

@ -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 }`",
)

View file

@ -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`).",
)

View file

@ -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) => {