feat: configurable shell selection + desktop settings UI (#20602)

This commit is contained in:
Luke Parker
2026-04-27 10:54:55 +10:00
committed by GitHub
parent c4d8a8183e
commit 141f33d24b
18 changed files with 720 additions and 156 deletions

View File

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

View File

@@ -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 "."

View File

@@ -6,14 +6,41 @@ 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({

View File

@@ -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),

View File

@@ -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 const preferred = lazy(() => select(process.env.SHELL))
export function ps(file: string) {
return meta(file)?.ps === true
}
export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
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 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"

View File

@@ -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),

View File

@@ -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) => {

View File

@@ -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 },
)
})

View File

@@ -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) =>
@@ -1263,6 +1291,45 @@ it.live(
3_000,
)
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",
() =>

View File

@@ -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)

View File

@@ -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", () => {