feat: configurable shell selection + desktop settings UI (#20602)
This commit is contained in:
@@ -11,7 +11,9 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
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 {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
monoFontFamily,
|
monoFontFamily,
|
||||||
@@ -40,6 +42,18 @@ type ThemeOption = {
|
|||||||
name: string
|
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,
|
// 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.
|
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||||
const stopDemoSound = () => {
|
const stopDemoSound = () => {
|
||||||
@@ -75,10 +89,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
void theme.loadThemes()
|
|
||||||
})
|
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
checking: false,
|
checking: false,
|
||||||
})
|
})
|
||||||
@@ -165,6 +175,70 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
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 }[] => [
|
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||||
{ value: "system", label: language.t("theme.scheme.system") },
|
{ value: "system", label: language.t("theme.scheme.system") },
|
||||||
{ value: "light", label: language.t("theme.scheme.light") },
|
{ value: "light", label: language.t("theme.scheme.light") },
|
||||||
@@ -243,6 +317,27 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</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
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||||
@@ -651,70 +746,32 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
<SoundsSection />
|
<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 />
|
<UpdatesSection />
|
||||||
|
|
||||||
<Show when={linux()}>
|
<Show when={linux()}>
|
||||||
{(_) => {
|
<div class="flex flex-col gap-1">
|
||||||
const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||||
const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
|
|
||||||
|
|
||||||
const onChange = (checked: boolean) =>
|
<SettingsList>
|
||||||
platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
|
<SettingsRow
|
||||||
|
title={
|
||||||
return (
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex flex-col gap-1">
|
<span>{language.t("settings.general.row.wayland.title")}</span>
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
||||||
|
<span class="text-text-weak">
|
||||||
<SettingsList>
|
<Icon name="help" size="small" />
|
||||||
<SettingsRow
|
</span>
|
||||||
title={
|
</Tooltip>
|
||||||
<div class="flex items-center gap-2">
|
</div>
|
||||||
<span>{language.t("settings.general.row.wayland.title")}</span>
|
}
|
||||||
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
description={language.t("settings.general.row.wayland.description")}
|
||||||
<span class="text-text-weak">
|
>
|
||||||
<Icon name="help" size="small" />
|
<div data-action="settings-wayland">
|
||||||
</span>
|
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
|
||||||
</Tooltip>
|
</div>
|
||||||
</div>
|
</SettingsRow>
|
||||||
}
|
</SettingsList>
|
||||||
description={language.t("settings.general.row.wayland.description")}
|
</div>
|
||||||
>
|
|
||||||
<div data-action="settings-wayland">
|
|
||||||
<Switch checked={value() === "wayland"} onChange={onChange} />
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
|
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export async function bootstrapGlobal(input: {
|
|||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.globalSDK.global.config.get().then((x) => {
|
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)
|
input.setStore("provider", input.global.provider)
|
||||||
}
|
}
|
||||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
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) {
|
if (loading || input.store.provider.all.length === 0) {
|
||||||
input.setStore("provider_ready", false)
|
input.setStore("provider_ready", false)
|
||||||
@@ -265,7 +265,8 @@ export async function bootstrapDirectory(input: {
|
|||||||
input.queryClient.ensureQueryData(
|
input.queryClient.ensureQueryData(
|
||||||
loadAgentsQuery(input.directory, input.sdk, (x) => input.setStore("agent", normalizeAgentList(x.data))),
|
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!))),
|
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||||
!seededProject &&
|
!seededProject &&
|
||||||
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
||||||
|
|||||||
@@ -728,6 +728,11 @@ export const dict = {
|
|||||||
|
|
||||||
"settings.general.row.language.title": "Language",
|
"settings.general.row.language.title": "Language",
|
||||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
"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.title": "Appearance",
|
||||||
"settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
|
"settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
|
||||||
"settings.general.row.colorScheme.title": "Color scheme",
|
"settings.general.row.colorScheme.title": "Color scheme",
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export const Info = Schema.Struct({
|
|||||||
$schema: Schema.optional(Schema.String).annotate({
|
$schema: Schema.optional(Schema.String).annotate({
|
||||||
description: "JSON schema reference for configuration validation",
|
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" }),
|
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
|
||||||
server: Schema.optional(ConfigServer.Server).annotate({
|
server: Schema.optional(ConfigServer.Server).annotate({
|
||||||
description: "Server configuration for opencode serve and web commands",
|
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 applyEdits(input, edits)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.entries(patch).reduce((result, [key, value]) => {
|
return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input)
|
||||||
if (value === undefined) return result
|
|
||||||
return patchJsonc(result, value, [...path, key])
|
|
||||||
}, input)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writable(info: Info) {
|
function writable(info: Info) {
|
||||||
@@ -321,6 +321,13 @@ function writable(info: Info) {
|
|||||||
return next
|
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(
|
export const ConfigDirectoryTypoError = NamedError.create(
|
||||||
"ConfigDirectoryTypoError",
|
"ConfigDirectoryTypoError",
|
||||||
z.object({
|
z.object({
|
||||||
@@ -749,15 +756,16 @@ export const layer = Layer.effect(
|
|||||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||||
const file = globalConfigFile()
|
const file = globalConfigFile()
|
||||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||||
|
const patch = writableGlobal(config)
|
||||||
|
|
||||||
let next: Info
|
let next: Info
|
||||||
if (!file.endsWith(".jsonc")) {
|
if (!file.endsWith(".jsonc")) {
|
||||||
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
|
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)
|
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||||
next = merged
|
next = merged
|
||||||
} else {
|
} else {
|
||||||
const updated = patchJsonc(before, writable(config))
|
const updated = patchJsonc(before, patch)
|
||||||
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
|
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
|
||||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Bus } from "@/bus"
|
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 { Instance } from "@/project/instance"
|
||||||
|
import { Shell } from "@/shell/shell"
|
||||||
import type { Proc } from "#pty"
|
import type { Proc } from "#pty"
|
||||||
import { Log } from "../util"
|
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 { PtyID } from "./schema"
|
||||||
import { Effect, Layer, Context, Schema, Types } from "effect"
|
import { Effect, Layer, Context, Schema, Types } from "effect"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { withStatics } from "@/util/schema"
|
||||||
import { EffectBridge } from "@/effect"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "pty" })
|
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(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
|
|
||||||
function teardown(session: Active) {
|
function teardown(session: Active) {
|
||||||
try {
|
try {
|
||||||
session.process.kill()
|
session.process.kill()
|
||||||
@@ -174,8 +176,9 @@ export const layer = Layer.effect(
|
|||||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
const bridge = yield* EffectBridge.make()
|
const bridge = yield* EffectBridge.make()
|
||||||
|
const cfg = yield* config.get()
|
||||||
const id = PtyID.ascending()
|
const id = PtyID.ascending()
|
||||||
const command = input.command || Shell.preferred()
|
const command = input.command || Shell.preferred(cfg.shell)
|
||||||
const args = input.args || []
|
const args = input.args || []
|
||||||
if (Shell.login(command)) {
|
if (Shell.login(command)) {
|
||||||
args.push("-l")
|
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 "."
|
export * as Pty from "."
|
||||||
|
|||||||
@@ -6,14 +6,41 @@ import z from "zod"
|
|||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
import { PtyID } from "@/pty/schema"
|
import { PtyID } from "@/pty/schema"
|
||||||
|
import { Shell } from "@/shell/shell"
|
||||||
import { NotFoundError } from "@/storage"
|
import { NotFoundError } from "@/storage"
|
||||||
import { errors } from "../../error"
|
import { errors } from "../../error"
|
||||||
import { jsonRequest, runRequest } from "./trace"
|
import { jsonRequest, runRequest } from "./trace"
|
||||||
|
|
||||||
|
const ShellItem = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
acceptable: z.boolean(),
|
||||||
|
})
|
||||||
const decodePtyID = Schema.decodeUnknownSync(PtyID)
|
const decodePtyID = Schema.decodeUnknownSync(PtyID)
|
||||||
|
|
||||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||||
return new Hono()
|
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(
|
.get(
|
||||||
"/",
|
"/",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { pathToFileURL, fileURLToPath } from "url"
|
import { pathToFileURL, fileURLToPath } from "url"
|
||||||
import { ConfigMarkdown } from "../config"
|
import { Config, ConfigMarkdown } from "../config"
|
||||||
import { SessionSummary } from "./summary"
|
import { SessionSummary } from "./summary"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { SessionProcessor } from "./processor"
|
import { SessionProcessor } from "./processor"
|
||||||
@@ -92,6 +92,7 @@ export const layer = Layer.effect(
|
|||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const commands = yield* Command.Service
|
const commands = yield* Command.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const fsys = yield* AppFileSystem.Service
|
const fsys = yield* AppFileSystem.Service
|
||||||
const mcp = yield* MCP.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)
|
yield* sessions.updatePart(part)
|
||||||
|
|
||||||
const sh = Shell.preferred()
|
const cfg = yield* config.get()
|
||||||
const shellName = (
|
const sh = Shell.preferred(cfg.shell)
|
||||||
process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
|
|
||||||
).toLowerCase()
|
|
||||||
const cwd = ctx.directory
|
const cwd = ctx.directory
|
||||||
const invocations: Record<string, { args: string[] }> = {
|
const args = Shell.args(sh, input.command, cwd)
|
||||||
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(
|
const shellEnv = yield* plugin.trigger(
|
||||||
"shell.env",
|
"shell.env",
|
||||||
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
{ 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 output = ""
|
||||||
let aborted = false
|
let aborted = false
|
||||||
|
|
||||||
const finish = Effect.uninterruptible(
|
const finish = Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (aborted) {
|
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)
|
const shellMatches = ConfigMarkdown.shell(template)
|
||||||
if (shellMatches.length > 0) {
|
if (shellMatches.length > 0) {
|
||||||
const sh = Shell.preferred()
|
const cfg = yield* config.get()
|
||||||
|
const sh = Shell.preferred(cfg.shell)
|
||||||
const results = yield* Effect.promise(() =>
|
const results = yield* Effect.promise(() =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
|
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(ToolRegistry.defaultLayer),
|
||||||
Layer.provide(Truncate.defaultLayer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
|||||||
@@ -7,10 +7,23 @@ import { spawn, type ChildProcess } from "child_process"
|
|||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
const SIGKILL_TIMEOUT_MS = 200
|
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"])
|
export type Item = {
|
||||||
const LOGIN = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"])
|
path: string
|
||||||
const POSIX = new Set(["bash", "dash", "ksh", "sh", "zsh"])
|
name: string
|
||||||
|
acceptable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
||||||
const pid = proc.pid
|
const pid = proc.pid
|
||||||
@@ -50,22 +63,53 @@ function full(file: string) {
|
|||||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
|
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
|
||||||
return shell
|
return shell
|
||||||
}
|
}
|
||||||
|
if (name(shell) === "bash") return gitbash() || which(shell) || shell
|
||||||
return which(shell) || shell
|
return which(shell) || shell
|
||||||
}
|
}
|
||||||
|
|
||||||
function pick() {
|
function meta(file: string) {
|
||||||
const pwsh = which("pwsh.exe")
|
return META[name(file)]
|
||||||
if (pwsh) return pwsh
|
}
|
||||||
const powershell = which("powershell.exe")
|
|
||||||
if (powershell) return powershell
|
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 }) {
|
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
|
||||||
if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
|
if (file && (!opts?.acceptable || ok(file))) {
|
||||||
if (process.platform === "win32") {
|
const shell = resolve(file)
|
||||||
const shell = pick()
|
|
||||||
if (shell) return shell
|
if (shell) return shell
|
||||||
}
|
}
|
||||||
|
if (process.platform === "win32") return win()[0]!
|
||||||
return fallback()
|
return fallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +123,6 @@ export function gitbash() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fallback() {
|
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"
|
if (process.platform === "darwin") return "/bin/zsh"
|
||||||
const bash = which("bash")
|
const bash = which("bash")
|
||||||
if (bash) return bash
|
if (bash) return bash
|
||||||
@@ -96,15 +135,81 @@ export function name(file: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function login(file: string) {
|
export function login(file: string) {
|
||||||
return LOGIN.has(name(file))
|
return meta(file)?.login === true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function posix(file: string) {
|
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"
|
export * as Shell from "./shell"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Language, type Node } from "web-tree-sitter"
|
|||||||
|
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
import { Config } from "@/config"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Shell } from "@/shell/shell"
|
import { Shell } from "@/shell/shell"
|
||||||
|
|
||||||
@@ -24,7 +25,6 @@ import { InstanceState } from "@/effect"
|
|||||||
|
|
||||||
const MAX_METADATA_LENGTH = 30_000
|
const MAX_METADATA_LENGTH = 30_000
|
||||||
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
|
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 CWD = new Set(["cd", "push-location", "set-location"])
|
||||||
const FILES = new Set([
|
const FILES = new Set([
|
||||||
...CWD,
|
...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) {
|
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||||
if (process.platform === "win32" && PS.has(name)) {
|
if (process.platform === "win32" && Shell.ps(shell)) {
|
||||||
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
|
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
@@ -296,7 +296,6 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
|
|||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const parser = lazy(async () => {
|
const parser = lazy(async () => {
|
||||||
const { Parser } = await import("web-tree-sitter")
|
const { Parser } = await import("web-tree-sitter")
|
||||||
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
|
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(
|
export const BashTool = Tool.define(
|
||||||
"bash",
|
"bash",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
const spawner = yield* ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
const trunc = yield* Truncate.Service
|
const trunc = yield* Truncate.Service
|
||||||
@@ -408,7 +408,6 @@ export const BashTool = Tool.define(
|
|||||||
const run = Effect.fn("BashTool.run")(function* (
|
const run = Effect.fn("BashTool.run")(function* (
|
||||||
input: {
|
input: {
|
||||||
shell: string
|
shell: string
|
||||||
name: string
|
|
||||||
command: string
|
command: string
|
||||||
cwd: string
|
cwd: string
|
||||||
env: NodeJS.ProcessEnv
|
env: NodeJS.ProcessEnv
|
||||||
@@ -438,7 +437,7 @@ export const BashTool = Tool.define(
|
|||||||
|
|
||||||
const code: number | null = yield* Effect.scoped(
|
const code: number | null = yield* Effect.scoped(
|
||||||
Effect.gen(function* () {
|
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(
|
yield* Effect.forkScoped(
|
||||||
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
|
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
|
||||||
@@ -567,7 +566,8 @@ export const BashTool = Tool.define(
|
|||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const shell = Shell.acceptable()
|
const cfg = yield* config.get()
|
||||||
|
const shell = Shell.acceptable(cfg.shell)
|
||||||
const name = Shell.name(shell)
|
const name = Shell.name(shell)
|
||||||
const chain =
|
const chain =
|
||||||
name === "powershell"
|
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.`)
|
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||||
}
|
}
|
||||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||||
const ps = PS.has(name)
|
const ps = Shell.ps(shell)
|
||||||
const root = yield* parse(params.command, ps)
|
const root = yield* parse(params.command, ps)
|
||||||
const scan = yield* collect(root, cwd, ps, shell)
|
const scan = yield* collect(root, cwd, ps, shell)
|
||||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||||
@@ -604,7 +604,6 @@ export const BashTool = Tool.define(
|
|||||||
return yield* run(
|
return yield* run(
|
||||||
{
|
{
|
||||||
shell,
|
shell,
|
||||||
name,
|
|
||||||
command: params.command,
|
command: params.command,
|
||||||
cwd,
|
cwd,
|
||||||
env: yield* shellEnv(ctx, 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 load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||||
const save = (config: Config.Info) =>
|
const save = (config: Config.Info) =>
|
||||||
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
|
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) =>
|
const clear = (wait = false) =>
|
||||||
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
|
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||||
const listDirs = () =>
|
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 () => {
|
test("loads formatter boolean config", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -316,9 +316,11 @@ const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
|
const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
|
||||||
|
const config = yield* Config.Service
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
const run = yield* SessionRunState.Service
|
const run = yield* SessionRunState.Service
|
||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
|
yield* config.get()
|
||||||
const chat = yield* sessions.create(input ?? { title: "Pinned" })
|
const chat = yield* sessions.create(input ?? { title: "Pinned" })
|
||||||
return { prompt, run, sessions, chat }
|
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", () =>
|
unix("shell commands can change directory after startup", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
@@ -1263,6 +1291,45 @@ it.live(
|
|||||||
3_000,
|
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(
|
unix(
|
||||||
"cancel interrupts shell and resolves cleanly",
|
"cancel interrupts shell and resolves cleanly",
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Shell } from "../../src/shell/shell"
|
import { Shell } from "../../src/shell/shell"
|
||||||
import { Filesystem } from "../../src/util"
|
import { Filesystem } from "../../src/util"
|
||||||
|
import { which } from "../../src/util/which"
|
||||||
|
|
||||||
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
|
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
|
||||||
const prev = process.env.SHELL
|
const prev = process.env.SHELL
|
||||||
@@ -39,6 +40,20 @@ describe("shell", () => {
|
|||||||
expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false)
|
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") {
|
if (process.platform === "win32") {
|
||||||
test("rejects blacklisted shells case-insensitively", async () => {
|
test("rejects blacklisted shells case-insensitively", async () => {
|
||||||
await withShell("NU.EXE", 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 () => {
|
test("resolves bare PowerShell shells", async () => {
|
||||||
const shell = Bun.which("pwsh") || Bun.which("powershell")
|
const shell = which("pwsh") || which("powershell")
|
||||||
if (!shell) return
|
if (!shell) return
|
||||||
await withShell(path.win32.basename(shell), async () => {
|
await withShell(path.win32.basename(shell), async () => {
|
||||||
expect(Shell.preferred()).toBe(shell)
|
expect(Shell.preferred()).toBe(shell)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { Config } from "../../src/config"
|
||||||
import { Shell } from "../../src/shell/shell"
|
import { Shell } from "../../src/shell/shell"
|
||||||
import { BashTool } from "../../src/tool/bash"
|
import { BashTool } from "../../src/tool/bash"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
@@ -21,6 +22,7 @@ const runtime = ManagedRuntime.make(
|
|||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
|
Config.defaultLayer,
|
||||||
Agent.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", () => {
|
describe("tool.bash permissions", () => {
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ import type {
|
|||||||
PtyListResponses,
|
PtyListResponses,
|
||||||
PtyRemoveErrors,
|
PtyRemoveErrors,
|
||||||
PtyRemoveResponses,
|
PtyRemoveResponses,
|
||||||
|
PtyShellsResponses,
|
||||||
PtyUpdateErrors,
|
PtyUpdateErrors,
|
||||||
PtyUpdateResponses,
|
PtyUpdateResponses,
|
||||||
QuestionAnswer,
|
QuestionAnswer,
|
||||||
@@ -1080,6 +1081,36 @@ export class Project extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Pty 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
|
* List PTY sessions
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1470,6 +1470,10 @@ export type Config = {
|
|||||||
* JSON schema reference for configuration validation
|
* JSON schema reference for configuration validation
|
||||||
*/
|
*/
|
||||||
$schema?: string
|
$schema?: string
|
||||||
|
/**
|
||||||
|
* Default shell to use for terminal and bash tool
|
||||||
|
*/
|
||||||
|
shell?: string
|
||||||
logLevel?: LogLevel
|
logLevel?: LogLevel
|
||||||
server?: ServerConfig
|
server?: ServerConfig
|
||||||
/**
|
/**
|
||||||
@@ -2694,6 +2698,29 @@ export type ProjectUpdateResponses = {
|
|||||||
|
|
||||||
export type ProjectUpdateResponse = ProjectUpdateResponses[keyof 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 = {
|
export type PtyListData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|||||||
@@ -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": {
|
"/pty": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "pty.list",
|
"operationId": "pty.list",
|
||||||
@@ -11454,6 +11510,10 @@
|
|||||||
"description": "JSON schema reference for configuration validation",
|
"description": "JSON schema reference for configuration validation",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"shell": {
|
||||||
|
"description": "Default shell to use for terminal and bash tool",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"logLevel": {
|
"logLevel": {
|
||||||
"$ref": "#/components/schemas/LogLevel"
|
"$ref": "#/components/schemas/LogLevel"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
### Tools
|
||||||
|
|
||||||
You can manage the tools an LLM can use through the `tools` option.
|
You can manage the tools an LLM can use through the `tools` option.
|
||||||
|
|||||||
Reference in New Issue
Block a user