diff options
| author | Luke Parker <[email protected]> | 2026-04-27 10:54:55 +1000 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-27 00:54:55 +0000 |
| commit | 141f33d24bdc059aa26bd1e32c9416ac3aed36e1 (patch) | |
| tree | ffd2f8b70439cdc134cb3b7e70c7fbf7130f1b81 | |
| parent | c4d8a8183e6c2d15831767f1b898a8d0ed0297b9 (diff) | |
| download | opencode-141f33d24bdc059aa26bd1e32c9416ac3aed36e1.tar.gz opencode-141f33d24bdc059aa26bd1e32c9416ac3aed36e1.zip | |
feat: configurable shell selection + desktop settings UI (#20602)
| -rw-r--r-- | packages/app/src/components/settings-general.tsx | 189 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync/bootstrap.ts | 7 | ||||
| -rw-r--r-- | packages/app/src/i18n/en.ts | 5 | ||||
| -rw-r--r-- | packages/opencode/src/config/config.ts | 20 | ||||
| -rw-r--r-- | packages/opencode/src/pty/index.ts | 21 | ||||
| -rw-r--r-- | packages/opencode/src/server/routes/instance/pty.ts | 27 | ||||
| -rw-r--r-- | packages/opencode/src/session/prompt.ts | 53 | ||||
| -rw-r--r-- | packages/opencode/src/shell/shell.ts | 145 | ||||
| -rw-r--r-- | packages/opencode/src/tool/bash.ts | 17 | ||||
| -rw-r--r-- | packages/opencode/test/config/config.test.ts | 102 | ||||
| -rw-r--r-- | packages/opencode/test/pty/pty-shell.test.ts | 35 | ||||
| -rw-r--r-- | packages/opencode/test/session/prompt.test.ts | 67 | ||||
| -rw-r--r-- | packages/opencode/test/shell/shell.test.ts | 28 | ||||
| -rw-r--r-- | packages/opencode/test/tool/bash.test.ts | 29 | ||||
| -rw-r--r-- | packages/sdk/js/src/v2/gen/sdk.gen.ts | 31 | ||||
| -rw-r--r-- | packages/sdk/js/src/v2/gen/types.gen.ts | 27 | ||||
| -rw-r--r-- | packages/sdk/openapi.json | 60 | ||||
| -rw-r--r-- | packages/web/src/content/docs/config.mdx | 15 |
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. |
