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 6b84d1bff8a..538087d0049 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 @@ -52,11 +53,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 eff52b1d307..9c94426a8ff 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. @@ -81,7 +81,7 @@ IMPORTANT: ONLY COMMIT IF THE USER ASKS YOU TO. If and only if the user asks you to create a new git commit, follow these steps carefully: -1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following bash commands in parallel, each using the Bash tool: +1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS 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. @@ -120,7 +120,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 have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS 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 have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS 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..96d955f1740 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -34,6 +34,115 @@ 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 NOT contain references to other shells (shell-agnostic validation) + const commonShells = ["bash", "zsh", "fish", "ksh", "csh", "tcsh", "dash", "cmd", "powershell"] + for (const otherShell of commonShells) { + if (otherShell !== detectedShell) { + expect(bash.description).not.toContain(`${otherShell} command`) + expect(bash.description).not.toContain(`${otherShell} 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", () => {