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 6ab24e26be..7e7554f84e 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -51,6 +51,7 @@ import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" import { InstallationVersion } from "@/installation/version" +import { ShellToolID } from "@/tool/shell/id" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } @@ -144,7 +145,7 @@ export class Agent implements ACPAgent { 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[] = [ @@ -283,16 +284,16 @@ export class Agent implements ACPAgent { 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") { - if (this.bashSnapshots.get(part.callID) === hash) { + if (ShellToolID.normalize(part.tool) === ShellToolID.id) { + if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ sessionId, @@ -311,7 +312,7 @@ export class Agent implements ACPAgent { }) return } - this.bashSnapshots.set(part.callID, hash) + this.shellSnapshots.set(part.callID, hash) } content.push({ type: "content", @@ -342,7 +343,7 @@ export class Agent implements ACPAgent { 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[] = [ { @@ -423,7 +424,7 @@ export class Agent implements ACPAgent { } case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -837,10 +838,10 @@ export class Agent implements ACPAgent { 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({ @@ -871,7 +872,7 @@ export class Agent implements ACPAgent { 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[] = [ { @@ -951,7 +952,7 @@ export class Agent implements ACPAgent { break case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -1105,8 +1106,8 @@ export class Agent implements ACPAgent { } } - private bashOutput(part: ToolPart) { - if (part.tool !== "bash") return + private shellOutput(part: ToolPart) { + 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,9 +1550,9 @@ export class Agent implements ACPAgent { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() + if (ShellToolID.normalize(tool) === ShellToolID.id) return "execute" + switch (tool) { - case "bash": - return "execute" case "webfetch": return "fetch" @@ -1576,6 +1577,8 @@ function toToolKind(toolName: string): ToolKind { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() + if (ShellToolID.normalize(tool) === ShellToolID.id) return [] + switch (tool) { case "read": case "edit": @@ -1584,8 +1587,6 @@ function toLocations(toolName: string, input: Record): { path: stri case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] - case "bash": - return [] default: return [] } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 1ad3e9e4fa..485a7049b0 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -168,7 +168,7 @@ export const layer = Layer.effect( 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 fd559935fc..e49f09e62a 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -10,12 +10,12 @@ 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" -const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"] +const AVAILABLE_TOOLS = ["shell", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"] const AgentCreateCommand = cmd({ command: "create", @@ -123,7 +123,17 @@ 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) => t.trim()) + .map(ShellToolID.normalize) + .filter(Boolean), + ), + ] + : AVAILABLE_TOOLS } else { const result = await prompts.multiselect({ message: "Select tools to enable (Space to toggle)", diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index fe8e233dd1..00291daf9f 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -879,7 +879,7 @@ 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], 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 ed962ffa51..1a690ffd08 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -23,7 +23,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/bash" +import { ShellTool } from "../../tool/shell" +import { ShellToolID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util" import { AppRuntime } from "@/effect/app-runtime" @@ -183,7 +184,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( { @@ -413,7 +414,7 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (part.tool === "bash") return bash(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/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 c04e58acec..8e6274ff53 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,8 @@ import { Locale } from "@/util" import type { Tool } from "@/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { BashTool } from "@/tool/bash" +import { ShellTool } from "@/tool/shell" +import { ShellToolID } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1550,8 +1551,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - - + + @@ -1785,7 +1786,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 e48f348b98..2581be10ee 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -15,6 +15,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util" import { Locale } from "@/util" import { Global } from "@/global" +import { ShellToolID } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -287,7 +288,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "bash") { + 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/config/agent.ts b/packages/opencode/src/config/agent.ts index a8693c8aaf..aab13ac5b8 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -13,6 +13,7 @@ import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" import { ConfigModelID } from "./model-id" import { ConfigPermission } from "./permission" +import { ShellToolID } from "@/tool/shell/id" const log = Log.create({ service: "config" }) @@ -86,10 +87,14 @@ const normalize = (agent: z.infer) => { const permission: ConfigPermission.Info = {} for (const [tool, enabled] of Object.entries(agent.tools ?? {})) { const action = enabled ? "allow" : "deny" - if (tool === "write" || tool === "edit" || tool === "patch") { + if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { permission.edit = action continue } + if (ShellToolID.normalize(tool) === ShellToolID.id) { + permission.shell = action + continue + } permission[tool] = action } globalThis.Object.assign(permission, agent.permission) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3238287bee..a4920cbbf9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -43,6 +43,7 @@ import { ConfigServer } from "./server" import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { Npm } from "@/npm" +import { ShellToolID } from "@/tool/shell/id" const log = Log.create({ service: "config" }) @@ -661,10 +662,14 @@ export const layer = Layer.effect( const perms: Record = {} for (const [tool, enabled] of Object.entries(result.tools)) { const action: ConfigPermission.Action = enabled ? "allow" : "deny" - if (tool === "write" || tool === "edit" || tool === "patch") { + if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { perms.edit = action continue } + if (ShellToolID.normalize(tool) === ShellToolID.id) { + perms.shell = action + continue + } perms[tool] = action } result.permission = mergeDeep(perms, result.permission ?? {}) diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index a7390e9534..00a4b422f2 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -29,7 +29,7 @@ const InputObject = Schema.StructWithRest( glob: Schema.optional(Rule), grep: Schema.optional(Rule), list: Schema.optional(Rule), - bash: Schema.optional(Rule), + shell: Schema.optional(Rule), task: Schema.optional(Rule), external_directory: Schema.optional(Rule), todowrite: Schema.optional(Action), diff --git a/packages/opencode/src/permission/evaluate.ts b/packages/opencode/src/permission/evaluate.ts index bcc4e58118..f01d6b8538 100644 --- a/packages/opencode/src/permission/evaluate.ts +++ b/packages/opencode/src/permission/evaluate.ts @@ -1,4 +1,5 @@ import { Wildcard } from "@/util" +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 428514ecd6..c920752774 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -14,6 +14,7 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" import { evaluate as evalRule } from "./evaluate" import { PermissionID } from "./schema" +import { ShellToolID } from "@/tool/shell/id" const log = Log.create({ service: "permission" }) @@ -185,7 +186,9 @@ export const layer = Layer.effect( 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,12 +293,13 @@ function expand(pattern: string): string { export function fromConfig(permission: ConfigPermission.Info) { const ruleset: Ruleset = [] for (const [key, value] of Object.entries(permission)) { + const permission = ShellToolID.normalize(key) if (typeof value === "string") { - ruleset.push({ permission: key, action: value, pattern: "*" }) + ruleset.push({ permission, action: value, pattern: "*" }) continue } ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), + ...Object.entries(value).map(([pattern, action]) => ({ permission, pattern: expand(pattern), action })), ) } return ruleset @@ -310,8 +314,8 @@ const EDIT_TOOLS = ["edit", "write", "apply_patch"] 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/llm.ts b/packages/opencode/src/session/llm.ts index b72f873de0..a693f47fd0 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" @@ -206,6 +207,8 @@ const live: Layer.Layer< input.model.providerID.toLowerCase().includes("litellm") || input.model.api.id.toLowerCase().includes("litellm") + 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. // during compaction), inject a stub tool to satisfy the validation requirement. @@ -239,7 +242,7 @@ const live: Layer.Layer< workflowModel.sessionID = input.sessionID 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}` } } @@ -337,15 +340,15 @@ const live: Layer.Layer< }) }, 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 { @@ -442,12 +445,23 @@ 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), Permission.merge(input.agent.permission, input.permission ?? []), ) - return Record.filter(input.tools, (_, k) => 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/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 8a2d352a51..dc6e70b367 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -672,7 +672,7 @@ const part = (row: typeof PartTable.$inferSelect) => 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))) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8e227e6021..53b3b4747b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -40,6 +40,7 @@ import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" +import { ShellToolID } from "@/tool/shell/id" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool" import { decodeDataUrl } from "@/util/data-url" @@ -773,7 +774,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the id: PartID.ascending(), messageID: msg.id, sessionID: input.sessionID, - tool: "bash", + 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 629c57965c..9a889d357d 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 { BashTool } from "./bash" +import { ShellTool } from "./shell" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" @@ -107,7 +107,7 @@ export const layer: Layer.Layer< const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool - const bash = yield* BashTool + const shell = yield* ShellTool const codesearch = yield* CodeSearchTool const globtool = yield* GlobTool const writetool = yield* WriteTool @@ -188,7 +188,7 @@ export const layer: Layer.Layer< const tool = yield* Effect.all({ invalid: Tool.init(invalid), - bash: Tool.init(bash), + shell: Tool.init(shell), read: Tool.init(read), glob: Tool.init(globtool), grep: Tool.init(greptool), @@ -211,7 +211,7 @@ export const layer: Layer.Layer< builtin: [ tool.invalid, ...(questionEnabled ? [tool.question] : []), - tool.bash, + tool.shell, tool.read, tool.glob, tool.grep, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/shell.ts similarity index 83% rename from packages/opencode/src/tool/bash.ts rename to packages/opencode/src/tool/shell.ts index 1b88753261..b4c6b341be 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/shell.ts @@ -1,9 +1,8 @@ -import { Schema } 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 "./bash.txt" import { Log } from "../util" import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" @@ -13,13 +12,16 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" +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" +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 @@ -50,18 +52,6 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -export const Parameters = 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: - "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'", - }), -}) - type Part = { type: string text: string @@ -78,7 +68,7 @@ type Chunk = { size: number } -export const log = Log.create({ service: "bash-tool" }) +export const log = Log.create({ service: "shell-tool" }) const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) @@ -248,13 +238,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, "*")) @@ -270,7 +260,7 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) if (scan.patterns.size === 0) return yield* ctx.ask({ - permission: "bash", + permission: ShellToolID.id, patterns: Array.from(scan.patterns), always: Array.from(scan.always), metadata: {}, @@ -323,16 +313,15 @@ const parser = lazy(async () => { return { bash, ps } }) -// TODO: we may wanna rename this tool so it works better on other shells -export const BashTool = Tool.define( - "bash", +export const ShellTool = Tool.define( + ShellToolID.id, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner const fs = yield* AppFileSystem.Service 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[]))) @@ -341,7 +330,7 @@ export const BashTool = 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) @@ -352,7 +341,7 @@ export const BashTool = 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 @@ -361,12 +350,13 @@ export const BashTool = 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(), always: new Set(), } + const shellKind = ShellKind.from(Shell.name(shell)) for (const node of commands(root)) { const command = parts(node) @@ -385,14 +375,14 @@ export const BashTool = 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(" ") + " *") } } 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 }, @@ -404,7 +394,7 @@ export const BashTool = Tool.define( } }) - const run = Effect.fn("BashTool.run")(function* ( + const run = Effect.fn("ShellTool.run")(function* ( input: { shell: string name: string @@ -519,7 +509,7 @@ export const BashTool = Tool.define( const meta: string[] = [] if (expired) { meta.push( - `bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, + `shell tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, ) } if (aborted) meta.push("User aborted the command") @@ -538,7 +528,7 @@ export const BashTool = Tool.define( } if (meta.length > 0) { - output += "\n\n\n" + meta.join("\n") + "\n" + output += "\n\n\n" + meta.join("\n") + "\n" } if (sink) { const stream = sink @@ -568,23 +558,14 @@ export const BashTool = Tool.define( Effect.gen(function* () { 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 }) - const limits = yield* trunc.limits() + const prompt = ShellPrompt.render(name, process.platform, limits) + log.info("shell tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory) - .replaceAll("${os}", process.platform) - .replaceAll("${shell}", name) - .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(limits.maxLines)) - .replaceAll("${maxBytes}", String(limits.maxBytes)), - parameters: Parameters, - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + description: prompt.description, + parameters: prompt.parameters, + 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/arity.ts b/packages/opencode/src/tool/shell/arity.ts new file mode 100644 index 0000000000..ec7d7e728a --- /dev/null +++ b/packages/opencode/src/tool/shell/arity.ts @@ -0,0 +1,8 @@ +import { BashArity } from "@/permission/arity" +import type { ShellKind } from "./id" + +export namespace ShellArity { + export function prefix(tokens: string[], _shellType: ShellKind.ID) { + return BashArity.prefix(tokens) + } +} diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts new file mode 100644 index 0000000000..60673dd698 --- /dev/null +++ b/packages/opencode/src/tool/shell/id.ts @@ -0,0 +1,33 @@ +export namespace ShellKind { + export const ids = ["bash", "pwsh", "powershell"] as const + export type ID = (typeof ids)[number] + + const kind = new Set(ids) + const ps = new Set(["pwsh", "powershell"]) + + export function has(value: string): value is ID { + return kind.has(value) + } + + export function from(value: string): ID { + return has(value) ? value : "bash" + } + + export function powershell(value: string) { + return ps.has(value) + } +} + +export namespace ShellToolID { + export const id = "shell" + export const legacy = "bash" + export type ID = typeof id + + export function has(value: string): value is ID { + return value === id + } + + export function normalize(value: string) { + return value === legacy ? id : value + } +} diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts new file mode 100644 index 0000000000..1401a94ad4 --- /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: "Shell", + 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" diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/shell/shell.txt similarity index 58% rename from packages/opencode/src/tool/bash.txt rename to packages/opencode/src/tool/shell/shell.txt index c2fe873791..0f53baf600 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,52 +1,12 @@ -Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. +${intro} Be aware: OS: ${os}, Shell: ${shell} -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. +${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. -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 ${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: - - 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 @@ -65,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 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 ${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. @@ -82,18 +42,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 ${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. - 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 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 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 ${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 @@ -102,11 +62,9 @@ IMPORTANT: When the user asks you to create a pull request, follow these steps c 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 5cb0dc6a83..acfb0fc904 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 { Session } from "../session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" @@ -39,6 +40,7 @@ export const TaskTool = Tool.define( ctx: Tool.Context, ) { const cfg = yield* config.get() + const primaryTools = (cfg.experimental?.primary_tools ?? []).map(ShellToolID.normalize) if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ @@ -88,11 +90,11 @@ export const TaskTool = Tool.define( action: "deny" as const, }, ]), - ...(cfg.experimental?.primary_tools?.map((item) => ({ + ...primaryTools.map((item) => ({ pattern: "*", action: "allow" as const, permission: item, - })) ?? []), + })), ], })) @@ -139,7 +141,7 @@ export const TaskTool = Tool.define( tools: { ...(canTodo ? {} : { todowrite: false }), ...(canTask ? {} : { task: false }), - ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), + ...Object.fromEntries(primaryTools.map((item) => [item, false])), }, parts, }) diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index bce5e94598..9fe1210c37 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -431,7 +431,7 @@ describe("acp.agent event subscription", () => { properties: { id: "perm_1", sessionID: sessionA, - permission: "bash", + permission: "shell", patterns: ["*"], metadata: {}, always: [], @@ -490,7 +490,7 @@ describe("acp.agent event subscription", () => { properties: { id: "perm_a", sessionID: sessionA, - permission: "bash", + permission: "shell", patterns: ["*"], metadata: {}, always: [], @@ -549,7 +549,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "running", input, metadata: { output }, @@ -581,7 +581,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" }, @@ -635,7 +635,7 @@ describe("acp.agent event subscription", () => { { type: "tool", callID: "call_1", - tool: "bash", + tool: "shell", state: { status: "running", input, @@ -652,7 +652,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" }, @@ -686,7 +686,7 @@ describe("acp.agent event subscription", () => { controller.push( toolEvent(sessionId, cwd, { callID: "call_1", - tool: "bash", + tool: "shell", status: "running", input, metadata: { output: "a" }, @@ -695,7 +695,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"}', @@ -704,7 +704,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 56b8e7acdd..786f944679 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1224,7 +1224,7 @@ test("migrates legacy tools config to permissions - allow", async () => { fn: async () => { const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ - bash: "allow", + shell: "allow", read: "allow", }) }, @@ -1255,7 +1255,7 @@ test("migrates legacy tools config to permissions - deny", async () => { fn: async () => { const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ - bash: "deny", + shell: "deny", webfetch: "deny", }) }, @@ -1453,7 +1453,7 @@ test("migrates mixed legacy tools config", async () => { fn: async () => { const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ - bash: "allow", + shell: "allow", edit: "allow", read: "deny", webfetch: "allow", @@ -1489,7 +1489,7 @@ test("merges legacy tools with existing permission config", async () => { const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ glob: "allow", - bash: "allow", + shell: "allow", }) }, }) @@ -1540,6 +1540,34 @@ test("permission config preserves user 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 load() + 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 () => { diff --git a/packages/opencode/test/permission/arity.test.ts b/packages/opencode/test/permission/arity.test.ts index 634e41e724..01a0dfba61 100644 --- a/packages/opencode/test/permission/arity.test.ts +++ b/packages/opencode/test/permission/arity.test.ts @@ -1,33 +1,40 @@ 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"]) + expect(ShellArity.prefix(["redis-cli", "ping"], "pwsh")).toEqual(["redis-cli", "ping"]) }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index b58c716d8a..e2c0b9c034 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -78,14 +78,14 @@ function withProvided(dir: string) { 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: "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: "shell", pattern: "*", action: "allow" }, + { permission: "shell", pattern: "rm", action: "deny" }, ]) }) @@ -96,13 +96,35 @@ test("fromConfig - mixed string and object values", () => { webfetch: "ask", }) expect(result).toEqual([ - { permission: "bash", pattern: "*", action: "allow" }, - { permission: "bash", 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 - shell and legacy bash normalize to shell in key order", () => { + const result = Permission.fromConfig({ + shell: "deny", + bash: "allow", + }) + expect(result).toEqual([ + { permission: "shell", pattern: "*", action: "deny" }, + { permission: "shell", pattern: "*", action: "allow" }, + ]) + expect(Permission.evaluate("bash", "ls", result).action).toBe("allow") + expect(Permission.evaluate("shell", "ls", result).action).toBe("allow") +}) + +test("fromConfig - legacy bash rules coexist with canonical shell rules", () => { + const result = Permission.fromConfig({ + shell: { "rm *": "deny" }, + bash: { "*": "allow", "rm *": "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", () => { const result = Permission.fromConfig({}) expect(result).toEqual([]) @@ -156,7 +178,7 @@ test("fromConfig - top-level ordering is not sorted by wildcard specificity", () edit: "deny", "mcp_*": "allow", }) - expect(ruleset.map((r) => r.permission)).toEqual(["bash", "*", "edit", "mcp_*"]) + expect(ruleset.map((r) => r.permission)).toEqual(["shell", "*", "edit", "mcp_*"]) }) test("fromConfig - sub-pattern insertion order inside a tool key is preserved", () => { @@ -282,6 +304,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 791fcdedc6..b586bcede7 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -865,7 +865,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { { type: "tool-call", toolCallId: "test", - toolName: "bash", + toolName: "shell", input: { command: "echo hello" }, }, ], @@ -916,7 +916,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { { type: "tool-call", toolCallId: "test", - toolName: "bash", + toolName: "shell", input: { command: "echo hello" }, }, ]) @@ -1193,7 +1193,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[] @@ -1205,7 +1205,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 4fe9c15511..2ac06395f1 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -651,7 +651,7 @@ describe("session.compaction.prune", () => { sessionID: info.id, type: "tool", callID: crypto.randomUUID(), - tool: "bash", + tool: "shell", state: { status: "completed", input: {}, diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 4d82096f3f..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" @@ -63,7 +63,7 @@ describe("session.llm.hasToolCalls", () => { { type: "tool-call", toolCallId: "call-123", - toolName: "bash", + toolName: "shell", }, ], }, @@ -79,7 +79,7 @@ describe("session.llm.hasToolCalls", () => { { type: "tool-result", toolCallId: "call-123", - toolName: "bash", + toolName: "shell", }, ], }, @@ -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 @@ -561,6 +572,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/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index abada013df..f1e89444b0 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -296,7 +296,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a2"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { cmd: "ls" }, @@ -332,7 +332,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" } }, @@ -345,7 +345,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "content", value: [ @@ -471,7 +471,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a2"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { cmd: "ls" }, @@ -498,7 +498,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, }, @@ -510,7 +510,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "text", value: "ok" }, }, ], @@ -540,7 +540,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "completed", input: { cmd: "ls" }, @@ -565,7 +565,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, }, @@ -577,7 +577,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]" }, }, ], @@ -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 }, }, @@ -632,7 +632,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, }, @@ -644,7 +644,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "text", value: "abcd\n[Tool output truncated for compaction: omitted 6 chars]", @@ -677,7 +677,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", - tool: "bash", + tool: "shell", state: { status: "error", input: { cmd: "ls" }, @@ -702,7 +702,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" } }, @@ -715,7 +715,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" } }, }, @@ -732,9 +732,9 @@ describe("session.message-v2.toModelMessage", () => { "12179", "4575", "", - "", + "", "User aborted the command", - "", + "", ].join("\n") const input: MessageV2.WithParts[] = [ @@ -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" }, @@ -779,7 +779,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" }, providerExecuted: undefined, }, @@ -791,7 +791,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "bash", + toolName: "shell", output: { type: "text", value: output }, }, ], @@ -950,7 +950,7 @@ describe("session.message-v2.toModelMessage", () => { ...basePart(assistantID, "a1"), type: "tool", callID: "call-pending", - tool: "bash", + tool: "shell", state: { status: "pending", input: { cmd: "ls" }, @@ -985,7 +985,7 @@ describe("session.message-v2.toModelMessage", () => { { type: "tool-call", toolCallId: "call-pending", - toolName: "bash", + toolName: "shell", input: { cmd: "ls" }, providerExecuted: undefined, }, @@ -1004,7 +1004,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 74ce913077..d528fbb7e0 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -650,7 +650,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 f28fb94c0b..7f0889a481 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -73,7 +73,7 @@ const tool = Effect.fn("test.tool")(function* (sessionID: SessionID, messageID: messageID, sessionID, type: "tool" as const, - tool: "bash", + tool: "shell", callID: "call-1", state: { status: "completed" as const, 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/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index c7e3522621..80b83a03b3 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -23,6 +23,7 @@ import { SessionRevert } from "../../src/session/revert" import { SessionSummary } from "../../src/session/summary" import { MessageV2 } from "../../src/session/message-v2" import { Log } from "../../src/util" +import { ShellToolID } from "../../src/tool/shell/id" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestLLMServer } from "../lib/llm-server" @@ -198,13 +199,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 = ShellToolID.id + + // 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({ @@ -232,7 +235,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 @@ -246,4 +249,5 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => }), { git: true, config: providerCfg }, ), + 20_000, ) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index eb3fe6cce4..3f95271996 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -16,7 +16,7 @@ exports[`tool parameters JSON Schema (wire shape) apply_patch 1`] = ` } `; -exports[`tool parameters JSON Schema (wire shape) bash 1`] = ` +exports[`tool parameters JSON Schema (wire shape) shell 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 8ea008a457..5d4a448469 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -10,7 +10,6 @@ import { toJsonSchema } from "../../src/util/effect-zod" // byte-identical regardless of whether a tool has migrated from zod to Schema. import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" -import { Parameters as Bash } from "../../src/tool/bash" import { Parameters as CodeSearch } from "../../src/tool/codesearch" import { Parameters as Edit } from "../../src/tool/edit" import { Parameters as Glob } from "../../src/tool/glob" @@ -20,6 +19,7 @@ import { Parameters as Lsp } from "../../src/tool/lsp" import { Parameters as Plan } from "../../src/tool/plan" import { Parameters as Question } from "../../src/tool/question" import { Parameters as Read } from "../../src/tool/read" +import { Parameters as Shell } from "../../src/tool/shell" import { Parameters as Skill } from "../../src/tool/skill" import { Parameters as Task } from "../../src/tool/task" import { Parameters as Todo } from "../../src/tool/todo" @@ -36,7 +36,7 @@ const accepts = (schema: Schema.Decoder, input: unknown): boolean => describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) - test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot()) + test("shell", () => expect(toJsonSchema(Shell)).toMatchSnapshot()) test("codesearch", () => expect(toJsonSchema(CodeSearch)).toMatchSnapshot()) test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot()) test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot()) @@ -68,20 +68,20 @@ describe("tool parameters", () => { }) }) - describe("bash", () => { + describe("shell", () => { test("accepts minimum: command + description", () => { - expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) }) test("accepts optional timeout + workdir", () => { - const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) - test("rejects missing description (required by zod)", () => { - expect(accepts(Bash, { command: "ls" })).toBe(false) + test("rejects missing description", () => { + expect(accepts(Shell, { command: "ls" })).toBe(false) }) test("rejects missing command", () => { - expect(accepts(Bash, { description: "list" })).toBe(false) + expect(accepts(Shell, { description: "list" })).toBe(false) }) }) 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 fd35c9aeba..c2ee53ad43 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -3,7 +3,8 @@ import { Effect, Layer, ManagedRuntime } from "effect" import os from "os" import path from "path" import { Shell } from "../../src/shell/shell" -import { BashTool } from "../../src/tool/bash" +import { ShellToolID } from "../../src/tool/shell/id" +import { ShellTool } from "../../src/tool/shell" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" @@ -26,9 +27,11 @@ const runtime = ManagedRuntime.make( ) function initBash() { - return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init()))) + return runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init()))) } +const initShell = initBash + const ctx = { sessionID: SessionID.make("ses_test"), messageID: MessageID.make(""), @@ -133,12 +136,14 @@ const mustTruncate = (result: { ) } -describe("tool.bash", () => { +const expectedPermission = ShellToolID.id + +describe("tool.shell", () => { each("basic", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -155,13 +160,13 @@ describe("tool.bash", () => { }) }) -describe("tool.bash permissions", () => { - each("asks for bash permission with correct pattern", async () => { +describe("tool.shell permissions", () => { + each("asks for shell permission with correct pattern", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -173,18 +178,18 @@ describe("tool.bash permissions", () => { ), ) expect(requests.length).toBe(1) - expect(requests[0].permission).toBe("bash") + expect(requests[0].permission).toBe(expectedPermission) expect(requests[0].patterns).toContain("echo hello") }, }) }) - 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, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -196,7 +201,7 @@ describe("tool.bash permissions", () => { ), ) 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") }, @@ -210,7 +215,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -221,7 +226,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") @@ -232,11 +237,43 @@ describe("tool.bash 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, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" @@ -272,7 +309,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const requests: Array> = [] await Effect.runPromise( @@ -285,7 +322,7 @@ describe("tool.bash permissions", () => { ), ) 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() @@ -305,7 +342,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -335,7 +372,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` await Effect.runPromise( @@ -348,7 +385,7 @@ describe("tool.bash permissions", () => { ), ) 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() @@ -367,7 +404,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -397,7 +434,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -492,7 +529,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") @@ -632,7 +669,7 @@ describe("tool.bash permissions", () => { ), ) 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!, "*")), @@ -651,7 +688,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -662,7 +699,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 *") @@ -911,12 +948,12 @@ describe("tool.bash 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, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -927,7 +964,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() }, }) @@ -938,7 +975,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -949,7 +986,7 @@ describe("tool.bash permissions", () => { ), ), ).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") }, @@ -964,7 +1001,7 @@ describe("tool.bash permissions", () => { const bash = await initBash() const requests: Array> = [] await Effect.runPromise(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 *") }, @@ -972,12 +1009,12 @@ describe("tool.bash permissions", () => { }) }) -describe("tool.bash abort", () => { +describe("tool.shell abort", () => { test("preserves output when aborted", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const controller = new AbortController() const collected: string[] = [] const res = await Effect.runPromise( @@ -1011,7 +1048,7 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1023,7 +1060,7 @@ describe("tool.bash abort", () => { ), ) expect(result.output).toContain("started") - expect(result.output).toContain("bash tool terminated command after exceeding timeout") + expect(result.output).toContain("shell tool terminated command after exceeding timeout") expect(result.output).toContain("retry with a larger timeout value in milliseconds") }, }) @@ -1033,7 +1070,7 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1054,7 +1091,7 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1099,12 +1136,12 @@ describe("tool.bash abort", () => { }) }) -describe("tool.bash truncation", () => { +describe("tool.shell truncation", () => { test("truncates output exceeding line limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const lineCount = Truncate.MAX_LINES + 500 const result = await Effect.runPromise( bash.execute( @@ -1126,7 +1163,7 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const byteCount = Truncate.MAX_BYTES + 10000 const result = await Effect.runPromise( bash.execute( @@ -1148,7 +1185,7 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1168,7 +1205,7 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const lineCount = Truncate.MAX_LINES + 100 const result = await Effect.runPromise( bash.execute( diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index b94dd52086..06f28bde25 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -351,7 +351,7 @@ describe("tool.task", () => { action: "deny", }, { - permission: "bash", + permission: "shell", pattern: "*", action: "allow", }, @@ -363,7 +363,7 @@ describe("tool.task", () => { ]) expect(seen?.tools).toEqual({ todowrite: false, - bash: false, + shell: false, read: false, }) }), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0ad88bb50c..6ee58215e9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1214,7 +1214,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/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 0132722050..5a92b00c51 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -271,6 +271,8 @@ export type ToolInfo = { subtitle?: string } +const SHELL = new Set(["shell", "bash"]) + function agentTitle(i18n: UiI18n, type?: string) { if (!type) return i18n.t("ui.tool.agent.default") return i18n.t("ui.tool.agent", { type }) @@ -319,6 +321,14 @@ function taskAgent( 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 { @@ -373,12 +383,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { subtitle: input.description, } } - case "bash": - return { - icon: "console", - title: i18n.t("ui.tool.shell"), - subtitle: input.description, - } case "edit": return { icon: "code-lines", @@ -582,7 +586,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 } @@ -1254,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 } @@ -1818,7 +1823,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 72f5730612..2c7c68fe61 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -314,8 +314,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.13\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", @@ -1333,7 +1333,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), ]) } @@ -1356,7 +1356,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 9983e2fe79..7d22c9ff74 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -35,6 +35,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { 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", } diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index 34cfe3f42f..543a5b885d 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(["shell", "bash"]) 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} /> - +