From 048ac63abd13a158e7dcca9d87a780e8e30b38ff Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:08:27 +1000 Subject: [PATCH 01/27] refactor: split monolithic bash tool into separate bash/pwsh/powershell tools --- packages/opencode/src/acp/agent.ts | 8 +- packages/opencode/src/agent/agent.ts | 2 + packages/opencode/src/cli/cmd/agent.ts | 15 +- packages/opencode/src/cli/cmd/run.ts | 5 +- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- .../cli/cmd/tui/routes/session/permission.tsx | 2 +- packages/opencode/src/config/config.ts | 4 + packages/opencode/src/permission/arity.ts | 163 ------ packages/opencode/src/permission/index.ts | 30 ++ packages/opencode/src/session/prompt.ts | 6 +- packages/opencode/src/tool/bash.ts | 500 ------------------ packages/opencode/src/tool/registry.ts | 10 +- packages/opencode/src/tool/shell/arity.ts | 157 ++++++ packages/opencode/src/tool/shell/bash.ts | 76 +++ packages/opencode/src/tool/shell/parser.ts | 203 +++++++ .../opencode/src/tool/shell/powershell.ts | 76 +++ packages/opencode/src/tool/shell/pwsh.ts | 76 +++ packages/opencode/src/tool/shell/runner.ts | 140 +++++ .../src/tool/{bash.txt => shell/shell.txt} | 28 +- packages/opencode/src/tool/shell/util.ts | 115 ++++ .../opencode/test/permission/arity.test.ts | 34 +- .../opencode/test/permission/next.test.ts | 14 +- .../test/tool/{bash.test.ts => shell.test.ts} | 118 +++-- 23 files changed, 1030 insertions(+), 756 deletions(-) delete mode 100644 packages/opencode/src/permission/arity.ts delete mode 100644 packages/opencode/src/tool/bash.ts create mode 100644 packages/opencode/src/tool/shell/arity.ts create mode 100644 packages/opencode/src/tool/shell/bash.ts create mode 100644 packages/opencode/src/tool/shell/parser.ts create mode 100644 packages/opencode/src/tool/shell/powershell.ts create mode 100644 packages/opencode/src/tool/shell/pwsh.ts create mode 100644 packages/opencode/src/tool/shell/runner.ts rename packages/opencode/src/tool/{bash.txt => shell/shell.txt} (77%) create mode 100644 packages/opencode/src/tool/shell/util.ts rename packages/opencode/test/tool/{bash.test.ts => shell.test.ts} (92%) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2a6bbbb1e4..ddcdebeceb 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -285,7 +285,7 @@ export namespace ACP { const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (part.tool === "bash") { + if (part.tool === "bash" || part.tool === "pwsh" || part.tool === "powershell") { if (this.bashSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ @@ -1081,7 +1081,7 @@ export namespace ACP { } private bashOutput(part: ToolPart) { - if (part.tool !== "bash") return + if (part.tool !== "bash" && part.tool !== "pwsh" && part.tool !== "powershell") return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1484,6 +1484,8 @@ export namespace ACP { const tool = toolName.toLocaleLowerCase() switch (tool) { case "bash": + case "pwsh": + case "powershell": return "execute" case "webfetch": return "fetch" @@ -1519,6 +1521,8 @@ export namespace ACP { case "grep": return input["path"] ? [{ path: input["path"] }] : [] case "bash": + case "pwsh": + case "powershell": return [] case "list": return input["path"] ? [{ path: input["path"] }] : [] diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 53c655d1b3..7ce4daf8db 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -167,6 +167,8 @@ export namespace Agent { glob: "allow", list: "allow", bash: "allow", + pwsh: "allow", + powershell: "allow", webfetch: "allow", websearch: "allow", codesearch: "allow", diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 70082c8e2e..a8e73c90b7 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -14,7 +14,20 @@ import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" -const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"] +const AVAILABLE_TOOLS = [ + "bash", + "pwsh", + "powershell", + "read", + "write", + "edit", + "list", + "glob", + "grep", + "webfetch", + "task", + "todowrite", +] const AgentCreateCommand = cmd({ command: "create", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0aeb864e86..615cbf6494 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -24,7 +24,7 @@ import { CodeSearchTool } from "../../tool/codesearch" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/bash" +import { BashTool } from "../../tool/shell/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" @@ -411,7 +411,8 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (part.tool === "bash") return bash(props(part)) + if (part.tool === "bash" || part.tool === "pwsh" || part.tool === "powershell") + return bash(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "list") return list(props(part)) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index fb62de9acf..14282e825a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -35,7 +35,7 @@ import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { BashTool } from "@/tool/bash" +import { BashTool } from "@/tool/shell/bash" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1514,7 +1514,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a50cd96fc8..e8a5b4c0ef 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -280,7 +280,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "bash") { + if (permission === "bash" || permission === "pwsh" || permission === "powershell") { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3cbb341623..f1374e0686 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -639,6 +639,10 @@ export namespace Config { // write, edit, patch, multiedit all map to edit permission if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { permission.edit = action + } else if (tool === "bash") { + permission.bash = action + permission.pwsh = action + permission.powershell = action } else { permission[tool] = action } diff --git a/packages/opencode/src/permission/arity.ts b/packages/opencode/src/permission/arity.ts deleted file mode 100644 index 948841c8e7..0000000000 --- a/packages/opencode/src/permission/arity.ts +++ /dev/null @@ -1,163 +0,0 @@ -export namespace BashArity { - export function prefix(tokens: string[]) { - for (let len = tokens.length; len > 0; len--) { - const prefix = tokens.slice(0, len).join(" ") - const arity = ARITY[prefix] - if (arity !== undefined) return tokens.slice(0, arity) - } - if (tokens.length === 0) return [] - return tokens.slice(0, 1) - } - - /* Generated with following prompt: -You are generating a dictionary of command-prefix arities for bash-style commands. -This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command. -2. **Flags NEVER count as tokens**. Only subcommands count. -3. **Longest matching prefix wins**. -4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity. -5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical -6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` → `touch` (arity 1, explicitly listed) -* `git checkout main` → `git checkout` (because `git` has arity 2) -* `npm install` → `npm install` (because `npm` has arity 2) -* `npm run dev` → `npm run dev` (because `npm run` has arity 3) -* `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.** -*/ - const ARITY: Record = { - cat: 1, // cat file.txt - cd: 1, // cd /path/to/dir - chmod: 1, // chmod 755 script.sh - chown: 1, // chown user:group file.txt - cp: 1, // cp source.txt dest.txt - echo: 1, // echo "hello world" - env: 1, // env - export: 1, // export PATH=/usr/bin - grep: 1, // grep pattern file.txt - kill: 1, // kill 1234 - killall: 1, // killall process - ln: 1, // ln -s source target - ls: 1, // ls -la - mkdir: 1, // mkdir new-dir - mv: 1, // mv old.txt new.txt - ps: 1, // ps aux - pwd: 1, // pwd - rm: 1, // rm file.txt - rmdir: 1, // rmdir empty-dir - sleep: 1, // sleep 5 - source: 1, // source ~/.bashrc - tail: 1, // tail -f log.txt - touch: 1, // touch file.txt - unset: 1, // unset VAR - which: 1, // which node - aws: 3, // aws s3 ls - az: 3, // az storage blob list - bazel: 2, // bazel build - brew: 2, // brew install node - bun: 2, // bun install - "bun run": 3, // bun run dev - "bun x": 3, // bun x vite - cargo: 2, // cargo build - "cargo add": 3, // cargo add tokio - "cargo run": 3, // cargo run main - cdk: 2, // cdk deploy - cf: 2, // cf push app - cmake: 2, // cmake build - composer: 2, // composer require laravel - consul: 2, // consul members - "consul kv": 3, // consul kv get config/app - crictl: 2, // crictl ps - deno: 2, // deno run server.ts - "deno task": 3, // deno task dev - doctl: 3, // doctl kubernetes cluster list - docker: 2, // docker run nginx - "docker builder": 3, // docker builder prune - "docker compose": 3, // docker compose up - "docker container": 3, // docker container ls - "docker image": 3, // docker image prune - "docker network": 3, // docker network inspect - "docker volume": 3, // docker volume ls - eksctl: 2, // eksctl get clusters - "eksctl create": 3, // eksctl create cluster - firebase: 2, // firebase deploy - flyctl: 2, // flyctl deploy - gcloud: 3, // gcloud compute instances list - gh: 3, // gh pr list - git: 2, // git checkout main - "git config": 3, // git config user.name - "git remote": 3, // git remote add origin - "git stash": 3, // git stash pop - go: 2, // go build - gradle: 2, // gradle build - helm: 2, // helm install mychart - heroku: 2, // heroku logs - hugo: 2, // hugo new site blog - ip: 2, // ip link show - "ip addr": 3, // ip addr show - "ip link": 3, // ip link set eth0 up - "ip netns": 3, // ip netns exec foo bash - "ip route": 3, // ip route add default via 1.1.1.1 - kind: 2, // kind delete cluster - "kind create": 3, // kind create cluster - kubectl: 2, // kubectl get pods - "kubectl kustomize": 3, // kubectl kustomize overlays/dev - "kubectl rollout": 3, // kubectl rollout restart deploy/api - kustomize: 2, // kustomize build . - make: 2, // make build - mc: 2, // mc ls myminio - "mc admin": 3, // mc admin info myminio - minikube: 2, // minikube start - mongosh: 2, // mongosh test - mysql: 2, // mysql -u root - mvn: 2, // mvn compile - ng: 2, // ng generate component home - npm: 2, // npm install - "npm exec": 3, // npm exec vite - "npm init": 3, // npm init vue - "npm run": 3, // npm run dev - "npm view": 3, // npm view react version - nvm: 2, // nvm use 18 - nx: 2, // nx build - openssl: 2, // openssl genrsa 2048 - "openssl req": 3, // openssl req -new -key key.pem - "openssl x509": 3, // openssl x509 -in cert.pem - pip: 2, // pip install numpy - pipenv: 2, // pipenv install flask - pnpm: 2, // pnpm install - "pnpm dlx": 3, // pnpm dlx create-next-app - "pnpm exec": 3, // pnpm exec vite - "pnpm run": 3, // pnpm run dev - poetry: 2, // poetry add requests - podman: 2, // podman run alpine - "podman container": 3, // podman container ls - "podman image": 3, // podman image prune - psql: 2, // psql -d mydb - pulumi: 2, // pulumi up - "pulumi stack": 3, // pulumi stack output - pyenv: 2, // pyenv install 3.11 - python: 2, // python -m venv env - rake: 2, // rake db:migrate - rbenv: 2, // rbenv install 3.2.0 - "redis-cli": 2, // redis-cli ping - rustup: 2, // rustup update - serverless: 2, // serverless invoke - sfdx: 3, // sfdx force:org:list - skaffold: 2, // skaffold dev - sls: 2, // sls deploy - sst: 2, // sst deploy - swift: 2, // swift build - systemctl: 2, // systemctl restart nginx - terraform: 2, // terraform apply - "terraform workspace": 3, // terraform workspace select prod - tmux: 2, // tmux new -s dev - turbo: 2, // turbo run build - ufw: 2, // ufw allow 22 - vault: 2, // vault login - "vault auth": 3, // vault auth list - "vault kv": 3, // vault kv get secret/api - vercel: 2, // vercel deploy - volta: 2, // volta install node - wp: 2, // wp plugin install - yarn: 2, // yarn add react - "yarn dlx": 3, // yarn dlx create-react-app - "yarn run": 3, // yarn run dev - } -} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1a7bd2c610..c4909566ad 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -278,6 +278,36 @@ export namespace Permission { export function fromConfig(permission: Config.Permission) { const ruleset: Ruleset = [] for (const [key, value] of Object.entries(permission)) { + if (key === "bash") { + if (typeof value === "string") { + ruleset.push({ permission: "bash", action: value, pattern: "*" }) + ruleset.push({ permission: "pwsh", action: value, pattern: "*" }) + ruleset.push({ permission: "powershell", action: value, pattern: "*" }) + } else { + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ + permission: "bash", + pattern: expand(pattern), + action, + })), + ) + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ + permission: "pwsh", + pattern: expand(pattern), + action, + })), + ) + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ + permission: "powershell", + pattern: expand(pattern), + action, + })), + ) + } + continue + } if (typeof value === "string") { ruleset.push({ permission: key, action: value, pattern: "*" }) continue diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a9edf838ca..ad265b1483 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1629,12 +1629,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: model.providerID, } await Session.updateMessage(msg) + const shell = Shell.preferred() + const shellName = Shell.name(shell) const part: MessageV2.Part = { type: "tool", id: PartID.ascending(), messageID: msg.id, sessionID: input.sessionID, - tool: "bash", + tool: shellName === "pwsh" ? "pwsh" : shellName === "powershell" ? "powershell" : "bash", callID: ulid(), state: { status: "running", @@ -1647,8 +1649,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, } await Session.updatePart(part) - const shell = Shell.preferred() - const shellName = Shell.name(shell) const invocations: Record = { nu: { diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts deleted file mode 100644 index 50aa9e14ad..0000000000 --- a/packages/opencode/src/tool/bash.ts +++ /dev/null @@ -1,500 +0,0 @@ -import z from "zod" -import os from "os" -import { spawn } from "child_process" -import { Tool } from "./tool" -import path from "path" -import DESCRIPTION from "./bash.txt" -import { Log } from "../util/log" -import { Instance } from "../project/instance" -import { lazy } from "@/util/lazy" -import { Language, type Node } from "web-tree-sitter" - -import { Filesystem } from "@/util/filesystem" -import { Process } from "@/util/process" -import { fileURLToPath } from "url" -import { Flag } from "@/flag/flag" -import { Shell } from "@/shell/shell" - -import { BashArity } from "@/permission/arity" -import { Truncate } from "./truncate" -import { Plugin } from "@/plugin" - -const MAX_METADATA_LENGTH = 30_000 -const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -const PS = new Set(["powershell", "pwsh"]) -const CWD = new Set(["cd", "push-location", "set-location"]) -const FILES = new Set([ - ...CWD, - "rm", - "cp", - "mv", - "mkdir", - "touch", - "chmod", - "chown", - "cat", - // Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir - // already hit the entries above, and alias normalization should happen in one - // place later so we do not risk double-prompting. - "get-content", - "set-content", - "add-content", - "copy-item", - "move-item", - "remove-item", - "new-item", - "rename-item", -]) -const FLAGS = new Set(["-destination", "-literalpath", "-path"]) -const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) - -type Part = { - type: string - text: string -} - -type Scan = { - dirs: Set - patterns: Set - always: Set -} - -export const log = Log.create({ service: "bash-tool" }) - -const resolveWasm = (asset: string) => { - if (asset.startsWith("file://")) return fileURLToPath(asset) - if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset - const url = new URL(asset, import.meta.url) - return fileURLToPath(url) -} - -function parts(node: Node) { - const out: Part[] = [] - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i) - if (!child) continue - if (child.type === "command_elements") { - for (let j = 0; j < child.childCount; j++) { - const item = child.child(j) - if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue - out.push({ type: item.type, text: item.text }) - } - continue - } - if ( - child.type !== "command_name" && - child.type !== "command_name_expr" && - child.type !== "word" && - child.type !== "string" && - child.type !== "raw_string" && - child.type !== "concatenation" - ) { - continue - } - out.push({ type: child.type, text: child.text }) - } - return out -} - -function source(node: Node) { - return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim() -} - -function commands(node: Node) { - return node.descendantsOfType("command").filter((child): child is Node => Boolean(child)) -} - -function unquote(text: string) { - if (text.length < 2) return text - const first = text[0] - const last = text[text.length - 1] - if ((first === '"' || first === "'") && first === last) return text.slice(1, -1) - return text -} - -function home(text: string) { - if (text === "~") return os.homedir() - if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2)) - return text -} - -function envValue(key: string) { - if (process.platform !== "win32") return process.env[key] - const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase()) - return name ? process.env[name] : undefined -} - -function auto(key: string, cwd: string, shell: string) { - const name = key.toUpperCase() - if (name === "HOME") return os.homedir() - if (name === "PWD") return cwd - if (name === "PSHOME") return path.dirname(shell) -} - -function expand(text: string, cwd: string, shell: string) { - const out = unquote(text) - .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "") - .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "") - .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "") - return home(out) -} - -function provider(text: string) { - const match = text.match(/^([A-Za-z]+)::(.*)$/) - if (match) { - if (match[1].toLowerCase() !== "filesystem") return - return match[2] - } - const prefix = text.match(/^([A-Za-z]+):(.*)$/) - if (!prefix) return text - if (prefix[1].length === 1) return text - return -} - -function dynamic(text: string, ps: boolean) { - if (text.startsWith("(") || text.startsWith("@(")) return true - if (text.includes("$(") || text.includes("${") || text.includes("`")) return true - if (ps) return /\$(?!env:)/i.test(text) - return text.includes("$") -} - -function prefix(text: string) { - const match = /[?*\[]/.exec(text) - if (!match) return text - if (match.index === 0) return - return text.slice(0, match.index) -} - -async function cygpath(shell: string, text: string) { - const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true }) - if (out.code !== 0) return - const file = out.text.trim() - if (!file) return - return Filesystem.normalizePath(file) -} - -async function resolvePath(text: string, root: string, shell: string) { - if (process.platform === "win32") { - if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) { - const file = await cygpath(shell, text) - if (file) return file - } - return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text))) - } - return path.resolve(root, text) -} - -async function argPath(arg: string, cwd: string, ps: boolean, shell: string) { - const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) - const file = text && prefix(text) - if (!file || dynamic(file, ps)) return - const next = ps ? provider(file) : file - if (!next) return - return resolvePath(next, cwd, shell) -} - -function pathArgs(list: Part[], ps: boolean) { - if (!ps) { - return list - .slice(1) - .filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+"))) - .map((item) => item.text) - } - - const out: string[] = [] - let want = false - for (const item of list.slice(1)) { - if (want) { - out.push(item.text) - want = false - continue - } - if (item.type === "command_parameter") { - const flag = item.text.toLowerCase() - if (SWITCHES.has(flag)) continue - want = FLAGS.has(flag) - continue - } - out.push(item.text) - } - return out -} - -async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise { - const scan: Scan = { - dirs: new Set(), - patterns: new Set(), - always: new Set(), - } - - for (const node of commands(root)) { - const command = parts(node) - const tokens = command.map((item) => item.text) - const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] - - if (cmd && FILES.has(cmd)) { - for (const arg of pathArgs(command, ps)) { - const resolved = await argPath(arg, cwd, ps, shell) - log.info("resolved path", { arg, resolved }) - if (!resolved || Instance.containsPath(resolved)) continue - const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved) - scan.dirs.add(dir) - } - } - - if (tokens.length && (!cmd || !CWD.has(cmd))) { - scan.patterns.add(source(node)) - scan.always.add(BashArity.prefix(tokens).join(" ") + " *") - } - } - - return scan -} - -function preview(text: string) { - if (text.length <= MAX_METADATA_LENGTH) return text - return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." -} - -async function parse(command: string, ps: boolean) { - const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command)) - if (!tree) throw new Error("Failed to parse command") - return tree.rootNode -} - -async function ask(ctx: Tool.Context, scan: Scan) { - if (scan.dirs.size > 0) { - const globs = Array.from(scan.dirs).map((dir) => { - if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*")) - return path.join(dir, "*") - }) - await ctx.ask({ - permission: "external_directory", - patterns: globs, - always: globs, - metadata: {}, - }) - } - - if (scan.patterns.size === 0) return - await ctx.ask({ - permission: "bash", - patterns: Array.from(scan.patterns), - always: Array.from(scan.always), - metadata: {}, - }) -} - -async function shellEnv(ctx: Tool.Context, cwd: string) { - const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }) - return { - ...process.env, - ...extra.env, - } -} - -function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { - if (process.platform === "win32" && PS.has(name)) { - return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { - cwd, - env, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - windowsHide: true, - }) - } - - return spawn(command, { - shell, - cwd, - env, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - windowsHide: process.platform === "win32", - }) -} - -async function run( - input: { - shell: string - name: string - command: string - cwd: string - env: NodeJS.ProcessEnv - timeout: number - description: string - }, - ctx: Tool.Context, -) { - const proc = launch(input.shell, input.name, input.command, input.cwd, input.env) - let output = "" - - ctx.metadata({ - metadata: { - output: "", - description: input.description, - }, - }) - - const append = (chunk: Buffer) => { - output += chunk.toString() - ctx.metadata({ - metadata: { - output: preview(output), - description: input.description, - }, - }) - } - - proc.stdout?.on("data", append) - proc.stderr?.on("data", append) - - let expired = false - let aborted = false - let exited = false - - const kill = () => Shell.killTree(proc, { exited: () => exited }) - - if (ctx.abort.aborted) { - aborted = true - await kill() - } - - const abort = () => { - aborted = true - void kill() - } - - ctx.abort.addEventListener("abort", abort, { once: true }) - const timer = setTimeout(() => { - expired = true - void kill() - }, input.timeout + 100) - - await new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timer) - ctx.abort.removeEventListener("abort", abort) - } - - proc.once("exit", () => { - exited = true - }) - - proc.once("close", () => { - exited = true - cleanup() - resolve() - }) - - proc.once("error", (error) => { - exited = true - cleanup() - reject(error) - }) - }) - - const metadata: string[] = [] - if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`) - if (aborted) metadata.push("User aborted the command") - if (metadata.length > 0) { - output += "\n\n\n" + metadata.join("\n") + "\n" - } - - return { - title: input.description, - metadata: { - output: preview(output), - exit: proc.exitCode, - description: input.description, - }, - output, - } -} - -const parser = lazy(async () => { - const { Parser } = await import("web-tree-sitter") - const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { - with: { type: "wasm" }, - }) - const treePath = resolveWasm(treeWasm) - await Parser.init({ - locateFile() { - return treePath - }, - }) - const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { - with: { type: "wasm" }, - }) - const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { - with: { type: "wasm" }, - }) - const bashPath = resolveWasm(bashWasm) - const psPath = resolveWasm(psWasm) - const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)]) - const bash = new Parser() - bash.setLanguage(bashLanguage) - const ps = new Parser() - ps.setLanguage(psLanguage) - return { bash, ps } -}) - -// 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() - const name = Shell.name(shell) - const chain = - name === "powershell" - ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." - : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." - log.info("bash tool using shell", { shell }) - - return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory) - .replaceAll("${os}", process.platform) - .replaceAll("${shell}", name) - .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) - .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), - 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 ps = PS.has(name) - const root = await parse(params.command, ps) - const scan = await collect(root, cwd, ps, shell) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - await ask(ctx, scan) - - return run( - { - shell, - name, - command: params.command, - cwd, - env: await shellEnv(ctx, cwd), - timeout, - description: params.description, - }, - ctx, - ) - }, - } -}) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index eeb7334806..8c187cdbfc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,6 +1,5 @@ import { PlanExitTool } from "./plan" import { QuestionTool } from "./question" -import { BashTool } from "./bash" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" @@ -32,6 +31,10 @@ import { pathToFileURL } from "url" import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { BashTool } from "./shell/bash" +import { PwshTool } from "./shell/pwsh" +import { PowershellTool } from "./shell/powershell" +import { Shell } from "@/shell/shell" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -115,10 +118,13 @@ export namespace ToolRegistry { const cfg = yield* config.get() const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL + const shellName = Shell.name(Shell.acceptable()) + const ActiveShellTool = shellName === "pwsh" ? PwshTool : shellName === "powershell" ? PowershellTool : BashTool + return [ InvalidTool, ...(question ? [QuestionTool] : []), - BashTool, + ActiveShellTool, ReadTool, GlobTool, GrepTool, diff --git a/packages/opencode/src/tool/shell/arity.ts b/packages/opencode/src/tool/shell/arity.ts new file mode 100644 index 0000000000..3ec40b6646 --- /dev/null +++ b/packages/opencode/src/tool/shell/arity.ts @@ -0,0 +1,157 @@ +export namespace ShellArity { + export function prefix(tokens: string[], shellType: "bash" | "pwsh" | "powershell") { + if ( + (shellType === "pwsh" || shellType === "powershell") && + tokens.length > 0 && + /^[a-z]+-[a-z]+$/i.test(tokens[0]) + ) { + return [tokens[0]] + } + for (let len = tokens.length; len > 0; len--) { + const prefix = tokens.slice(0, len).join(" ") + const arity = ARITY[prefix] + if (arity !== undefined) return tokens.slice(0, arity) + } + if (tokens.length === 0) return [] + return tokens.slice(0, 1) + } + + const ARITY: Record = { + cat: 1, + cd: 1, + chmod: 1, + chown: 1, + cp: 1, + echo: 1, + env: 1, + export: 1, + grep: 1, + kill: 1, + killall: 1, + ln: 1, + ls: 1, + mkdir: 1, + mv: 1, + ps: 1, + pwd: 1, + rm: 1, + rmdir: 1, + sleep: 1, + source: 1, + tail: 1, + touch: 1, + unset: 1, + which: 1, + aws: 3, + az: 3, + bazel: 2, + brew: 2, + bun: 2, + "bun run": 3, + "bun x": 3, + cargo: 2, + "cargo add": 3, + "cargo run": 3, + cdk: 2, + cf: 2, + cmake: 2, + composer: 2, + consul: 2, + "consul kv": 3, + crictl: 2, + deno: 2, + "deno task": 3, + doctl: 3, + docker: 2, + "docker builder": 3, + "docker compose": 3, + "docker container": 3, + "docker image": 3, + "docker network": 3, + "docker volume": 3, + eksctl: 2, + "eksctl create": 3, + firebase: 2, + flyctl: 2, + gcloud: 3, + gh: 3, + git: 2, + "git config": 3, + "git remote": 3, + "git stash": 3, + go: 2, + gradle: 2, + helm: 2, + heroku: 2, + hugo: 2, + ip: 2, + "ip addr": 3, + "ip link": 3, + "ip netns": 3, + "ip route": 3, + kind: 2, + "kind create": 3, + kubectl: 2, + "kubectl kustomize": 3, + "kubectl rollout": 3, + kustomize: 2, + make: 2, + mc: 2, + "mc admin": 3, + minikube: 2, + mongosh: 2, + mysql: 2, + mvn: 2, + ng: 2, + npm: 2, + "npm exec": 3, + "npm init": 3, + "npm run": 3, + "npm view": 3, + nvm: 2, + nx: 2, + openssl: 2, + "openssl req": 3, + "openssl x509": 3, + pip: 2, + pipenv: 2, + pnpm: 2, + "pnpm dlx": 3, + "pnpm exec": 3, + "pnpm run": 3, + poetry: 2, + podman: 2, + "podman container": 3, + "podman image": 3, + psql: 2, + pulumi: 2, + "pulumi stack": 3, + pyenv: 2, + python: 2, + rake: 2, + rbenv: 2, + "redis-cli": 2, + rustup: 2, + serverless: 2, + sfdx: 3, + skaffold: 2, + sls: 2, + sst: 2, + swift: 2, + systemctl: 2, + terraform: 2, + "terraform workspace": 3, + tmux: 2, + turbo: 2, + ufw: 2, + vault: 2, + "vault auth": 3, + "vault kv": 3, + vercel: 2, + volta: 2, + wp: 2, + yarn: 2, + "yarn dlx": 3, + "yarn run": 3, + } +} diff --git a/packages/opencode/src/tool/shell/bash.ts b/packages/opencode/src/tool/shell/bash.ts new file mode 100644 index 0000000000..98e05c82d6 --- /dev/null +++ b/packages/opencode/src/tool/shell/bash.ts @@ -0,0 +1,76 @@ +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" + +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, + ) + }, + } +}) diff --git a/packages/opencode/src/tool/shell/parser.ts b/packages/opencode/src/tool/shell/parser.ts new file mode 100644 index 0000000000..c1507553eb --- /dev/null +++ b/packages/opencode/src/tool/shell/parser.ts @@ -0,0 +1,203 @@ +import { Language, Parser, type Node } from "web-tree-sitter" +import { lazy } from "@/util/lazy" +import { resolveWasm, resolvePath, unquote, home, expand, type Scan, type Part } from "./util" +import { Instance } from "@/project/instance" +import { Filesystem } from "@/util/filesystem" +import path from "path" +import { ShellArity } from "./arity" +import { Log } from "@/util/log" + +const log = Log.create({ service: "shell-parser" }) + +const CWD = new Set(["cd", "push-location", "set-location"]) +const FILES_BASE = new Set(["rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"]) +const FILES_PWSH = new Set([ + "get-content", + "set-content", + "add-content", + "copy-item", + "move-item", + "remove-item", + "new-item", + "rename-item", +]) + +const FLAGS = new Set(["-destination", "-literalpath", "-path"]) +const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) + +function parts(node: Node) { + const out: Part[] = [] + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i) + if (!child) continue + if (child.type === "command_elements") { + for (let j = 0; j < child.childCount; j++) { + const item = child.child(j) + if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue + out.push({ type: item.type, text: item.text }) + } + continue + } + if ( + child.type !== "command_name" && + child.type !== "command_name_expr" && + child.type !== "word" && + child.type !== "string" && + child.type !== "raw_string" && + child.type !== "concatenation" + ) { + continue + } + out.push({ type: child.type, text: child.text }) + } + return out +} + +function source(node: Node) { + return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim() +} + +function commands(node: Node) { + return node.descendantsOfType("command").filter((child): child is Node => Boolean(child)) +} + +function dynamic(text: string, isPwsh: boolean) { + if (text.startsWith("(") || text.startsWith("@(")) return true + if (text.includes("$(") || text.includes("${") || text.includes("`")) return true + if (isPwsh) return /\$(?!env:)/i.test(text) + return text.includes("$") +} + +function prefix(text: string) { + const match = /[?*\[]/.exec(text) + if (!match) return text + if (match.index === 0) return + return text.slice(0, match.index) +} + +function provider(text: string) { + const match = text.match(/^([A-Za-z]+)::(.*)$/) + if (match) { + if (match[1].toLowerCase() !== "filesystem") return + return match[2] + } + const pre = text.match(/^([A-Za-z]+):(.*)$/) + if (!pre) return text + if (pre[1].length === 1) return text + return +} + +async function argPath(arg: string, cwd: string, shell: string, isPwsh: boolean) { + const text = isPwsh ? expand(arg, cwd, shell) : home(unquote(arg)) + const file = text && prefix(text) + if (!file || dynamic(file, isPwsh)) return + const next = isPwsh ? provider(file) : file + if (!next) return + return resolvePath(next, cwd, shell) +} + +function pathArgs(list: Part[], isPwsh: boolean) { + if (!isPwsh) { + return list + .slice(1) + .filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+"))) + .map((item) => item.text) + } + + const out: string[] = [] + let want = false + for (const item of list.slice(1)) { + if (want) { + out.push(item.text) + want = false + continue + } + if (item.type === "command_parameter") { + const flag = item.text.toLowerCase() + if (SWITCHES.has(flag)) continue + want = FLAGS.has(flag) + continue + } + out.push(item.text) + } + return out +} + +export namespace ShellParser { + const getParser = lazy(async () => { + const { Parser } = await import("web-tree-sitter") + const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { + with: { type: "wasm" }, + }) + const treePath = resolveWasm(treeWasm) + await Parser.init({ + locateFile() { + return treePath + }, + }) + const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { + with: { type: "wasm" }, + }) + const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { + with: { type: "wasm" }, + }) + const bashPath = resolveWasm(bashWasm) + const psPath = resolveWasm(psWasm) + const bashLanguage = await Language.load(bashPath) + const psLanguage = await Language.load(psPath) + + const bash = new Parser() + bash.setLanguage(bashLanguage) + + const ps = new Parser() + ps.setLanguage(psLanguage) + + return { bash, ps } + }) + + export async function collect(opts: { + command: string + cwd: string + shell: string + shellType: "bash" | "pwsh" | "powershell" + }): Promise { + const isPwsh = opts.shellType === "pwsh" || opts.shellType === "powershell" + const parsers = await getParser() + const parser = isPwsh ? parsers.ps : parsers.bash + + const tree = parser.parse(opts.command) + if (!tree) throw new Error("Failed to parse command") + const root = tree.rootNode + + const scan: Scan = { + dirs: new Set(), + patterns: new Set(), + always: new Set(), + } + + const filesSet = new Set([...CWD, ...FILES_BASE, ...(isPwsh ? FILES_PWSH : [])]) + + for (const node of commands(root)) { + const commandParts = parts(node) + const tokens = commandParts.map((item) => item.text) + const cmd = isPwsh ? tokens[0]?.toLowerCase() : tokens[0] + + if (cmd && filesSet.has(cmd)) { + for (const arg of pathArgs(commandParts, isPwsh)) { + const resolved = await argPath(arg, opts.cwd, opts.shell, isPwsh) + log.info("resolved path", { arg, resolved }) + if (!resolved || Instance.containsPath(resolved)) continue + const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved) + scan.dirs.add(dir) + } + } + + if (tokens.length && (!cmd || !CWD.has(cmd))) { + scan.patterns.add(source(node)) + scan.always.add(ShellArity.prefix(tokens, opts.shellType).join(" ") + " *") + } + } + + return scan + } +} diff --git a/packages/opencode/src/tool/shell/powershell.ts b/packages/opencode/src/tool/shell/powershell.ts new file mode 100644 index 0000000000..cc63ac2c02 --- /dev/null +++ b/packages/opencode/src/tool/shell/powershell.ts @@ -0,0 +1,76 @@ +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" + +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, + ) + }, + } +}) diff --git a/packages/opencode/src/tool/shell/pwsh.ts b/packages/opencode/src/tool/shell/pwsh.ts new file mode 100644 index 0000000000..525c9d4be3 --- /dev/null +++ b/packages/opencode/src/tool/shell/pwsh.ts @@ -0,0 +1,76 @@ +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" + +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, + ) + }, + } +}) diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts new file mode 100644 index 0000000000..0576040764 --- /dev/null +++ b/packages/opencode/src/tool/shell/runner.ts @@ -0,0 +1,140 @@ +import { spawn } from "child_process" +import { Shell } from "@/shell/shell" +import { Tool } from "../tool" +import { Plugin } from "@/plugin" + +const MAX_METADATA_LENGTH = 30_000 + +export function preview(text: string) { + if (text.length <= MAX_METADATA_LENGTH) return text + return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." +} + +export namespace ShellRunner { + export async function shellEnv(ctx: Tool.Context, cwd: string) { + const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }) + return { + ...process.env, + ...extra.env, + } + } + + export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { + if (process.platform === "win32" && (name === "powershell" || name === "pwsh")) { + return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + windowsHide: true, + }) + } + + return spawn(command, { + shell, + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + windowsHide: process.platform === "win32", + }) + } + + export async function run( + input: { + shell: string + name: string + command: string + cwd: string + env: NodeJS.ProcessEnv + timeout: number + description: string + }, + ctx: Tool.Context, + ) { + const proc = launch(input.shell, input.name, input.command, input.cwd, input.env) + let output = "" + + ctx.metadata({ + metadata: { + output: "", + description: input.description, + }, + }) + + const append = (chunk: Buffer) => { + output += chunk.toString() + ctx.metadata({ + metadata: { + output: preview(output), + description: input.description, + }, + }) + } + + proc.stdout?.on("data", append) + proc.stderr?.on("data", append) + + let expired = false + let aborted = false + let exited = false + + const kill = () => Shell.killTree(proc, { exited: () => exited }) + + if (ctx.abort.aborted) { + aborted = true + await kill() + } + + const abort = () => { + aborted = true + void kill() + } + + ctx.abort.addEventListener("abort", abort, { once: true }) + const timer = setTimeout(() => { + expired = true + void kill() + }, input.timeout + 100) + + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer) + ctx.abort.removeEventListener("abort", abort) + } + + proc.once("exit", () => { + exited = true + }) + + proc.once("close", () => { + exited = true + cleanup() + resolve() + }) + + proc.once("error", (error) => { + exited = true + cleanup() + reject(error) + }) + }) + + const metadata: string[] = [] + if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`) + if (aborted) metadata.push("User aborted the command") + if (metadata.length > 0) { + output += "\n\n\n" + metadata.join("\n") + "\n" + } + + return { + title: input.description, + metadata: { + output: preview(output), + exit: proc.exitCode, + description: input.description, + }, + output, + } + } +} diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/shell/shell.txt similarity index 77% rename from packages/opencode/src/tool/bash.txt rename to packages/opencode/src/tool/shell/shell.txt index 8d53c90ab4..7ef2ca73fb 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,16 +1,16 @@ -Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. +Executes a given shell command in a persistent ${shellName} session with optional timeout, ensuring proper handling and security measures. Be aware: OS: ${os}, Shell: ${shell} -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. +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. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. Before executing the command, please follow these steps: 1. Directory Verification: - - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory 2. Command Execution: - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") @@ -26,9 +26,9 @@ Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use `head`, `tail`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - File search: Use Glob (NOT find or ls) - Content search: Use Grep (NOT grep or rg) - Read files: Use Read (NOT cat/head/tail) @@ -36,11 +36,11 @@ Usage notes: - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead. + - AVOID using \`cd && \`. Use the \`workdir\` parameter to change directories instead. Use workdir="/foo/bar" with command: pytest tests @@ -65,7 +65,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 commands in parallel: - 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. @@ -89,15 +89,15 @@ Important notes: - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit # Creating pull requests -Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. +Use the gh command for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. 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 commands in parallel, 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 - - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch) + - Run a git log command and \`git diff [base-branch]...HEAD\` to understand the full commit history for the current branch (from the time it diverged from the base branch) 2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary 3. 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 commands in parallel: - Create new branch if needed @@ -114,4 +114,4 @@ Important: - Return the PR URL when you're done, so the user can see it # Other common operations -- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments +- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments \ No newline at end of file diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts new file mode 100644 index 0000000000..0091ff8e8c --- /dev/null +++ b/packages/opencode/src/tool/shell/util.ts @@ -0,0 +1,115 @@ +import path from "path" +import os from "os" +import { Filesystem } from "@/util/filesystem" +import { Process } from "@/util/process" +import { Shell } from "@/shell/shell" +import { Truncate } from "../truncate" +import { Instance } from "@/project/instance" +import { Tool } from "../tool" +import { fileURLToPath } from "url" + +export type Part = { + type: string + text: string +} + +export type Scan = { + dirs: Set + patterns: Set + always: Set +} + +export function resolveWasm(asset: string) { + if (asset.startsWith("file://")) return fileURLToPath(asset) + if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset + const url = new URL(asset, import.meta.url) + return fileURLToPath(url) +} + +export function unquote(text: string) { + if (text.length < 2) return text + const first = text[0] + const last = text[text.length - 1] + if ((first === '"' || first === "'") && first === last) return text.slice(1, -1) + return text +} + +export function home(text: string) { + if (text === "~") return os.homedir() + if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2)) + return text +} + +export function envValue(key: string) { + if (process.platform !== "win32") return process.env[key] + const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase()) + return name ? process.env[name] : undefined +} + +export function auto(key: string, cwd: string, shell: string) { + const name = key.toUpperCase() + if (name === "HOME") return os.homedir() + if (name === "PWD") return cwd + if (name === "PSHOME") return path.dirname(shell) +} + +export function expand(text: string, cwd: string, shell: string) { + const out = unquote(text) + .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "") + .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "") + .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "") + return home(out) +} + +export async function cygpath(shell: string, text: string) { + const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true }) + if (out.code !== 0) return + const file = out.text.trim() + if (!file) return + return Filesystem.normalizePath(file) +} + +export async function resolvePath(text: string, root: string, shell: string) { + if (process.platform === "win32") { + if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) { + const file = await cygpath(shell, text) + if (file) return file + } + return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text))) + } + return path.resolve(root, text) +} + +export function formatShellDescription(template: string, opts: { name: string; shellName: string; chaining: string }) { + return template + .replaceAll("${directory}", Instance.directory) + .replaceAll("${os}", process.platform) + .replaceAll("${shell}", opts.name) + .replaceAll("${shellName}", opts.shellName) + .replaceAll("${chaining}", opts.chaining) + .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) + .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)) +} + +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) => { + if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*")) + return path.join(dir, "*") + }) + await ctx.ask({ + permission: "external_directory", + patterns: globs, + always: globs, + metadata: {}, + }) + } + + if (scan.patterns.size === 0) return + await ctx.ask({ + permission: permissionName, + patterns: Array.from(scan.patterns), + always: Array.from(scan.always), + metadata: {}, + }) +} diff --git a/packages/opencode/test/permission/arity.test.ts b/packages/opencode/test/permission/arity.test.ts index 634e41e724..5e2af7afc1 100644 --- a/packages/opencode/test/permission/arity.test.ts +++ b/packages/opencode/test/permission/arity.test.ts @@ -1,33 +1,39 @@ import { test, expect } from "bun:test" -import { BashArity } from "../../src/permission/arity" +import { ShellArity } from "../../src/tool/shell/arity" test("arity 1 - unknown commands default to first token", () => { - expect(BashArity.prefix(["unknown", "command", "subcommand"])).toEqual(["unknown"]) - expect(BashArity.prefix(["touch", "foo.txt"])).toEqual(["touch"]) + expect(ShellArity.prefix(["unknown", "command", "subcommand"], "bash")).toEqual(["unknown"]) + expect(ShellArity.prefix(["touch", "foo.txt"], "bash")).toEqual(["touch"]) }) test("arity 2 - two token commands", () => { - expect(BashArity.prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]) - expect(BashArity.prefix(["docker", "run", "nginx"])).toEqual(["docker", "run"]) + expect(ShellArity.prefix(["git", "checkout", "main"], "bash")).toEqual(["git", "checkout"]) + expect(ShellArity.prefix(["docker", "run", "nginx"], "bash")).toEqual(["docker", "run"]) }) test("arity 3 - three token commands", () => { - expect(BashArity.prefix(["aws", "s3", "ls", "my-bucket"])).toEqual(["aws", "s3", "ls"]) - expect(BashArity.prefix(["npm", "run", "dev", "script"])).toEqual(["npm", "run", "dev"]) + expect(ShellArity.prefix(["aws", "s3", "ls", "my-bucket"], "bash")).toEqual(["aws", "s3", "ls"]) + expect(ShellArity.prefix(["npm", "run", "dev", "script"], "bash")).toEqual(["npm", "run", "dev"]) }) test("longest match wins - nested prefixes", () => { - expect(BashArity.prefix(["docker", "compose", "up", "service"])).toEqual(["docker", "compose", "up"]) - expect(BashArity.prefix(["consul", "kv", "get", "config"])).toEqual(["consul", "kv", "get"]) + expect(ShellArity.prefix(["docker", "compose", "up", "service"], "bash")).toEqual(["docker", "compose", "up"]) + expect(ShellArity.prefix(["consul", "kv", "get", "config"], "bash")).toEqual(["consul", "kv", "get"]) }) test("exact length matches", () => { - expect(BashArity.prefix(["git", "checkout"])).toEqual(["git", "checkout"]) - expect(BashArity.prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]) + expect(ShellArity.prefix(["git", "checkout"], "bash")).toEqual(["git", "checkout"]) + expect(ShellArity.prefix(["npm", "run", "dev"], "bash")).toEqual(["npm", "run", "dev"]) }) test("edge cases", () => { - expect(BashArity.prefix([])).toEqual([]) - expect(BashArity.prefix(["single"])).toEqual(["single"]) - expect(BashArity.prefix(["git"])).toEqual(["git"]) + expect(ShellArity.prefix([], "bash")).toEqual([]) + expect(ShellArity.prefix(["single"], "bash")).toEqual(["single"]) + expect(ShellArity.prefix(["git"], "bash")).toEqual(["git"]) +}) + +test("powershell verb-noun structures", () => { + expect(ShellArity.prefix(["Get-Content", "file.txt"], "pwsh")).toEqual(["Get-Content"]) + expect(ShellArity.prefix(["Remove-Item", "-Recurse", "dir"], "powershell")).toEqual(["Remove-Item"]) + expect(ShellArity.prefix(["git", "checkout", "main"], "pwsh")).toEqual(["git", "checkout"]) }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 043e3257b6..7e48fe33ff 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -34,7 +34,11 @@ async function waitForPending(count: number) { test("fromConfig - string value becomes wildcard rule", () => { const result = Permission.fromConfig({ bash: "allow" }) - expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "*", action: "allow" }, + { permission: "powershell", pattern: "*", action: "allow" }, + ]) }) test("fromConfig - object value converts to rules array", () => { @@ -42,6 +46,10 @@ test("fromConfig - object value converts to rules array", () => { expect(result).toEqual([ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "rm", action: "deny" }, + { permission: "pwsh", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "rm", action: "deny" }, + { permission: "powershell", pattern: "*", action: "allow" }, + { permission: "powershell", pattern: "rm", action: "deny" }, ]) }) @@ -54,6 +62,10 @@ test("fromConfig - mixed string and object values", () => { expect(result).toEqual([ { permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "rm", action: "deny" }, + { permission: "pwsh", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "rm", action: "deny" }, + { permission: "powershell", pattern: "*", action: "allow" }, + { permission: "powershell", pattern: "rm", action: "deny" }, { permission: "edit", pattern: "*", action: "allow" }, { permission: "webfetch", pattern: "*", action: "ask" }, ]) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/shell.test.ts similarity index 92% rename from packages/opencode/test/tool/bash.test.ts rename to packages/opencode/test/tool/shell.test.ts index 0ea8ea073a..82dc5ca515 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test" import os from "os" import path from "path" import { Shell } from "../../src/shell/shell" -import { BashTool } from "../../src/tool/bash" +import { BashTool } from "../../src/tool/shell/bash" +import { PwshTool } from "../../src/tool/shell/pwsh" +import { PowershellTool } from "../../src/tool/shell/powershell" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -86,6 +88,20 @@ const withShell = (item: { label: string; shell: string }, fn: () => Promise { + const name = sh() + if (name === "pwsh") return "pwsh" + if (name === "powershell") return "powershell" + return "bash" +} + +const getTool = async () => { + const name = sh() + if (name === "pwsh") return await PwshTool.init() + if (name === "powershell") return await PowershellTool.init() + return await BashTool.init() +} + const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { for (const item of shells) { test( @@ -113,12 +129,12 @@ const mustTruncate = (result: { ) } -describe("tool.bash", () => { +describe("tool.shell", () => { each("basic", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const result = await bash.execute( { command: "echo test", @@ -133,13 +149,13 @@ describe("tool.bash", () => { }) }) -describe("tool.bash permissions", () => { +describe("tool.shell permissions", () => { each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -149,7 +165,7 @@ describe("tool.bash permissions", () => { capture(requests), ) expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") + expect(requests[0].permission).toBe(expectedPermission()) expect(requests[0].patterns).toContain("echo hello") }, }) @@ -160,7 +176,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -170,7 +186,7 @@ describe("tool.bash permissions", () => { capture(requests), ) expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") + expect(requests[0].permission).toBe(expectedPermission()) expect(requests[0].patterns).toContain("echo foo") expect(requests[0].patterns).toContain("echo bar") }, @@ -184,7 +200,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -193,7 +209,7 @@ describe("tool.bash permissions", () => { }, capture(requests), ) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("Write-Host foo") expect(bashReq!.patterns).toContain("Write-Host bar") @@ -208,7 +224,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" @@ -242,7 +258,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const requests: Array> = [] await bash.execute( @@ -253,7 +269,7 @@ describe("tool.bash permissions", () => { capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) expect(bashReq).toBeDefined() @@ -273,7 +289,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -301,7 +317,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` await bash.execute( @@ -312,7 +328,7 @@ describe("tool.bash permissions", () => { capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) expect(bashReq).toBeDefined() @@ -331,7 +347,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -359,7 +375,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -388,7 +404,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -416,7 +432,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -448,7 +464,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") @@ -481,7 +497,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -508,7 +524,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -538,7 +554,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -568,7 +584,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -578,7 +594,7 @@ describe("tool.bash permissions", () => { capture(requests), ) const extDirReq = requests.find((r) => r.permission === "external_directory") - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain( Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), @@ -597,7 +613,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -606,7 +622,7 @@ describe("tool.bash permissions", () => { }, capture(requests), ) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.patterns).not.toContain("a * 3") expect(bashReq!.always).not.toContain("a *") @@ -622,7 +638,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -645,7 +661,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -673,7 +689,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) for (const dir of forms(outerTmp.path)) { @@ -707,7 +723,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) @@ -737,7 +753,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) @@ -772,7 +788,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] const filepath = path.join(outerTmp.path, "outside.txt") @@ -803,7 +819,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -823,7 +839,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -844,7 +860,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute( { @@ -853,7 +869,7 @@ describe("tool.bash permissions", () => { }, capture(requests), ) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeUndefined() }, }) @@ -864,7 +880,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -873,7 +889,7 @@ describe("tool.bash permissions", () => { capture(requests, err), ), ).rejects.toThrow(err.message) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("echo test > output.txt") }, @@ -885,10 +901,10 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const requests: Array> = [] await bash.execute({ command: "ls -la", description: "List" }, capture(requests)) - const bashReq = requests.find((r) => r.permission === "bash") + const bashReq = requests.find((r) => r.permission === expectedPermission()) expect(bashReq).toBeDefined() expect(bashReq!.always[0]).toBe("ls *") }, @@ -896,12 +912,12 @@ describe("tool.bash permissions", () => { }) }) -describe("tool.bash truncation", () => { - test("truncates output exceeding line limit", async () => { +describe("tool.shell truncation", () => { + each("truncates output exceeding line limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const lineCount = Truncate.MAX_LINES + 500 const result = await bash.execute( { @@ -917,11 +933,11 @@ describe("tool.bash truncation", () => { }) }) - test("truncates output exceeding byte limit", async () => { + each("truncates output exceeding byte limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const byteCount = Truncate.MAX_BYTES + 10000 const result = await bash.execute( { @@ -937,11 +953,11 @@ describe("tool.bash truncation", () => { }) }) - test("does not truncate small output", async () => { + each("does not truncate small output", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const result = await bash.execute( { command: "echo hello", @@ -955,11 +971,11 @@ describe("tool.bash truncation", () => { }) }) - test("full output is saved to file when truncated", async () => { + each("full output is saved to file when truncated", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await getTool() const lineCount = Truncate.MAX_LINES + 100 const result = await bash.execute( { From 67dfbcbcfd0e40564298450d7cc9d30be10c38b7 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:12:36 +1000 Subject: [PATCH 02/27] fix: use dynamic imports for tree-sitter and shell-aware metadata tags --- packages/opencode/src/tool/shell/parser.ts | 3 ++- packages/opencode/src/tool/shell/runner.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/shell/parser.ts b/packages/opencode/src/tool/shell/parser.ts index c1507553eb..6260ac6042 100644 --- a/packages/opencode/src/tool/shell/parser.ts +++ b/packages/opencode/src/tool/shell/parser.ts @@ -1,4 +1,4 @@ -import { Language, Parser, type Node } from "web-tree-sitter" +import type { Node } from "web-tree-sitter" import { lazy } from "@/util/lazy" import { resolveWasm, resolvePath, unquote, home, expand, type Scan, type Part } from "./util" import { Instance } from "@/project/instance" @@ -141,6 +141,7 @@ export namespace ShellParser { const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { with: { type: "wasm" }, }) + const { Language } = await import("web-tree-sitter") const bashPath = resolveWasm(bashWasm) const psPath = resolveWasm(psWasm) const bashLanguage = await Language.load(bashPath) diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index 0576040764..9bdfbb66f9 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -121,10 +121,10 @@ export namespace ShellRunner { }) const metadata: string[] = [] - if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`) + if (expired) metadata.push(`${input.name} tool terminated command after exceeding timeout ${input.timeout} ms`) if (aborted) metadata.push("User aborted the command") if (metadata.length > 0) { - output += "\n\n\n" + metadata.join("\n") + "\n" + output += "\n\n\n" + metadata.join("\n") + "\n" } return { From 3e26c3ae8328784197a6d77e90fcf376c99de73b Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:15:58 +1000 Subject: [PATCH 03/27] refactor: extract shell tool factory to eliminate duplication --- packages/opencode/src/tool/shell/bash.ts | 81 ++----------------- .../opencode/src/tool/shell/powershell.ts | 81 ++----------------- packages/opencode/src/tool/shell/pwsh.ts | 81 ++----------------- packages/opencode/src/tool/shell/util.ts | 70 ++++++++++++++++ 4 files changed, 88 insertions(+), 225 deletions(-) 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) => { From 51ebba2975c824ea7035b6afc4294099a5d2afab Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:18:50 +1000 Subject: [PATCH 04/27] refactor: add shell-specific guidance to each tool prompt --- packages/opencode/src/tool/shell/bash.ts | 16 ++++++++---- .../opencode/src/tool/shell/powershell.ts | 20 +++++++++++---- packages/opencode/src/tool/shell/pwsh.ts | 18 +++++++++---- packages/opencode/src/tool/shell/shell.txt | 2 ++ packages/opencode/src/tool/shell/util.ts | 25 +++++++++++++------ 5 files changed, 58 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/tool/shell/bash.ts b/packages/opencode/src/tool/shell/bash.ts index 3539f18482..95fc524a01 100644 --- a/packages/opencode/src/tool/shell/bash.ts +++ b/packages/opencode/src/tool/shell/bash.ts @@ -1,7 +1,13 @@ import { createShellTool } from "./util" -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`).", -) +export const BashTool = createShellTool({ + id: "bash", + shellName: "Bash", + chaining: + "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + guidance: `# Bash shell notes +- This is a POSIX-compatible shell. Standard Unix conventions apply. +- Use double quotes for variable interpolation, single quotes for literal strings. +- Use \`$(...)\` for command substitution (not backticks). +- Redirect stderr with \`2>&1\` or \`2>/dev/null\`.`, +}) diff --git a/packages/opencode/src/tool/shell/powershell.ts b/packages/opencode/src/tool/shell/powershell.ts index 0910ec1347..0df02aee49 100644 --- a/packages/opencode/src/tool/shell/powershell.ts +++ b/packages/opencode/src/tool/shell/powershell.ts @@ -1,7 +1,17 @@ import { createShellTool } from "./util" -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 }`", -) +export const PowershellTool = createShellTool({ + id: "powershell", + shellName: "Windows PowerShell 5.1", + chaining: + "avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success.", + guidance: `# Windows PowerShell 5.1 shell notes +- This is Windows PowerShell 5.1 (legacy), NOT PowerShell 7+. It does NOT support \`&&\` or \`||\` pipeline chain operators. +- For conditional chaining use: \`cmd1; if ($?) { cmd2 }\` +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` resolve to cmdlets with different behavior than Unix equivalents. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with backtick (\\\`) not backslash. +- Some modern PowerShell features (ternary operator, null-coalescing, etc.) are NOT available in 5.1.`, +}) diff --git a/packages/opencode/src/tool/shell/pwsh.ts b/packages/opencode/src/tool/shell/pwsh.ts index d22dc2bd70..cff2cd702a 100644 --- a/packages/opencode/src/tool/shell/pwsh.ts +++ b/packages/opencode/src/tool/shell/pwsh.ts @@ -1,7 +1,15 @@ import { createShellTool } from "./util" -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`).", -) +export const PwshTool = createShellTool({ + id: "pwsh", + shellName: "PowerShell 7+", + chaining: + "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + guidance: `# PowerShell 7+ (pwsh) shell notes +- This is PowerShell 7+ (Core), a cross-platform shell. It supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` are available but resolve to cmdlets. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with backtick (\\\`) not backslash.`, +}) diff --git a/packages/opencode/src/tool/shell/shell.txt b/packages/opencode/src/tool/shell/shell.txt index 7ef2ca73fb..f6454cab8f 100644 --- a/packages/opencode/src/tool/shell/shell.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -6,6 +6,8 @@ All commands run in ${directory} by default. Use the \`workdir\` parameter if yo IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. +${guidance} + Before executing the command, please follow these steps: 1. Directory Verification: diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts index 335fe3432f..dbd78a192e 100644 --- a/packages/opencode/src/tool/shell/util.ts +++ b/packages/opencode/src/tool/shell/util.ts @@ -80,13 +80,17 @@ export async function resolvePath(text: string, root: string, shell: string) { return path.resolve(root, text) } -export function formatShellDescription(template: string, opts: { name: string; shellName: string; chaining: string }) { +export function formatShellDescription( + template: string, + opts: { name: string; shellName: string; chaining: string; guidance: string }, +) { return template .replaceAll("${directory}", Instance.directory) .replaceAll("${os}", process.platform) .replaceAll("${shell}", opts.name) .replaceAll("${shellName}", opts.shellName) .replaceAll("${chaining}", opts.chaining) + .replaceAll("${guidance}", opts.guidance) .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)) } @@ -102,16 +106,21 @@ 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` }) +export function createShellTool(opts: { id: ShellType; shellName: string; chaining: string; guidance: string }) { + const log = Log.create({ service: `${opts.id}-tool` }) - return Tool.define(id, async () => { + return Tool.define(opts.id, async () => { const shell = Shell.acceptable() const name = Shell.name(shell) - log.info(`${id} tool using shell`, { shell, name }) + log.info(`${opts.id} tool using shell`, { shell, name }) return { - description: formatShellDescription(DESCRIPTION, { name, shellName, chaining }), + description: formatShellDescription(DESCRIPTION, { + name, + shellName: opts.shellName, + chaining: opts.chaining, + guidance: opts.guidance, + }), parameters: z.object({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), @@ -138,11 +147,11 @@ export function createShellTool(id: ShellType, shellName: string, chaining: stri command: params.command, cwd, shell, - shellType: id, + shellType: opts.id, }) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - await askPermission(ctx, scan, id) + await askPermission(ctx, scan, opts.id) return ShellRunner.run( { From 48f9082d0a938fcd9b458999ec5c472a3125555b Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:24:49 +1000 Subject: [PATCH 05/27] refactor: use positive tone in shell guidance prompts --- packages/opencode/src/tool/shell/shell.txt | 29 ++++++++++------------ 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/tool/shell/shell.txt b/packages/opencode/src/tool/shell/shell.txt index f6454cab8f..2faabb2c3d 100644 --- a/packages/opencode/src/tool/shell/shell.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -2,7 +2,7 @@ Executes a given shell command in a persistent ${shellName} session with optiona Be aware: OS: ${os}, Shell: ${shell} -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. +All commands run in ${directory} by default. Use the \`workdir\` parameter to run a command in a different directory instead of \`cd && \` patterns. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. @@ -28,27 +28,24 @@ Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. Use Read with offset/limit to read specific sections or Grep to search the full content. - - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. - + - Use the `workdir` parameter to change directories instead of chaining commands with `cd`. + Use workdir="/foo/bar" with command: pytest tests - - - cd /foo/bar && pytest tests - + # Committing changes with git @@ -59,7 +56,7 @@ Git Safety Protocol: - NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them - NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it - NEVER run force push to main/master, warn the user if they request it -- Avoid git commit --amend. ONLY use --amend when ALL conditions are met: +- Only use git commit --amend when ALL conditions are met: (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae') (3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead") From 676519d79d6062d2c489602ddd3dd34816901be1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:42:42 +1000 Subject: [PATCH 06/27] refactor: apply positive guidance and parameterize shell commands in prompt template --- packages/opencode/src/tool/shell/bash.ts | 11 ++--- .../opencode/src/tool/shell/powershell.ts | 15 +++--- packages/opencode/src/tool/shell/pwsh.ts | 11 +++-- packages/opencode/src/tool/shell/shell.txt | 47 ++++++++++--------- packages/opencode/src/tool/shell/util.ts | 23 ++++++++- 5 files changed, 66 insertions(+), 41 deletions(-) diff --git a/packages/opencode/src/tool/shell/bash.ts b/packages/opencode/src/tool/shell/bash.ts index 95fc524a01..3a07511b21 100644 --- a/packages/opencode/src/tool/shell/bash.ts +++ b/packages/opencode/src/tool/shell/bash.ts @@ -2,12 +2,11 @@ import { createShellTool } from "./util" export const BashTool = createShellTool({ id: "bash", - shellName: "Bash", + shellName: "bash", + toolName: "Bash", + listCmd: "ls", + gitCmds: "git bash commands", chaining: "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", - guidance: `# Bash shell notes -- This is a POSIX-compatible shell. Standard Unix conventions apply. -- Use double quotes for variable interpolation, single quotes for literal strings. -- Use \`$(...)\` for command substitution (not backticks). -- Redirect stderr with \`2>&1\` or \`2>/dev/null\`.`, + guidance: "", }) diff --git a/packages/opencode/src/tool/shell/powershell.ts b/packages/opencode/src/tool/shell/powershell.ts index 0df02aee49..fe12aadb14 100644 --- a/packages/opencode/src/tool/shell/powershell.ts +++ b/packages/opencode/src/tool/shell/powershell.ts @@ -2,16 +2,17 @@ import { createShellTool } from "./util" export const PowershellTool = createShellTool({ id: "powershell", - shellName: "Windows PowerShell 5.1", + shellName: "Windows PowerShell", + toolName: "PowerShell", + listCmd: "Get-ChildItem", + gitCmds: "git commands", chaining: - "avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success.", + "use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success.", guidance: `# Windows PowerShell 5.1 shell notes -- This is Windows PowerShell 5.1 (legacy), NOT PowerShell 7+. It does NOT support \`&&\` or \`||\` pipeline chain operators. -- For conditional chaining use: \`cmd1; if ($?) { cmd2 }\` +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. - Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` resolve to cmdlets with different behavior than Unix equivalents. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. - Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. - To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with backtick (\\\`) not backslash. -- Some modern PowerShell features (ternary operator, null-coalescing, etc.) are NOT available in 5.1.`, +- Escape special characters with backtick (\\\`).`, }) diff --git a/packages/opencode/src/tool/shell/pwsh.ts b/packages/opencode/src/tool/shell/pwsh.ts index cff2cd702a..59e9b626ad 100644 --- a/packages/opencode/src/tool/shell/pwsh.ts +++ b/packages/opencode/src/tool/shell/pwsh.ts @@ -2,14 +2,17 @@ import { createShellTool } from "./util" export const PwshTool = createShellTool({ id: "pwsh", - shellName: "PowerShell 7+", + shellName: "PowerShell Core", + toolName: "PowerShell", + listCmd: "Get-ChildItem", + gitCmds: "git commands", chaining: "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", guidance: `# PowerShell 7+ (pwsh) shell notes -- This is PowerShell 7+ (Core), a cross-platform shell. It supports pipeline chain operators (\`&&\` and \`||\`). +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). - Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` are available but resolve to cmdlets. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. - Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. - To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with backtick (\\\`) not backslash.`, +- Escape special characters with backtick (\\\`).`, }) diff --git a/packages/opencode/src/tool/shell/shell.txt b/packages/opencode/src/tool/shell/shell.txt index 2faabb2c3d..a874ad619d 100644 --- a/packages/opencode/src/tool/shell/shell.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,8 +1,8 @@ -Executes a given shell command in a persistent ${shellName} 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. Be aware: OS: ${os}, Shell: ${shell} -All commands run in ${directory} by default. Use the \`workdir\` parameter to run a command in a different directory instead of \`cd && \` patterns. +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. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. @@ -11,8 +11,8 @@ ${guidance} Before executing the command, please follow these steps: 1. Directory Verification: - - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory + - If the command will create new directories or files, first use \`${listCmd}\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`${listCmd} foo\` to check that "foo" exists and is the intended parent directory 2. Command Execution: - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") @@ -28,24 +28,27 @@ Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. Use Read with offset/limit to read specific sections or Grep to search the full content. + - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - Use the dedicated tools for file operations and communication instead of shell commands: - - File search: Use Glob - - Content search: Use Grep - - Read files: Use Read - - Edit files: Use Edit - - Write files: Use Write - - Communication: Output text directly + - Avoid using ${toolName} with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < + - AVOID using \`cd && \`. Use the \`workdir\` parameter to change directories instead. + Use workdir="/foo/bar" with command: pytest tests - + + + cd /foo/bar && pytest tests + # Committing changes with git @@ -56,7 +59,7 @@ Git Safety Protocol: - NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them - NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it - NEVER run force push to main/master, warn the user if they request it -- Only use git commit --amend when ALL conditions are met: +- Avoid git commit --amend. ONLY use --amend when ALL conditions are met: (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae') (3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead") @@ -64,7 +67,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 commands in parallel: +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 ${gitCmds} in parallel, each using the ${toolName} 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. @@ -81,18 +84,18 @@ Git Safety Protocol: 4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) Important notes: -- NEVER run additional commands to read or explore code, besides git bash commands +- NEVER run additional commands to read or explore code, besides ${gitCmds} - NEVER use the TodoWrite or Task tools - DO NOT push to the remote repository unless the user explicitly asks you to do so - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit # Creating pull requests -Use the gh command for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. +Use the gh command via the ${toolName} tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. 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 commands in parallel, 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 ${gitCmds} in parallel using the ${toolName} 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/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts index dbd78a192e..46845b731c 100644 --- a/packages/opencode/src/tool/shell/util.ts +++ b/packages/opencode/src/tool/shell/util.ts @@ -82,7 +82,15 @@ export async function resolvePath(text: string, root: string, shell: string) { export function formatShellDescription( template: string, - opts: { name: string; shellName: string; chaining: string; guidance: string }, + opts: { + name: string + shellName: string + chaining: string + guidance: string + listCmd: string + toolName: string + gitCmds: string + }, ) { return template .replaceAll("${directory}", Instance.directory) @@ -106,7 +114,15 @@ export type ShellType = "bash" | "pwsh" | "powershell" const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -export function createShellTool(opts: { id: ShellType; shellName: string; chaining: string; guidance: string }) { +export function createShellTool(opts: { + id: ShellType + shellName: string + chaining: string + guidance: string + listCmd: string + toolName: string + gitCmds: string +}) { const log = Log.create({ service: `${opts.id}-tool` }) return Tool.define(opts.id, async () => { @@ -120,6 +136,9 @@ export function createShellTool(opts: { id: ShellType; shellName: string; chaini shellName: opts.shellName, chaining: opts.chaining, guidance: opts.guidance, + listCmd: opts.listCmd, + toolName: opts.toolName, + gitCmds: opts.gitCmds, }), parameters: z.object({ command: z.string().describe("The command to execute"), From 95577c75a327fb51851ddba7beba70ef0497a495 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:43:37 +1000 Subject: [PATCH 07/27] fix(config): preserve bash permission compatibility Keep legacy tools.bash migration mapped to the single bash permission since the permission layer already expands it to pwsh and powershell. This preserves the backward-compatible config shape while retaining shell compatibility. --- packages/opencode/src/config/config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0166eec527..bfc003f741 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -588,8 +588,6 @@ export namespace Config { permission.edit = action } else if (tool === "bash") { permission.bash = action - permission.pwsh = action - permission.powershell = action } else { permission[tool] = action } From 6ad6358eb152db4a8a5d1ab27f2ce3da098a1b6f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:01:13 +1000 Subject: [PATCH 08/27] fix: render pwsh and powershell tools correctly in UI This fixes regressions from splitting the shell tools where powershell commands were missing their native exit codes and their correct UI rendering. --- packages/opencode/.opencode/package-lock.json | 31 +++++++++++++++++++ packages/opencode/src/tool/shell/runner.ts | 16 +++++++--- packages/ui/src/components/message-part.tsx | 6 +++- packages/web/src/components/share/part.tsx | 5 +-- 4 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/.opencode/package-lock.json diff --git a/packages/opencode/.opencode/package-lock.json b/packages/opencode/.opencode/package-lock.json new file mode 100644 index 0000000000..c36f597373 --- /dev/null +++ b/packages/opencode/.opencode/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "*" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.2.24", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.2.24", + "zod": "4.1.8" + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.2.24", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index 9bdfbb66f9..0c4f7ee05d 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -11,6 +11,11 @@ export function preview(text: string) { } export namespace ShellRunner { + function wrap(name: string, command: string) { + if (name !== "powershell" && name !== "pwsh") return command + return `${command}; if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }; if ($?) { exit 0 }; exit 1` + } + export async function shellEnv(ctx: Tool.Context, cwd: string) { const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }) return { @@ -21,7 +26,7 @@ export namespace ShellRunner { export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { if (process.platform === "win32" && (name === "powershell" || name === "pwsh")) { - return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", wrap(name, command)], { cwd, env, stdio: ["ignore", "pipe", "pipe"], @@ -54,6 +59,7 @@ export namespace ShellRunner { ) { const proc = launch(input.shell, input.name, input.command, input.cwd, input.env) let output = "" + let code: number | null = null ctx.metadata({ metadata: { @@ -103,12 +109,14 @@ export namespace ShellRunner { ctx.abort.removeEventListener("abort", abort) } - proc.once("exit", () => { + proc.once("exit", (next) => { exited = true + code = next }) - proc.once("close", () => { + proc.once("close", (next) => { exited = true + code = next cleanup() resolve() }) @@ -131,7 +139,7 @@ export namespace ShellRunner { title: input.description, metadata: { output: preview(output), - exit: proc.exitCode, + exit: code, description: input.description, }, output, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 03477e5a7f..a2e644d887 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -269,6 +269,8 @@ export type ToolInfo = { subtitle?: string } +const SHELL = new Set(["bash", "pwsh", "powershell"]) + function agentTitle(i18n: UiI18n, type?: string) { if (!type) return i18n.t("ui.tool.agent.default") return i18n.t("ui.tool.agent", { type }) @@ -331,6 +333,8 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { } } case "bash": + case "pwsh": + case "powershell": return { icon: "console", title: i18n.t("ui.tool.shell"), @@ -518,7 +522,7 @@ function renderable(part: PartType, showReasoningSummaries = true) { } function toolDefaultOpen(tool: string, shell = false, edit = false) { - if (tool === "bash") return shell + if (SHELL.has(tool)) return shell if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit } diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index c7d177df7d..3558fd9452 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -33,6 +33,7 @@ import type { Diagnostic } from "vscode-languageserver-types" import styles from "./part.module.css" const MIN_DURATION = 2000 +const SHELL = new Set(["bash", "pwsh", "powershell"]) export interface PartProps { index: number @@ -90,7 +91,7 @@ export function Part(props: PartProps) { - + @@ -240,7 +241,7 @@ export function Part(props: PartProps) { state={props.part.state} /> - + Date: Fri, 3 Apr 2026 14:27:03 +1000 Subject: [PATCH 09/27] fix(shell): preserve powershell exit codes Use a multiline PowerShell trailer so native Windows commands keep their actual exit status without masking cmdlet failures, and add focused regression coverage. Remove the accidentally committed .opencode package-lock to keep generated state out of the branch. --- packages/opencode/src/tool/shell/runner.ts | 10 +++-- packages/opencode/test/tool/shell.test.ts | 43 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index 0c4f7ee05d..2adbf77b14 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -11,9 +11,11 @@ export function preview(text: string) { } export namespace ShellRunner { - function wrap(name: string, command: string) { - if (name !== "powershell" && name !== "pwsh") return command - return `${command}; if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }; if ($?) { exit 0 }; exit 1` + function preserveExitCode(command: string) { + return `${command} +if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } +if ($?) { exit 0 } +exit 1` } export async function shellEnv(ctx: Tool.Context, cwd: string) { @@ -26,7 +28,7 @@ export namespace ShellRunner { export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { if (process.platform === "win32" && (name === "powershell" || name === "pwsh")) { - return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", wrap(name, command)], { + return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", preserveExitCode(command)], { cwd, env, stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 53dee21311..39b54a7a26 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -115,6 +115,15 @@ const each = (name: string, fn: (item: { label: string; shell: string }) => Prom } } +const eachps = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { + for (const item of ps) { + test( + `${name} [${item.label}]`, + withShell(item, () => fn(item)), + ) + } +} + const capture = (requests: Array>, stop?: Error) => ({ ...ctx, ask: async (req: Omit) => { @@ -1018,6 +1027,40 @@ describe("tool.shell runtime", () => { }) }) + eachps("preserves native exit code with trailing comment", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await getTool() + const result = await bash.execute( + { + command: `${js("process.exit(42)")} # keep wrapper separate`, + description: "Trailing comment exit", + }, + ctx, + ) + expect(result.metadata.exit).toBe(42) + }, + }) + }) + + eachps("returns non-zero exit for powershell cmdlet errors", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await getTool() + const result = await bash.execute( + { + command: "Write-Error x", + description: "Cmdlet error exit", + }, + ctx, + ) + expect(result.metadata.exit).toBe(1) + }, + }) + }) + each("streams metadata updates progressively", async () => { await Instance.provide({ directory: projectRoot, From baf476f4317f941d1ef07f4780bf09fccc2a2209 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:29:04 +1000 Subject: [PATCH 10/27] test(shell): handle nullable exit metadata Make the shell exit assertions typecheck cleanly while keeping the PowerShell regression coverage. Remove the accidentally committed .opencode package-lock so generated state does not ship in the branch. --- packages/opencode/.opencode/package-lock.json | 31 ------------------- packages/opencode/test/tool/shell.test.ts | 10 +++--- 2 files changed, 5 insertions(+), 36 deletions(-) delete mode 100644 packages/opencode/.opencode/package-lock.json diff --git a/packages/opencode/.opencode/package-lock.json b/packages/opencode/.opencode/package-lock.json deleted file mode 100644 index c36f597373..0000000000 --- a/packages/opencode/.opencode/package-lock.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": ".opencode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@opencode-ai/plugin": "*" - } - }, - "node_modules/@opencode-ai/plugin": { - "version": "1.2.24", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.2.24", - "zod": "4.1.8" - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.2.24", - "license": "MIT" - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 39b54a7a26..224e4d6fc5 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -155,7 +155,7 @@ describe("tool.shell", () => { }, ctx, ) - expect(result.metadata.exit).toBe(0) + expect(result.metadata.exit ?? -1).toBe(0) expect(result.metadata.output).toContain("test") }, }) @@ -1005,7 +1005,7 @@ describe("tool.shell runtime", () => { ) expect(result.output).toContain("333") expect(result.output).toContain("444") - expect(result.metadata.exit).toBe(0) + expect(result.metadata.exit ?? -1).toBe(0) }, }) }) @@ -1022,7 +1022,7 @@ describe("tool.shell runtime", () => { }, ctx, ) - expect(result.metadata.exit).toBe(42) + expect(result.metadata.exit ?? -1).toBe(42) }, }) }) @@ -1039,7 +1039,7 @@ describe("tool.shell runtime", () => { }, ctx, ) - expect(result.metadata.exit).toBe(42) + expect(result.metadata.exit ?? -1).toBe(42) }, }) }) @@ -1056,7 +1056,7 @@ describe("tool.shell runtime", () => { }, ctx, ) - expect(result.metadata.exit).toBe(1) + expect(result.metadata.exit ?? -1).toBe(1) }, }) }) From 2eb9ae4d34340e298ade122cc0051a464615561d Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:56:40 +1000 Subject: [PATCH 11/27] refactor(shell): centralize shell tool identity Move shell tool ID checks behind shared helpers so runtime code and tests stop duplicating bash, pwsh, and powershell branches. This keeps shell-specific behavior aligned across consumers and makes follow-on shell changes less error-prone. --- .../app/e2e/prompt/prompt-history.spec.ts | 12 +----- packages/app/e2e/prompt/prompt-shell.spec.ts | 11 +---- packages/app/e2e/utils.ts | 10 ++++- packages/opencode/src/acp/agent.ts | 41 +++++++++---------- packages/opencode/src/agent/agent.ts | 2 - packages/opencode/src/cli/cmd/agent.ts | 5 +-- packages/opencode/src/cli/cmd/run.ts | 4 +- .../src/cli/cmd/tui/routes/session/index.tsx | 3 +- .../cli/cmd/tui/routes/session/permission.tsx | 3 +- packages/opencode/src/session/prompt.ts | 3 +- packages/opencode/src/tool/registry.ts | 8 ++-- packages/opencode/src/tool/shell/arity.ts | 10 ++--- packages/opencode/src/tool/shell/id.ts | 19 +++++++++ packages/opencode/src/tool/shell/parser.ts | 5 ++- packages/opencode/src/tool/shell/runner.ts | 3 +- packages/opencode/src/tool/shell/util.ts | 3 +- .../test/session/snapshot-tool-race.test.ts | 13 +++--- packages/opencode/test/tool/shell.test.ts | 20 ++++----- packages/ui/src/components/message-part.tsx | 16 ++++---- 19 files changed, 99 insertions(+), 92 deletions(-) create mode 100644 packages/opencode/src/tool/shell/id.ts diff --git a/packages/app/e2e/prompt/prompt-history.spec.ts b/packages/app/e2e/prompt/prompt-history.spec.ts index 55cb0c9aa3..0ecac65ef4 100644 --- a/packages/app/e2e/prompt/prompt-history.spec.ts +++ b/packages/app/e2e/prompt/prompt-history.spec.ts @@ -1,20 +1,12 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" import { assistantText } from "../actions" import { promptSelector } from "../selectors" -import { createSdk } from "../utils" +import { createSdk, isShell } from "../utils" const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() type Sdk = ReturnType -const isBash = (part: unknown): part is ToolPart => { - if (!part || typeof part !== "object") return false - if (!("type" in part) || part.type !== "tool") return false - if (!("tool" in part) || part.tool !== "bash") return false - return "state" in part -} - async function wait(page: Page, value: string) { await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value) } @@ -31,7 +23,7 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) { const part = messages .filter((item) => item.info.role === "assistant") .flatMap((item) => item.parts) - .filter(isBash) + .filter(isShell) .find((item) => item.state.input?.command === cmd && item.state.status === "completed") if (!part || part.state.status !== "completed") return diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts index d81f1d4c40..366011bdef 100644 --- a/packages/app/e2e/prompt/prompt-shell.spec.ts +++ b/packages/app/e2e/prompt/prompt-shell.spec.ts @@ -1,13 +1,6 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client" import { test, expect } from "../fixtures" import { withSession } from "../actions" - -const isBash = (part: unknown): part is ToolPart => { - if (!part || typeof part !== "object") return false - if (!("type" in part) || part.type !== "tool") return false - if (!("tool" in part) || part.tool !== "bash") return false - return "state" in part -} +import { isShell } from "../utils" async function setAutoAccept(page: Parameters[0]["page"], enabled: boolean) { const button = page.locator('[data-action="prompt-permissions"]').first() @@ -42,7 +35,7 @@ test("shell mode runs a command in the project directory", async ({ page, projec if (!msg) return const part = msg.parts - .filter(isBash) + .filter(isShell) .find((item) => item.state.input?.command === cmd && item.state.status === "completed") if (!part || part.state.status !== "completed") return diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index 17a8785664..9e4d13e0ad 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -1,4 +1,4 @@ -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { createOpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2/client" import { base64Encode, checksum } from "@opencode-ai/util/encode" export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1" @@ -18,6 +18,7 @@ const serverLabels = (() => { export const serverNames = [...new Set(serverLabels)] export const serverUrls = serverNames.map((name) => `http://${name}`) +const shell = new Set(["bash", "pwsh", "powershell"]) const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") @@ -30,6 +31,13 @@ export function createSdk(directory?: string, baseUrl = serverUrl) { return createOpencodeClient({ baseUrl, directory, throwOnError: true }) } +export function isShell(part: unknown): part is ToolPart { + if (!part || typeof part !== "object") return false + if (!("type" in part) || part.type !== "tool") return false + if (!("tool" in part) || typeof part.tool !== "string" || !shell.has(part.tool)) return false + return "state" in part +} + export async function resolveDirectory(directory: string, baseUrl = serverUrl) { return createSdk(directory, baseUrl) .path.get() diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index f5b264cbe5..a2c93cbf5a 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -38,6 +38,7 @@ import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { Agent as AgentModule } from "../agent/agent" import { Installation } from "@/installation" +import { ShellTool } from "@/tool/shell/id" import { MessageV2 } from "@/session/message-v2" import { Config } from "@/config/config" import { Todo } from "@/session/todo" @@ -138,7 +139,7 @@ export namespace ACP { private sessionManager: ACPSessionManager private eventAbort = new AbortController() private eventStarted = false - private bashSnapshots = new Map() + private shellSnapshots = new Map() private toolStarts = new Set() private permissionQueues = new Map>() private permissionOptions: PermissionOption[] = [ @@ -277,16 +278,16 @@ export namespace ACP { switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) return case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (part.tool === "bash" || part.tool === "pwsh" || part.tool === "powershell") { - if (this.bashSnapshots.get(part.callID) === hash) { + if (ShellTool.has(part.tool)) { + if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ sessionId, @@ -305,7 +306,7 @@ export namespace ACP { }) return } - this.bashSnapshots.set(part.callID, hash) + this.shellSnapshots.set(part.callID, hash) } content.push({ type: "content", @@ -336,7 +337,7 @@ export namespace ACP { case "completed": { this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -417,7 +418,7 @@ export namespace ACP { } case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -832,10 +833,10 @@ export namespace ACP { await this.toolStart(sessionId, part) switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) break case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const runningContent: ToolCallContent[] = [] if (output) { runningContent.push({ @@ -866,7 +867,7 @@ export namespace ACP { break case "completed": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -946,7 +947,7 @@ export namespace ACP { break case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -1100,8 +1101,8 @@ export namespace ACP { } } - private bashOutput(part: ToolPart) { - if (part.tool !== "bash" && part.tool !== "pwsh" && part.tool !== "powershell") return + private shellOutput(part: ToolPart) { + if (!ShellTool.has(part.tool)) return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1502,11 +1503,9 @@ export namespace ACP { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() + if (ShellTool.has(tool)) return "execute" + switch (tool) { - case "bash": - case "pwsh": - case "powershell": - return "execute" case "webfetch": return "fetch" @@ -1532,6 +1531,8 @@ export namespace ACP { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() + if (ShellTool.has(tool)) return [] + switch (tool) { case "read": case "edit": @@ -1540,10 +1541,6 @@ export namespace ACP { case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] - case "bash": - case "pwsh": - case "powershell": - return [] case "list": return input["path"] ? [{ path: input["path"] }] : [] default: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 02592b0d6a..0c6fe6ec91 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -168,8 +168,6 @@ export namespace Agent { glob: "allow", list: "allow", bash: "allow", - pwsh: "allow", - powershell: "allow", webfetch: "allow", websearch: "allow", codesearch: "allow", diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index a8e73c90b7..d0777d3ef7 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -9,15 +9,14 @@ import fs from "fs/promises" import { Filesystem } from "../../util/filesystem" import matter from "gray-matter" import { Instance } from "../../project/instance" +import { ShellTool } from "../../tool/shell/id" import { EOL } from "os" import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" const AVAILABLE_TOOLS = [ - "bash", - "pwsh", - "powershell", + ...ShellTool.ids, "read", "write", "edit", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 615cbf6494..de09719933 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -25,6 +25,7 @@ import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/shell/bash" +import { ShellTool } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" @@ -411,8 +412,7 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (part.tool === "bash" || part.tool === "pwsh" || part.tool === "powershell") - return bash(props(part)) + if (ShellTool.has(part.tool)) return bash(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "list") return list(props(part)) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index a9129bb9f4..e47b50ac33 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -36,6 +36,7 @@ import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" import { BashTool } from "@/tool/shell/bash" +import { ShellTool } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1519,7 +1520,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 9147da78aa..a5723e1d7a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -14,6 +14,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@/global" +import { ShellTool } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -283,7 +284,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "bash" || permission === "pwsh" || permission === "powershell") { + if (ShellTool.has(permission)) { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 115c93547b..823bb971b8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -43,6 +43,7 @@ import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" +import { ShellTool } from "@/tool/shell/id" import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" @@ -791,7 +792,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.updateMessage(msg) const sh = Shell.preferred() const name = Shell.name(sh) - const tool = name === "pwsh" ? "pwsh" : name === "powershell" ? "powershell" : "bash" + const tool = ShellTool.from(name) const part: MessageV2.ToolPart = { type: "tool", id: PartID.ascending(), diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6dce6d75e1..15f84a781a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -32,6 +32,7 @@ import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { BashTool } from "./shell/bash" +import { ShellTool } from "./shell/id" import { PwshTool } from "./shell/pwsh" import { PowershellTool } from "./shell/powershell" import { Shell } from "@/shell/shell" @@ -39,6 +40,7 @@ import { Env } from "../env" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) + const shells = { bash: BashTool, pwsh: PwshTool, powershell: PowershellTool } as const type State = { custom: Tool.Info[] @@ -118,14 +120,12 @@ export namespace ToolRegistry { const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) { const cfg = yield* config.get() const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL - - const shellName = Shell.name(Shell.acceptable()) - const ActiveShellTool = shellName === "pwsh" ? PwshTool : shellName === "powershell" ? PowershellTool : BashTool + const active = shells[ShellTool.from(Shell.name(Shell.acceptable()))] return [ InvalidTool, ...(question ? [QuestionTool] : []), - ActiveShellTool, + active, ReadTool, GlobTool, GrepTool, diff --git a/packages/opencode/src/tool/shell/arity.ts b/packages/opencode/src/tool/shell/arity.ts index 3ec40b6646..c97d4b8eec 100644 --- a/packages/opencode/src/tool/shell/arity.ts +++ b/packages/opencode/src/tool/shell/arity.ts @@ -1,10 +1,8 @@ +import { ShellTool } from "./id" + export namespace ShellArity { - export function prefix(tokens: string[], shellType: "bash" | "pwsh" | "powershell") { - if ( - (shellType === "pwsh" || shellType === "powershell") && - tokens.length > 0 && - /^[a-z]+-[a-z]+$/i.test(tokens[0]) - ) { + export function prefix(tokens: string[], shellType: ShellTool.ID) { + if (ShellTool.powershell(shellType) && tokens.length > 0 && /^[a-z]+-[a-z]+$/i.test(tokens[0])) { return [tokens[0]] } for (let len = tokens.length; len > 0; len--) { diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts new file mode 100644 index 0000000000..0ea57064cf --- /dev/null +++ b/packages/opencode/src/tool/shell/id.ts @@ -0,0 +1,19 @@ +export namespace ShellTool { + export const ids = ["bash", "pwsh", "powershell"] as const + export type ID = (typeof ids)[number] + + const shell = new Set(ids) + const ps = new Set(["pwsh", "powershell"]) + + export function has(value: string): value is ID { + return shell.has(value) + } + + export function from(value: string): ID { + return has(value) ? value : "bash" + } + + export function powershell(value: string) { + return ps.has(value) + } +} diff --git a/packages/opencode/src/tool/shell/parser.ts b/packages/opencode/src/tool/shell/parser.ts index 6260ac6042..3860f65ea5 100644 --- a/packages/opencode/src/tool/shell/parser.ts +++ b/packages/opencode/src/tool/shell/parser.ts @@ -1,6 +1,7 @@ import type { Node } from "web-tree-sitter" import { lazy } from "@/util/lazy" import { resolveWasm, resolvePath, unquote, home, expand, type Scan, type Part } from "./util" +import { ShellTool } from "./id" import { Instance } from "@/project/instance" import { Filesystem } from "@/util/filesystem" import path from "path" @@ -160,9 +161,9 @@ export namespace ShellParser { command: string cwd: string shell: string - shellType: "bash" | "pwsh" | "powershell" + shellType: ShellTool.ID }): Promise { - const isPwsh = opts.shellType === "pwsh" || opts.shellType === "powershell" + const isPwsh = ShellTool.powershell(opts.shellType) const parsers = await getParser() const parser = isPwsh ? parsers.ps : parsers.bash diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index 2adbf77b14..b86d39cecb 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -2,6 +2,7 @@ import { spawn } from "child_process" import { Shell } from "@/shell/shell" import { Tool } from "../tool" import { Plugin } from "@/plugin" +import { ShellTool } from "./id" const MAX_METADATA_LENGTH = 30_000 @@ -27,7 +28,7 @@ exit 1` } export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { - if (process.platform === "win32" && (name === "powershell" || name === "pwsh")) { + if (process.platform === "win32" && ShellTool.powershell(name)) { return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", preserveExitCode(command)], { cwd, env, diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts index b1fb167fbd..a09289235c 100644 --- a/packages/opencode/src/tool/shell/util.ts +++ b/packages/opencode/src/tool/shell/util.ts @@ -108,12 +108,13 @@ export function formatShellDescription( import z from "zod" import DESCRIPTION from "./shell.txt" +import { ShellTool } from "./id" import { Log } from "@/util/log" import { Flag } from "@/flag/flag" import { ShellParser } from "./parser" import { ShellRunner } from "./runner" -export type ShellType = "bash" | "pwsh" | "powershell" +export type ShellType = ShellTool.ID const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 4f4376e341..cf9f340e98 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -16,10 +16,12 @@ import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { Session } from "../../src/session" +import { Shell } from "../../src/shell/shell" import { LLM } from "../../src/session/llm" import { SessionPrompt } from "../../src/session/prompt" import { SessionSummary } from "../../src/session/summary" import { MessageV2 } from "../../src/session/message-v2" +import { ShellTool } from "../../src/tool/shell/id" import { Log } from "../../src/util/log" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -42,7 +44,6 @@ import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" import { SessionStatus } from "../../src/session/status" -import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" @@ -183,13 +184,15 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => permission: [{ permission: "*", pattern: "*", action: "allow" }], }) - // Use bash tool (always registered) to create a file + const shell = ShellTool.from(Shell.name(Shell.acceptable())) + + // Use the active shell tool to create a file const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}` - yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", { + yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), shell, { command, description: "create test file", }) - yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done") + yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes(shell), "done") // Seed user message yield* prompt.prompt({ @@ -217,7 +220,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => const allMsgs = yield* MessageV2.filterCompactedEffect(session.id) const tool = allMsgs .flatMap((m) => m.parts) - .find((p): p is MessageV2.ToolPart => p.type === "tool" && p.tool === "bash") + .find((p): p is MessageV2.ToolPart => p.type === "tool" && p.tool === shell) expect(tool?.state.status).toBe("completed") // Poll for diff — summarize() is fire-and-forget diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 224e4d6fc5..26a6e7d056 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -3,6 +3,7 @@ import os from "os" import path from "path" import { Shell } from "../../src/shell/shell" import { BashTool } from "../../src/tool/shell/bash" +import { ShellTool } from "../../src/tool/shell/id" import { PwshTool } from "../../src/tool/shell/pwsh" import { PowershellTool } from "../../src/tool/shell/powershell" import { Instance } from "../../src/project/instance" @@ -47,15 +48,14 @@ const shells = (() => { (item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i, ) })() -const PS = new Set(["pwsh", "powershell"]) -const ps = shells.filter((item) => PS.has(item.label)) +const ps = shells.filter((item) => ShellTool.powershell(item.label)) const sh = () => Shell.name(Shell.acceptable()) const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) const js = (code: string, ...args: Array) => { const tail = args.length ? ` ${args.map(String).join(" ")}` : "" const text = `${bin} -e ${evalarg(code)}${tail}` - if (PS.has(sh())) return `& ${text}` + if (ShellTool.powershell(sh())) return `& ${text}` return text } @@ -92,18 +92,12 @@ const withShell = (item: { label: string; shell: string }, fn: () => Promise { - const name = sh() - if (name === "pwsh") return "pwsh" - if (name === "powershell") return "powershell" - return "bash" -} +const expectedPermission = () => ShellTool.from(sh()) + +const tools = { bash: BashTool, pwsh: PwshTool, powershell: PowershellTool } as const const getTool = async () => { - const name = sh() - if (name === "pwsh") return await PwshTool.init() - if (name === "powershell") return await PowershellTool.init() - return await BashTool.init() + return await tools[ShellTool.from(sh())].init() } const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index a2e644d887..a1b1b00c9c 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -278,6 +278,14 @@ function agentTitle(i18n: UiI18n, type?: string) { export function getToolInfo(tool: string, input: any = {}): ToolInfo { const i18n = useI18n() + if (SHELL.has(tool)) { + return { + icon: "console", + title: i18n.t("ui.tool.shell"), + subtitle: input.description, + } + } + switch (tool) { case "read": return { @@ -332,14 +340,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { subtitle: input.description, } } - case "bash": - case "pwsh": - case "powershell": - return { - icon: "console", - title: i18n.t("ui.tool.shell"), - subtitle: input.description, - } case "edit": return { icon: "code-lines", From 32ec3666b7ec18d644a6a8f720a75056bf27daa1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:08:30 +1000 Subject: [PATCH 12/27] fix(shell): keep shell config consistent Treat shell access as one logical toggle during agent creation and apply bash compatibility rules before explicit per-shell overrides. This avoids disabling the active Windows shell unexpectedly and keeps pwsh and powershell overrides deterministic. --- packages/opencode/src/cli/cmd/agent.ts | 14 +---- packages/opencode/src/permission/index.ts | 58 +++++++------------ .../opencode/test/permission/next.test.ts | 24 ++++++++ 3 files changed, 46 insertions(+), 50 deletions(-) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index d0777d3ef7..70082c8e2e 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -9,24 +9,12 @@ import fs from "fs/promises" import { Filesystem } from "../../util/filesystem" import matter from "gray-matter" import { Instance } from "../../project/instance" -import { ShellTool } from "../../tool/shell/id" import { EOL } from "os" import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" -const AVAILABLE_TOOLS = [ - ...ShellTool.ids, - "read", - "write", - "edit", - "list", - "glob", - "grep", - "webfetch", - "task", - "todowrite", -] +const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"] const AgentCreateCommand = cmd({ command: "create", diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 638d5ebe12..1ddd9cec32 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -276,46 +276,30 @@ export namespace Permission { return pattern } + function pushRules(ruleset: Ruleset, permission: string, value: Config.PermissionRule) { + if (typeof value === "string") { + ruleset.push({ permission, action: value, pattern: "*" }) + return + } + + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ permission, pattern: expand(pattern), action })), + ) + } + export function fromConfig(permission: Config.Permission) { const ruleset: Ruleset = [] + + const bash = permission["bash"] + if (bash !== undefined) { + pushRules(ruleset, "bash", bash) + pushRules(ruleset, "pwsh", bash) + pushRules(ruleset, "powershell", bash) + } + for (const [key, value] of Object.entries(permission)) { - if (key === "bash") { - if (typeof value === "string") { - ruleset.push({ permission: "bash", action: value, pattern: "*" }) - ruleset.push({ permission: "pwsh", action: value, pattern: "*" }) - ruleset.push({ permission: "powershell", action: value, pattern: "*" }) - } else { - ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ - permission: "bash", - pattern: expand(pattern), - action, - })), - ) - ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ - permission: "pwsh", - pattern: expand(pattern), - action, - })), - ) - ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ - permission: "powershell", - pattern: expand(pattern), - action, - })), - ) - } - continue - } - if (typeof value === "string") { - ruleset.push({ permission: key, action: value, pattern: "*" }) - continue - } - ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), - ) + if (key === "bash") continue + pushRules(ruleset, key, value) } return ruleset } diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 7e48fe33ff..b98f4a3ae0 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -71,6 +71,30 @@ test("fromConfig - mixed string and object values", () => { ]) }) +test("fromConfig - explicit pwsh overrides bash regardless of key order", () => { + const result = Permission.fromConfig({ + pwsh: "deny", + bash: "allow", + }) + expect(result).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "*", action: "allow" }, + { permission: "powershell", pattern: "*", action: "allow" }, + { permission: "pwsh", pattern: "*", action: "deny" }, + ]) + expect(Permission.evaluate("pwsh", "ls", result).action).toBe("deny") + expect(Permission.evaluate("bash", "ls", result).action).toBe("allow") +}) + +test("fromConfig - explicit powershell pattern overrides bash pattern regardless of key order", () => { + const result = Permission.fromConfig({ + powershell: { "rm *": "deny" }, + bash: { "*": "allow", "rm *": "ask" }, + }) + expect(Permission.evaluate("powershell", "rm foo", result).action).toBe("deny") + expect(Permission.evaluate("pwsh", "rm foo", result).action).toBe("ask") +}) + test("fromConfig - empty object", () => { const result = Permission.fromConfig({}) expect(result).toEqual([]) From 25551172c9c546a32c79703d62d04f97653329d2 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:04:41 +1000 Subject: [PATCH 13/27] fix(shell): avoid abort hangs and utf8 corruption Attach shell process listeners before handling already-aborted tool signals so canceled runs always settle, and decode shell output as UTF-8 to preserve multibyte characters across chunk boundaries. Also lazy-load shell-specific parsers and hoist command sets so parsing work stays focused on the active shell. --- packages/opencode/src/tool/shell/parser.ts | 43 ++++++++++---------- packages/opencode/src/tool/shell/runner.ts | 31 ++++++++------- packages/opencode/test/tool/shell.test.ts | 46 ++++++++++++++++++++++ 3 files changed, 86 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/tool/shell/parser.ts b/packages/opencode/src/tool/shell/parser.ts index 3860f65ea5..7b8dff37b7 100644 --- a/packages/opencode/src/tool/shell/parser.ts +++ b/packages/opencode/src/tool/shell/parser.ts @@ -22,6 +22,8 @@ const FILES_PWSH = new Set([ "new-item", "rename-item", ]) +const FILES_BASH = new Set([...CWD, ...FILES_BASE]) +const FILES_PWSH_ALL = new Set([...FILES_BASH, ...FILES_PWSH]) const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) @@ -125,36 +127,38 @@ function pathArgs(list: Part[], isPwsh: boolean) { } export namespace ShellParser { - const getParser = lazy(async () => { - const { Parser } = await import("web-tree-sitter") + const getCore = lazy(async () => { + const tree = await import("web-tree-sitter") const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { with: { type: "wasm" }, }) const treePath = resolveWasm(treeWasm) - await Parser.init({ + await tree.Parser.init({ locateFile() { return treePath }, }) + return tree + }) + + const getBashParser = lazy(async () => { + const tree = await getCore() const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { with: { type: "wasm" }, }) + const bash = new tree.Parser() + bash.setLanguage(await tree.Language.load(resolveWasm(bashWasm))) + return bash + }) + + const getPsParser = lazy(async () => { + const tree = await getCore() const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { with: { type: "wasm" }, }) - const { Language } = await import("web-tree-sitter") - const bashPath = resolveWasm(bashWasm) - const psPath = resolveWasm(psWasm) - const bashLanguage = await Language.load(bashPath) - const psLanguage = await Language.load(psPath) - - const bash = new Parser() - bash.setLanguage(bashLanguage) - - const ps = new Parser() - ps.setLanguage(psLanguage) - - return { bash, ps } + const ps = new tree.Parser() + ps.setLanguage(await tree.Language.load(resolveWasm(psWasm))) + return ps }) export async function collect(opts: { @@ -164,8 +168,7 @@ export namespace ShellParser { shellType: ShellTool.ID }): Promise { const isPwsh = ShellTool.powershell(opts.shellType) - const parsers = await getParser() - const parser = isPwsh ? parsers.ps : parsers.bash + const parser = isPwsh ? await getPsParser() : await getBashParser() const tree = parser.parse(opts.command) if (!tree) throw new Error("Failed to parse command") @@ -177,14 +180,14 @@ export namespace ShellParser { always: new Set(), } - const filesSet = new Set([...CWD, ...FILES_BASE, ...(isPwsh ? FILES_PWSH : [])]) + const files = isPwsh ? FILES_PWSH_ALL : FILES_BASH for (const node of commands(root)) { const commandParts = parts(node) const tokens = commandParts.map((item) => item.text) const cmd = isPwsh ? tokens[0]?.toLowerCase() : tokens[0] - if (cmd && filesSet.has(cmd)) { + if (cmd && files.has(cmd)) { for (const arg of pathArgs(commandParts, isPwsh)) { const resolved = await argPath(arg, opts.cwd, opts.shell, isPwsh) log.info("resolved path", { arg, resolved }) diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index b86d39cecb..647605d599 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -71,8 +71,11 @@ exit 1` }, }) - const append = (chunk: Buffer) => { - output += chunk.toString() + proc.stdout?.setEncoding("utf8") + proc.stderr?.setEncoding("utf8") + + const append = (chunk: string) => { + output += chunk ctx.metadata({ metadata: { output: preview(output), @@ -87,26 +90,16 @@ exit 1` let expired = false let aborted = false let exited = false + let timer: ReturnType const kill = () => Shell.killTree(proc, { exited: () => exited }) - if (ctx.abort.aborted) { - aborted = true - await kill() - } - const abort = () => { aborted = true void kill() } - ctx.abort.addEventListener("abort", abort, { once: true }) - const timer = setTimeout(() => { - expired = true - void kill() - }, input.timeout + 100) - - await new Promise((resolve, reject) => { + const wait = new Promise((resolve, reject) => { const cleanup = () => { clearTimeout(timer) ctx.abort.removeEventListener("abort", abort) @@ -131,6 +124,16 @@ exit 1` }) }) + ctx.abort.addEventListener("abort", abort, { once: true }) + timer = setTimeout(() => { + expired = true + void kill() + }, input.timeout + 100) + + if (ctx.abort.aborted) abort() + + await wait + const metadata: string[] = [] if (expired) metadata.push(`${input.name} tool terminated command after exceeding timeout ${input.timeout} ms`) if (aborted) metadata.push("User aborted the command") diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 26a6e7d056..aedfacc3a2 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -6,6 +6,7 @@ import { BashTool } from "../../src/tool/shell/bash" import { ShellTool } from "../../src/tool/shell/id" import { PwshTool } from "../../src/tool/shell/pwsh" import { PowershellTool } from "../../src/tool/shell/powershell" +import { ShellRunner } from "../../src/tool/shell/runner" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -966,6 +967,32 @@ describe("tool.shell runtime", () => { }) }) + each("does not hang when already aborted", async (item) => { + const controller = new AbortController() + controller.abort() + const result = await Promise.race([ + ShellRunner.run( + { + shell: item.shell, + name: item.label, + command: js("setTimeout(()=>{},30000)"), + cwd: projectRoot, + env: process.env, + timeout: 500, + description: "Already aborted", + }, + { + ...ctx, + abort: controller.signal, + }, + ), + Bun.sleep(1500).then(() => "timeout" as const), + ]) + expect(result).not.toBe("timeout") + if (result === "timeout") return + expect(result.output).toContain("User aborted the command") + }) + each("terminates command on timeout", async () => { await Instance.provide({ directory: projectRoot, @@ -1055,6 +1082,25 @@ describe("tool.shell runtime", () => { }) }) + each("preserves multibyte utf8 output across chunks", async (item) => { + const result = await ShellRunner.run( + { + shell: item.shell, + name: item.label, + command: js( + "process.stdout.write(Buffer.from([0xF0,0x9F]));setTimeout(()=>process.stdout.write(Buffer.from([0x98,0x80])),20);setTimeout(()=>process.exit(0),40)", + ), + cwd: projectRoot, + env: process.env, + timeout: 1000, + description: "Utf8 output", + }, + ctx, + ) + expect(result.output).toContain("😀") + expect(result.output).not.toContain("\ufffd") + }) + each("streams metadata updates progressively", async () => { await Instance.provide({ directory: projectRoot, From f1547de528438c1fc72a2d3780f1f5cd1970c18a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:35:16 +1000 Subject: [PATCH 14/27] ok --- .../e2e/session/session-composer-dock.spec.ts | 8 +- packages/app/e2e/utils.ts | 3 +- .../composer/session-permission-dock.tsx | 3 +- packages/opencode/src/acp/agent.ts | 10 +-- packages/opencode/src/agent/agent.ts | 2 +- .../opencode/src/agent/prompt/explore.txt | 4 +- packages/opencode/src/agent/prompt/title.txt | 2 +- packages/opencode/src/cli/cmd/agent.ts | 2 +- packages/opencode/src/cli/cmd/github.ts | 3 +- packages/opencode/src/cli/cmd/run.ts | 8 +- .../tui/feature-plugins/home/tips-view.tsx | 6 +- .../src/cli/cmd/tui/routes/session/index.tsx | 10 +-- .../cli/cmd/tui/routes/session/permission.tsx | 4 +- packages/opencode/src/config/config.ts | 16 ++-- packages/opencode/src/permission/evaluate.ts | 4 +- packages/opencode/src/permission/index.ts | 19 ++-- packages/opencode/src/session/index.ts | 7 +- packages/opencode/src/session/llm.ts | 17 ++-- packages/opencode/src/session/message-v2.ts | 34 ++++--- packages/opencode/src/session/prompt.ts | 5 +- .../opencode/src/session/prompt/anthropic.txt | 2 +- .../opencode/src/session/prompt/default.txt | 4 +- .../opencode/src/session/prompt/gemini.txt | 16 ++-- packages/opencode/src/session/prompt/gpt.txt | 2 +- packages/opencode/src/session/prompt/kimi.txt | 4 +- packages/opencode/src/session/prompt/plan.txt | 2 +- .../opencode/src/session/prompt/trinity.txt | 2 +- packages/opencode/src/tool/registry.ts | 13 +-- packages/opencode/src/tool/shell/arity.ts | 6 +- packages/opencode/src/tool/shell/bash.ts | 12 --- packages/opencode/src/tool/shell/id.ts | 22 ++++- packages/opencode/src/tool/shell/parser.ts | 6 +- .../opencode/src/tool/shell/powershell.ts | 18 ---- packages/opencode/src/tool/shell/pwsh.ts | 18 ---- packages/opencode/src/tool/shell/runner.ts | 12 +-- packages/opencode/src/tool/shell/tool.ts | 3 + packages/opencode/src/tool/shell/util.ts | 90 +++++++++++++------ .../test/acp/event-subscription.test.ts | 18 ++-- .../opencode/test/cli/tui/transcript.test.ts | 14 +-- packages/opencode/test/config/config.test.ts | 14 +-- .../opencode/test/permission/next.test.ts | 47 ++++------ .../opencode/test/provider/transform.test.ts | 8 +- .../opencode/test/session/compaction.test.ts | 2 +- packages/opencode/test/session/llm.test.ts | 4 +- .../opencode/test/session/message-v2.test.ts | 30 +++---- .../test/session/processor-effect.test.ts | 2 +- .../test/session/revert-compact.test.ts | 2 +- .../test/session/snapshot-tool-race.test.ts | 5 +- packages/opencode/test/tool/shell.test.ts | 30 +++---- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 2 +- packages/ui/src/components/message-part.tsx | 4 +- .../shell-submessage-motion.stories.tsx | 2 +- .../timeline-playground.stories.tsx | 8 +- .../components/tool-error-card.stories.tsx | 8 +- .../ui/src/components/tool-error-card.tsx | 1 + packages/web/src/components/share/part.tsx | 2 +- 57 files changed, 310 insertions(+), 294 deletions(-) delete mode 100644 packages/opencode/src/tool/shell/bash.ts delete mode 100644 packages/opencode/src/tool/shell/powershell.ts delete mode 100644 packages/opencode/src/tool/shell/pwsh.ts create mode 100644 packages/opencode/src/tool/shell/tool.ts diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index ecacea83dc..8a92fdef66 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -401,7 +401,7 @@ test("blocked permission flow supports allow once", async ({ page, project }) => { id: "per_e2e_once", sessionID: session.id, - permission: "bash", + permission: "shell", patterns: ["/tmp/opencode-e2e-perm-once"], metadata: { description: "Need permission for command" }, }, @@ -434,7 +434,7 @@ test("blocked permission flow supports reject", async ({ page, project }) => { { id: "per_e2e_reject", sessionID: session.id, - permission: "bash", + permission: "shell", patterns: ["/tmp/opencode-e2e-perm-reject"], }, undefined, @@ -466,7 +466,7 @@ test("blocked permission flow supports allow always", async ({ page, project }) { id: "per_e2e_always", sessionID: session.id, - permission: "bash", + permission: "shell", patterns: ["/tmp/opencode-e2e-perm-always"], metadata: { description: "Need permission for command" }, }, @@ -561,7 +561,7 @@ test("child session permission request blocks parent dock and supports allow onc { id: "per_e2e_child", sessionID: child.id, - permission: "bash", + permission: "shell", patterns: ["/tmp/opencode-e2e-perm-child"], metadata: { description: "Need child permission" }, }, diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index 9e4d13e0ad..3df083ef45 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -18,7 +18,6 @@ const serverLabels = (() => { export const serverNames = [...new Set(serverLabels)] export const serverUrls = serverNames.map((name) => `http://${name}`) -const shell = new Set(["bash", "pwsh", "powershell"]) const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") @@ -34,7 +33,7 @@ export function createSdk(directory?: string, baseUrl = serverUrl) { export function isShell(part: unknown): part is ToolPart { if (!part || typeof part !== "object") return false if (!("type" in part) || part.type !== "tool") return false - if (!("tool" in part) || typeof part.tool !== "string" || !shell.has(part.tool)) return false + if (!("tool" in part) || part.tool !== "shell") return false return "state" in part } diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx index 06ff4f4aa7..bd1ecdffd3 100644 --- a/packages/app/src/pages/session/composer/session-permission-dock.tsx +++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx @@ -14,8 +14,9 @@ export function SessionPermissionDock(props: { const toolDescription = () => { const key = `settings.permissions.tool.${props.request.permission}.description` + const fallback = props.request.permission === "shell" ? "settings.permissions.tool.bash.description" : key const value = language.t(key as Parameters[0]) - if (value === key) return "" + if (value === key) return fallback === key ? "" : language.t(fallback as Parameters[0]) return value } diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 63e6ddf0fc..12cf4b1991 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -41,7 +41,7 @@ import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { Agent as AgentModule } from "../agent/agent" import { Installation } from "@/installation" -import { ShellTool } from "@/tool/shell/id" +import { ShellToolID } from "@/tool/shell/id" import { MessageV2 } from "@/session/message-v2" import { Config } from "@/config/config" import { Todo } from "@/session/todo" @@ -289,7 +289,7 @@ export namespace ACP { const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (ShellTool.has(part.tool)) { + if (ShellToolID.has(part.tool)) { if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ @@ -1111,7 +1111,7 @@ export namespace ACP { } private shellOutput(part: ToolPart) { - if (!ShellTool.has(part.tool)) return + if (!ShellToolID.has(part.tool)) return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1555,7 +1555,7 @@ export namespace ACP { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() - if (ShellTool.has(tool)) return "execute" + if (ShellToolID.has(tool)) return "execute" switch (tool) { case "webfetch": @@ -1583,7 +1583,7 @@ export namespace ACP { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() - if (ShellTool.has(tool)) return [] + if (ShellToolID.has(tool)) return [] switch (tool) { case "read": diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 0c6fe6ec91..14cfa89117 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -167,7 +167,7 @@ export namespace Agent { grep: "allow", glob: "allow", list: "allow", - bash: "allow", + shell: "allow", webfetch: "allow", websearch: "allow", codesearch: "allow", diff --git a/packages/opencode/src/agent/prompt/explore.txt b/packages/opencode/src/agent/prompt/explore.txt index 5761077cbd..e5e19ff2e5 100644 --- a/packages/opencode/src/agent/prompt/explore.txt +++ b/packages/opencode/src/agent/prompt/explore.txt @@ -9,10 +9,10 @@ Guidelines: - Use Glob for broad file pattern matching - Use Grep for searching file contents with regex - Use Read when you know the specific file path you need to read -- Use Bash for file operations like copying, moving, or listing directory contents +- Use Shell for file operations like copying, moving, or listing directory contents - Adapt your search approach based on the thoroughness level specified by the caller - Return file paths as absolute paths in your final response - For clear communication, avoid using emojis -- Do not create any files, or run bash commands that modify the user's system state in any way +- Do not create any files, or run shell commands that modify the user's system state in any way Complete the user's search request efficiently and report your findings clearly. diff --git a/packages/opencode/src/agent/prompt/title.txt b/packages/opencode/src/agent/prompt/title.txt index 62960b2c47..729871e5fa 100644 --- a/packages/opencode/src/agent/prompt/title.txt +++ b/packages/opencode/src/agent/prompt/title.txt @@ -14,7 +14,7 @@ Your output must be: - you MUST use the same language as the user message you are summarizing - Title must be grammatically correct and read naturally - no word salad -- Never include tool names in the title (e.g. "read tool", "bash tool", "edit tool") +- Never include tool names in the title (e.g. "read tool", "shell tool", "edit tool") - Focus on the main topic or question the user needs to retrieve - Vary your phrasing - avoid repetitive patterns like always starting with "Analyzing" - When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 70082c8e2e..eda4175b9f 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -14,7 +14,7 @@ import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" -const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"] +const AVAILABLE_TOOLS = ["shell", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"] const AgentCreateCommand = cmd({ command: "create", diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index e8f3e6a11e..a5d4ad3ce2 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -869,7 +869,8 @@ export const GithubRunCommand = cmd({ function subscribeSessionEvents() { const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], - bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], + shell: ["Shell", UI.Style.TEXT_DANGER_BOLD], + bash: ["Shell", UI.Style.TEXT_DANGER_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD], grep: ["Grep", UI.Style.TEXT_INFO_BOLD], diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 618dea5188..20b0f0c823 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -24,8 +24,8 @@ import { CodeSearchTool } from "../../tool/codesearch" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/shell/bash" -import { ShellTool } from "../../tool/shell/id" +import { ShellTool } from "../../tool/shell/tool" +import { ShellToolID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" @@ -192,7 +192,7 @@ function skill(info: ToolProps) { }) } -function bash(info: ToolProps) { +function shell(info: ToolProps) { const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined block( { @@ -417,7 +417,7 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (ShellTool.has(part.tool)) return bash(props(part)) + if (ShellToolID.has(part.tool)) return shell(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "list") return list(props(part)) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index 1a9d907bb9..4f0d01aa3f 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -92,8 +92,8 @@ const TIPS = [ "Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input", "Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})", "Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas", - "Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools", - 'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions', + "Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}shell{/highlight}, and {highlight}webfetch{/highlight} tools", + 'Use patterns like {highlight}"git *": "allow"{/highlight} for granular shell permissions', 'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands', 'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing', "OpenCode auto-formats files using prettier, gofmt, ruff, and more", @@ -127,7 +127,7 @@ const TIPS = [ "Use {highlight}instructions{/highlight} in config to load additional rules files", "Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)", "Configure {highlight}steps{/highlight} to limit agentic iterations per request", - 'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools', + 'Set {highlight}"tools": {"shell": false}{/highlight} to disable specific tools', 'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server', "Override global tool settings per agent configuration", 'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions', diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 2599bcfc24..efb4cd2f9b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -35,8 +35,8 @@ import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { BashTool } from "@/tool/shell/bash" -import { ShellTool } from "@/tool/shell/id" +import { ShellTool } from "@/tool/shell/tool" +import { ShellToolID } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1514,8 +1514,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - - + + @@ -1752,7 +1752,7 @@ function BlockTool(props: { ) } -function Bash(props: ToolProps) { +function Shell(props: ToolProps) { const { theme } = useTheme() const sync = useSync() const isRunning = createMemo(() => props.part.state.status === "running") diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a5723e1d7a..d9d660b6a4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -14,7 +14,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@/global" -import { ShellTool } from "@/tool/shell/id" +import { ShellToolID } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -284,7 +284,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (ShellTool.has(permission)) { + if (ShellToolID.has(permission)) { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2851290f8a..e47cbfc7c3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -38,6 +38,7 @@ import { AppFileSystem } from "@/filesystem" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { Duration, Effect, Layer, Option, ServiceMap } from "effect" +import { ShellToolID } from "@/tool/shell/id" import { Flock } from "@/util/flock" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { Npm } from "@/npm" @@ -460,10 +461,15 @@ export namespace Config { if (typeof x === "string") return { "*": x as PermissionAction } const obj = x as { __originalKeys?: string[] } & Record const { __originalKeys, ...rest } = obj - if (!__originalKeys) return rest as Record + if (!__originalKeys) { + return Object.fromEntries( + Object.entries(rest).map(([key, value]) => [ShellToolID.normalize(key), value as PermissionRule]), + ) + } const result: Record = {} for (const key of __originalKeys) { - if (key in rest) result[key] = rest[key] as PermissionRule + if (!(key in rest)) continue + result[ShellToolID.normalize(key)] = rest[key] as PermissionRule } return result } @@ -479,7 +485,7 @@ export namespace Config { glob: PermissionRule.optional(), grep: PermissionRule.optional(), list: PermissionRule.optional(), - bash: PermissionRule.optional(), + shell: PermissionRule.optional(), task: PermissionRule.optional(), external_directory: PermissionRule.optional(), todowrite: PermissionAction.optional(), @@ -587,8 +593,8 @@ export namespace Config { // write, edit, patch, multiedit all map to edit permission if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { permission.edit = action - } else if (tool === "bash") { - permission.bash = action + } else if (ShellToolID.normalize(tool) === ShellToolID.id) { + permission.shell = action } else { permission[tool] = action } diff --git a/packages/opencode/src/permission/evaluate.ts b/packages/opencode/src/permission/evaluate.ts index 2b0604f4ba..48c4d5f082 100644 --- a/packages/opencode/src/permission/evaluate.ts +++ b/packages/opencode/src/permission/evaluate.ts @@ -1,4 +1,5 @@ import { Wildcard } from "@/util/wildcard" +import { ShellToolID } from "@/tool/shell/id" type Rule = { permission: string @@ -7,9 +8,10 @@ type Rule = { } export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule { + const next = ShellToolID.normalize(permission) const rules = rulesets.flat() const match = rules.findLast( - (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), + (rule) => Wildcard.match(next, ShellToolID.normalize(rule.permission)) && Wildcard.match(pattern, rule.pattern), ) return match ?? { action: "ask", permission, pattern: "*" } } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1ddd9cec32..f7a072c7df 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -15,6 +15,7 @@ import os from "os" import z from "zod" import { evaluate as evalRule } from "./evaluate" import { PermissionID } from "./schema" +import { ShellToolID } from "@/tool/shell/id" export namespace Permission { const log = Log.create({ service: "permission" }) @@ -174,7 +175,9 @@ export namespace Permission { log.info("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") { return yield* new DeniedError({ - ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), + ruleset: ruleset.filter((rule) => + Wildcard.match(ShellToolID.normalize(request.permission), ShellToolID.normalize(rule.permission)), + ), }) } if (rule.action === "allow") continue @@ -290,16 +293,8 @@ export namespace Permission { export function fromConfig(permission: Config.Permission) { const ruleset: Ruleset = [] - const bash = permission["bash"] - if (bash !== undefined) { - pushRules(ruleset, "bash", bash) - pushRules(ruleset, "pwsh", bash) - pushRules(ruleset, "powershell", bash) - } - for (const [key, value] of Object.entries(permission)) { - if (key === "bash") continue - pushRules(ruleset, key, value) + pushRules(ruleset, ShellToolID.normalize(key), value) } return ruleset } @@ -313,8 +308,8 @@ export namespace Permission { export function disabled(tools: string[], ruleset: Ruleset): Set { const result = new Set() for (const tool of tools) { - const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) + const permission = EDIT_TOOLS.includes(tool) ? "edit" : ShellToolID.normalize(tool) + const rule = ruleset.findLast((rule) => Wildcard.match(permission, ShellToolID.normalize(rule.permission))) if (!rule) continue if (rule.pattern === "*" && rule.action === "deny") result.add(tool) } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 65032de962..b03b755a60 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -482,14 +482,15 @@ export namespace Session { const updatePart = (part: T): Effect.Effect => Effect.gen(function* () { + const next = MessageV2.normalizePart(part) yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.PartUpdated, { - sessionID: part.sessionID, - part: structuredClone(part), + sessionID: next.sessionID, + part: structuredClone(next), time: Date.now(), }), ) - return part + return next as T }).pipe(Effect.withSpan("Session.updatePart")) const create = Effect.fn("Session.create")(function* (input?: { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c9a62c8645..08cdbcdb68 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -17,6 +17,7 @@ import { Flag } from "@/flag/flag" import { Permission } from "@/permission" import { Auth } from "@/auth" import { Installation } from "@/installation" +import { ShellToolID } from "@/tool/shell/id" export namespace LLM { const log = Log.create({ service: "llm" }) @@ -226,6 +227,12 @@ export namespace LLM { }) } + const repair = (toolName: string) => { + const next = ShellToolID.normalize(toolName.toLowerCase()) + if (!tools[next]) return + return next + } + // Wire up toolExecutor for DWS workflow models so that tool calls // from the workflow service are executed via opencode's tool system // and results sent back over the WebSocket. @@ -233,7 +240,7 @@ export namespace LLM { const workflowModel = language workflowModel.systemPrompt = system.join("\n") workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { - const t = tools[toolName] + const t = tools[repair(toolName) ?? toolName] if (!t || !t.execute) { return { result: "", error: `Unknown tool: ${toolName}` } } @@ -262,15 +269,15 @@ export namespace LLM { }) }, async experimental_repairToolCall(failed) { - const lower = failed.toolCall.toolName.toLowerCase() - if (lower !== failed.toolCall.toolName && tools[lower]) { + const repaired = repair(failed.toolCall.toolName) + if (repaired && repaired !== failed.toolCall.toolName) { l.info("repairing tool call", { tool: failed.toolCall.toolName, - repaired: lower, + repaired, }) return { ...failed.toolCall, - toolName: lower, + toolName: repaired, } } return { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e8aab62d84..8d609e59b6 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -15,6 +15,7 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect } from "effect" +import { ShellToolID } from "@/tool/shell/id" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -24,6 +25,17 @@ interface FetchDecompressionError extends Error { } export namespace MessageV2 { + export function normalizeTool(tool: string) { + return ShellToolID.normalize(tool) + } + + export function normalizePart(part: T): T { + if (part.type !== "tool") return part + const tool = normalizeTool(part.tool) + if (tool === part.tool) return part + return { ...part, tool } as T + } + export function isMedia(mime: string) { return mime.startsWith("image/") || mime === "application/pdf" } @@ -534,12 +546,12 @@ export namespace MessageV2 { }) as MessageV2.Info const part = (row: typeof PartTable.$inferSelect) => - ({ + normalizePart({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id, - }) as MessageV2.Part + } as MessageV2.Part) const older = (row: Cursor) => or( @@ -701,7 +713,8 @@ export namespace MessageV2 { role: "assistant", parts: [], } - for (const part of msg.parts) { + for (const raw of msg.parts) { + const part = normalizePart(raw) if (part.type === "text") assistantMessage.parts.push({ type: "text", @@ -874,14 +887,13 @@ export namespace MessageV2 { const rows = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), ) - return rows.map( - (row) => - ({ - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - }) as MessageV2.Part, + return rows.map((row) => + normalizePart({ + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part), ) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1e26676fc8..23f59bf8a4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -40,7 +40,7 @@ import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" -import { ShellTool } from "@/tool/shell/id" +import { ShellToolID } from "@/tool/shell/id" import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" @@ -791,13 +791,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.updateMessage(msg) const sh = Shell.preferred() const name = Shell.name(sh) - const tool = ShellTool.from(name) const part: MessageV2.ToolPart = { type: "tool", id: PartID.ascending(), messageID: msg.id, sessionID: input.sessionID, - tool, + tool: ShellToolID.id, callID: ulid(), state: { status: "running", diff --git a/packages/opencode/src/session/prompt/anthropic.txt b/packages/opencode/src/session/prompt/anthropic.txt index 21d9c0e9f2..e97a282851 100644 --- a/packages/opencode/src/session/prompt/anthropic.txt +++ b/packages/opencode/src/session/prompt/anthropic.txt @@ -82,7 +82,7 @@ The user will primarily request you perform software engineering tasks. This inc - When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. - You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. - If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. -- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. +- Use specialized tools instead of shell commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve the shell tool exclusively for actual system commands and terminal operations that require shell execution. NEVER use shell echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. - VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly. user: Where are errors from the client handled? diff --git a/packages/opencode/src/session/prompt/default.txt b/packages/opencode/src/session/prompt/default.txt index 365663eeef..e3b4dd940d 100644 --- a/packages/opencode/src/session/prompt/default.txt +++ b/packages/opencode/src/session/prompt/default.txt @@ -9,7 +9,7 @@ If the user asks for help or wants to give feedback inform them of the following When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from opencode docs at https://opencode.ai # Tone and style -You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). +You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. @@ -89,7 +89,7 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN # Tool usage policy - When doing file search, prefer to use the Task tool in order to reduce context usage. -- 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. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel. +- 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. When making multiple shell tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel. You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail. diff --git a/packages/opencode/src/session/prompt/gemini.txt b/packages/opencode/src/session/prompt/gemini.txt index 87fe422bc7..328ce2aaf2 100644 --- a/packages/opencode/src/session/prompt/gemini.txt +++ b/packages/opencode/src/session/prompt/gemini.txt @@ -19,18 +19,18 @@ You are opencode, an interactive CLI agent specializing in software engineering When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence: 1. **Understand:** Think about the user's request and the relevant codebase context. Use 'grep' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use 'read' to understand context and validate any assumptions you may have. 2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should try to use a self-verification loop by writing unit tests if relevant to the task. Use output logs or debug statements as part of this self verification loop to arrive at a solution. -3. **Implement:** Use the available tools (e.g., 'edit', 'write' 'bash' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +3. **Implement:** Use the available tools (e.g., 'edit', 'write' 'shell' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). 4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. 5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. ## New Applications -**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write', 'edit' and 'bash'. +**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write', 'edit' and 'shell'. 1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. 3. **User Approval:** Obtain user approval for the proposed plan. -4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'bash' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible. +4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'shell' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible. 5. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors. 6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype. @@ -46,13 +46,13 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with 'bash' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with 'shell' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage - **File Paths:** Always use absolute paths when referring to files with tools like 'read' or 'write'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). -- **Command Execution:** Use the 'bash' tool for running shell commands, remembering the safety rule to explain modifying commands first. +- **Command Execution:** Use the 'shell' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. @@ -79,7 +79,7 @@ model: [tool_call: ls for path '/path/to/project'] user: start the server implemented in server.js -model: [tool_call: bash for 'node server.js &' because it must run in the background] +model: [tool_call: shell for 'node server.js &' because it must run in the background] @@ -106,7 +106,7 @@ user: Yes model: [tool_call: write or edit to apply the refactoring to 'src/auth.py'] Refactoring complete. Running verification... -[tool_call: bash for 'ruff check src/auth.py && pytest'] +[tool_call: shell for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -125,7 +125,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write to create /path/to/someFile.test.ts with the test code] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: bash for 'npm run test'] +[tool_call: shell for 'npm run test'] diff --git a/packages/opencode/src/session/prompt/gpt.txt b/packages/opencode/src/session/prompt/gpt.txt index 9068df4778..4a8f1d3cfd 100644 --- a/packages/opencode/src/session/prompt/gpt.txt +++ b/packages/opencode/src/session/prompt/gpt.txt @@ -3,7 +3,7 @@ You are OpenCode, You and the user share the same workspace and collaborate to a You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer. - When searching for text or files, prefer using Glob and Grep tools (they are powered by `rg`) -- Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly. +- Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together shell commands with separators like `echo "====";` as this renders to the user poorly. ## Editing Approach diff --git a/packages/opencode/src/session/prompt/kimi.txt b/packages/opencode/src/session/prompt/kimi.txt index beff6755f9..19461bcfe7 100644 --- a/packages/opencode/src/session/prompt/kimi.txt +++ b/packages/opencode/src/session/prompt/kimi.txt @@ -30,8 +30,8 @@ When building something from scratch, you should: Always use tools to implement your code changes: - Use `write`/`edit` to create or modify source files. Code that only appears in your text response is NOT saved to the file system and will not take effect. -- Use `bash` to run and test your code after writing it. -- Iterate: if tests fail, read the error, fix the code with `write`/`edit`, and re-test with `bash`. +- Use `shell` to run and test your code after writing it. +- Iterate: if tests fail, read the error, fix the code with `write`/`edit`, and re-test with `shell`. When working on an existing codebase, you should: diff --git a/packages/opencode/src/session/prompt/plan.txt b/packages/opencode/src/session/prompt/plan.txt index 1806e0eba6..b2113d2319 100644 --- a/packages/opencode/src/session/prompt/plan.txt +++ b/packages/opencode/src/session/prompt/plan.txt @@ -3,7 +3,7 @@ CRITICAL: Plan mode ACTIVE - you are in READ-ONLY phase. STRICTLY FORBIDDEN: ANY file edits, modifications, or system changes. Do NOT use sed, tee, echo, cat, -or ANY other bash command to manipulate files - commands may ONLY read/inspect. +or ANY other shell command to manipulate files - commands may ONLY read/inspect. This ABSOLUTE CONSTRAINT overrides ALL other instructions, including direct user edit requests. You may ONLY observe, analyze, and plan. Any modification attempt is a critical violation. ZERO exceptions. diff --git a/packages/opencode/src/session/prompt/trinity.txt b/packages/opencode/src/session/prompt/trinity.txt index 28ee4c4f26..06fb75799e 100644 --- a/packages/opencode/src/session/prompt/trinity.txt +++ b/packages/opencode/src/session/prompt/trinity.txt @@ -1,7 +1,7 @@ You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. # Tone and style -You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). +You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6cdf3f697c..4e5663bbdb 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -29,11 +29,8 @@ import { pathToFileURL } from "url" import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" -import { BashTool } from "./shell/bash" -import { ShellTool } from "./shell/id" -import { PwshTool } from "./shell/pwsh" -import { PowershellTool } from "./shell/powershell" -import { Shell } from "@/shell/shell" +import { ShellTool } from "./shell/tool" +import { ShellToolID } from "./shell/id" import { Env } from "../env" import { Question } from "../question" import { Todo } from "../session/todo" @@ -45,7 +42,6 @@ import { Agent } from "../agent/agent" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) - const shells = { bash: BashTool, pwsh: PwshTool, powershell: PowershellTool } as const type State = { custom: Tool.Def[] @@ -138,14 +134,13 @@ export namespace ToolRegistry { const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL - const active = shells[ShellTool.from(Shell.name(Shell.acceptable()))] return { custom, builtin: yield* Effect.forEach( [ InvalidTool, - active, + ShellTool, ReadTool, GlobTool, GrepTool, @@ -176,7 +171,7 @@ export namespace ToolRegistry { const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) { const tools = yield* all() - const match = tools.find((tool) => tool.id === id) + const match = tools.find((tool) => tool.id === ShellToolID.normalize(id)) if (!match) return yield* Effect.die(`Tool not found: ${id}`) return match }) diff --git a/packages/opencode/src/tool/shell/arity.ts b/packages/opencode/src/tool/shell/arity.ts index c97d4b8eec..8610c73f6a 100644 --- a/packages/opencode/src/tool/shell/arity.ts +++ b/packages/opencode/src/tool/shell/arity.ts @@ -1,8 +1,8 @@ -import { ShellTool } from "./id" +import { ShellKind } from "./id" export namespace ShellArity { - export function prefix(tokens: string[], shellType: ShellTool.ID) { - if (ShellTool.powershell(shellType) && tokens.length > 0 && /^[a-z]+-[a-z]+$/i.test(tokens[0])) { + export function prefix(tokens: string[], shellType: ShellKind.ID) { + if (ShellKind.powershell(shellType) && tokens.length > 0 && /^[a-z]+-[a-z]+$/i.test(tokens[0])) { return [tokens[0]] } for (let len = tokens.length; len > 0; len--) { diff --git a/packages/opencode/src/tool/shell/bash.ts b/packages/opencode/src/tool/shell/bash.ts deleted file mode 100644 index 3a07511b21..0000000000 --- a/packages/opencode/src/tool/shell/bash.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createShellTool } from "./util" - -export const BashTool = createShellTool({ - id: "bash", - shellName: "bash", - toolName: "Bash", - listCmd: "ls", - gitCmds: "git bash commands", - chaining: - "use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", - guidance: "", -}) diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts index 0ea57064cf..2808d99ee8 100644 --- a/packages/opencode/src/tool/shell/id.ts +++ b/packages/opencode/src/tool/shell/id.ts @@ -1,12 +1,12 @@ -export namespace ShellTool { +export namespace ShellKind { export const ids = ["bash", "pwsh", "powershell"] as const export type ID = (typeof ids)[number] - const shell = new Set(ids) + const kind = new Set(ids) const ps = new Set(["pwsh", "powershell"]) export function has(value: string): value is ID { - return shell.has(value) + return kind.has(value) } export function from(value: string): ID { @@ -17,3 +17,19 @@ export namespace ShellTool { return ps.has(value) } } + +export namespace ShellToolID { + export const id = "shell" + export const legacy = "bash" + export type ID = typeof id | typeof legacy + + const tool = new Set([id, legacy]) + + export function has(value: string): value is ID { + return tool.has(value) + } + + export function normalize(value: string) { + return value === legacy ? id : value + } +} diff --git a/packages/opencode/src/tool/shell/parser.ts b/packages/opencode/src/tool/shell/parser.ts index 7b8dff37b7..cce46853d5 100644 --- a/packages/opencode/src/tool/shell/parser.ts +++ b/packages/opencode/src/tool/shell/parser.ts @@ -1,7 +1,7 @@ import type { Node } from "web-tree-sitter" import { lazy } from "@/util/lazy" import { resolveWasm, resolvePath, unquote, home, expand, type Scan, type Part } from "./util" -import { ShellTool } from "./id" +import { ShellKind } from "./id" import { Instance } from "@/project/instance" import { Filesystem } from "@/util/filesystem" import path from "path" @@ -165,9 +165,9 @@ export namespace ShellParser { command: string cwd: string shell: string - shellType: ShellTool.ID + shellType: ShellKind.ID }): Promise { - const isPwsh = ShellTool.powershell(opts.shellType) + const isPwsh = ShellKind.powershell(opts.shellType) const parser = isPwsh ? await getPsParser() : await getBashParser() const tree = parser.parse(opts.command) diff --git a/packages/opencode/src/tool/shell/powershell.ts b/packages/opencode/src/tool/shell/powershell.ts deleted file mode 100644 index fe12aadb14..0000000000 --- a/packages/opencode/src/tool/shell/powershell.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createShellTool } from "./util" - -export const PowershellTool = createShellTool({ - id: "powershell", - shellName: "Windows PowerShell", - toolName: "PowerShell", - listCmd: "Get-ChildItem", - gitCmds: "git commands", - chaining: - "use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success.", - guidance: `# Windows PowerShell 5.1 shell notes -- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with backtick (\\\`).`, -}) diff --git a/packages/opencode/src/tool/shell/pwsh.ts b/packages/opencode/src/tool/shell/pwsh.ts deleted file mode 100644 index 59e9b626ad..0000000000 --- a/packages/opencode/src/tool/shell/pwsh.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createShellTool } from "./util" - -export const PwshTool = createShellTool({ - id: "pwsh", - shellName: "PowerShell Core", - toolName: "PowerShell", - listCmd: "Get-ChildItem", - gitCmds: "git commands", - chaining: - "use a single PowerShell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", - guidance: `# PowerShell 7+ (pwsh) shell notes -- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with backtick (\\\`).`, -}) diff --git a/packages/opencode/src/tool/shell/runner.ts b/packages/opencode/src/tool/shell/runner.ts index 647605d599..8281f03686 100644 --- a/packages/opencode/src/tool/shell/runner.ts +++ b/packages/opencode/src/tool/shell/runner.ts @@ -2,7 +2,7 @@ import { spawn } from "child_process" import { Shell } from "@/shell/shell" import { Tool } from "../tool" import { Plugin } from "@/plugin" -import { ShellTool } from "./id" +import { ShellKind } from "./id" const MAX_METADATA_LENGTH = 30_000 @@ -27,8 +27,8 @@ exit 1` } } - export function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { - if (process.platform === "win32" && ShellTool.powershell(name)) { + export function launch(shell: string, kind: ShellKind.ID, command: string, cwd: string, env: NodeJS.ProcessEnv) { + if (process.platform === "win32" && ShellKind.powershell(kind)) { return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", preserveExitCode(command)], { cwd, env, @@ -51,7 +51,7 @@ exit 1` export async function run( input: { shell: string - name: string + kind: ShellKind.ID command: string cwd: string env: NodeJS.ProcessEnv @@ -60,7 +60,7 @@ exit 1` }, ctx: Tool.Context, ) { - const proc = launch(input.shell, input.name, input.command, input.cwd, input.env) + const proc = launch(input.shell, input.kind, input.command, input.cwd, input.env) let output = "" let code: number | null = null @@ -135,7 +135,7 @@ exit 1` await wait const metadata: string[] = [] - if (expired) metadata.push(`${input.name} tool terminated command after exceeding timeout ${input.timeout} ms`) + if (expired) metadata.push(`shell tool terminated command after exceeding timeout ${input.timeout} ms`) if (aborted) metadata.push("User aborted the command") if (metadata.length > 0) { output += "\n\n\n" + metadata.join("\n") + "\n" diff --git a/packages/opencode/src/tool/shell/tool.ts b/packages/opencode/src/tool/shell/tool.ts new file mode 100644 index 0000000000..0b552045c5 --- /dev/null +++ b/packages/opencode/src/tool/shell/tool.ts @@ -0,0 +1,3 @@ +import { createShellTool } from "./util" + +export const ShellTool = createShellTool() diff --git a/packages/opencode/src/tool/shell/util.ts b/packages/opencode/src/tool/shell/util.ts index a09289235c..f73de28051 100644 --- a/packages/opencode/src/tool/shell/util.ts +++ b/packages/opencode/src/tool/shell/util.ts @@ -108,41 +108,81 @@ export function formatShellDescription( import z from "zod" import DESCRIPTION from "./shell.txt" -import { ShellTool } from "./id" +import { ShellKind, ShellToolID } from "./id" import { Log } from "@/util/log" import { Flag } from "@/flag/flag" import { ShellParser } from "./parser" import { ShellRunner } from "./runner" -export type ShellType = ShellTool.ID - const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -export function createShellTool(opts: { - id: ShellType - shellName: string - chaining: string - guidance: string - listCmd: string - toolName: string - gitCmds: string -}) { - const log = Log.create({ service: `${opts.id}-tool` }) +const info = { + bash: { + shellName: "bash", + listCmd: "ls", + gitCmds: "git commands", + chaining: + "use a single shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + guidance: "", + }, + pwsh: { + shellName: "PowerShell Core", + listCmd: "Get-ChildItem", + gitCmds: "git commands", + chaining: + "use a single shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`).", + guidance: `# PowerShell 7+ (pwsh) shell notes +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with backtick (\\\`).`, + }, + powershell: { + shellName: "Windows PowerShell", + listCmd: "Get-ChildItem", + gitCmds: "git commands", + chaining: + "use shell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success.", + guidance: `# Windows PowerShell 5.1 shell notes +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with backtick (\\\`).`, + }, +} satisfies Record< + ShellKind.ID, + { + shellName: string + listCmd: string + gitCmds: string + chaining: string + guidance: string + } +> - return Tool.define(opts.id, async () => { +export function createShellTool() { + const log = Log.create({ service: "shell-tool" }) + + return Tool.define(ShellToolID.id, async () => { const shell = Shell.acceptable() const name = Shell.name(shell) - log.info(`${opts.id} tool using shell`, { shell, name }) + const kind = ShellKind.from(name) + const cfg = info[kind] + log.info("shell tool using shell", { shell, name, kind }) return { description: formatShellDescription(DESCRIPTION, { name, - shellName: opts.shellName, - chaining: opts.chaining, - guidance: opts.guidance, - listCmd: opts.listCmd, - toolName: opts.toolName, - gitCmds: opts.gitCmds, + shellName: cfg.shellName, + chaining: cfg.chaining, + guidance: cfg.guidance, + listCmd: cfg.listCmd, + toolName: "Shell", + gitCmds: cfg.gitCmds, }), parameters: z.object({ command: z.string().describe("The command to execute"), @@ -170,16 +210,16 @@ export function createShellTool(opts: { command: params.command, cwd, shell, - shellType: opts.id, + shellType: kind, }) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - await askPermission(ctx, scan, opts.id) + await askPermission(ctx, scan, ShellToolID.id) return ShellRunner.run( { shell, - name, + kind, command: params.command, cwd, env: await ShellRunner.shellEnv(ctx, cwd), @@ -193,7 +233,7 @@ export function createShellTool(opts: { }) } -export async function askPermission(ctx: Tool.Context, scan: Scan, permissionName: string = "bash") { +export async function askPermission(ctx: Tool.Context, scan: Scan, permissionName: string = ShellToolID.id) { if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*")) diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index a3ae01c5c1..08b394cfa6 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -391,7 +391,7 @@ describe("acp.agent event subscription", () => { properties: { id: "perm_1", sessionID: sessionA, - permission: "bash", + permission: "shell", patterns: ["*"], metadata: {}, always: [], @@ -450,7 +450,7 @@ describe("acp.agent event subscription", () => { properties: { id: "perm_a", sessionID: sessionA, - permission: "bash", + permission: "shell", patterns: ["*"], metadata: {}, always: [], @@ -509,7 +509,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "running", input, metadata: { output }, @@ -541,7 +541,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_bash", - tool: "bash", + tool: "shell", status: "running", input: { command: "echo hi", description: "run command" }, metadata: { output: "hi\n" }, @@ -595,7 +595,7 @@ describe("acp.agent event subscription", () => { { type: "tool", callID: "call_1", - tool: "bash", + tool: "shell", state: { status: "running", input, @@ -612,7 +612,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "running", input, metadata: { output: "hi\nthere\n" }, @@ -646,7 +646,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "running", input, metadata: { output: "a" }, @@ -655,7 +655,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "pending", input, raw: '{"command":"echo hello"}', @@ -664,7 +664,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "running", input, metadata: { output: "a" }, diff --git a/packages/opencode/test/cli/tui/transcript.test.ts b/packages/opencode/test/cli/tui/transcript.test.ts index 712f9112ea..d8532c4215 100644 --- a/packages/opencode/test/cli/tui/transcript.test.ts +++ b/packages/opencode/test/cli/tui/transcript.test.ts @@ -172,7 +172,7 @@ describe("transcript", () => { messageID: "msg_123", type: "tool", callID: "call_1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { command: "ls" }, @@ -183,7 +183,7 @@ describe("transcript", () => { }, } const result = formatPart(part, options) - expect(result).toContain("**Tool: bash**") + expect(result).toContain("**Tool: shell**") expect(result).toContain("**Input:**") expect(result).toContain('"command": "ls"') expect(result).toContain("**Output:**") @@ -197,7 +197,7 @@ describe("transcript", () => { messageID: "msg_123", type: "tool", callID: "call_1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { command: "echo '```hello```'" }, @@ -209,7 +209,7 @@ describe("transcript", () => { } const result = formatPart(part, options) // The tool header should not be inside a code block - expect(result).toStartWith("**Tool: bash**\n") + expect(result).toStartWith("**Tool: shell**\n") // Input and output should each be in their own code blocks expect(result).toContain("**Input:**\n```json") expect(result).toContain("**Output:**\n```\n```hello```\n```") @@ -222,7 +222,7 @@ describe("transcript", () => { messageID: "msg_123", type: "tool", callID: "call_1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { command: "ls" }, @@ -233,7 +233,7 @@ describe("transcript", () => { }, } const result = formatPart(part, { ...options, toolDetails: false }) - expect(result).toContain("**Tool: bash**") + expect(result).toContain("**Tool: shell**") expect(result).not.toContain("**Input:**") expect(result).not.toContain("**Output:**") }) @@ -245,7 +245,7 @@ describe("transcript", () => { messageID: "msg_123", type: "tool", callID: "call_1", - tool: "bash", + tool: "shell", state: { status: "error", input: { command: "invalid" }, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 0ac61aee71..fe7cf31913 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1266,7 +1266,7 @@ test("migrates legacy tools config to permissions - allow", async () => { fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ - bash: "allow", + shell: "allow", read: "allow", }) }, @@ -1297,7 +1297,7 @@ test("migrates legacy tools config to permissions - deny", async () => { fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ - bash: "deny", + shell: "deny", webfetch: "deny", }) }, @@ -1524,7 +1524,7 @@ test("migrates mixed legacy tools config", async () => { fn: async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ - bash: "allow", + shell: "allow", edit: "allow", read: "deny", webfetch: "allow", @@ -1560,7 +1560,7 @@ test("merges legacy tools with existing permission config", async () => { const config = await Config.get() expect(config.agent?.["test"]?.permission).toEqual({ glob: "allow", - bash: "allow", + shell: "allow", }) }, }) @@ -2339,9 +2339,9 @@ test("parseManagedPlist parses permission rules", async () => { expect(config.permission?.grep).toBe("allow") expect(config.permission?.webfetch).toBe("ask") expect(config.permission?.["~/.ssh/*"]).toBe("deny") - const bash = config.permission?.bash as Record - expect(bash?.["rm -rf *"]).toBe("deny") - expect(bash?.["curl *"]).toBe("deny") + const shell = config.permission?.shell as Record + expect(shell?.["rm -rf *"]).toBe("deny") + expect(shell?.["curl *"]).toBe("deny") }) test("parseManagedPlist parses enabled_providers", async () => { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index b98f4a3ae0..57733cc043 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -34,22 +34,14 @@ async function waitForPending(count: number) { test("fromConfig - string value becomes wildcard rule", () => { const result = Permission.fromConfig({ bash: "allow" }) - expect(result).toEqual([ - { permission: "bash", pattern: "*", action: "allow" }, - { permission: "pwsh", pattern: "*", action: "allow" }, - { permission: "powershell", pattern: "*", action: "allow" }, - ]) + expect(result).toEqual([{ permission: "shell", pattern: "*", action: "allow" }]) }) test("fromConfig - object value converts to rules array", () => { const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } }) expect(result).toEqual([ - { permission: "bash", pattern: "*", action: "allow" }, - { permission: "bash", pattern: "rm", action: "deny" }, - { permission: "pwsh", pattern: "*", action: "allow" }, - { permission: "pwsh", pattern: "rm", action: "deny" }, - { permission: "powershell", pattern: "*", action: "allow" }, - { permission: "powershell", pattern: "rm", action: "deny" }, + { permission: "shell", pattern: "*", action: "allow" }, + { permission: "shell", pattern: "rm", action: "deny" }, ]) }) @@ -60,39 +52,33 @@ test("fromConfig - mixed string and object values", () => { webfetch: "ask", }) expect(result).toEqual([ - { permission: "bash", pattern: "*", action: "allow" }, - { permission: "bash", pattern: "rm", action: "deny" }, - { permission: "pwsh", pattern: "*", action: "allow" }, - { permission: "pwsh", pattern: "rm", action: "deny" }, - { permission: "powershell", pattern: "*", action: "allow" }, - { permission: "powershell", pattern: "rm", action: "deny" }, + { permission: "shell", pattern: "*", action: "allow" }, + { permission: "shell", pattern: "rm", action: "deny" }, { permission: "edit", pattern: "*", action: "allow" }, { permission: "webfetch", pattern: "*", action: "ask" }, ]) }) -test("fromConfig - explicit pwsh overrides bash regardless of key order", () => { +test("fromConfig - shell and legacy bash normalize to shell in key order", () => { const result = Permission.fromConfig({ - pwsh: "deny", + shell: "deny", bash: "allow", }) expect(result).toEqual([ - { permission: "bash", pattern: "*", action: "allow" }, - { permission: "pwsh", pattern: "*", action: "allow" }, - { permission: "powershell", pattern: "*", action: "allow" }, - { permission: "pwsh", pattern: "*", action: "deny" }, + { permission: "shell", pattern: "*", action: "deny" }, + { permission: "shell", pattern: "*", action: "allow" }, ]) - expect(Permission.evaluate("pwsh", "ls", result).action).toBe("deny") expect(Permission.evaluate("bash", "ls", result).action).toBe("allow") + expect(Permission.evaluate("shell", "ls", result).action).toBe("allow") }) -test("fromConfig - explicit powershell pattern overrides bash pattern regardless of key order", () => { +test("fromConfig - legacy bash rules coexist with canonical shell rules", () => { const result = Permission.fromConfig({ - powershell: { "rm *": "deny" }, + shell: { "rm *": "deny" }, bash: { "*": "allow", "rm *": "ask" }, }) - expect(Permission.evaluate("powershell", "rm foo", result).action).toBe("deny") - expect(Permission.evaluate("pwsh", "rm foo", result).action).toBe("ask") + expect(Permission.evaluate("shell", "rm foo", result).action).toBe("ask") + expect(Permission.evaluate("bash", "rm foo", result).action).toBe("ask") }) test("fromConfig - empty object", () => { @@ -234,6 +220,11 @@ test("evaluate - exact pattern match", () => { expect(result.action).toBe("deny") }) +test("evaluate - shell matches legacy bash rules", () => { + const result = Permission.evaluate("shell", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }]) + expect(result.action).toBe("deny") +}) + test("evaluate - wildcard pattern match", () => { const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }]) expect(result.action).toBe("allow") diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0aee396f44..41530bd467 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -797,7 +797,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { { type: "tool-call", toolCallId: "test", - toolName: "bash", + toolName: "shell", input: { command: "echo hello" }, }, ], @@ -848,7 +848,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { { type: "tool-call", toolCallId: "test", - toolName: "bash", + toolName: "shell", input: { command: "echo hello" }, }, ]) @@ -1125,7 +1125,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => role: "assistant", content: [ { type: "text", text: "" }, - { type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } }, + { type: "tool-call", toolCallId: "123", toolName: "shell", input: { command: "ls" } }, ], }, ] as any[] @@ -1137,7 +1137,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[0].content[0]).toEqual({ type: "tool-call", toolCallId: "123", - toolName: "bash", + toolName: "shell", input: { command: "ls" }, }) }) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 799bb3e2ae..0ada95be0c 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -470,7 +470,7 @@ describe("session.compaction.prune", () => { const session = await Session.create({}) const a = await user(session.id, "first") const b = await assistant(session.id, a.id, tmp.path) - await tool(session.id, b.id, "bash", "x".repeat(200_000)) + await tool(session.id, b.id, "shell", "x".repeat(200_000)) await user(session.id, "second") await user(session.id, "third") diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 1fa2e61eb2..07fc50ad23 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -47,7 +47,7 @@ describe("session.llm.hasToolCalls", () => { { type: "tool-call", toolCallId: "call-123", - toolName: "bash", + toolName: "shell", }, ], }, @@ -63,7 +63,7 @@ describe("session.llm.hasToolCalls", () => { { type: "tool-result", toolCallId: "call-123", - toolName: "bash", + toolName: "shell", }, ], }, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 3634d6fb7e..6cd79c9b72 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -295,7 +295,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a2"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { cmd: "ls" }, @@ -331,7 +331,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, providerOptions: { openai: { tool: "meta" } }, @@ -344,7 +344,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "content", value: [ @@ -387,7 +387,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a2"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { cmd: "ls" }, @@ -414,7 +414,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, }, @@ -426,7 +426,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "text", value: "ok" }, }, ], @@ -456,7 +456,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { cmd: "ls" }, @@ -481,7 +481,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, }, @@ -493,7 +493,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "text", value: "[Old tool result content cleared]" }, }, ], @@ -523,7 +523,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "error", input: { cmd: "ls" }, @@ -548,7 +548,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, providerOptions: { openai: { tool: "meta" } }, @@ -561,7 +561,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "error-text", value: "nope" }, providerOptions: { openai: { tool: "meta" } }, }, @@ -721,7 +721,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-pending", - tool: "bash", + tool: "shell", state: { status: "pending", input: { cmd: "ls" }, @@ -756,7 +756,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-pending", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, }, @@ -775,7 +775,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-pending", - toolName: "bash", + toolName: "shell", output: { type: "error-text", value: "[Tool execution was interrupted]" }, }, { diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 0fc25c1a6b..0fc72316e5 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -550,7 +550,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup Effect.gen(function* () { const { processors, session, provider } = yield* boot() - yield* llm.toolHang("bash", { cmd: "pwd" }) + yield* llm.toolHang("shell", { cmd: "pwd" }) const chat = yield* session.create({}) const parent = yield* user(chat.id, "tool abort") diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 95d90325ad..0a34285482 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -59,7 +59,7 @@ function tool(sessionID: string, messageID: string) { messageID: messageID as any, sessionID: sessionID as any, type: "tool" as const, - tool: "bash", + tool: "shell", callID: "call-1", state: { status: "completed" as const, diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index cbeaed3289..01801279cc 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -16,12 +16,11 @@ import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { Session } from "../../src/session" -import { Shell } from "../../src/shell/shell" import { LLM } from "../../src/session/llm" import { SessionPrompt } from "../../src/session/prompt" import { SessionSummary } from "../../src/session/summary" import { MessageV2 } from "../../src/session/message-v2" -import { ShellTool } from "../../src/tool/shell/id" +import { ShellToolID } from "../../src/tool/shell/id" import { Log } from "../../src/util/log" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -192,7 +191,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => permission: [{ permission: "*", pattern: "*", action: "allow" }], }) - const shell = ShellTool.from(Shell.name(Shell.acceptable())) + const shell = ShellToolID.id // Use the active shell tool to create a file const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}` diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index aedfacc3a2..2c587b96ae 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -2,10 +2,8 @@ import { describe, expect, test } from "bun:test" import os from "os" import path from "path" import { Shell } from "../../src/shell/shell" -import { BashTool } from "../../src/tool/shell/bash" -import { ShellTool } from "../../src/tool/shell/id" -import { PwshTool } from "../../src/tool/shell/pwsh" -import { PowershellTool } from "../../src/tool/shell/powershell" +import { ShellKind, ShellToolID } from "../../src/tool/shell/id" +import { ShellTool } from "../../src/tool/shell/tool" import { ShellRunner } from "../../src/tool/shell/runner" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" @@ -49,14 +47,14 @@ const shells = (() => { (item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i, ) })() -const ps = shells.filter((item) => ShellTool.powershell(item.label)) +const ps = shells.filter((item) => ShellKind.powershell(item.label)) const sh = () => Shell.name(Shell.acceptable()) const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) const js = (code: string, ...args: Array) => { const tail = args.length ? ` ${args.map(String).join(" ")}` : "" const text = `${bin} -e ${evalarg(code)}${tail}` - if (ShellTool.powershell(sh())) return `& ${text}` + if (ShellKind.powershell(sh())) return `& ${text}` return text } @@ -93,12 +91,10 @@ const withShell = (item: { label: string; shell: string }, fn: () => Promise ShellTool.from(sh()) - -const tools = { bash: BashTool, pwsh: PwshTool, powershell: PowershellTool } as const +const expectedPermission = () => ShellToolID.id const getTool = async () => { - return await tools[ShellTool.from(sh())].init() + return await ShellTool.init() } const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { @@ -158,7 +154,7 @@ describe("tool.shell", () => { }) describe("tool.shell permissions", () => { - each("asks for bash permission with correct pattern", async () => { + each("asks for shell permission with correct pattern", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -179,7 +175,7 @@ describe("tool.shell permissions", () => { }) }) - each("asks for bash permission with multiple commands", async () => { + each("asks for shell permission with multiple commands", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -256,7 +252,7 @@ describe("tool.shell permissions", () => { if (process.platform === "win32") { if (bash) { test( - "asks for nested bash command permissions [bash]", + "asks for nested shell command permissions [bash]", withShell({ label: "bash", shell: bash }, async () => { await using outerTmp = await tmpdir({ init: async (dir) => { @@ -863,7 +859,7 @@ describe("tool.shell permissions", () => { }) }) - each("does not ask for bash permission when command is cd only", async () => { + each("does not ask for shell permission when command is cd only", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -974,7 +970,7 @@ describe("tool.shell runtime", () => { ShellRunner.run( { shell: item.shell, - name: item.label, + kind: ShellKind.from(item.label), command: js("setTimeout(()=>{},30000)"), cwd: projectRoot, env: process.env, @@ -1007,7 +1003,7 @@ describe("tool.shell runtime", () => { ctx, ) expect(result.output).toContain("222") - expect(result.output).toContain(`${sh()} tool terminated command after exceeding timeout`) + expect(result.output).toContain("shell tool terminated command after exceeding timeout") }, }) }) @@ -1086,7 +1082,7 @@ describe("tool.shell runtime", () => { const result = await ShellRunner.run( { shell: item.shell, - name: item.label, + kind: ShellKind.from(item.label), command: js( "process.stdout.write(Buffer.from([0xF0,0x9F]));setTimeout(()=>process.stdout.write(Buffer.from([0x98,0x80])),20);setTimeout(()=>process.exit(0),40)", ), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0a9aa4358e..7beb941e41 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1161,7 +1161,7 @@ export type PermissionConfig = glob?: PermissionRuleConfig grep?: PermissionRuleConfig list?: PermissionRuleConfig - bash?: PermissionRuleConfig + shell?: PermissionRuleConfig task?: PermissionRuleConfig external_directory?: PermissionRuleConfig todowrite?: PermissionActionConfig diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 207b400a7d..7c8a9e06d3 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10642,7 +10642,7 @@ "list": { "$ref": "#/components/schemas/PermissionRuleConfig" }, - "bash": { + "shell": { "$ref": "#/components/schemas/PermissionRuleConfig" }, "task": { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 092c2c7e03..f2b028e9a8 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -271,7 +271,7 @@ export type ToolInfo = { subtitle?: string } -const SHELL = new Set(["bash", "pwsh", "powershell"]) +const SHELL = new Set(["shell"]) function agentTitle(i18n: UiI18n, type?: string) { if (!type) return i18n.t("ui.tool.agent.default") @@ -1822,7 +1822,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "bash", + name: "shell", render(props) { const i18n = useI18n() const pending = () => props.status === "pending" || props.status === "running" diff --git a/packages/ui/src/components/shell-submessage-motion.stories.tsx b/packages/ui/src/components/shell-submessage-motion.stories.tsx index 1780c83ba9..308d2dbcba 100644 --- a/packages/ui/src/components/shell-submessage-motion.stories.tsx +++ b/packages/ui/src/components/shell-submessage-motion.stories.tsx @@ -16,7 +16,7 @@ Interactive playground for animating the Shell tool subtitle ("submessage") in t ### Production component path - Trigger layout: \`packages/ui/src/components/basic-tool.tsx\` -- Bash tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`bash\`, \`trigger.subtitle\`) +- Shell tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`shell\`, \`trigger.subtitle\`) ### What this playground tunes - Width reveal (spring-driven pixel width via \`useSpring\`) diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index e79e97a3ab..1a5c1efe1e 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -315,8 +315,8 @@ const TOOL_SAMPLES = { title: "Found 2 matches", metadata: {}, }, - bash: { - tool: "bash", + shell: { + tool: "shell", input: { command: "bun test --filter session", description: "Run session tests" }, output: "bun test v1.3.11\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", @@ -1309,7 +1309,7 @@ function Playground() { toolPart(TOOL_SAMPLES.glob), toolPart(TOOL_SAMPLES.grep), toolPart(TOOL_SAMPLES.edit), - toolPart(TOOL_SAMPLES.bash), + toolPart(TOOL_SAMPLES.shell), textPart(MARKDOWN_SAMPLES.mixed), ]) } @@ -1332,7 +1332,7 @@ function Playground() { toolPart(TOOL_SAMPLES.glob), toolPart(TOOL_SAMPLES.grep), toolPart(TOOL_SAMPLES.edit), - toolPart(TOOL_SAMPLES.bash), + toolPart(TOOL_SAMPLES.shell), textPart(MARKDOWN_SAMPLES.blockquote), ]) addContextGroupTurn() diff --git a/packages/ui/src/components/tool-error-card.stories.tsx b/packages/ui/src/components/tool-error-card.stories.tsx index 03349ce011..0331ba6862 100644 --- a/packages/ui/src/components/tool-error-card.stories.tsx +++ b/packages/ui/src/components/tool-error-card.stories.tsx @@ -5,7 +5,7 @@ const docs = `### Overview Tool call failure summary styled like a tool trigger. ### API -- Required: \`tool\` (tool id, e.g. apply_patch, bash) +- Required: \`tool\` (tool id, e.g. apply_patch, shell) - Required: \`error\` (error string) ### Behavior @@ -19,8 +19,8 @@ const samples = [ "apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx", }, { - tool: "bash", - error: "bash Command failed: exit code 1: bun test --watch", + tool: "shell", + error: "shell Command failed: exit code 1: bun test --watch", }, { tool: "read", @@ -72,7 +72,7 @@ export default { argTypes: { tool: { control: "select", - options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "codesearch", "question"], + options: ["apply_patch", "shell", "read", "glob", "grep", "webfetch", "websearch", "codesearch", "question"], }, error: { control: "text", diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index 038870d384..c2dadd98ae 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -34,6 +34,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", codesearch: "ui.tool.codesearch", + shell: "ui.tool.shell", bash: "ui.tool.shell", apply_patch: "ui.tool.patch", question: "ui.tool.questions", diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index 3558fd9452..a45fed4c3c 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -33,7 +33,7 @@ import type { Diagnostic } from "vscode-languageserver-types" import styles from "./part.module.css" const MIN_DURATION = 2000 -const SHELL = new Set(["bash", "pwsh", "powershell"]) +const SHELL = new Set(["shell"]) export interface PartProps { index: number From ee0884ad313786ee97969bd42178ac66df394a81 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:14:45 +1000 Subject: [PATCH 15/27] fix(shell): preserve legacy bash compatibility Keep mixed shell/bash permission configs ordered correctly and treat --tools bash as the legacy alias during agent creation. --- packages/opencode/src/cli/cmd/agent.ts | 12 ++++++- packages/opencode/src/config/config.ts | 8 ++--- packages/opencode/test/config/config.test.ts | 34 ++++++++++++++++++-- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index eda4175b9f..2b8999bb59 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -11,6 +11,7 @@ import matter from "gray-matter" import { Instance } from "../../project/instance" import { EOL } from "os" import type { Argv } from "yargs" +import { ShellToolID } from "../../tool/shell/id" type AgentMode = "all" | "primary" | "subagent" @@ -120,7 +121,16 @@ const AgentCreateCommand = cmd({ // Select tools let selectedTools: string[] if (cliTools !== undefined) { - selectedTools = cliTools ? cliTools.split(",").map((t) => t.trim()) : AVAILABLE_TOOLS + selectedTools = cliTools + ? [ + ...new Set( + cliTools + .split(",") + .map((t) => ShellToolID.normalize(t.trim())) + .filter(Boolean), + ), + ] + : AVAILABLE_TOOLS } else { const result = await prompts.multiselect({ message: "Select tools to enable (Space to toggle)", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index e47cbfc7c3..27e6ebe547 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -461,15 +461,11 @@ export namespace Config { if (typeof x === "string") return { "*": x as PermissionAction } const obj = x as { __originalKeys?: string[] } & Record const { __originalKeys, ...rest } = obj - if (!__originalKeys) { - return Object.fromEntries( - Object.entries(rest).map(([key, value]) => [ShellToolID.normalize(key), value as PermissionRule]), - ) - } + if (!__originalKeys) return rest as Record const result: Record = {} for (const key of __originalKeys) { if (!(key in rest)) continue - result[ShellToolID.normalize(key)] = rest[key] as PermissionRule + result[key] = rest[key] as PermissionRule } return result } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index fe7cf31913..302374e4e2 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1609,6 +1609,34 @@ test("permission config preserves key order", async () => { }) }) +test("permission config preserves shell and legacy bash order", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + shell: "deny", + bash: "allow", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(Object.keys(config.permission!)).toEqual(["shell", "bash"]) + expect(config.permission).toEqual({ + shell: "deny", + bash: "allow", + }) + }, + }) +}) + // MCP config merging tests test("project config can override MCP server enabled status", async () => { @@ -2339,9 +2367,9 @@ test("parseManagedPlist parses permission rules", async () => { expect(config.permission?.grep).toBe("allow") expect(config.permission?.webfetch).toBe("ask") expect(config.permission?.["~/.ssh/*"]).toBe("deny") - const shell = config.permission?.shell as Record - expect(shell?.["rm -rf *"]).toBe("deny") - expect(shell?.["curl *"]).toBe("deny") + const bash = config.permission?.bash as Record + expect(bash?.["rm -rf *"]).toBe("deny") + expect(bash?.["curl *"]).toBe("deny") }) test("parseManagedPlist parses enabled_providers", async () => { From b75f831eaa05289f926167b0614621439eddcbab Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:34:57 +1000 Subject: [PATCH 16/27] . --- packages/opencode/src/config/config.ts | 31 -------------------- packages/opencode/test/config/config.test.ts | 2 +- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 9316994f13..979fa25142 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -44,7 +44,6 @@ import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { Npm } from "@/npm" import { ShellToolID } from "@/tool/shell/id" -import { makeRuntime } from "@/effect/run-service" const log = Log.create({ service: "config" }) @@ -802,33 +801,3 @@ export const defaultLayer = layer.pipe( Layer.provide(Account.defaultLayer), Layer.provide(Npm.defaultLayer), ) - -const { runPromise } = makeRuntime(Service, defaultLayer) - -export async function get() { - return runPromise((svc) => svc.get()) -} - -export async function getGlobal() { - return runPromise((svc) => svc.getGlobal()) -} - -export async function update(...args: Parameters) { - return runPromise((svc) => svc.update(...args)) -} - -export async function updateGlobal(...args: Parameters) { - return runPromise((svc) => svc.updateGlobal(...args)) -} - -export async function invalidate(...args: Parameters) { - return runPromise((svc) => svc.invalidate(...args)) -} - -export async function directories() { - return runPromise((svc) => svc.directories()) -} - -export async function waitForDependencies() { - return runPromise((svc) => svc.waitForDependencies()) -} diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 3f4bfbfa18..3126836dc9 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1568,7 +1568,7 @@ test("permission config preserves shell and legacy bash order", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(Object.keys(config.permission!)).toEqual(["shell", "bash"]) expect(config.permission).toEqual({ shell: "deny", From 3e30068907d98e7e3e9ce01297224ca9b407fd18 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:46:00 +1000 Subject: [PATCH 17/27] refactor: make shell the canonical tool internals --- packages/opencode/src/cli/cmd/agent.ts | 4 +--- packages/opencode/src/cli/cmd/github.ts | 1 - packages/opencode/src/cli/cmd/run.ts | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/session/llm.ts | 3 +-- packages/opencode/src/session/message-v2.ts | 19 +++------------ packages/opencode/src/tool/registry.ts | 2 +- .../opencode/src/tool/{bash.ts => shell.ts} | 18 +++++++------- packages/opencode/src/tool/shell/id.ts | 6 ++--- packages/opencode/src/tool/shell/tool.ts | 1 - packages/opencode/src/tool/task.ts | 3 +-- .../opencode/test/session/message-v2.test.ts | 6 ++--- .../session/session-entry-stepper.test.ts | 24 +++++++++---------- packages/opencode/test/tool/shell.test.ts | 2 +- packages/opencode/test/tool/task.test.ts | 2 +- .../ui/src/components/tool-error-card.tsx | 1 - 16 files changed, 36 insertions(+), 60 deletions(-) rename packages/opencode/src/tool/{bash.ts => shell.ts} (96%) delete mode 100644 packages/opencode/src/tool/shell/tool.ts diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index f8cbae42fc..dee5fea7ac 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -12,8 +12,6 @@ import matter from "gray-matter" import { Instance } from "../../project/instance" import { EOL } from "os" import type { Argv } from "yargs" -import { ShellToolID } from "../../tool/shell/id" - type AgentMode = "all" | "primary" | "subagent" const AVAILABLE_TOOLS = ["shell", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"] @@ -129,7 +127,7 @@ const AgentCreateCommand = cmd({ ...new Set( cliTools .split(",") - .map((t) => ShellToolID.normalize(t.trim())) + .map((t) => t.trim()) .filter(Boolean), ), ] diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index ba33cbd81e..00291daf9f 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -880,7 +880,6 @@ export const GithubRunCommand = cmd({ const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], shell: ["Shell", UI.Style.TEXT_DANGER_BOLD], - bash: ["Shell", UI.Style.TEXT_DANGER_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD], grep: ["Grep", UI.Style.TEXT_INFO_BOLD], diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f63d762ae0..06cfda542d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -23,7 +23,7 @@ import { CodeSearchTool } from "../../tool/codesearch" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { ShellTool } from "../../tool/shell/tool" +import { ShellTool } from "../../tool/shell" import { ShellToolID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 86ad2ca986..31f3561373 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -37,7 +37,7 @@ import { Locale } from "@/util" import type { Tool } from "@/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { ShellTool } from "@/tool/shell/tool" +import { ShellTool } from "@/tool/shell" import { ShellToolID } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 0824cba426..848d06c888 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -22,7 +22,6 @@ import { Auth } from "@/auth" import { Installation } from "@/installation" import { InstallationVersion } from "@/installation/version" import { EffectBridge } from "@/effect" -import { ShellToolID } from "@/tool/shell/id" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" @@ -208,7 +207,7 @@ const live: Layer.Layer< input.model.api.id.toLowerCase().includes("litellm") const repair = (toolName: string) => { - const next = ShellToolID.normalize(toolName.toLowerCase()) + const next = toolName.toLowerCase() if (!tools[next]) return return next } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2b66fc285c..c6d2c2be1f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -16,7 +16,6 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" -import { ShellToolID } from "@/tool/shell/id" import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" import { namedSchemaError } from "@/util/named-schema-error" @@ -443,17 +442,6 @@ export type Part = | RetryPart | CompactionPart -function normalizeTool(tool: string) { - return ShellToolID.normalize(tool) -} - -function normalizePart(part: T): T { - if (part.type !== "tool") return part - const tool = normalizeTool(part.tool) - if (tool === part.tool) return part - return { ...part, tool } as T -} - // Errors are still NamedError-based Zod; bridge via ZodOverride so the derived // Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the // error classes to Schema.TaggedErrorClass is a separate slice. @@ -673,12 +661,12 @@ const info = (row: typeof MessageTable.$inferSelect) => }) as Info const part = (row: typeof PartTable.$inferSelect) => - normalizePart(({ + ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id, - } as Part)) + } as Part) const older = (row: Cursor) => or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id))) @@ -843,8 +831,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "assistant", parts: [], } - for (const raw of msg.parts) { - const part = normalizePart(raw) + for (const part of msg.parts) { if (part.type === "text") assistantMessage.parts.push({ type: "text", diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index aa27e900e5..0c1289a78b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,7 +1,7 @@ import { PlanExitTool } from "./plan" import { Session } from "../session" import { QuestionTool } from "./question" -import { ShellTool } from "./shell/tool" +import { ShellTool } from "./shell" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/shell.ts similarity index 96% rename from packages/opencode/src/tool/bash.ts rename to packages/opencode/src/tool/shell.ts index 8bf86303dd..9523629959 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/shell.ts @@ -253,13 +253,13 @@ function tail(text: string, maxLines: number, maxBytes: number) { } } -const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) { +const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) { const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command))) if (!tree) throw new Error("Failed to parse command") return tree.rootNode }) -const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { +const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) { if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) @@ -336,7 +336,7 @@ export const ShellTool = Tool.define( const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service - const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { + const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) { const lines = yield* spawner .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text])) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) @@ -345,7 +345,7 @@ export const ShellTool = Tool.define( return AppFileSystem.normalizePath(file) }) - const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) { + const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) { if (process.platform === "win32") { if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) { const file = yield* cygpath(shell, text) @@ -356,7 +356,7 @@ export const ShellTool = Tool.define( return path.resolve(root, text) }) - const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { + const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) const file = text && prefix(text) if (!file || dynamic(file, ps)) return @@ -365,7 +365,7 @@ export const ShellTool = Tool.define( return yield* resolvePath(next, cwd, shell) }) - const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) { + const collect = Effect.fn("ShellTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) { const scan: Scan = { dirs: new Set(), patterns: new Set(), @@ -396,7 +396,7 @@ export const ShellTool = Tool.define( return scan }) - const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { + const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { const extra = yield* plugin.trigger( "shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, @@ -408,7 +408,7 @@ export const ShellTool = Tool.define( } }) - const run = Effect.fn("BashTool.run")(function* ( + const run = Effect.fn("ShellTool.run")(function* ( input: { shell: string name: string @@ -644,5 +644,3 @@ export const ShellTool = Tool.define( }) }), ) - -export const BashTool = ShellTool diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts index 2808d99ee8..60673dd698 100644 --- a/packages/opencode/src/tool/shell/id.ts +++ b/packages/opencode/src/tool/shell/id.ts @@ -21,12 +21,10 @@ export namespace ShellKind { export namespace ShellToolID { export const id = "shell" export const legacy = "bash" - export type ID = typeof id | typeof legacy - - const tool = new Set([id, legacy]) + export type ID = typeof id export function has(value: string): value is ID { - return tool.has(value) + return value === id } export function normalize(value: string) { diff --git a/packages/opencode/src/tool/shell/tool.ts b/packages/opencode/src/tool/shell/tool.ts deleted file mode 100644 index b3af7377a8..0000000000 --- a/packages/opencode/src/tool/shell/tool.ts +++ /dev/null @@ -1 +0,0 @@ -export { ShellTool } from "../bash" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 05efe69b63..e525a25bcd 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -8,7 +8,6 @@ import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" import { Config } from "../config" import { Effect } from "effect" -import { ShellToolID } from "./shell/id" export interface TaskPromptOps { cancel(sessionID: SessionID): void @@ -40,7 +39,7 @@ export const TaskTool = Tool.define( const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { const cfg = yield* config.get() - const primaryTools = (cfg.experimental?.primary_tools ?? []).map((item) => ShellToolID.normalize(item)) + const primaryTools = cfg.experimental?.primary_tools ?? [] if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index b5825ab36f..3d0112ba51 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -607,12 +607,12 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { cmd: "ls" }, output: "abcdefghij", - title: "Bash", + title: "Shell", metadata: {}, time: { start: 0, end: 1 }, }, @@ -755,7 +755,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "error", input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" }, diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts index defce40c14..014f9ed294 100644 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ b/packages/opencode/test/session/session-entry-stepper.test.ts @@ -414,13 +414,13 @@ describe("session-entry-stepper", () => { (callID, title, input, output, metadata, attachments, parts) => { const next = run( [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(1) }), ...parts.map((x, i) => SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }), ), SessionEvent.Tool.Called.create({ callID, - tool: "bash", + tool: "shell", input, provider: { executed: true }, timestamp: time(parts.length + 2), @@ -459,10 +459,10 @@ describe("session-entry-stepper", () => { FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => { const next = run( [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(1) }), SessionEvent.Tool.Called.create({ callID, - tool: "bash", + tool: "shell", input, provider: { executed: true }, timestamp: time(2), @@ -496,7 +496,7 @@ describe("session-entry-stepper", () => { FastCheck.property(word, word, (callID, title) => { const next = run( [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(1) }), SessionEvent.Tool.Success.create({ callID, title, @@ -691,10 +691,10 @@ describe("session-entry-stepper", () => { SessionEvent.Reasoning.Started.create({ timestamp: time(2) }), ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })), SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }), - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }), + SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(reason.length + 4) }), SessionEvent.Tool.Called.create({ callID, - tool: "bash", + tool: "shell", input, provider: { executed: true }, timestamp: time(reason.length + 5), @@ -771,10 +771,10 @@ describe("session-entry-stepper", () => { FastCheck.property(dict, dict, word, word, (a, b, title, error) => { const next = run( [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Input.Started.create({ callID: "a", name: "shell", timestamp: time(1) }), SessionEvent.Tool.Called.create({ callID: "a", - tool: "bash", + tool: "shell", input: a, provider: { executed: true }, timestamp: time(2), @@ -789,7 +789,7 @@ describe("session-entry-stepper", () => { SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }), SessionEvent.Tool.Called.create({ callID: "b", - tool: "bash", + tool: "shell", input: b, provider: { executed: true }, timestamp: time(5), @@ -827,13 +827,13 @@ describe("session-entry-stepper", () => { FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => { const next = run( [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Input.Started.create({ callID: "a", name: "shell", timestamp: time(1) }), SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }), SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }), SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }), SessionEvent.Tool.Called.create({ callID: "a", - tool: "bash", + tool: "shell", input: a, provider: { executed: true }, timestamp: time(5), diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index b14cad7e01..b06bee2ddc 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -4,7 +4,7 @@ import os from "os" import path from "path" import { Shell } from "../../src/shell/shell" import { ShellToolID } from "../../src/tool/shell/id" -import { ShellTool } from "../../src/tool/shell/tool" +import { ShellTool } from "../../src/tool/shell" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 06f28bde25..faf73d9edf 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -378,7 +378,7 @@ describe("tool.task", () => { }, }, experimental: { - primary_tools: ["bash", "read"], + primary_tools: ["shell", "read"], }, }, }, diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index 1ee7d3c360..3b0b293319 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -35,7 +35,6 @@ export function ToolErrorCard(props: ToolErrorCardProps) { websearch: "ui.tool.websearch", codesearch: "ui.tool.codesearch", shell: "ui.tool.shell", - bash: "ui.tool.shell", apply_patch: "ui.tool.patch", question: "ui.tool.questions", } From 6d66973fd5f56bd58887cf610771ea84227707b2 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:39:19 +1000 Subject: [PATCH 18/27] clean --- packages/opencode/src/acp/agent.ts | 8 +- packages/opencode/src/cli/cmd/run.ts | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- .../cli/cmd/tui/routes/session/permission.tsx | 2 +- packages/opencode/src/tool/shell.ts | 263 ++++++++++++++---- packages/opencode/src/tool/shell/shell.txt | 62 +---- packages/opencode/src/tool/task.ts | 3 +- packages/opencode/test/tool/task.test.ts | 2 +- packages/ui/src/components/message-part.tsx | 2 +- packages/web/src/components/share/part.tsx | 2 +- 10 files changed, 232 insertions(+), 116 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 342ee3955f..615db6c717 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -291,7 +291,7 @@ export class Agent implements ACPAgent { const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (ShellToolID.has(part.tool)) { + if (ShellToolID.normalize(part.tool) === ShellToolID.id) { if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ @@ -1106,7 +1106,7 @@ export class Agent implements ACPAgent { } private shellOutput(part: ToolPart) { - if (!ShellToolID.has(part.tool)) return + if (ShellToolID.normalize(part.tool) !== ShellToolID.id) return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1549,7 +1549,7 @@ export class Agent implements ACPAgent { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() - if (ShellToolID.has(tool)) return "execute" + if (ShellToolID.normalize(tool) === ShellToolID.id) return "execute" switch (tool) { case "webfetch": @@ -1576,7 +1576,7 @@ function toToolKind(toolName: string): ToolKind { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() - if (ShellToolID.has(tool)) return [] + if (ShellToolID.normalize(tool) === ShellToolID.id) return [] switch (tool) { case "read": diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 06cfda542d..ca5ba6ccf0 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -409,7 +409,7 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (ShellToolID.has(part.tool)) return shell(props(part)) + if (ShellToolID.normalize(part.tool) === ShellToolID.id) return shell(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "read") return read(props(part)) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 31f3561373..bf22654dbb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1541,7 +1541,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 0dbca2199e..2581be10ee 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -288,7 +288,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (ShellToolID.has(permission)) { + if (ShellToolID.normalize(permission) === ShellToolID.id) { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 9523629959..97e447df6a 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -51,21 +51,201 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -const 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 the current 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'", - ), -}) +const describe = { + bash: + "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'", + powershell: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', +} + +const Parameters = (description: string) => + 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 the current directory. Use this instead of 'cd' commands.`, + ) + .optional(), + description: z.string().describe(description), + }) + +type Parameters = z.infer> + +function renderPrompt(template: string, values: Record) { + return template.replace(/\$\{(\w+)\}/g, (_, key: string) => { + const value = values[key] + if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`) + return value + }) +} + +function shellDisplayName(name: string) { + if (name === "pwsh") return "PowerShell (7+)" + if (name === "powershell") return "Windows PowerShell (5.1)" + return name +} + +function powershellNotes(name: string) { + if (name === "pwsh") { + return `# PowerShell (7+) shell notes +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + if (name === "powershell") { + return `# Windows PowerShell (5.1) shell notes +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + return "" +} + +function chainGuidance(name: string) { + if (name === "powershell") { + return "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell (5.1) does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." + } + if (PS.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single Shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before Shell for git operations, or git add before git commit), run these operations sequentially instead." + } + return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." +} + +function bashCommandSection(chain: string) { + return `Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") + - Examples of proper quoting: + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${Truncate.MAX_LINES} lines or ${Truncate.MAX_BYTES} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. + + Use workdir="/foo/bar" with command: pytest tests + + + cd /foo/bar && pytest tests + ` +} + +function powershellCommandSection(name: string, chain: string, pathSep: string) { + return `${powershellNotes(name)} + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`Test-Path -LiteralPath \` to verify the parent directory exists and is the correct location + - For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt") + - Examples of proper quoting: + - New-Item -ItemType Directory -Path "My Documents" (correct) + - New-Item -ItemType Directory -Path My Documents (incorrect - path is split) + - & "path with spaces${pathSep}script.ps1" (correct) + - path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${Truncate.MAX_LINES} lines or ${Truncate.MAX_BYTES} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT Get-ChildItem) + - Content search: Use Grep (NOT Select-String) + - Read files: Use Read (NOT Get-Content) + - Edit files: Use Edit (NOT Set-Content) + - Write files: Use Write (NOT Set-Content/Out-File or here-strings) + - Communication: Output text directly (NOT Write-Output/Write-Host) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel. + - ${chain} + - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project${pathSep}subdir" with command: pytest tests + + + ${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`} + ` +} + +function promptProfile(name: string, platform: NodeJS.Platform) { + const isPowerShell = PS.has(name) + const chain = chainGuidance(name) + if (isPowerShell) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/"), + gitCommands: "git commands", + toolName: "Shell", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.", + createPrExample: `gh pr create --title "the pr title" --body @' +## Summary +- <1-3 bullet points> +'@`, + parameterDescription: describe.powershell, + } + } + return { + intro: + "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.", + workdirSection: + "All commands run in the current working 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.", + commandSection: bashCommandSection(chain), + gitCommands: "bash commands", + toolName: "Bash", + gitCommandRestriction: "git bash commands", + createPrInstruction: + "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", + createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points>`, + parameterDescription: describe.bash, + } +} type Part = { type: string @@ -573,46 +753,25 @@ export const ShellTool = Tool.define( Effect.sync(() => { const shell = Shell.acceptable() const name = Shell.name(shell) - const shellName = name === "pwsh" ? "PowerShell Core" : name === "powershell" ? "Windows PowerShell" : name - const listCmd = name === "cmd" ? "dir" : PS.has(name) ? "Get-ChildItem" : "ls" - const guidance = - name === "pwsh" - ? `# PowerShell 7+ (pwsh) shell notes -- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with the PowerShell backtick character.` - : name === "powershell" - ? `# Windows PowerShell 5.1 shell notes -- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Cmdlets use Verb-Noun naming (e.g., \`Get-ChildItem\`, \`Set-Content\`). Common aliases like \`ls\`, \`cat\`, \`rm\` execute the equivalent PowerShell cmdlets. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with the PowerShell backtick character.` - : "" - const chain = - name === "powershell" - ? "use shell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." - : "use a single shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`)." + const profile = promptProfile(name, process.platform) + const description = renderPrompt(DESCRIPTION, { + intro: profile.intro, + os: process.platform, + shell: name, + workdirSection: profile.workdirSection, + commandSection: profile.commandSection, + gitCommands: profile.gitCommands, + toolName: profile.toolName, + gitCommandRestriction: profile.gitCommandRestriction, + createPrInstruction: profile.createPrInstruction, + createPrExample: profile.createPrExample, + }) log.info("shell tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory) - .replaceAll("${os}", process.platform) - .replaceAll("${shell}", name) - .replaceAll("${shellName}", shellName) - .replaceAll("${guidance}", guidance) - .replaceAll("${listCmd}", listCmd) - .replaceAll("${toolName}", "Shell") - .replaceAll("${gitCmds}", "git commands") - .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) - .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), - parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + description, + parameters: Parameters(profile.parameterDescription), + execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const cwd = params.workdir ? yield* resolvePath(params.workdir, Instance.directory, shell) diff --git a/packages/opencode/src/tool/shell/shell.txt b/packages/opencode/src/tool/shell/shell.txt index 99d12f6dd8..1b137c46b5 100644 --- a/packages/opencode/src/tool/shell/shell.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,54 +1,12 @@ -Executes a given ${shellName} command with optional timeout, ensuring proper handling and security measures. +${intro} Be aware: OS: ${os}, Shell: ${shell} -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. +${workdirSection} IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. -${guidance} - -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use \`${listCmd}\` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use \`${listCmd} foo\` to check that "foo" exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") - - Examples of proper quoting: - - mkdir "/Users/name/My Documents" (correct) - - mkdir /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using ${toolName} with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. - - Use workdir="/foo/bar" with command: pytest tests - - - cd /foo/bar && pytest tests - +${commandSection} # Committing changes with git @@ -67,7 +25,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 ${gitCmds} in parallel, each using the ${toolName} 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 ${gitCommands} in parallel, each using the ${toolName} 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. @@ -84,7 +42,7 @@ Git Safety Protocol: 4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) Important notes: -- NEVER run additional commands to read or explore code, besides ${gitCmds} +- NEVER run additional commands to read or explore code, besides ${gitCommandRestriction} - NEVER use the TodoWrite or Task tools - DO NOT push to the remote repository unless the user explicitly asks you to do so - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. @@ -95,20 +53,18 @@ Use the gh command via the ${toolName} tool for ALL GitHub-related tasks includi 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 ${gitCmds} in parallel using the ${toolName} 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 ${gitCommands} in parallel using the ${toolName} 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 - - Run a git log command and \`git diff [base-branch]...HEAD\` to understand the full commit history for the current branch (from the time it diverged from the base branch) + - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch) 2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary 3. 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 commands in parallel: - Create new branch if needed - Push to remote with -u flag if needed - - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting. + - ${createPrInstruction} -gh pr create --title "the pr title" --body "$(cat <<'EOF' -## Summary -<1-3 bullet points> +${createPrExample} Important: diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e525a25bcd..b469fb376e 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,5 +1,6 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" +import { ShellToolID } from "./shell/id" import z from "zod" import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" @@ -39,7 +40,7 @@ export const TaskTool = Tool.define( const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { const cfg = yield* config.get() - const primaryTools = cfg.experimental?.primary_tools ?? [] + const primaryTools = (cfg.experimental?.primary_tools ?? []).map(ShellToolID.normalize) if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index faf73d9edf..06f28bde25 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -378,7 +378,7 @@ describe("tool.task", () => { }, }, experimental: { - primary_tools: ["shell", "read"], + primary_tools: ["bash", "read"], }, }, }, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 3ea80677eb..34fea63c26 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -271,7 +271,7 @@ export type ToolInfo = { subtitle?: string } -const SHELL = new Set(["shell"]) +const SHELL = new Set(["shell", "bash"]) function agentTitle(i18n: UiI18n, type?: string) { if (!type) return i18n.t("ui.tool.agent.default") diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index 974ccef43f..543a5b885d 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -33,7 +33,7 @@ import type { Diagnostic } from "vscode-languageserver-types" import styles from "./part.module.css" const MIN_DURATION = 2000 -const SHELL = new Set(["shell"]) +const SHELL = new Set(["shell", "bash"]) export interface PartProps { index: number From 0d500a735f87178a0058486a8b7ca83eb0f25ba0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:44:06 +1000 Subject: [PATCH 19/27] Create todo.spec.ts --- packages/app/e2e/todo.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/app/e2e/todo.spec.ts diff --git a/packages/app/e2e/todo.spec.ts b/packages/app/e2e/todo.spec.ts new file mode 100644 index 0000000000..dac2d8ee82 --- /dev/null +++ b/packages/app/e2e/todo.spec.ts @@ -0,0 +1,11 @@ +import { test } from "@playwright/test" + +test( + "test something cool", + { + annotation: { type: "todo" }, + }, + async () => { + test.fixme() + }, +) From cffb8eb1e303bd4636b8e39cf2f84b317dac4b50 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:54:08 +1000 Subject: [PATCH 20/27] . --- packages/opencode/src/cli/cmd/agent.ts | 2 ++ packages/ui/src/components/message-part.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index dee5fea7ac..e49f09e62a 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -10,6 +10,7 @@ import fs from "fs/promises" import { Filesystem } from "../../util" import matter from "gray-matter" import { Instance } from "../../project/instance" +import { ShellToolID } from "../../tool/shell/id" import { EOL } from "os" import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" @@ -128,6 +129,7 @@ const AgentCreateCommand = cmd({ cliTools .split(",") .map((t) => t.trim()) + .map(ShellToolID.normalize) .filter(Boolean), ), ] diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 34fea63c26..429b0abd9e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1258,6 +1258,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) { } export function getTool(name: string) { + if (name === "bash") return state.shell?.render return state[name]?.render } From 26d77add77e52214bb611504ddab8a977729cca3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:03:16 +1000 Subject: [PATCH 21/27] edges --- packages/opencode/src/session/llm.ts | 8 +- packages/opencode/src/tool/shell.ts | 7 +- packages/opencode/test/session/llm.test.ts | 94 ++++++++++++++++++++++ packages/opencode/test/tool/shell.test.ts | 32 ++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 848d06c888..bfc84df1ba 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -22,6 +22,7 @@ import { Auth } from "@/auth" import { Installation } from "@/installation" import { InstallationVersion } from "@/installation/version" import { EffectBridge } from "@/effect" +import { ShellToolID } from "@/tool/shell/id" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" @@ -453,7 +454,12 @@ function resolveTools(input: Pick input.user.tools?.[k] !== false && !disabled.has(k)) + return Record.filter(input.tools, (_, k) => { + const userTool = input.user.tools?.[k] + if (userTool !== undefined) return userTool !== false && !disabled.has(k) + if (k === ShellToolID.id && input.user.tools?.[ShellToolID.legacy] === false) return false + return !disabled.has(k) + }) } // Check if messages contain any tool-call content diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 97e447df6a..8dc7d7ccb8 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -13,14 +13,14 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" -import { ShellToolID } from "./shell/id" +import { ShellKind, ShellToolID } from "./shell/id" -import { BashArity } from "@/permission/arity" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { ShellArity } from "./shell/arity" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -551,6 +551,7 @@ export const ShellTool = Tool.define( patterns: new Set(), always: new Set(), } + const shellKind = ShellKind.from(Shell.name(shell)) for (const node of commands(root)) { const command = parts(node) @@ -569,7 +570,7 @@ export const ShellTool = Tool.define( if (tokens.length && (!cmd || !CWD.has(cmd))) { scan.patterns.add(source(node)) - scan.always.add(BashArity.prefix(tokens).join(" ") + " *") + scan.always.add(ShellArity.prefix(tokens, shellKind).join(" ") + " *") } } diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 2b1df92131..663bfe3218 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -561,6 +561,100 @@ describe("session.llm.stream", () => { }) }) + test("disables shell when user message uses legacy bash override", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const providerID = "alibaba" + const modelID = "qwen-plus" + const fixture = await loadFixture(providerID, modelID) + const model = fixture.model + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-legacy-bash-tools") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [], + } satisfies Agent.Info + + const user = { + id: MessageID.make("user-legacy-bash-tools"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, + tools: { bash: false }, + } satisfies MessageV2.User + + await drain({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: { + shell: tool({ + description: "Run a shell command", + inputSchema: z.object({ command: z.string() }), + execute: async () => ({ output: "" }), + }), + read: tool({ + description: "Read a file", + inputSchema: z.object({ filePath: z.string() }), + execute: async () => ({ output: "" }), + }), + }, + }) + + const capture = await request + const names = + (capture.body.tools as Array<{ function?: { name?: string } }> | undefined)?.flatMap((item) => + item.function?.name ? [item.function.name] : [], + ) ?? [] + + expect(names).not.toContain("shell") + expect(names).toContain("read") + }, + }) + }) + test("sends responses API payload for OpenAI models", async () => { const server = state.server if (!server) { diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index b06bee2ddc..6f1366ff53 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -237,6 +237,38 @@ describe("tool.shell permissions", () => { ) } + for (const item of ps) { + test( + `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "Remove-Item -Recurse tmp", + description: "Remove a temp directory", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const bashReq = requests.find((r) => r.permission === expectedPermission) + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("Remove-Item *") + expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") + }, + }) + }), + ) + } + each("asks for external_directory permission for wildcard external paths", async () => { await Instance.provide({ directory: projectRoot, From 4f8ff6ab53aa7515ad3fe8eee6eb978e58dbe407 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:23:18 +1000 Subject: [PATCH 22/27] . --- packages/opencode/src/session/llm.ts | 12 +++++++----- packages/opencode/test/session/llm.test.ts | 13 ++++++++++++- packages/ui/src/components/tool-error-card.tsx | 1 + 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index bfc84df1ba..a693f47fd0 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -207,11 +207,7 @@ const live: Layer.Layer< input.model.providerID.toLowerCase().includes("litellm") || input.model.api.id.toLowerCase().includes("litellm") - const repair = (toolName: string) => { - const next = toolName.toLowerCase() - if (!tools[next]) return - return next - } + const repair = (toolName: string) => repairToolName(toolName, tools) // LiteLLM/Bedrock rejects requests where the message history contains tool // calls but no tools param is present. When there are no active tools (e.g. @@ -449,6 +445,12 @@ export const defaultLayer = Layer.suspend(() => ), ) +export function repairToolName(toolName: string, tools: Record) { + const next = ShellToolID.normalize(toolName.toLowerCase()) + if (!tools[next]) return + return next +} + function resolveTools(input: Pick) { const disabled = Permission.disabled( Object.keys(input.tools), diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 663bfe3218..0ad0b2b695 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import path from "path" -import { tool, type ModelMessage } from "ai" +import { tool, type ModelMessage, type Tool } from "ai" import { Cause, Effect, Exit, Stream } from "effect" import z from "zod" import { makeRuntime } from "../../src/effect/run-service" @@ -119,6 +119,17 @@ describe("session.llm.hasToolCalls", () => { }) }) +describe("session.llm.repairToolName", () => { + test("normalizes legacy bash alias to shell when available", () => { + expect(LLM.repairToolName("bash", { shell: {} as Tool })).toBe("shell") + expect(LLM.repairToolName("BASH", { shell: {} as Tool })).toBe("shell") + }) + + test("returns undefined when normalized tool is unavailable", () => { + expect(LLM.repairToolName("bash", { read: {} as Tool })).toBeUndefined() + }) +}) + type Capture = { url: URL headers: Headers diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index 3b0b293319..7d22c9ff74 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -34,6 +34,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", codesearch: "ui.tool.codesearch", + bash: "ui.tool.shell", shell: "ui.tool.shell", apply_patch: "ui.tool.patch", question: "ui.tool.questions", From 341b8e78c90ed171a8578e6bf5662e532f69219e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:11:42 +1000 Subject: [PATCH 23/27] perms --- packages/opencode/src/tool/shell/arity.ts | 7 ++----- packages/opencode/test/permission/arity.test.ts | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/shell/arity.ts b/packages/opencode/src/tool/shell/arity.ts index 073b62053d..ec7d7e728a 100644 --- a/packages/opencode/src/tool/shell/arity.ts +++ b/packages/opencode/src/tool/shell/arity.ts @@ -1,11 +1,8 @@ import { BashArity } from "@/permission/arity" -import { ShellKind } from "./id" +import type { ShellKind } from "./id" export namespace ShellArity { - export function prefix(tokens: string[], shellType: ShellKind.ID) { - if (ShellKind.powershell(shellType) && tokens.length > 0 && /^[a-z]+-[a-z]+$/i.test(tokens[0])) { - return [tokens[0]] - } + export function prefix(tokens: string[], _shellType: ShellKind.ID) { return BashArity.prefix(tokens) } } diff --git a/packages/opencode/test/permission/arity.test.ts b/packages/opencode/test/permission/arity.test.ts index 5e2af7afc1..01a0dfba61 100644 --- a/packages/opencode/test/permission/arity.test.ts +++ b/packages/opencode/test/permission/arity.test.ts @@ -36,4 +36,5 @@ test("powershell verb-noun structures", () => { expect(ShellArity.prefix(["Get-Content", "file.txt"], "pwsh")).toEqual(["Get-Content"]) expect(ShellArity.prefix(["Remove-Item", "-Recurse", "dir"], "powershell")).toEqual(["Remove-Item"]) expect(ShellArity.prefix(["git", "checkout", "main"], "pwsh")).toEqual(["git", "checkout"]) + expect(ShellArity.prefix(["redis-cli", "ping"], "pwsh")).toEqual(["redis-cli", "ping"]) }) From 428b0c46a7b1eaf2ffa0c71aa401aed1ecc32920 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:15:31 +1000 Subject: [PATCH 24/27] cmd --- packages/opencode/src/tool/shell.ts | 73 ++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index e4b273cb18..683622a0d6 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -24,6 +24,7 @@ import { ShellArity } from "./shell/arity" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 const PS = new Set(["powershell", "pwsh"]) +const CMD = new Set(["cmd"]) const CWD = new Set(["cd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, @@ -55,6 +56,8 @@ const 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'", powershell: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', + cmd: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', } function parameterSchema(description: string) { @@ -88,6 +91,7 @@ function renderPrompt(template: string, values: Record) { function shellDisplayName(name: string) { if (name === "pwsh") return "PowerShell (7+)" if (name === "powershell") return "Windows PowerShell (5.1)" + if (name === "cmd") return "cmd.exe" return name } @@ -120,6 +124,9 @@ function chainGuidance(name: string) { if (PS.has(name)) { return "If the commands depend on each other and must run sequentially, use a single Shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before Shell for git operations, or git add before git commit), run these operations sequentially instead." } + if (CMD.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single Shell call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead." + } return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." } @@ -200,7 +207,7 @@ Usage notes: - Write files: Use Write (NOT Set-Content/Out-File or here-strings) - Communication: Output text directly (NOT Write-Output/Write-Host) - When issuing multiple commands: - - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel. + - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two Shell tool calls in parallel. - ${chain} - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail - DO NOT use newlines to separate commands (newlines are ok in quoted strings) @@ -213,9 +220,73 @@ Usage notes: ` } +function cmdCommandSection(chain: string, limits: Limits) { + return `# cmd.exe shell notes +- Use double quotes for paths with spaces. +- Use %VAR% for environment variables. +- Use \`if exist\` for existence checks. +- Use \`call\` when invoking batch files from another batch-style command. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location + - For example, before creating \`foo\\bar\`, first use \`if exist "foo" dir "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., del "path with spaces\\file.txt") + - Examples of proper quoting: + - mkdir "My Documents" (correct) + - mkdir My Documents (incorrect - path is split) + - call "path with spaces\\script.bat" (correct) + - path with spaces\\script.bat (incorrect - path is split and not invoked correctly) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT dir /s) + - Content search: Use Grep (NOT findstr) + - Read files: Use Read (NOT type) + - Edit files: Use Edit (NOT copy) + - Write files: Use Write (NOT echo > file) + - Communication: Output text directly (NOT echo) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel. + - ${chain} + - Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project\\subdir" with command: dir + + + cd /d "project\\subdir" && dir + ` +} + function promptProfile(name: string, platform: NodeJS.Platform, limits: Limits) { const isPowerShell = PS.has(name) const chain = chainGuidance(name) + if (CMD.has(name)) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: cmdCommandSection(chain, limits), + gitCommands: "git commands", + toolName: "Shell", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", + createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, + parameterDescription: describe.cmd, + } + } if (isPowerShell) { return { intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, From ecac4c4e2a3a93c4039c5178e02c83c23acc34a6 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:32:18 +1000 Subject: [PATCH 25/27] split prompt/definition from logic --- packages/opencode/src/tool/shell.ts | 296 +-------------------- packages/opencode/src/tool/shell/prompt.ts | 296 +++++++++++++++++++++ 2 files changed, 303 insertions(+), 289 deletions(-) create mode 100644 packages/opencode/src/tool/shell/prompt.ts diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 683622a0d6..aad08161a4 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -1,9 +1,8 @@ -import { Effect, Schema, Stream } from "effect" +import { Effect, Stream } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" import path from "path" -import DESCRIPTION from "./shell/shell.txt" import { Log } from "../util" import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" @@ -20,11 +19,13 @@ import { Plugin } from "@/plugin" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ShellArity } from "./shell/arity" +import { ShellPrompt, type Parameters } from "./shell/prompt" + +export { Parameters } from "./shell/prompt" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 const PS = new Set(["powershell", "pwsh"]) -const CMD = new Set(["cmd"]) const CWD = new Set(["cd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, @@ -51,277 +52,6 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -const describe = { - bash: - "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'", - powershell: - 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', - cmd: - 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', -} - -function parameterSchema(description: string) { - return Schema.Struct({ - command: Schema.String.annotate({ description: "The command to execute" }), - timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }), - workdir: Schema.optional(Schema.String).annotate({ - description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - }), - description: Schema.String.annotate({ description }), - }) -} - -export const Parameters = parameterSchema(describe.bash) - -type Parameters = Schema.Schema.Type - -type Limits = { - maxLines: number - maxBytes: number -} - -function renderPrompt(template: string, values: Record) { - return template.replace(/\$\{(\w+)\}/g, (_, key: string) => { - const value = values[key] - if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`) - return value - }) -} - -function shellDisplayName(name: string) { - if (name === "pwsh") return "PowerShell (7+)" - if (name === "powershell") return "Windows PowerShell (5.1)" - if (name === "cmd") return "cmd.exe" - return name -} - -function powershellNotes(name: string) { - if (name === "pwsh") { - return `# PowerShell (7+) shell notes -- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with the PowerShell backtick character.` - } - if (name === "powershell") { - return `# Windows PowerShell (5.1) shell notes -- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. -- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. -- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. -- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. -- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. -- Escape special characters with the PowerShell backtick character.` - } - return "" -} - -function chainGuidance(name: string) { - if (name === "powershell") { - return "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell (5.1) does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." - } - if (PS.has(name)) { - return "If the commands depend on each other and must run sequentially, use a single Shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before Shell for git operations, or git add before git commit), run these operations sequentially instead." - } - if (CMD.has(name)) { - return "If the commands depend on each other and must run sequentially, use a single Shell call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead." - } - return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." -} - -function bashCommandSection(chain: string, limits: Limits) { - return `Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") - - Examples of proper quoting: - - mkdir "/Users/name/My Documents" (correct) - - mkdir /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. - - Use workdir="/foo/bar" with command: pytest tests - - - cd /foo/bar && pytest tests - ` -} - -function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) { - return `${powershellNotes(name)} - -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use \`Test-Path -LiteralPath \` to verify the parent directory exists and is the correct location - - For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "foo"\` to check that \`foo\` exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt") - - Examples of proper quoting: - - New-Item -ItemType Directory -Path "My Documents" (correct) - - New-Item -ItemType Directory -Path My Documents (incorrect - path is split) - - & "path with spaces${pathSep}script.ps1" (correct) - - path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT Get-ChildItem) - - Content search: Use Grep (NOT Select-String) - - Read files: Use Read (NOT Get-Content) - - Edit files: Use Edit (NOT Set-Content) - - Write files: Use Write (NOT Set-Content/Out-File or here-strings) - - Communication: Output text directly (NOT Write-Output/Write-Host) - - When issuing multiple commands: - - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two Shell tool calls in parallel. - - ${chain} - - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail - - DO NOT use newlines to separate commands (newlines are ok in quoted strings) - - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. - - Use workdir="project${pathSep}subdir" with command: pytest tests - - - ${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`} - ` -} - -function cmdCommandSection(chain: string, limits: Limits) { - return `# cmd.exe shell notes -- Use double quotes for paths with spaces. -- Use %VAR% for environment variables. -- Use \`if exist\` for existence checks. -- Use \`call\` when invoking batch files from another batch-style command. - -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location - - For example, before creating \`foo\\bar\`, first use \`if exist "foo" dir "foo"\` to check that \`foo\` exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., del "path with spaces\\file.txt") - - Examples of proper quoting: - - mkdir "My Documents" (correct) - - mkdir My Documents (incorrect - path is split) - - call "path with spaces\\script.bat" (correct) - - path with spaces\\script.bat (incorrect - path is split and not invoked correctly) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT dir /s) - - Content search: Use Grep (NOT findstr) - - Read files: Use Read (NOT type) - - Edit files: Use Edit (NOT copy) - - Write files: Use Write (NOT echo > file) - - Communication: Output text directly (NOT echo) - - When issuing multiple commands: - - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel. - - ${chain} - - Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail - - DO NOT use newlines to separate commands (newlines are ok in quoted strings) - - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. - - Use workdir="project\\subdir" with command: dir - - - cd /d "project\\subdir" && dir - ` -} - -function promptProfile(name: string, platform: NodeJS.Platform, limits: Limits) { - const isPowerShell = PS.has(name) - const chain = chainGuidance(name) - if (CMD.has(name)) { - return { - intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, - workdirSection: - "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", - commandSection: cmdCommandSection(chain, limits), - gitCommands: "git commands", - toolName: "Shell", - gitCommandRestriction: "git commands", - createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", - createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, - parameterDescription: describe.cmd, - } - } - if (isPowerShell) { - return { - intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, - workdirSection: - "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", - commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits), - gitCommands: "git commands", - toolName: "Shell", - gitCommandRestriction: "git commands", - createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.", - createPrExample: `gh pr create --title "the pr title" --body @' -## Summary -- <1-3 bullet points> -'@`, - parameterDescription: describe.powershell, - } - } - return { - intro: - "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.", - workdirSection: - "All commands run in the current working 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.", - commandSection: bashCommandSection(chain, limits), - gitCommands: "bash commands", - toolName: "Bash", - gitCommandRestriction: "git bash commands", - createPrInstruction: - "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", - createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' -## Summary -<1-3 bullet points>`, - parameterDescription: describe.bash, - } -} - type Part = { type: string text: string @@ -829,24 +559,12 @@ export const ShellTool = Tool.define( const shell = Shell.acceptable() const name = Shell.name(shell) const limits = yield* trunc.limits() - const profile = promptProfile(name, process.platform, limits) - const description = renderPrompt(DESCRIPTION, { - intro: profile.intro, - os: process.platform, - shell: name, - workdirSection: profile.workdirSection, - commandSection: profile.commandSection, - gitCommands: profile.gitCommands, - toolName: profile.toolName, - gitCommandRestriction: profile.gitCommandRestriction, - createPrInstruction: profile.createPrInstruction, - createPrExample: profile.createPrExample, - }) + const prompt = ShellPrompt.render(name, process.platform, limits) log.info("shell tool using shell", { shell }) return { - description, - parameters: parameterSchema(profile.parameterDescription), + description: prompt.description, + parameters: prompt.parameters, execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const cwd = params.workdir diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts new file mode 100644 index 0000000000..dbad3b91bd --- /dev/null +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -0,0 +1,296 @@ +import { Schema } from "effect" +import DESCRIPTION from "./shell.txt" + +const PS = new Set(["powershell", "pwsh"]) +const CMD = new Set(["cmd"]) + +const descriptions = { + bash: + "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'", + powershell: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', + cmd: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', +} + +export type Limits = { + maxLines: number + maxBytes: number +} + +export function parameterSchema(description: string) { + return Schema.Struct({ + command: Schema.String.annotate({ description: "The command to execute" }), + timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }), + workdir: Schema.optional(Schema.String).annotate({ + description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, + }), + description: Schema.String.annotate({ description }), + }) +} + +export const Parameters = parameterSchema(descriptions.bash) +export type Parameters = Schema.Schema.Type + +function renderPrompt(template: string, values: Record) { + return template.replace(/\$\{(\w+)\}/g, (_, key: string) => { + const value = values[key] + if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`) + return value + }) +} + +function shellDisplayName(name: string) { + if (name === "pwsh") return "PowerShell (7+)" + if (name === "powershell") return "Windows PowerShell (5.1)" + if (name === "cmd") return "cmd.exe" + return name +} + +function powershellNotes(name: string) { + if (name === "pwsh") { + return `# PowerShell (7+) shell notes +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + if (name === "powershell") { + return `# Windows PowerShell (5.1) shell notes +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + return "" +} + +function chainGuidance(name: string) { + if (name === "powershell") { + return "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell (5.1) does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." + } + if (PS.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single Shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before Shell for git operations, or git add before git commit), run these operations sequentially instead." + } + if (CMD.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single Shell call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead." + } + return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." +} + +function bashCommandSection(chain: string, limits: Limits) { + return `Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") + - Examples of proper quoting: + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. + + Use workdir="/foo/bar" with command: pytest tests + + + cd /foo/bar && pytest tests + ` +} + +function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) { + return `${powershellNotes(name)} + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`Test-Path -LiteralPath \` to verify the parent directory exists and is the correct location + - For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt") + - Examples of proper quoting: + - New-Item -ItemType Directory -Path "My Documents" (correct) + - New-Item -ItemType Directory -Path My Documents (incorrect - path is split) + - & "path with spaces${pathSep}script.ps1" (correct) + - path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT Get-ChildItem) + - Content search: Use Grep (NOT Select-String) + - Read files: Use Read (NOT Get-Content) + - Edit files: Use Edit (NOT Set-Content) + - Write files: Use Write (NOT Set-Content/Out-File or here-strings) + - Communication: Output text directly (NOT Write-Output/Write-Host) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel. + - ${chain} + - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project${pathSep}subdir" with command: pytest tests + + + ${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`} + ` +} + +function cmdCommandSection(chain: string, limits: Limits) { + return `# cmd.exe shell notes +- Use double quotes for paths with spaces. +- Use %VAR% for environment variables. +- Use \`if exist\` for existence checks. +- Use \`call\` when invoking batch files from another batch-style command. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location + - For example, before creating \`foo\\bar\`, first use \`if exist "foo" dir "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., del "path with spaces\\file.txt") + - Examples of proper quoting: + - mkdir "My Documents" (correct) + - mkdir My Documents (incorrect - path is split) + - call "path with spaces\\script.bat" (correct) + - path with spaces\\script.bat (incorrect - path is split and not invoked correctly) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT dir /s) + - Content search: Use Grep (NOT findstr) + - Read files: Use Read (NOT type) + - Edit files: Use Edit (NOT copy) + - Write files: Use Write (NOT echo > file) + - Communication: Output text directly (NOT echo) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two Shell tool calls in parallel. + - ${chain} + - Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project\\subdir" with command: dir + + + cd /d "project\\subdir" && dir + ` +} + +function profile(name: string, platform: NodeJS.Platform, limits: Limits) { + const isPowerShell = PS.has(name) + const chain = chainGuidance(name) + if (CMD.has(name)) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: cmdCommandSection(chain, limits), + gitCommands: "git commands", + toolName: "Shell", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", + createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, + parameterDescription: descriptions.cmd, + } + } + if (isPowerShell) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits), + gitCommands: "git commands", + toolName: "Shell", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.", + createPrExample: `gh pr create --title "the pr title" --body @' +## Summary +- <1-3 bullet points> +'@`, + parameterDescription: descriptions.powershell, + } + } + return { + intro: + "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.", + workdirSection: + "All commands run in the current working 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.", + commandSection: bashCommandSection(chain, limits), + gitCommands: "bash commands", + toolName: "Bash", + gitCommandRestriction: "git bash commands", + createPrInstruction: + "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", + createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points>`, + parameterDescription: descriptions.bash, + } +} + +export function render(name: string, platform: NodeJS.Platform, limits: Limits) { + const selected = profile(name, platform, limits) + return { + description: renderPrompt(DESCRIPTION, { + intro: selected.intro, + os: platform, + shell: name, + workdirSection: selected.workdirSection, + commandSection: selected.commandSection, + gitCommands: selected.gitCommands, + toolName: selected.toolName, + gitCommandRestriction: selected.gitCommandRestriction, + createPrInstruction: selected.createPrInstruction, + createPrExample: selected.createPrExample, + }), + parameters: parameterSchema(selected.parameterDescription), + } +} + +export * as ShellPrompt from "./prompt" From 790d181d8a5f6ac35114f5f6494f6dcd9354a71b Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:37:32 +1000 Subject: [PATCH 26/27] slight accuracy --- packages/opencode/src/tool/shell/prompt.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index dbad3b91bd..d38ae9990f 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -113,7 +113,7 @@ Usage notes: - Write files: Use Write (NOT echo >/cat < && ` patterns - use `workdir` instead.", commandSection: bashCommandSection(chain, limits), gitCommands: "bash commands", - toolName: "Bash", - gitCommandRestriction: "git bash commands", + toolName: "Shell", + gitCommandRestriction: "git commands", createPrInstruction: "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' From 2051cadcb82f47214b25ddfd393397af3fbcafaa Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:18:30 +1000 Subject: [PATCH 27/27] Update prompt.ts --- packages/opencode/src/tool/shell/prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index d38ae9990f..1401a94ad4 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -264,7 +264,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits) { commandSection: bashCommandSection(chain, limits), gitCommands: "bash commands", toolName: "Shell", - gitCommandRestriction: "git commands", + gitCommandRestriction: "git bash commands", createPrInstruction: "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF'