diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index 2e8d48bfd92..9c7c163f4b8 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -33,7 +33,6 @@ export namespace Shell { } } } - const BLACKLIST = new Set(["fish", "nu"]) function fallback() { if (process.platform === "win32") { @@ -58,10 +57,4 @@ export namespace Shell { if (s) return s return fallback() }) - - export const acceptable = lazy(() => { - const s = process.env.SHELL - if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s) : path.basename(s))) return s - return fallback() - }) } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 92d4ced0f7b..fdd99501ecb 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -15,6 +15,7 @@ import { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" import path from "path" import { Shell } from "@/shell/shell" +import { iife } from "@/util/iife" const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -51,11 +52,56 @@ const parser = lazy(async () => { // TODO: we may wanna rename this tool so it works better on other shells export const BashTool = Tool.define("bash", async () => { - const shell = Shell.acceptable() - log.info("bash tool using shell", { shell }) + const shell = (() => { + const s = process.env.SHELL + if (s) return s + + if (process.platform === "darwin") { + return "/bin/zsh" + } + + if (process.platform === "win32") { + return process.env.COMSPEC || true + } + + const bash = Bun.which("bash") + if (bash) return bash + + return true + })() + + const shellName = (() => { + if (typeof shell === "boolean") { + // When shell is true (fallback), assume appropriate default for platform + return process.platform === "win32" ? "cmd" : "bash" + } + if (typeof shell === "string") { + let name = path.basename(shell) + // Handle Windows paths (both forward and back slashes) + if (shell.includes("\\") || shell.includes("/")) { + // Extract the last part after both types of separators + const parts = shell.split(/[\\/]/) + name = parts[parts.length - 1] + } + // Handle Windows executables + if (name.toLowerCase().endsWith(".exe")) { + return name.slice(0, -4) + } + return name + } + return "bash" + })() + + log.info("bash tool using shell", { shell, shellName }) + + const description = `**Shell**: You are executing commands in \`${shellName}\`. Ensure your command syntax is compatible with this shell. + +${DESCRIPTION.replace(/\$\{shellName\} command/g, `${shellName} command`) + .replace(/\$\{shellName\} commands/g, `${shellName} commands`) + .replaceAll("${directory}", Instance.directory)}` return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory), + description, parameters: z.object({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index c31263c04eb..f2d0b97160a 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -1,4 +1,4 @@ -Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. +Executes a given ${shellName} command in a persistent shell session with optional timeout, ensuring proper handling and security measures. All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. @@ -64,7 +64,7 @@ Git Safety Protocol: - CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push) - NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${shellName} commands in parallel, each using the Bash tool: - Run a git status command to see all untracked files. - Run a git diff command to see both staged and unstaged changes that will be committed. - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. @@ -92,7 +92,7 @@ Use the gh command via the Bash tool for ALL GitHub-related tasks including work IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${shellName} commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: - Run a git status command to see all untracked files - Run a git diff command to see both staged and unstaged changes that will be committed - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 9ef7dfb9d8f..6697e3b0dcb 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -34,6 +34,106 @@ describe("tool.bash", () => { }, }) }) + + test("description includes shell information", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + expect(bash.description).toContain("**Shell**:") + expect(bash.description).toContain("Ensure your command syntax is compatible with this shell") + // Should contain a shell name (bash, zsh, fish, etc.) + const shellMatch = bash.description.match(/You are executing commands in `([^`]+)`/) + expect(shellMatch).toBeTruthy() + expect(shellMatch?.[1]).toBeTruthy() + }, + }) + }) + + test("shell name detection is platform-aware", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const shellMatch = bash.description.match(/You are executing commands in `([^`]+)`/) + const detectedShell = shellMatch?.[1] + + expect(detectedShell).toBeTruthy() + + // Verify detected shell is appropriate for the platform + if (process.platform === "win32") { + expect(["cmd", "powershell"]).toContain(detectedShell!) + } else { + expect(["bash", "zsh", "fish", "ksh", "csh", "tcsh", "dash"]).toContain(detectedShell!) + } + }, + }) + }) + + test("description uses dynamic shell-specific language", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const shellMatch = bash.description.match(/You are executing commands in `([^`]+)`/) + const detectedShell = shellMatch?.[1] + + expect(detectedShell).toBeTruthy() + + // Should contain shell-specific command references + if (detectedShell) { + expect(bash.description).toContain(`${detectedShell} command`) + expect(bash.description).toContain(`${detectedShell} commands`) + } + + // Should still contain "Bash tool" references (tool name) + expect(bash.description).toContain("Bash tool") + }, + }) + }) + + test("shell-specific language works for different shell types", async () => { + // Test with different shell environments + const originalShell = process.env.SHELL + + await Instance.provide({ + directory: projectRoot, + fn: async () => { + try { + // Mock zsh shell environment + process.env.SHELL = "/bin/zsh" + const bashZsh = await BashTool.init() + expect(bashZsh.description).toContain("zsh command") + expect(bashZsh.description).toContain("zsh commands") + + // Mock bash shell environment + process.env.SHELL = "/bin/bash" + const bashBash = await BashTool.init() + expect(bashBash.description).toContain("bash command") + expect(bashBash.description).toContain("bash commands") + + // Mock ksh shell environment + process.env.SHELL = "/bin/ksh" + const bashKsh = await BashTool.init() + expect(bashKsh.description).toContain("ksh command") + expect(bashKsh.description).toContain("ksh commands") + + // Mock fish shell environment (fish is now supported, not blacklisted) + process.env.SHELL = "/usr/bin/fish" + const bashFish = await BashTool.init() + expect(bashFish.description).toContain("fish command") + expect(bashFish.description).toContain("fish commands") + } finally { + // Restore original shell + if (originalShell) { + process.env.SHELL = originalShell + } else { + delete process.env.SHELL + } + } + }, + }) + }) }) describe("tool.bash permissions", () => {