summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLuke Parker <[email protected]>2026-04-27 10:54:55 +1000
committerGitHub <[email protected]>2026-04-27 00:54:55 +0000
commit141f33d24bdc059aa26bd1e32c9416ac3aed36e1 (patch)
treeffd2f8b70439cdc134cb3b7e70c7fbf7130f1b81
parentc4d8a8183e6c2d15831767f1b898a8d0ed0297b9 (diff)
downloadopencode-141f33d24bdc059aa26bd1e32c9416ac3aed36e1.tar.gz
opencode-141f33d24bdc059aa26bd1e32c9416ac3aed36e1.zip
feat: configurable shell selection + desktop settings UI (#20602)
-rw-r--r--packages/app/src/components/settings-general.tsx189
-rw-r--r--packages/app/src/context/global-sync/bootstrap.ts7
-rw-r--r--packages/app/src/i18n/en.ts5
-rw-r--r--packages/opencode/src/config/config.ts20
-rw-r--r--packages/opencode/src/pty/index.ts21
-rw-r--r--packages/opencode/src/server/routes/instance/pty.ts27
-rw-r--r--packages/opencode/src/session/prompt.ts53
-rw-r--r--packages/opencode/src/shell/shell.ts145
-rw-r--r--packages/opencode/src/tool/bash.ts17
-rw-r--r--packages/opencode/test/config/config.test.ts102
-rw-r--r--packages/opencode/test/pty/pty-shell.test.ts35
-rw-r--r--packages/opencode/test/session/prompt.test.ts67
-rw-r--r--packages/opencode/test/shell/shell.test.ts28
-rw-r--r--packages/opencode/test/tool/bash.test.ts29
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts31
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts27
-rw-r--r--packages/sdk/openapi.json60
-rw-r--r--packages/web/src/content/docs/config.mdx15
18 files changed, 721 insertions, 157 deletions
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx
index f38442379..8060ae94d 100644
--- a/packages/app/src/components/settings-general.tsx
+++ b/packages/app/src/components/settings-general.tsx
@@ -11,7 +11,9 @@ import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
-import { usePlatform } from "@/context/platform"
+import { usePlatform, type DisplayBackend } from "@/context/platform"
+import { useGlobalSync } from "@/context/global-sync"
+import { useGlobalSDK } from "@/context/global-sdk"
import {
monoDefault,
monoFontFamily,
@@ -40,6 +42,18 @@ type ThemeOption = {
name: string
}
+type ShellOption = {
+ path: string
+ name: string
+ acceptable: boolean
+}
+
+type ShellSelectOption = {
+ id: string
+ value: string
+ label: string
+}
+
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
@@ -75,10 +89,6 @@ export const SettingsGeneral: Component = () => {
const params = useParams()
const settings = useSettings()
- onMount(() => {
- void theme.loadThemes()
- })
-
const [store, setStore] = createStore({
checking: false,
})
@@ -165,6 +175,70 @@ export const SettingsGeneral: Component = () => {
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
+ const globalSync = useGlobalSync()
+ const globalSdk = useGlobalSDK()
+
+ const [shells] = createResource(
+ () =>
+ globalSdk.client.pty
+ .shells()
+ .then((res) => res.data ?? [])
+ .catch(() => [] as ShellOption[]),
+ { initialValue: [] as ShellOption[] },
+ )
+
+ const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
+ () => (linux() && platform.getDisplayBackend ? true : false),
+ () => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
+ { initialValue: null as DisplayBackend | null },
+ )
+
+ onMount(() => {
+ void theme.loadThemes()
+ })
+
+ const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
+ const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
+
+ const shellOptions = createMemo<ShellSelectOption[]>(() => {
+ const list = shells.latest
+ const current = globalSync.data.config.shell
+
+ const nameCounts = new Map<string, number>()
+ for (const s of list) {
+ nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
+ }
+
+ const options = [
+ autoOption,
+ ...list.map((s) => {
+ const ambiguousName = (nameCounts.get(s.name) || 0) > 1
+ const text = ambiguousName ? s.path : s.name
+ const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
+ return {
+ id: s.path,
+ // Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
+ value: ambiguousName ? s.path : s.name,
+ label,
+ }
+ }),
+ ]
+
+ if (current && !options.some((o) => o.value === current)) {
+ options.push({ id: current, value: current, label: current })
+ }
+
+ return options
+ })
+
+ const onDisplayBackendChange = (checked: boolean) => {
+ const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
+ if (!update) return
+ void update.finally(() => {
+ void refetchDisplayBackend()
+ })
+ }
+
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
@@ -244,6 +318,27 @@ export const SettingsGeneral: Component = () => {
</SettingsRow>
<SettingsRow
+ title={language.t("settings.general.row.shell.title")}
+ description={language.t("settings.general.row.shell.description")}
+ >
+ <Select
+ data-action="settings-shell"
+ options={shellOptions()}
+ current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
+ value={(o) => o.id}
+ label={(o) => o.label}
+ onSelect={(option) => {
+ if (!option) return
+ globalSync.updateConfig({ shell: option.value })
+ }}
+ variant="secondary"
+ size="small"
+ triggerVariant="settings"
+ triggerStyle={{ "min-width": "180px" }}
+ />
+ </SettingsRow>
+
+ <SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
@@ -651,70 +746,32 @@ export const SettingsGeneral: Component = () => {
<SoundsSection />
- {/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
- {(_) => {
- const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
- const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
-
- return (
- <div class="flex flex-col gap-1">
- <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
-
- <SettingsList>
- <SettingsRow
- title={language.t("settings.desktop.wsl.title")}
- description={language.t("settings.desktop.wsl.description")}
- >
- <div data-action="settings-wsl">
- <Switch
- checked={enabled() ?? false}
- disabled={enabledResource.state === "pending"}
- onChange={(checked) => platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
- />
- </div>
- </SettingsRow>
- </SettingsList>
- </div>
- )
- }}
- </Show>*/}
-
<UpdatesSection />
<Show when={linux()}>
- {(_) => {
- const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
- const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
-
- const onChange = (checked: boolean) =>
- platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
-
- return (
- <div class="flex flex-col gap-1">
- <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
-
- <SettingsList>
- <SettingsRow
- title={
- <div class="flex items-center gap-2">
- <span>{language.t("settings.general.row.wayland.title")}</span>
- <Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
- <span class="text-text-weak">
- <Icon name="help" size="small" />
- </span>
- </Tooltip>
- </div>
- }
- description={language.t("settings.general.row.wayland.description")}
- >
- <div data-action="settings-wayland">
- <Switch checked={value() === "wayland"} onChange={onChange} />
- </div>
- </SettingsRow>
- </SettingsList>
- </div>
- )
- }}
+ <div class="flex flex-col gap-1">
+ <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
+
+ <SettingsList>
+ <SettingsRow
+ title={
+ <div class="flex items-center gap-2">
+ <span>{language.t("settings.general.row.wayland.title")}</span>
+ <Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
+ <span class="text-text-weak">
+ <Icon name="help" size="small" />
+ </span>
+ </Tooltip>
+ </div>
+ }
+ description={language.t("settings.general.row.wayland.description")}
+ >
+ <div data-action="settings-wayland">
+ <Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
+ </div>
+ </SettingsRow>
+ </SettingsList>
+ </div>
</Show>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index 66f4a3b15..a83030fad 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -78,7 +78,7 @@ export async function bootstrapGlobal(input: {
() =>
retry(() =>
input.globalSDK.global.config.get().then((x) => {
- input.setGlobalStore("config", x.data!)
+ input.setGlobalStore("config", reconcile(x.data!, { merge: false }))
}),
),
]
@@ -245,7 +245,7 @@ export async function bootstrapDirectory(input: {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
- input.setStore("config", input.global.config)
+ input.setStore("config", reconcile(input.global.config, { merge: false }))
}
if (loading || input.store.provider.all.length === 0) {
input.setStore("provider_ready", false)
@@ -265,7 +265,8 @@ export async function bootstrapDirectory(input: {
input.queryClient.ensureQueryData(
loadAgentsQuery(input.directory, input.sdk, (x) => input.setStore("agent", normalizeAgentList(x.data))),
),
- () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
+ () =>
+ retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
!seededProject &&
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 7326f7c8b..eae5aeb94 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -728,6 +728,11 @@ export const dict = {
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
+ "settings.general.row.shell.title": "Terminal Shell",
+ "settings.general.row.shell.description":
+ "Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
+ "settings.general.row.shell.autoDefault": "Auto (Default)",
+ "settings.general.row.shell.terminalOnly": "terminal only",
"settings.general.row.appearance.title": "Appearance",
"settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
"settings.general.row.colorScheme.title": "Color scheme",
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index eee835fce..1b6b58500 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -98,6 +98,9 @@ export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
+ shell: Schema.optional(Schema.String).annotate({
+ description: "Default shell to use for terminal and bash tool",
+ }),
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
server: Schema.optional(ConfigServer.Server).annotate({
description: "Server configuration for opencode serve and web commands",
@@ -310,10 +313,7 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string
return applyEdits(input, edits)
}
- return Object.entries(patch).reduce((result, [key, value]) => {
- if (value === undefined) return result
- return patchJsonc(result, value, [...path, key])
- }, input)
+ return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input)
}
function writable(info: Info) {
@@ -321,6 +321,13 @@ function writable(info: Info) {
return next
}
+function writableGlobal(info: Info) {
+ const next = writable(info)
+ // When a user changes config from a value back to default in the Desktop app, we don't want to leave a blank `"shell": "",` key
+ if ("shell" in next && next.shell === "") return { ...next, shell: undefined }
+ return next
+}
+
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
z.object({
@@ -749,15 +756,16 @@ export const layer = Layer.effect(
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
+ const patch = writableGlobal(config)
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
- const merged = mergeDeep(writable(existing), writable(config))
+ const merged = mergeDeep(writable(existing), patch)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
- const updated = patchJsonc(before, writable(config))
+ const updated = patchJsonc(before, patch)
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts
index 918f4f86c..5dcb9e7ac 100644
--- a/packages/opencode/src/pty/index.ts
+++ b/packages/opencode/src/pty/index.ts
@@ -1,17 +1,17 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
-import { InstanceState } from "@/effect"
+import { Config } from "@/config"
+import { InstanceState, EffectBridge } from "@/effect"
+import { lazy } from "@opencode-ai/core/util/lazy"
+import { Plugin } from "@/plugin"
import { Instance } from "@/project/instance"
+import { Shell } from "@/shell/shell"
import type { Proc } from "#pty"
import { Log } from "../util"
-import { lazy } from "@opencode-ai/core/util/lazy"
-import { Shell } from "@/shell/shell"
-import { Plugin } from "@/plugin"
import { PtyID } from "./schema"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
-import { EffectBridge } from "@/effect"
const log = Log.create({ service: "pty" })
@@ -117,8 +117,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pt
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
+ const config = yield* Config.Service
const bus = yield* Bus.Service
const plugin = yield* Plugin.Service
+
function teardown(session: Active) {
try {
session.process.kill()
@@ -174,8 +176,9 @@ export const layer = Layer.effect(
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const s = yield* InstanceState.get(state)
const bridge = yield* EffectBridge.make()
+ const cfg = yield* config.get()
const id = PtyID.ascending()
- const command = input.command || Shell.preferred()
+ const command = input.command || Shell.preferred(cfg.shell)
const args = input.args || []
if (Shell.login(command)) {
args.push("-l")
@@ -360,6 +363,10 @@ export const layer = Layer.effect(
}),
)
-export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
+export const defaultLayer = layer.pipe(
+ Layer.provide(Bus.layer),
+ Layer.provide(Plugin.defaultLayer),
+ Layer.provide(Config.defaultLayer),
+)
export * as Pty from "."
diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts
index 581537221..2ccdb1be1 100644
--- a/packages/opencode/src/server/routes/instance/pty.ts
+++ b/packages/opencode/src/server/routes/instance/pty.ts
@@ -6,15 +6,42 @@ import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
+import { Shell } from "@/shell/shell"
import { NotFoundError } from "@/storage"
import { errors } from "../../error"
import { jsonRequest, runRequest } from "./trace"
+const ShellItem = z.object({
+ path: z.string(),
+ name: z.string(),
+ acceptable: z.boolean(),
+})
const decodePtyID = Schema.decodeUnknownSync(PtyID)
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return new Hono()
.get(
+ "/shells",
+ describeRoute({
+ summary: "List available shells",
+ description: "Get a list of available shells on the system.",
+ operationId: "pty.shells",
+ responses: {
+ 200: {
+ description: "List of shells",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(ShellItem)),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ return c.json(await Shell.list())
+ },
+ )
+ .get(
"/",
describeRoute({
summary: "List PTY sessions",
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 600eb42f7..861f7285c 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -31,7 +31,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import * as Stream from "effect/Stream"
import { Command } from "../command"
import { pathToFileURL, fileURLToPath } from "url"
-import { ConfigMarkdown } from "../config"
+import { Config, ConfigMarkdown } from "../config"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/core/util/error"
import { SessionProcessor } from "./processor"
@@ -92,6 +92,7 @@ export const layer = Layer.effect(
const compaction = yield* SessionCompaction.Service
const plugin = yield* Plugin.Service
const commands = yield* Command.Service
+ const config = yield* Config.Service
const permission = yield* Permission.Service
const fsys = yield* AppFileSystem.Service
const mcp = yield* MCP.Service
@@ -783,49 +784,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
yield* sessions.updatePart(part)
- const sh = Shell.preferred()
- const shellName = (
- process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
- ).toLowerCase()
+ const cfg = yield* config.get()
+ const sh = Shell.preferred(cfg.shell)
const cwd = ctx.directory
- const invocations: Record<string, { args: string[] }> = {
- nu: { args: ["-c", input.command] },
- fish: { args: ["-c", input.command] },
- zsh: {
- args: [
- "-l",
- "-c",
- `
- [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
- [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
- cd -- "$1"
- eval ${JSON.stringify(input.command)}
- `,
- "opencode",
- cwd,
- ],
- },
- bash: {
- args: [
- "-l",
- "-c",
- `
- shopt -s expand_aliases
- [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
- cd -- "$1"
- eval ${JSON.stringify(input.command)}
- `,
- "opencode",
- cwd,
- ],
- },
- cmd: { args: ["/c", input.command] },
- powershell: { args: ["-NoProfile", "-Command", input.command] },
- pwsh: { args: ["-NoProfile", "-Command", input.command] },
- "": { args: ["-c", input.command] },
- }
-
- const args = (invocations[shellName] ?? invocations[""]).args
+ const args = Shell.args(sh, input.command, cwd)
const shellEnv = yield* plugin.trigger(
"shell.env",
{ cwd, sessionID: input.sessionID, callID: part.callID },
@@ -842,7 +804,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
let output = ""
let aborted = false
-
const finish = Effect.uninterruptible(
Effect.gen(function* () {
if (aborted) {
@@ -1588,7 +1549,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) {
- const sh = Shell.preferred()
+ const cfg = yield* config.get()
+ const sh = Shell.preferred(cfg.shell)
const results = yield* Effect.promise(() =>
Promise.all(
shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
@@ -1689,6 +1651,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Truncate.defaultLayer),
Layer.provide(Provider.defaultLayer),
+ Layer.provide(Config.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Plugin.defaultLayer),
diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts
index 1c8996194..60af58059 100644
--- a/packages/opencode/src/shell/shell.ts
+++ b/packages/opencode/src/shell/shell.ts
@@ -7,10 +7,23 @@ import { spawn, type ChildProcess } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"
const SIGKILL_TIMEOUT_MS = 200
+const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
+ bash: { login: true, posix: true },
+ dash: { login: true, posix: true },
+ fish: { deny: true, login: true },
+ ksh: { login: true, posix: true },
+ nu: { deny: true },
+ powershell: { ps: true },
+ pwsh: { ps: true },
+ sh: { login: true, posix: true },
+ zsh: { login: true, posix: true },
+}
-const BLACKLIST = new Set(["fish", "nu"])
-const LOGIN = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"])
-const POSIX = new Set(["bash", "dash", "ksh", "sh", "zsh"])
+export type Item = {
+ path: string
+ name: string
+ acceptable: boolean
+}
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
@@ -50,22 +63,53 @@ function full(file: string) {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
return shell
}
+ if (name(shell) === "bash") return gitbash() || which(shell) || shell
return which(shell) || shell
}
-function pick() {
- const pwsh = which("pwsh.exe")
- if (pwsh) return pwsh
- const powershell = which("powershell.exe")
- if (powershell) return powershell
+function meta(file: string) {
+ return META[name(file)]
+}
+
+function ok(file: string) {
+ return meta(file)?.deny !== true
+}
+
+function rooted(file: string) {
+ return path.isAbsolute(Filesystem.windowsPath(file))
+}
+
+function resolve(file: string) {
+ const shell = full(file)
+ if (rooted(shell)) {
+ if (Filesystem.stat(shell)?.isFile()) return shell
+ return
+ }
+ return which(shell) ?? undefined
+}
+
+function win() {
+ return Array.from(
+ new Set(
+ [which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
+ .filter((item): item is string => Boolean(item))
+ .map(full),
+ ),
+ )
+}
+
+async function unix() {
+ const text = await Filesystem.readText("/etc/shells").catch(() => "")
+ if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
+ return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
- if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
- if (process.platform === "win32") {
- const shell = pick()
+ if (file && (!opts?.acceptable || ok(file))) {
+ const shell = resolve(file)
if (shell) return shell
}
+ if (process.platform === "win32") return win()[0]!
return fallback()
}
@@ -79,11 +123,6 @@ export function gitbash() {
}
function fallback() {
- if (process.platform === "win32") {
- const file = gitbash()
- if (file) return file
- return process.env.COMSPEC || "cmd.exe"
- }
if (process.platform === "darwin") return "/bin/zsh"
const bash = which("bash")
if (bash) return bash
@@ -96,15 +135,81 @@ export function name(file: string) {
}
export function login(file: string) {
- return LOGIN.has(name(file))
+ return meta(file)?.login === true
}
export function posix(file: string) {
- return POSIX.has(name(file))
+ return meta(file)?.posix === true
+}
+
+export function ps(file: string) {
+ return meta(file)?.ps === true
}
-export const preferred = lazy(() => select(process.env.SHELL))
+function info(file: string): Item {
+ const item = full(file)
+ const n = name(item)
+ return {
+ path: item,
+ name: resolve(n) ? n : item,
+ acceptable: ok(item),
+ }
+}
-export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
+export function args(file: string, command: string, cwd: string) {
+ const n = name(file)
+ if (n === "nu" || n === "fish") return ["-c", command]
+ if (n === "zsh") {
+ return [
+ "-l",
+ "-c",
+ `
+ [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
+ [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
+ cd -- "$1"
+ eval ${JSON.stringify(command)}
+ `,
+ "opencode",
+ cwd,
+ ]
+ }
+ if (n === "bash") {
+ return [
+ "-l",
+ "-c",
+ `
+ shopt -s expand_aliases
+ [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
+ cd -- "$1"
+ eval ${JSON.stringify(command)}
+ `,
+ "opencode",
+ cwd,
+ ]
+ }
+ if (n === "cmd") return ["/c", command]
+ if (ps(file)) return ["-NoProfile", "-Command", command]
+ return ["-c", command]
+}
+
+const defaultPreferred = lazy(() => select(process.env.SHELL))
+const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
+
+export function preferred(configShell?: string) {
+ if (configShell) return select(configShell)
+ return defaultPreferred()
+}
+preferred.reset = () => defaultPreferred.reset()
+
+export function acceptable(configShell?: string) {
+ if (configShell) return select(configShell, { acceptable: true })
+ return defaultAcceptable()
+}
+acceptable.reset = () => defaultAcceptable.reset()
+
+export async function list(): Promise<Item[]> {
+ const shells = process.platform === "win32" ? win() : await unix()
+ return shells.filter((s) => resolve(s)).map(info)
+}
export * as Shell from "./shell"
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index a27d7c5ec..2b72e58b9 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -11,6 +11,7 @@ import { Language, type Node } from "web-tree-sitter"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { fileURLToPath } from "url"
+import { Config } from "@/config"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Shell } from "@/shell/shell"
@@ -24,7 +25,6 @@ import { InstanceState } from "@/effect"
const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
-const PS = new Set(["powershell", "pwsh"])
const CWD = new Set(["cd", "push-location", "set-location"])
const FILES = new Set([
...CWD,
@@ -278,8 +278,8 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan)
})
})
-function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
- if (process.platform === "win32" && PS.has(name)) {
+function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
+ if (process.platform === "win32" && Shell.ps(shell)) {
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
cwd,
env,
@@ -296,7 +296,6 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
detached: process.platform !== "win32",
})
}
-
const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
@@ -328,6 +327,7 @@ const parser = lazy(async () => {
export const BashTool = Tool.define(
"bash",
Effect.gen(function* () {
+ const config = yield* Config.Service
const spawner = yield* ChildProcessSpawner
const fs = yield* AppFileSystem.Service
const trunc = yield* Truncate.Service
@@ -408,7 +408,6 @@ export const BashTool = Tool.define(
const run = Effect.fn("BashTool.run")(function* (
input: {
shell: string
- name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
@@ -438,7 +437,7 @@ export const BashTool = Tool.define(
const code: number | null = yield* Effect.scoped(
Effect.gen(function* () {
- const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
+ const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
@@ -567,7 +566,8 @@ export const BashTool = Tool.define(
return () =>
Effect.gen(function* () {
- const shell = Shell.acceptable()
+ const cfg = yield* config.get()
+ const shell = Shell.acceptable(cfg.shell)
const name = Shell.name(shell)
const chain =
name === "powershell"
@@ -595,7 +595,7 @@ export const BashTool = Tool.define(
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? DEFAULT_TIMEOUT
- const ps = PS.has(name)
+ const ps = Shell.ps(shell)
const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
@@ -604,7 +604,6 @@ export const BashTool = Tool.define(
return yield* run(
{
shell,
- name,
command: params.command,
cwd,
env: yield* shellEnv(ctx, cwd),
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 324914e6d..aad9d7e7a 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -55,6 +55,8 @@ const it = testEffect(layer)
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
const save = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
+const saveGlobal = (config: Config.Info) =>
+ Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer)))
const clear = (wait = false) =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
const listDirs = () =>
@@ -142,6 +144,106 @@ test("loads JSON config file", async () => {
})
})
+test("loads shell config field", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await writeConfig(dir, {
+ $schema: "https://opencode.ai/config.json",
+ shell: "bash",
+ })
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.shell).toBe("bash")
+ },
+ })
+})
+
+test("updates config and preserves empty shell sentinel", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await writeConfig(
+ dir,
+ {
+ $schema: "https://opencode.ai/config.json",
+ shell: "bash",
+ },
+ "config.json",
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await save({ shell: "" })
+
+ const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json"))
+ expect(writtenConfig.shell).toBe("")
+ },
+ })
+})
+
+test("updates global config and omits empty shell key in json", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await writeConfig(dir, {
+ $schema: "https://opencode.ai/config.json",
+ shell: "bash",
+ })
+ },
+ })
+
+ const prev = Global.Path.config
+ ;(Global.Path as { config: string }).config = tmp.path
+ await clear(true)
+
+ try {
+ await saveGlobal({ shell: "" })
+
+ const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "opencode.json"))
+ expect("shell" in writtenConfig).toBe(false)
+ } finally {
+ ;(Global.Path as { config: string }).config = prev
+ await clear(true)
+ }
+})
+
+test("updates global config and omits empty shell key in jsonc", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Filesystem.write(
+ path.join(dir, "opencode.jsonc"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ shell: "bash",
+ model: "test/model",
+ }),
+ )
+ },
+ })
+
+ const prev = Global.Path.config
+ ;(Global.Path as { config: string }).config = tmp.path
+ await clear(true)
+
+ try {
+ await saveGlobal({ shell: "" })
+
+ const file = path.join(tmp.path, "opencode.jsonc")
+ const writtenConfig = await Filesystem.readText(file)
+ const parsed = ConfigParse.schema(Config.Info.zod, ConfigParse.jsonc(writtenConfig, file), file)
+ expect(writtenConfig).not.toContain('"shell"')
+ expect(parsed.shell).toBeUndefined()
+ expect(parsed.model).toBe("test/model")
+ } finally {
+ ;(Global.Path as { config: string }).config = prev
+ await clear(true)
+ }
+})
+
test("loads formatter boolean config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
diff --git a/packages/opencode/test/pty/pty-shell.test.ts b/packages/opencode/test/pty/pty-shell.test.ts
index d5182061d..7b8b4d67c 100644
--- a/packages/opencode/test/pty/pty-shell.test.ts
+++ b/packages/opencode/test/pty/pty-shell.test.ts
@@ -67,3 +67,38 @@ describe("pty shell args", () => {
)
}
})
+
+describe("pty configured shell", () => {
+ test(
+ "uses configured shell for default PTY command",
+ async () => {
+ const configured = process.platform === "win32" ? Bun.which("pwsh") || Bun.which("powershell") : Bun.which("bash")
+ if (!configured) return
+
+ await using dir = await tmpdir({
+ config: { shell: Shell.name(configured) },
+ })
+ await Instance.provide({
+ directory: dir.path,
+ fn: () =>
+ AppRuntime.runPromise(
+ Effect.gen(function* () {
+ const pty = yield* Pty.Service
+ const info = yield* pty.create({ title: "configured" })
+ try {
+ if (process.platform === "win32") {
+ expect(info.command.toLowerCase()).toBe(configured.toLowerCase())
+ } else {
+ expect(info.command).toBe(configured)
+ }
+ expect(info.args).toEqual(process.platform === "win32" ? [] : ["-l"])
+ } finally {
+ yield* pty.remove(info.id)
+ }
+ }),
+ ),
+ })
+ },
+ { timeout: 30000 },
+ )
+})
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 7e3377746..b592d06df 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -316,9 +316,11 @@ const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) =>
})
const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
+ const config = yield* Config.Service
const prompt = yield* SessionPrompt.Service
const run = yield* SessionRunState.Service
const sessions = yield* Session.Service
+ yield* config.get()
const chat = yield* sessions.create(input ?? { title: "Pinned" })
return { prompt, run, sessions, chat }
})
@@ -1078,6 +1080,32 @@ unix("shell completes a fast command on the preferred shell", () =>
),
)
+unix(
+ "shell uses configured shell over env shell",
+ () =>
+ withSh(() =>
+ provideTmpdirInstance(
+ (_dir) =>
+ Effect.gen(function* () {
+ if (!Bun.which("bash")) return
+
+ const { prompt, chat } = yield* boot()
+ const result = yield* prompt.shell({
+ sessionID: chat.id,
+ agent: "build",
+ command: "[[ 1 -eq 1 ]] && printf configured",
+ })
+
+ const tool = completedTool(result.parts)
+ if (!tool) return
+ expect(tool.state.output).toContain("configured")
+ }),
+ { git: true, config: { ...cfg, shell: "bash" } },
+ ),
+ ),
+ 30_000,
+)
+
unix("shell commands can change directory after startup", () =>
provideTmpdirInstance(
(dir) =>
@@ -1264,6 +1292,45 @@ it.live(
)
unix(
+ "command ! expansion uses configured shell over env shell",
+ () =>
+ withSh(() =>
+ provideTmpdirServer(
+ ({ llm }) =>
+ Effect.gen(function* () {
+ if (!Bun.which("bash")) return
+
+ const { prompt, chat } = yield* boot()
+ yield* llm.text("done")
+
+ const result = yield* prompt.command({
+ sessionID: chat.id,
+ command: "probe",
+ arguments: "",
+ })
+
+ expect(result.info.role).toBe("assistant")
+ const inputs = yield* llm.inputs
+ expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
+ }),
+ {
+ git: true,
+ config: (url) => ({
+ ...providerCfg(url),
+ shell: "bash",
+ command: {
+ probe: {
+ template: "Probe: !`[[ 1 -eq 1 ]] && printf configured`",
+ },
+ },
+ }),
+ },
+ ),
+ ),
+ 30_000,
+)
+
+unix(
"cancel interrupts shell and resolves cleanly",
() =>
withSh(() =>
diff --git a/packages/opencode/test/shell/shell.test.ts b/packages/opencode/test/shell/shell.test.ts
index 6d7a77d72..ed29a4cc8 100644
--- a/packages/opencode/test/shell/shell.test.ts
+++ b/packages/opencode/test/shell/shell.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import path from "path"
import { Shell } from "../../src/shell/shell"
import { Filesystem } from "../../src/util"
+import { which } from "../../src/util/which"
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
const prev = process.env.SHELL
@@ -39,6 +40,20 @@ describe("shell", () => {
expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false)
})
+ test("falls back when configured shell cannot be resolved", async () => {
+ await withShell(undefined, async () => {
+ const preferred = Shell.preferred()
+ const acceptable = Shell.acceptable()
+ expect(Shell.preferred("opencode-missing-shell")).toBe(preferred)
+ expect(Shell.acceptable("opencode-missing-shell")).toBe(acceptable)
+ })
+ })
+
+ test("falls back for terminal-only acceptable shells", () => {
+ expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish")
+ expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
+ })
+
if (process.platform === "win32") {
test("rejects blacklisted shells case-insensitively", async () => {
await withShell("NU.EXE", async () => {
@@ -62,8 +77,19 @@ describe("shell", () => {
})
})
+ test("resolves bare bash to Git Bash before PATH", async () => {
+ const bash = Shell.gitbash()
+ if (!bash) return
+ expect(Shell.acceptable("bash")).toBe(bash)
+ expect(Shell.preferred("bash")).toBe(bash)
+ await withShell("bash", async () => {
+ expect(Shell.acceptable()).toBe(bash)
+ expect(Shell.preferred()).toBe(bash)
+ })
+ })
+
test("resolves bare PowerShell shells", async () => {
- const shell = Bun.which("pwsh") || Bun.which("powershell")
+ const shell = which("pwsh") || which("powershell")
if (!shell) return
await withShell(path.win32.basename(shell), async () => {
expect(Shell.preferred()).toBe(shell)
diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts
index 32cd43100..dd5d50f55 100644
--- a/packages/opencode/test/tool/bash.test.ts
+++ b/packages/opencode/test/tool/bash.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import os from "os"
import path from "path"
+import { Config } from "../../src/config"
import { Shell } from "../../src/shell/shell"
import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
@@ -21,6 +22,7 @@ const runtime = ManagedRuntime.make(
AppFileSystem.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
+ Config.defaultLayer,
Agent.defaultLayer,
),
)
@@ -153,6 +155,33 @@ describe("tool.bash", () => {
},
})
})
+
+ test("falls back from terminal-only configured shell", async () => {
+ await using tmp = await tmpdir({
+ config: { shell: "fish" },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const bash = await initBash()
+ const fallback = Shell.name(Shell.acceptable("fish"))
+ expect(fallback).not.toBe("fish")
+ expect(bash.description).toContain(fallback)
+
+ const result = await Effect.runPromise(
+ bash.execute(
+ {
+ command: "echo fallback",
+ description: "Echo fallback text",
+ },
+ ctx,
+ ),
+ )
+ expect(result.metadata.exit).toBe(0)
+ expect(result.output).toContain("fallback")
+ },
+ })
+ })
})
describe("tool.bash permissions", () => {
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 6248eb8e4..e21d1f496 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -105,6 +105,7 @@ import type {
PtyListResponses,
PtyRemoveErrors,
PtyRemoveResponses,
+ PtyShellsResponses,
PtyUpdateErrors,
PtyUpdateResponses,
QuestionAnswer,
@@ -1081,6 +1082,36 @@ export class Project extends HeyApiClient {
export class Pty extends HeyApiClient {
/**
+ * List available shells
+ *
+ * Get a list of available shells on the system.
+ */
+ public shells<ThrowOnError extends boolean = false>(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get<PtyShellsResponses, unknown, ThrowOnError>({
+ url: "/pty/shells",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
* List PTY sessions
*
* Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 40e661b46..a723b3083 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1470,6 +1470,10 @@ export type Config = {
* JSON schema reference for configuration validation
*/
$schema?: string
+ /**
+ * Default shell to use for terminal and bash tool
+ */
+ shell?: string
logLevel?: LogLevel
server?: ServerConfig
/**
@@ -2694,6 +2698,29 @@ export type ProjectUpdateResponses = {
export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses]
+export type PtyShellsData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/pty/shells"
+}
+
+export type PtyShellsResponses = {
+ /**
+ * List of shells
+ */
+ 200: Array<{
+ path: string
+ name: string
+ acceptable: boolean
+ }>
+}
+
+export type PtyShellsResponse = PtyShellsResponses[keyof PtyShellsResponses]
+
export type PtyListData = {
body?: never
path?: never
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 9fb2a3e6d..118329858 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -1032,6 +1032,62 @@
]
}
},
+ "/pty/shells": {
+ "get": {
+ "operationId": "pty.shells",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "List available shells",
+ "description": "Get a list of available shells on the system.",
+ "responses": {
+ "200": {
+ "description": "List of shells",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "acceptable": {
+ "type": "boolean"
+ }
+ },
+ "required": ["path", "name", "acceptable"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.shells({\n ...\n})"
+ }
+ ]
+ }
+ },
"/pty": {
"get": {
"operationId": "pty.list",
@@ -11454,6 +11510,10 @@
"description": "JSON schema reference for configuration validation",
"type": "string"
},
+ "shell": {
+ "description": "Default shell to use for terminal and bash tool",
+ "type": "string"
+ },
"logLevel": {
"$ref": "#/components/schemas/LogLevel"
},
diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx
index 52ee1da0a..14eefdd81 100644
--- a/packages/web/src/content/docs/config.mdx
+++ b/packages/web/src/content/docs/config.mdx
@@ -312,6 +312,21 @@ Available options:
---
+### Shell
+
+You can configure the shell used for the interactive terminal using the `shell` option. Compatible shells are also used for agent tool calls.
+
+```json title="opencode.json"
+{
+ "$schema": "https://opencode.ai/config.json",
+ "shell": "pwsh"
+}
+```
+
+If not specified, OpenCode will automatically discover and use a sensible default based on your operating system (e.g. `pwsh` or `cmd.exe` on Windows, `/bin/zsh` or `/bin/bash` on macOS/Linux). You can provide an absolute path or a short name.
+
+---
+
### Tools
You can manage the tools an LLM can use through the `tools` option.