diff --git a/src/cli/run/opencode-bin-path.test.ts b/src/cli/run/opencode-bin-path.test.ts
new file mode 100644
index 00000000..ad3350da
--- /dev/null
+++ b/src/cli/run/opencode-bin-path.test.ts
@@ -0,0 +1,52 @@
+///
+
+import { describe, expect, it } from "bun:test"
+import { prependResolvedOpencodeBinToPath } from "./opencode-bin-path"
+
+describe("prependResolvedOpencodeBinToPath", () => {
+ it("prepends resolved opencode-ai bin path to PATH", () => {
+ //#given
+ const env: Record = {
+ PATH: "/Users/yeongyu/node_modules/.bin:/usr/bin",
+ }
+ const resolver = () => "/tmp/bunx-123/node_modules/opencode-ai/bin/opencode"
+
+ //#when
+ prependResolvedOpencodeBinToPath(env, resolver)
+
+ //#then
+ expect(env.PATH).toBe(
+ "/tmp/bunx-123/node_modules/opencode-ai/bin:/Users/yeongyu/node_modules/.bin:/usr/bin",
+ )
+ })
+
+ it("does not duplicate an existing opencode-ai bin path", () => {
+ //#given
+ const env: Record = {
+ PATH: "/tmp/bunx-123/node_modules/opencode-ai/bin:/usr/bin",
+ }
+ const resolver = () => "/tmp/bunx-123/node_modules/opencode-ai/bin/opencode"
+
+ //#when
+ prependResolvedOpencodeBinToPath(env, resolver)
+
+ //#then
+ expect(env.PATH).toBe("/tmp/bunx-123/node_modules/opencode-ai/bin:/usr/bin")
+ })
+
+ it("keeps PATH unchanged when opencode-ai cannot be resolved", () => {
+ //#given
+ const env: Record = {
+ PATH: "/Users/yeongyu/node_modules/.bin:/usr/bin",
+ }
+ const resolver = () => {
+ throw new Error("module not found")
+ }
+
+ //#when
+ prependResolvedOpencodeBinToPath(env, resolver)
+
+ //#then
+ expect(env.PATH).toBe("/Users/yeongyu/node_modules/.bin:/usr/bin")
+ })
+})
diff --git a/src/cli/run/opencode-bin-path.ts b/src/cli/run/opencode-bin-path.ts
new file mode 100644
index 00000000..1a625cd9
--- /dev/null
+++ b/src/cli/run/opencode-bin-path.ts
@@ -0,0 +1,30 @@
+import { delimiter, dirname } from "node:path"
+import { createRequire } from "node:module"
+
+type EnvLike = Record
+
+const resolveFromCurrentModule = createRequire(import.meta.url).resolve
+
+export function prependResolvedOpencodeBinToPath(
+ env: EnvLike = process.env,
+ resolve: (id: string) => string = resolveFromCurrentModule,
+): void {
+ let resolvedPath: string
+ try {
+ resolvedPath = resolve("opencode-ai/bin/opencode")
+ } catch {
+ return
+ }
+
+ const opencodeBinDir = dirname(resolvedPath)
+ const currentPath = env.PATH ?? ""
+ const pathSegments = currentPath ? currentPath.split(delimiter) : []
+
+ if (pathSegments.includes(opencodeBinDir)) {
+ return
+ }
+
+ env.PATH = currentPath
+ ? `${opencodeBinDir}${delimiter}${currentPath}`
+ : opencodeBinDir
+}
diff --git a/src/cli/run/opencode-binary-resolver.test.ts b/src/cli/run/opencode-binary-resolver.test.ts
new file mode 100644
index 00000000..9cd55b7d
--- /dev/null
+++ b/src/cli/run/opencode-binary-resolver.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "bun:test"
+import { delimiter, join } from "node:path"
+import {
+ buildPathWithBinaryFirst,
+ collectCandidateBinaryPaths,
+ findWorkingOpencodeBinary,
+ withWorkingOpencodePath,
+} from "./opencode-binary-resolver"
+
+describe("collectCandidateBinaryPaths", () => {
+ it("includes Bun.which results first and removes duplicates", () => {
+ // given
+ const pathEnv = ["/bad", "/good"].join(delimiter)
+ const which = (command: string): string | undefined => {
+ if (command === "opencode") return "/bad/opencode"
+ return undefined
+ }
+
+ // when
+ const candidates = collectCandidateBinaryPaths(pathEnv, which, "darwin")
+
+ // then
+ expect(candidates[0]).toBe("/bad/opencode")
+ expect(candidates).toContain("/good/opencode")
+ expect(candidates.filter((candidate) => candidate === "/bad/opencode")).toHaveLength(1)
+ })
+})
+
+describe("findWorkingOpencodeBinary", () => {
+ it("returns the first runnable candidate", async () => {
+ // given
+ const pathEnv = ["/bad", "/good"].join(delimiter)
+ const which = (command: string): string | undefined => {
+ if (command === "opencode") return "/bad/opencode"
+ return undefined
+ }
+ const probe = async (binaryPath: string): Promise =>
+ binaryPath === "/good/opencode"
+
+ // when
+ const resolved = await findWorkingOpencodeBinary(pathEnv, probe, which, "darwin")
+
+ // then
+ expect(resolved).toBe("/good/opencode")
+ })
+})
+
+describe("buildPathWithBinaryFirst", () => {
+ it("prepends the binary directory and avoids duplicate entries", () => {
+ // given
+ const binaryPath = "/good/opencode"
+ const pathEnv = ["/bad", "/good", "/other"].join(delimiter)
+
+ // when
+ const updated = buildPathWithBinaryFirst(pathEnv, binaryPath)
+
+ // then
+ expect(updated).toBe(["/good", "/bad", "/other"].join(delimiter))
+ })
+})
+
+describe("withWorkingOpencodePath", () => {
+ it("temporarily updates PATH while starting the server", async () => {
+ // given
+ const originalPath = process.env.PATH
+ process.env.PATH = ["/bad", "/other"].join(delimiter)
+ const finder = async (): Promise => "/good/opencode"
+ let observedPath = ""
+
+ // when
+ await withWorkingOpencodePath(
+ async () => {
+ observedPath = process.env.PATH ?? ""
+ },
+ finder,
+ )
+
+ // then
+ expect(observedPath).toBe(["/good", "/bad", "/other"].join(delimiter))
+ expect(process.env.PATH).toBe(["/bad", "/other"].join(delimiter))
+ process.env.PATH = originalPath
+ })
+
+ it("restores PATH when server startup fails", async () => {
+ // given
+ const originalPath = process.env.PATH
+ process.env.PATH = ["/bad", "/other"].join(delimiter)
+ const finder = async (): Promise => join("/good", "opencode")
+
+ // when & then
+ await expect(
+ withWorkingOpencodePath(
+ async () => {
+ throw new Error("boom")
+ },
+ finder,
+ ),
+ ).rejects.toThrow("boom")
+ expect(process.env.PATH).toBe(["/bad", "/other"].join(delimiter))
+ process.env.PATH = originalPath
+ })
+})
diff --git a/src/cli/run/opencode-binary-resolver.ts b/src/cli/run/opencode-binary-resolver.ts
new file mode 100644
index 00000000..a4bbc60c
--- /dev/null
+++ b/src/cli/run/opencode-binary-resolver.ts
@@ -0,0 +1,95 @@
+import { delimiter, dirname, join } from "node:path"
+
+const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const
+const WINDOWS_SUFFIXES = ["", ".exe", ".cmd", ".bat", ".ps1"] as const
+
+function getCommandCandidates(platform: NodeJS.Platform): string[] {
+ if (platform !== "win32") return [...OPENCODE_COMMANDS]
+
+ return OPENCODE_COMMANDS.flatMap((command) =>
+ WINDOWS_SUFFIXES.map((suffix) => `${command}${suffix}`),
+ )
+}
+
+export function collectCandidateBinaryPaths(
+ pathEnv: string | undefined,
+ which: (command: string) => string | null | undefined = Bun.which,
+ platform: NodeJS.Platform = process.platform,
+): string[] {
+ const seen = new Set()
+ const candidates: string[] = []
+ const commandCandidates = getCommandCandidates(platform)
+
+ const addCandidate = (binaryPath: string | undefined | null): void => {
+ if (!binaryPath || seen.has(binaryPath)) return
+ seen.add(binaryPath)
+ candidates.push(binaryPath)
+ }
+
+ for (const command of commandCandidates) {
+ addCandidate(which(command))
+ }
+
+ for (const entry of (pathEnv ?? "").split(delimiter).filter(Boolean)) {
+ for (const command of commandCandidates) {
+ addCandidate(join(entry, command))
+ }
+ }
+
+ return candidates
+}
+
+export async function canExecuteBinary(binaryPath: string): Promise {
+ try {
+ const proc = Bun.spawn([binaryPath, "--version"], {
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+ await proc.exited
+ return proc.exitCode === 0
+ } catch {
+ return false
+ }
+}
+
+export async function findWorkingOpencodeBinary(
+ pathEnv: string | undefined = process.env.PATH,
+ probe: (binaryPath: string) => Promise = canExecuteBinary,
+ which: (command: string) => string | null | undefined = Bun.which,
+ platform: NodeJS.Platform = process.platform,
+): Promise {
+ const candidates = collectCandidateBinaryPaths(pathEnv, which, platform)
+ for (const candidate of candidates) {
+ if (await probe(candidate)) {
+ return candidate
+ }
+ }
+ return null
+}
+
+export function buildPathWithBinaryFirst(pathEnv: string | undefined, binaryPath: string): string {
+ const preferredDir = dirname(binaryPath)
+ const existing = (pathEnv ?? "").split(delimiter).filter(
+ (entry) => entry.length > 0 && entry !== preferredDir,
+ )
+ return [preferredDir, ...existing].join(delimiter)
+}
+
+export async function withWorkingOpencodePath(
+ startServer: () => Promise,
+ finder: (pathEnv: string | undefined) => Promise = findWorkingOpencodeBinary,
+): Promise {
+ const originalPath = process.env.PATH
+ const binaryPath = await finder(originalPath)
+
+ if (!binaryPath) {
+ return startServer()
+ }
+
+ process.env.PATH = buildPathWithBinaryFirst(originalPath, binaryPath)
+ try {
+ return await startServer()
+ } finally {
+ process.env.PATH = originalPath
+ }
+}
diff --git a/src/cli/run/server-connection.test.ts b/src/cli/run/server-connection.test.ts
index 9dc94587..ad5e1aa9 100644
--- a/src/cli/run/server-connection.test.ts
+++ b/src/cli/run/server-connection.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun
import * as originalSdk from "@opencode-ai/sdk"
import * as originalPortUtils from "../../shared/port-utils"
+import * as originalBinaryResolver from "./opencode-binary-resolver"
const originalConsole = globalThis.console
@@ -16,6 +17,7 @@ const mockCreateOpencodeClient = mock(() => ({ session: {} }))
const mockIsPortAvailable = mock(() => Promise.resolve(true))
const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasAutoSelected: false }))
const mockConsoleLog = mock(() => {})
+const mockWithWorkingOpencodePath = mock((startServer: () => Promise) => startServer())
mock.module("@opencode-ai/sdk", () => ({
createOpencode: mockCreateOpencode,
@@ -28,9 +30,14 @@ mock.module("../../shared/port-utils", () => ({
DEFAULT_SERVER_PORT: 4096,
}))
+mock.module("./opencode-binary-resolver", () => ({
+ withWorkingOpencodePath: mockWithWorkingOpencodePath,
+}))
+
afterAll(() => {
mock.module("@opencode-ai/sdk", () => originalSdk)
mock.module("../../shared/port-utils", () => originalPortUtils)
+ mock.module("./opencode-binary-resolver", () => originalBinaryResolver)
})
const { createServerConnection } = await import("./server-connection")
@@ -43,6 +50,7 @@ describe("createServerConnection", () => {
mockGetAvailableServerPort.mockClear()
mockServerClose.mockClear()
mockConsoleLog.mockClear()
+ mockWithWorkingOpencodePath.mockClear()
globalThis.console = { ...console, log: mockConsoleLog } as typeof console
})
@@ -60,6 +68,7 @@ describe("createServerConnection", () => {
// then
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
+ expect(mockWithWorkingOpencodePath).not.toHaveBeenCalled()
expect(result.client).toBeDefined()
expect(result.cleanup).toBeDefined()
result.cleanup()
@@ -77,6 +86,7 @@ describe("createServerConnection", () => {
// then
expect(mockIsPortAvailable).toHaveBeenCalledWith(8080, "127.0.0.1")
+ expect(mockWithWorkingOpencodePath).toHaveBeenCalledTimes(1)
expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 8080, hostname: "127.0.0.1" })
expect(mockCreateOpencodeClient).not.toHaveBeenCalled()
expect(result.client).toBeDefined()
@@ -114,6 +124,7 @@ describe("createServerConnection", () => {
// then
expect(mockGetAvailableServerPort).toHaveBeenCalledWith(4096, "127.0.0.1")
+ expect(mockWithWorkingOpencodePath).toHaveBeenCalledTimes(1)
expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 4100, hostname: "127.0.0.1" })
expect(mockCreateOpencodeClient).not.toHaveBeenCalled()
expect(result.client).toBeDefined()
diff --git a/src/cli/run/server-connection.ts b/src/cli/run/server-connection.ts
index 55b63b74..934d469d 100644
--- a/src/cli/run/server-connection.ts
+++ b/src/cli/run/server-connection.ts
@@ -2,12 +2,16 @@ import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk"
import pc from "picocolors"
import type { ServerConnection } from "./types"
import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
+import { withWorkingOpencodePath } from "./opencode-binary-resolver"
+import { prependResolvedOpencodeBinToPath } from "./opencode-bin-path"
export async function createServerConnection(options: {
port?: number
attach?: string
signal: AbortSignal
}): Promise {
+ prependResolvedOpencodeBinToPath()
+
const { port, attach, signal } = options
if (attach !== undefined) {
@@ -25,7 +29,9 @@ export async function createServerConnection(options: {
if (available) {
console.log(pc.dim("Starting server on port"), pc.cyan(port.toString()))
- const { client, server } = await createOpencode({ signal, port, hostname: "127.0.0.1" })
+ const { client, server } = await withWorkingOpencodePath(() =>
+ createOpencode({ signal, port, hostname: "127.0.0.1" }),
+ )
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
return { client, cleanup: () => server.close() }
}
@@ -41,7 +47,9 @@ export async function createServerConnection(options: {
} else {
console.log(pc.dim("Starting server on port"), pc.cyan(selectedPort.toString()))
}
- const { client, server } = await createOpencode({ signal, port: selectedPort, hostname: "127.0.0.1" })
+ const { client, server } = await withWorkingOpencodePath(() =>
+ createOpencode({ signal, port: selectedPort, hostname: "127.0.0.1" }),
+ )
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
return { client, cleanup: () => server.close() }
}