mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 10:36:19 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019466c095 | ||
|
|
374e36a8e3 | ||
|
|
75b76f34bd | ||
|
|
9018aa700a | ||
|
|
51528c33e9 | ||
|
|
8228b3093b |
@@ -197,7 +197,7 @@ const evaluateShell = Effect.fnUntraced(function* (
|
||||
) {
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.preferred()
|
||||
const shell = yield* services.shell.resolve({ preference: "configured" })
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
|
||||
@@ -164,7 +164,7 @@ const layer = () =>
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || (yield* shell.preferred())
|
||||
const command = input.command || (yield* shell.resolve({ preference: "configured" }))
|
||||
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || location.directory
|
||||
const env = {
|
||||
|
||||
@@ -30,6 +30,9 @@ export const RETENTION = Duration.days(7)
|
||||
export const DIRECTORY = "shell"
|
||||
|
||||
type Info = Shell.Info
|
||||
type CreateInput = Shell.CreateInput & {
|
||||
shell?: string
|
||||
}
|
||||
|
||||
type Active = {
|
||||
// Immutable snapshot; lifecycle updates replace it via immer `produce`.
|
||||
@@ -52,9 +55,8 @@ type Active = {
|
||||
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly name: () => Effect.Effect<string>
|
||||
readonly create: <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
input: CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) => Effect.Effect<Shell.Info, E | AppProcess.AppProcessError, R>
|
||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||
@@ -185,8 +187,6 @@ const layer = () =>
|
||||
return session.info
|
||||
})
|
||||
|
||||
const name = () => shell.preferred().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
@@ -218,7 +218,7 @@ const layer = () =>
|
||||
})
|
||||
|
||||
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
input: CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) {
|
||||
const sessionID = input.metadata?.sessionID
|
||||
@@ -230,7 +230,7 @@ const layer = () =>
|
||||
command: input.command,
|
||||
cwd: input.cwd ?? location.directory,
|
||||
timeout: input.timeout,
|
||||
shell: yield* shell.preferred(),
|
||||
shell: input.shell ?? (yield* shell.resolve({ preference: "configured" })),
|
||||
env: {
|
||||
...(sessionEnvironment ?? process.env),
|
||||
TERM: "xterm-256color",
|
||||
@@ -383,7 +383,7 @@ const layer = () =>
|
||||
return session.info
|
||||
})
|
||||
|
||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -41,8 +41,12 @@ export type Draft = {
|
||||
configure: (shell: string) => void
|
||||
}
|
||||
|
||||
export type ResolveInput = {
|
||||
preference: "configured" | "compatible"
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly preferred: () => Effect.Effect<string>
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<string>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellSelect") {}
|
||||
@@ -70,7 +74,7 @@ function meta(file: string) {
|
||||
return META[name(file)]
|
||||
}
|
||||
|
||||
function ok(file: string) {
|
||||
function compatible(file: string) {
|
||||
return meta(file)?.deny !== true
|
||||
}
|
||||
|
||||
@@ -78,7 +82,7 @@ function rooted(file: string) {
|
||||
return path.isAbsolute(FSUtil.windowsPath(file))
|
||||
}
|
||||
|
||||
function resolve(file: string, options?: Options, bin?: string) {
|
||||
function executable(file: string, options?: Options, bin?: string) {
|
||||
const shell = full(file, options, bin)
|
||||
if (rooted(shell)) {
|
||||
if (stat(shell)?.isFile()) return shell
|
||||
@@ -108,9 +112,9 @@ async function unix() {
|
||||
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
|
||||
}
|
||||
|
||||
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
|
||||
if (file && (!opts?.acceptable || ok(file))) {
|
||||
const shell = resolve(file, options, bin)
|
||||
function select(file: string | undefined, options?: Options, opts?: { compatible?: boolean }, bin?: string) {
|
||||
if (file && (!opts?.compatible || compatible(file))) {
|
||||
const shell = executable(file, options, bin)
|
||||
if (shell) return shell
|
||||
}
|
||||
if (process.platform === "win32") return win(options, bin)[0]
|
||||
@@ -151,8 +155,8 @@ function info(file: string, options?: Options, bin?: string): Item {
|
||||
const n = name(item)
|
||||
return {
|
||||
path: item,
|
||||
name: resolve(n, options, bin) ? n : item,
|
||||
acceptable: ok(item),
|
||||
name: executable(n, options, bin) ? n : item,
|
||||
acceptable: compatible(item),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,38 +167,28 @@ export function args(file: string, command: string) {
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
let defaultPreferred: { bin?: string; value: string } | undefined
|
||||
let defaultAcceptable: { bin?: string; value: string } | undefined
|
||||
let defaultConfigured: { bin?: string; value: string } | undefined
|
||||
let defaultCompatible: { bin?: string; value: string } | undefined
|
||||
|
||||
export function preferred(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, undefined, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
|
||||
const cached = defaultPreferred
|
||||
export function resolve(input: ResolveInput, configShell?: string, options?: Options, bin?: string) {
|
||||
const filter = input.preference === "compatible" ? { compatible: true } : undefined
|
||||
if (configShell) return select(configShell, options, filter, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, filter, bin)
|
||||
const cached = input.preference === "compatible" ? defaultCompatible : defaultConfigured
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
|
||||
defaultPreferred = { bin, value }
|
||||
const value = select(process.env.SHELL, undefined, filter, bin) ?? fallback(bin)
|
||||
if (input.preference === "compatible") defaultCompatible = { bin, value }
|
||||
if (input.preference === "configured") defaultConfigured = { bin, value }
|
||||
return value
|
||||
}
|
||||
preferred.reset = () => {
|
||||
defaultPreferred = undefined
|
||||
}
|
||||
|
||||
export function acceptable(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, { acceptable: true }, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
|
||||
const cached = defaultAcceptable
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
|
||||
defaultAcceptable = { bin, value }
|
||||
return value
|
||||
}
|
||||
acceptable.reset = () => {
|
||||
defaultAcceptable = undefined
|
||||
resolve.reset = () => {
|
||||
defaultConfigured = undefined
|
||||
defaultCompatible = undefined
|
||||
}
|
||||
|
||||
export async function list(options?: Options, bin?: string): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win(options, bin) : await unix()
|
||||
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
|
||||
return shells.filter((shell) => executable(shell, options, bin)).map((shell) => info(shell, options, bin))
|
||||
}
|
||||
|
||||
const layer = (options?: Options) =>
|
||||
@@ -214,7 +208,7 @@ const layer = (options?: Options) =>
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
preferred: () => Effect.sync(() => preferred(state.get().shell, options, global.bin)),
|
||||
resolve: (input) => Effect.sync(() => resolve(input, state.get().shell, options, global.bin)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { NonNegativeInt } from "../../schema.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
import { Shell } from "../../shell.js"
|
||||
import { ShellParse } from "../../shell/parse.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
export const name = "shell"
|
||||
@@ -109,6 +110,7 @@ export const Plugin = {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const shellSelect = yield* ShellSelect.Service
|
||||
const permission = yield* Permission.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -185,6 +187,7 @@ export const Plugin = {
|
||||
command: input.command,
|
||||
cwd: input.workdir,
|
||||
timeout,
|
||||
shell: yield* shellSelect.resolve({ preference: "compatible" }),
|
||||
metadata: { sessionID: context.sessionID },
|
||||
},
|
||||
(invocation) =>
|
||||
@@ -340,7 +343,8 @@ export const Plugin = {
|
||||
Effect.gen(function* () {
|
||||
const tool = event.tools[name]
|
||||
if (!tool) return
|
||||
tool.description = description(yield* shell.name())
|
||||
const selected = yield* shellSelect.resolve({ preference: "compatible" })
|
||||
tool.description = description(ShellSelect.name(selected))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -24,12 +24,12 @@ describe("ConfigShellPlugin.Plugin", () => {
|
||||
yield* ConfigShellPlugin.Plugin.effect(yield* PluginHost.make(plugins))
|
||||
|
||||
const configured = process.platform === "win32" ? FSUtil.windowsPath(process.execPath) : process.execPath
|
||||
expect(yield* shell.preferred()).toBe(configured)
|
||||
expect(yield* shell.resolve({ preference: "configured" })).toBe(configured)
|
||||
|
||||
yield* config.setEntries([])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if ((yield* shell.preferred()) !== configured) return
|
||||
if ((yield* shell.resolve({ preference: "configured" })) !== configured) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
yield* Effect.die(new Error("Timed out waiting for shell config reload"))
|
||||
|
||||
@@ -8,15 +8,13 @@ const withShell = async (shell: string | undefined, fn: () => void | Promise<voi
|
||||
const prev = process.env.SHELL
|
||||
if (shell === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = shell
|
||||
ShellSelect.acceptable.reset()
|
||||
ShellSelect.preferred.reset()
|
||||
ShellSelect.resolve.reset()
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = prev
|
||||
ShellSelect.acceptable.reset()
|
||||
ShellSelect.preferred.reset()
|
||||
ShellSelect.resolve.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,16 +34,16 @@ describe("shell", () => {
|
||||
|
||||
test("falls back when configured shell cannot be resolved", async () => {
|
||||
await withShell(undefined, async () => {
|
||||
const preferred = ShellSelect.preferred()
|
||||
const acceptable = ShellSelect.acceptable()
|
||||
expect(ShellSelect.preferred("opencode-missing-shell")).toBe(preferred)
|
||||
expect(ShellSelect.acceptable("opencode-missing-shell")).toBe(acceptable)
|
||||
const configured = ShellSelect.resolve({ preference: "configured" })
|
||||
const compatible = ShellSelect.resolve({ preference: "compatible" })
|
||||
expect(ShellSelect.resolve({ preference: "configured" }, "opencode-missing-shell")).toBe(configured)
|
||||
expect(ShellSelect.resolve({ preference: "compatible" }, "opencode-missing-shell")).toBe(compatible)
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back for terminal-only acceptable shells", () => {
|
||||
expect(ShellSelect.name(ShellSelect.acceptable("fish"))).not.toBe("fish")
|
||||
expect(ShellSelect.name(ShellSelect.acceptable("nu"))).not.toBe("nu")
|
||||
test("falls back for terminal-only shells when compatibility is required", () => {
|
||||
expect(ShellSelect.name(ShellSelect.resolve({ preference: "compatible" }, "fish"))).not.toBe("fish")
|
||||
expect(ShellSelect.name(ShellSelect.resolve({ preference: "compatible" }, "nu"))).not.toBe("nu")
|
||||
})
|
||||
|
||||
test("builds command args per shell family", () => {
|
||||
@@ -65,14 +63,14 @@ describe("shell", () => {
|
||||
if (process.platform === "win32") {
|
||||
test("rejects blacklisted shells case-insensitively", async () => {
|
||||
await withShell("NU.EXE", async () => {
|
||||
expect(ShellSelect.name(ShellSelect.acceptable())).not.toBe("nu")
|
||||
expect(ShellSelect.name(ShellSelect.resolve({ preference: "compatible" }))).not.toBe("nu")
|
||||
})
|
||||
})
|
||||
|
||||
test("normalizes Git Bash shell paths from env", async () => {
|
||||
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
|
||||
await withShell(shell, async () => {
|
||||
expect(ShellSelect.preferred()).toBe(FSUtil.windowsPath(shell))
|
||||
expect(ShellSelect.resolve({ preference: "configured" })).toBe(FSUtil.windowsPath(shell))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,19 +78,19 @@ describe("shell", () => {
|
||||
const bash = ShellSelect.gitbash()
|
||||
if (!bash) return
|
||||
await withShell("/usr/bin/bash", async () => {
|
||||
expect(ShellSelect.acceptable()).toBe(bash)
|
||||
expect(ShellSelect.preferred()).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "compatible" })).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "configured" })).toBe(bash)
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves bare bash to Git Bash before PATH", async () => {
|
||||
const bash = ShellSelect.gitbash()
|
||||
if (!bash) return
|
||||
expect(ShellSelect.acceptable("bash")).toBe(bash)
|
||||
expect(ShellSelect.preferred("bash")).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "compatible" }, "bash")).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "configured" }, "bash")).toBe(bash)
|
||||
await withShell("bash", async () => {
|
||||
expect(ShellSelect.acceptable()).toBe(bash)
|
||||
expect(ShellSelect.preferred()).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "compatible" })).toBe(bash)
|
||||
expect(ShellSelect.resolve({ preference: "configured" })).toBe(bash)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,7 +98,7 @@ describe("shell", () => {
|
||||
const shell = which("pwsh") || which("powershell")
|
||||
if (!shell) return
|
||||
await withShell(path.win32.basename(shell), async () => {
|
||||
expect(ShellSelect.preferred()).toBe(shell)
|
||||
expect(ShellSelect.resolve({ preference: "configured" })).toBe(shell)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
@@ -136,6 +137,7 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Shell.node,
|
||||
ShellSelect.node,
|
||||
Tool.node,
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user