Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,7 @@ 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("Shell to use for command execution"),
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
80 changes: 80 additions & 0 deletions packages/opencode/test/shell/shell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { expect, test, mock } from "bun:test"
import { Shell } from "../../src/shell/shell"
import { Config } from "../../src/config/config"
import { Plugin } from "../../src/plugin"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import path from "path"

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"
}
})

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.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
}
})
1 change: 1 addition & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ 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>
"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
/**
* Shell to use for command execution
*/
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": "Shell to use for command execution",
"type": "string"
},
"keybinds": {
"$ref": "#/components/schemas/KeybindsConfig"
},
Expand Down
Loading