mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-08 10:10:58 +00:00
Apply PR #20602: shell as config + desktop settings UI for it
This commit is contained in:
commit
edee841639
17 changed files with 652 additions and 159 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||
import { Component, Show, createMemo, onMount, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
|
@ -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,20 @@ 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,12 +91,10 @@ export const SettingsGeneral: Component = () => {
|
|||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
shells: [] as ShellOption[],
|
||||
displayBackend: null as DisplayBackend | null,
|
||||
})
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
|
|
@ -165,6 +179,61 @@ export const SettingsGeneral: Component = () => {
|
|||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSdk = useGlobalSDK()
|
||||
|
||||
const syncDisplayBackend = () => {
|
||||
if (!linux() || !platform.getDisplayBackend) return
|
||||
return Promise.resolve(platform.getDisplayBackend()).then((value) => setStore("displayBackend", value)).catch(() => undefined)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
void globalSdk.client.pty.shells().then((res) => setStore("shells", res.data || [])).catch(() => undefined)
|
||||
void syncDisplayBackend()
|
||||
})
|
||||
|
||||
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 = store.shells
|
||||
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 dup = (nameCounts.get(s.name) || 0) > 1
|
||||
const text = dup ? s.path : s.name
|
||||
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
||||
return {
|
||||
id: s.path,
|
||||
value: dup ? 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 syncDisplayBackend()
|
||||
})
|
||||
}
|
||||
|
||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
{ value: "light", label: language.t("theme.scheme.light") },
|
||||
|
|
@ -243,6 +312,27 @@ export const SettingsGeneral: Component = () => {
|
|||
</div>
|
||||
</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 +741,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)
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||
|
||||
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>
|
||||
)
|
||||
}}
|
||||
<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={store.displayBackend === "wayland"} onChange={onDisplayBackendChange} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
|
||||
|
|
|
|||
|
|
@ -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,7 @@ 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))),
|
||||
|
|
|
|||
|
|
@ -729,6 +729,10 @@ 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",
|
||||
|
|
|
|||
|
|
@ -101,6 +101,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",
|
||||
|
|
@ -313,10 +316,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) {
|
||||
|
|
@ -324,6 +324,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({
|
||||
|
|
@ -756,15 +763,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.schema(Info.zod, 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.schema(Info.zod, ConfigParse.jsonc(updated, file), file)
|
||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { Instance } from "@/project/instance"
|
||||
import type { Proc } from "#pty"
|
||||
import { Log } from "../util"
|
||||
import { lazy } from "@opencode-ai/core/util/lazy"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Config } from "@/config"
|
||||
import { InstanceState, EffectBridge } from "@/effect"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import type { Proc } from "#pty"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Log } from "../util"
|
||||
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 "."
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import * as CrossSpawnSpawner from "@/effect/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"
|
||||
|
|
@ -93,6 +93,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
|
||||
|
|
@ -784,49 +785,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 args = Shell.args(sh, input.command)
|
||||
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 shellEnv = yield* plugin.trigger(
|
||||
"shell.env",
|
||||
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
||||
|
|
@ -843,7 +805,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) {
|
||||
|
|
@ -1589,7 +1550,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),
|
||||
|
|
@ -1690,6 +1652,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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -53,19 +66,49 @@ function full(file: string) {
|
|||
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 +122,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 +134,77 @@ 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 {
|
||||
return {
|
||||
path: full(file),
|
||||
name: name(file),
|
||||
acceptable: ok(file),
|
||||
}
|
||||
}
|
||||
|
||||
export function args(file: string, command: string) {
|
||||
const n = name(file)
|
||||
if (n === "nu" || n === "fish") return ["-c", command]
|
||||
if (n === "zsh") {
|
||||
return [
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
__oc_cwd=$PWD
|
||||
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
||||
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
||||
cd "$__oc_cwd"
|
||||
eval ${JSON.stringify(command)}
|
||||
`,
|
||||
]
|
||||
}
|
||||
if (n === "bash") {
|
||||
return [
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
__oc_cwd=$PWD
|
||||
shopt -s expand_aliases
|
||||
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
||||
cd "$__oc_cwd"
|
||||
eval ${JSON.stringify(command)}
|
||||
`,
|
||||
]
|
||||
}
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,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 "@/flag/flag"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { ShellKind, ShellToolID } from "./shell/id"
|
||||
|
|
@ -25,7 +26,6 @@ export { Parameters } from "./shell/prompt"
|
|||
|
||||
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,
|
||||
|
|
@ -267,8 +267,8 @@ const ask = Effect.fn("ShellTool.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,
|
||||
|
|
@ -285,7 +285,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, {
|
||||
|
|
@ -316,6 +315,7 @@ const parser = lazy(async () => {
|
|||
export const ShellTool = Tool.define(
|
||||
ShellToolID.id,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const trunc = yield* Truncate.Service
|
||||
|
|
@ -397,7 +397,6 @@ export const ShellTool = Tool.define(
|
|||
const run = Effect.fn("ShellTool.run")(function* (
|
||||
input: {
|
||||
shell: string
|
||||
name: string
|
||||
command: string
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
|
|
@ -427,7 +426,7 @@ export const ShellTool = 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) => {
|
||||
|
|
@ -556,7 +555,8 @@ export const ShellTool = 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 limits = yield* trunc.limits()
|
||||
const prompt = ShellPrompt.render(name, process.platform, limits)
|
||||
|
|
@ -574,7 +574,7 @@ export const ShellTool = 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)
|
||||
|
|
@ -583,7 +583,6 @@ export const ShellTool = Tool.define(
|
|||
return yield* run(
|
||||
{
|
||||
shell,
|
||||
name,
|
||||
command: params.command,
|
||||
cwd,
|
||||
env: yield* shellEnv(ctx, cwd),
|
||||
|
|
|
|||
|
|
@ -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,102 @@ 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) => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,20 @@ import { tmpdir } from "../fixture/fixture"
|
|||
|
||||
Shell.preferred.reset()
|
||||
|
||||
const withShell = async (shell: string | undefined, fn: () => Promise<void>) => {
|
||||
const prev = process.env.SHELL
|
||||
if (shell === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = shell
|
||||
Shell.preferred.reset()
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = prev
|
||||
Shell.preferred.reset()
|
||||
}
|
||||
}
|
||||
|
||||
describe("pty shell args", () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
|
|
@ -67,3 +81,40 @@ 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 withShell(process.platform === "win32" ? process.env.COMSPEC || "cmd.exe" : "/bin/sh", async () => {
|
||||
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 },
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -1102,6 +1104,32 @@ unix("shell commands can change directory after startup", () =>
|
|||
),
|
||||
)
|
||||
|
||||
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 lists files from the project directory", () =>
|
||||
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",
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
@ -63,7 +78,7 @@ describe("shell", () => {
|
|||
})
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 { ShellToolID } from "../../src/tool/shell/id"
|
||||
import { ShellTool } from "../../src/tool/shell"
|
||||
|
|
@ -22,6 +23,7 @@ const runtime = ManagedRuntime.make(
|
|||
AppFileSystem.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
|
@ -158,6 +160,33 @@ describe("tool.shell", () => {
|
|||
},
|
||||
})
|
||||
})
|
||||
|
||||
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.shell permissions", () => {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ import type {
|
|||
PtyListResponses,
|
||||
PtyRemoveErrors,
|
||||
PtyRemoveResponses,
|
||||
PtyShellsResponses,
|
||||
PtyUpdateErrors,
|
||||
PtyUpdateResponses,
|
||||
QuestionAnswer,
|
||||
|
|
@ -1080,6 +1081,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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1472,6 +1472,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
|
||||
/**
|
||||
|
|
@ -2696,6 +2700,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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue