Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,12 @@ export namespace Config {
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
shell: z
.string()
.optional()
.describe(
'Absolute path to the shell binary to use for command execution. This value takes priority over the $SHELL environment variable and any shell resolved via plugin hooks. Example: "/bin/bash" or "/usr/bin/zsh"',
),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
logLevel: Log.Level.optional().describe("Log level"),
tui: TUI.optional().describe("TUI specific settings"),
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/pty/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export namespace Pty {

export async function create(input: CreateInput) {
const id = Identifier.create("pty", false)
const command = input.command || Shell.preferred()
const command = input.command || (await Shell.preferred())
const args = input.args || []
if (command.endsWith("sh")) {
args.push("-l")
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1430,7 +1430,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
},
}
await Session.updatePart(part)
const shell = Shell.preferred()
const shell = await Shell.preferred()
const shellName = (
process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)
).toLowerCase()
Expand Down
29 changes: 21 additions & 8 deletions packages/opencode/src/shell/shell.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Flag } from "@/flag/flag"
import { lazy } from "@/util/lazy"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { Config } from "@/config/config"
import { Plugin } from "@/plugin"

const SIGKILL_TIMEOUT_MS = 200

Expand Down Expand Up @@ -53,15 +54,27 @@ export namespace Shell {
return "/bin/sh"
}

export const preferred = lazy(() => {
const s = process.env.SHELL
if (s) return s
return fallback()
})
async function fromConfigOrPlugin() {
const config = await Config.get().catch(() => undefined)
if (config?.shell) return config.shell

const result = { shell: "" }
await Plugin.trigger("shell.resolve", { platform: process.platform }, result)
return result.shell || undefined
}

export async function preferred() {
const override = await fromConfigOrPlugin()
if (override) return override
return process.env.SHELL || fallback()
}

export async function acceptable() {
const override = await fromConfigOrPlugin()
if (override) return override

export const acceptable = lazy(() => {
const s = process.env.SHELL
if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s) : path.basename(s))) return s
return fallback()
})
}
}
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const parser = lazy(async () => {

// TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.define("bash", async () => {
const shell = Shell.acceptable()
const shell = await Shell.acceptable()
log.info("bash tool using shell", { shell })

return {
Expand Down
169 changes: 169 additions & 0 deletions packages/opencode/test/shell/shell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { expect, test, mock } from "bun:test"
import { Shell } from "../../src/shell/shell"
import { Plugin } from "../../src/plugin"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"

test("Shell.preferred resolves from config", async () => {
await using tmp = await tmpdir({
config: {
shell: "/custom/shell",
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const shell = await Shell.preferred()
expect(shell).toBe("/custom/shell")
},
})
})

test("Shell.acceptable resolves from config", async () => {
await using tmp = await tmpdir({
config: {
shell: "/custom/acceptable/shell",
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const shell = await Shell.acceptable()
expect(shell).toBe("/custom/acceptable/shell")
},
})
})

test("Shell.preferred resolves from plugin", async () => {
const originalTrigger = Plugin.trigger
Plugin.trigger = mock(async (name, input, output) => {
if (name === "shell.resolve") {
output.shell = "/plugin/resolved/shell"
}
return output
})

try {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const shell = await Shell.preferred()
expect(shell).toBe("/plugin/resolved/shell")
expect(Plugin.trigger).toHaveBeenCalledWith("shell.resolve", expect.anything(), expect.anything())
},
})
} finally {
Plugin.trigger = originalTrigger
}
})

test("Shell.acceptable resolves from plugin", async () => {
const originalTrigger = Plugin.trigger
Plugin.trigger = mock(async (name, input, output) => {
if (name === "shell.resolve") {
output.shell = "/plugin/resolved/shell"
}
return output
})

try {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const shell = await Shell.acceptable()
expect(shell).toBe("/plugin/resolved/shell")
},
})
} finally {
Plugin.trigger = originalTrigger
}
})

test("Shell.preferred falls back to environment/system", async () => {
const originalEnvShell = process.env.SHELL
process.env.SHELL = "/env/shell"

try {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const shell = await Shell.preferred()
expect(shell).toBe("/env/shell")
},
})
} finally {
process.env.SHELL = originalEnvShell
}
})

test("Shell.acceptable falls back when SHELL is blacklisted", async () => {
const originalEnvShell = process.env.SHELL
process.env.SHELL = "/usr/bin/fish"

try {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const shell = await Shell.acceptable()
// Should NOT return fish since it's blacklisted
expect(shell).not.toBe("/usr/bin/fish")
// Should return a fallback shell
expect(shell).toBeTruthy()
},
})
} finally {
process.env.SHELL = originalEnvShell
}
})

test("Config takes priority over plugin", async () => {
const originalTrigger = Plugin.trigger
Plugin.trigger = mock(async (name, input, output) => {
if (name === "shell.resolve") {
output.shell = "/plugin/shell"
}
return output
})

try {
await using tmp = await tmpdir({
config: {
shell: "/config/shell",
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const shell = await Shell.preferred()
// Config should win over plugin
expect(shell).toBe("/config/shell")
},
})
} finally {
Plugin.trigger = originalTrigger
}
})

test("Config can override blacklist for Shell.acceptable", async () => {
// fish is normally blacklisted, but config should allow explicit override
await using tmp = await tmpdir({
config: {
shell: "/usr/bin/fish",
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const shell = await Shell.acceptable()
// Config explicitly sets fish, so it should be allowed despite blacklist
expect(shell).toBe("/usr/bin/fish")
},
})
})
14 changes: 14 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,20 @@ export interface Hooks {
output: { temperature: number; topP: number; topK: number; options: Record<string, any> },
) => Promise<void>
"permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
/**
* Called when resolving which shell to use for command execution.
*
* This hook participates in the shell resolution priority chain and allows
* plugins to override the default shell selection based on the current
* platform or runtime environment.
*
* Example use cases:
* - Selecting a different shell when running inside a container or VM
* - Routing commands to a remote shell for specific platforms
* - Enforcing a particular shell (e.g., `bash`, `zsh`, `powershell`) for
* consistency across environments
*/
"shell.resolve"?: (input: { platform: string }, output: { shell: string }) => Promise<void>
"command.execute.before"?: (
input: { command: string; sessionID: string; arguments: string },
output: { parts: Part[] },
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1586,6 +1586,10 @@ export type Config = {
* Theme name to use for the interface
*/
theme?: string
/**
* Absolute path to the shell binary to use for command execution. This value takes priority over the $SHELL environment variable and any shell resolved via plugin hooks. Example: "/bin/bash" or "/usr/bin/zsh"
*/
shell?: string
keybinds?: KeybindsConfig
logLevel?: LogLevel
/**
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9313,6 +9313,10 @@
"description": "Theme name to use for the interface",
"type": "string"
},
"shell": {
"description": "Absolute path to the shell binary to use for command execution. This value takes priority over the $SHELL environment variable and any shell resolved via plugin hooks. Example: \"/bin/bash\" or \"/usr/bin/zsh\"",
"type": "string"
},
"keybinds": {
"$ref": "#/components/schemas/KeybindsConfig"
},
Expand Down
Loading