Просмотр исходного кода

refactor(opencode): replace Bun shell in core flows (#16286)

Dax 1 месяц назад
Родитель
Сommit
2f2856e20a

+ 56 - 42
packages/opencode/src/cli/cmd/github.ts

@@ -27,8 +27,9 @@ import { Provider } from "../../provider/provider"
 import { Bus } from "../../bus"
 import { MessageV2 } from "../../session/message-v2"
 import { SessionPrompt } from "@/session/prompt"
-import { $ } from "bun"
 import { setTimeout as sleep } from "node:timers/promises"
+import { Process } from "@/util/process"
+import { git } from "@/util/git"
 
 type GitHubAuthor = {
   login: string
@@ -255,7 +256,7 @@ export const GithubInstallCommand = cmd({
             }
 
             // Get repo info
-            const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim()
+            const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
             const parsed = parseGitHubRemote(info)
             if (!parsed) {
               prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -493,6 +494,26 @@ export const GithubRunCommand = cmd({
           ? "pr_review"
           : "issue"
         : undefined
+      const gitText = async (args: string[]) => {
+        const result = await git(args, { cwd: Instance.worktree })
+        if (result.exitCode !== 0) {
+          throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
+        }
+        return result.text().trim()
+      }
+      const gitRun = async (args: string[]) => {
+        const result = await git(args, { cwd: Instance.worktree })
+        if (result.exitCode !== 0) {
+          throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
+        }
+        return result
+      }
+      const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
+      const commitChanges = async (summary: string, actor?: string) => {
+        const args = ["commit", "-m", summary]
+        if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
+        await gitRun(args)
+      }
 
       try {
         if (useGithubToken) {
@@ -553,7 +574,7 @@ export const GithubRunCommand = cmd({
           }
           const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
           const branch = await checkoutNewBranch(branchPrefix)
-          const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
+          const head = await gitText(["rev-parse", "HEAD"])
           const response = await chat(userPrompt, promptFiles)
           const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
           if (switched) {
@@ -587,7 +608,7 @@ export const GithubRunCommand = cmd({
           // Local PR
           if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
             await checkoutLocalBranch(prData)
-            const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
+            const head = await gitText(["rev-parse", "HEAD"])
             const dataPrompt = buildPromptDataForPR(prData)
             const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
             const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
@@ -605,7 +626,7 @@ export const GithubRunCommand = cmd({
           // Fork PR
           else {
             const forkBranch = await checkoutForkBranch(prData)
-            const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
+            const head = await gitText(["rev-parse", "HEAD"])
             const dataPrompt = buildPromptDataForPR(prData)
             const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
             const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
@@ -624,7 +645,7 @@ export const GithubRunCommand = cmd({
         // Issue
         else {
           const branch = await checkoutNewBranch("issue")
-          const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
+          const head = await gitText(["rev-parse", "HEAD"])
           const issueData = await fetchIssue()
           const dataPrompt = buildPromptDataForIssue(issueData)
           const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
@@ -658,7 +679,7 @@ export const GithubRunCommand = cmd({
         exitCode = 1
         console.error(e instanceof Error ? e.message : String(e))
         let msg = e
-        if (e instanceof $.ShellError) {
+        if (e instanceof Process.RunFailedError) {
           msg = e.stderr.toString()
         } else if (e instanceof Error) {
           msg = e.message
@@ -1049,29 +1070,29 @@ export const GithubRunCommand = cmd({
         const config = "http.https://github.com/.extraheader"
         // actions/checkout@v6 no longer stores credentials in .git/config,
         // so this may not exist - use nothrow() to handle gracefully
-        const ret = await $`git config --local --get ${config}`.nothrow()
+        const ret = await gitStatus(["config", "--local", "--get", config])
         if (ret.exitCode === 0) {
           gitConfig = ret.stdout.toString().trim()
-          await $`git config --local --unset-all ${config}`
+          await gitRun(["config", "--local", "--unset-all", config])
         }
 
         const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
 
-        await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
-        await $`git config --global user.name "${AGENT_USERNAME}"`
-        await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"`
+        await gitRun(["config", "--local", config, `AUTHORIZATION: basic ${newCredentials}`])
+        await gitRun(["config", "--global", "user.name", AGENT_USERNAME])
+        await gitRun(["config", "--global", "user.email", `${AGENT_USERNAME}@users.noreply.github.com`])
       }
 
       async function restoreGitConfig() {
         if (gitConfig === undefined) return
         const config = "http.https://github.com/.extraheader"
-        await $`git config --local ${config} "${gitConfig}"`
+        await gitRun(["config", "--local", config, gitConfig])
       }
 
       async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
         console.log("Checking out new branch...")
         const branch = generateBranchName(type)
-        await $`git checkout -b ${branch}`
+        await gitRun(["checkout", "-b", branch])
         return branch
       }
 
@@ -1081,8 +1102,8 @@ export const GithubRunCommand = cmd({
         const branch = pr.headRefName
         const depth = Math.max(pr.commits.totalCount, 20)
 
-        await $`git fetch origin --depth=${depth} ${branch}`
-        await $`git checkout ${branch}`
+        await gitRun(["fetch", "origin", `--depth=${depth}`, branch])
+        await gitRun(["checkout", branch])
       }
 
       async function checkoutForkBranch(pr: GitHubPullRequest) {
@@ -1092,9 +1113,9 @@ export const GithubRunCommand = cmd({
         const localBranch = generateBranchName("pr")
         const depth = Math.max(pr.commits.totalCount, 20)
 
-        await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
-        await $`git fetch fork --depth=${depth} ${remoteBranch}`
-        await $`git checkout -b ${localBranch} fork/${remoteBranch}`
+        await gitRun(["remote", "add", "fork", `https://github.com/${pr.headRepository.nameWithOwner}.git`])
+        await gitRun(["fetch", "fork", `--depth=${depth}`, remoteBranch])
+        await gitRun(["checkout", "-b", localBranch, `fork/${remoteBranch}`])
         return localBranch
       }
 
@@ -1115,28 +1136,23 @@ export const GithubRunCommand = cmd({
       async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
         console.log("Pushing to new branch...")
         if (commit) {
-          await $`git add .`
+          await gitRun(["add", "."])
           if (isSchedule) {
-            // No co-author for scheduled events - the schedule is operating as the repo
-            await $`git commit -m "${summary}"`
+            await commitChanges(summary)
           } else {
-            await $`git commit -m "${summary}
-
-Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
+            await commitChanges(summary, actor)
           }
         }
-        await $`git push -u origin ${branch}`
+        await gitRun(["push", "-u", "origin", branch])
       }
 
       async function pushToLocalBranch(summary: string, commit: boolean) {
         console.log("Pushing to local branch...")
         if (commit) {
-          await $`git add .`
-          await $`git commit -m "${summary}
-
-Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
+          await gitRun(["add", "."])
+          await commitChanges(summary, actor)
         }
-        await $`git push`
+        await gitRun(["push"])
       }
 
       async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) {
@@ -1145,30 +1161,28 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
         const remoteBranch = pr.headRefName
 
         if (commit) {
-          await $`git add .`
-          await $`git commit -m "${summary}
-
-Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
+          await gitRun(["add", "."])
+          await commitChanges(summary, actor)
         }
-        await $`git push fork HEAD:${remoteBranch}`
+        await gitRun(["push", "fork", `HEAD:${remoteBranch}`])
       }
 
       async function branchIsDirty(originalHead: string, expectedBranch: string) {
         console.log("Checking if branch is dirty...")
         // Detect if the agent switched branches during chat (e.g. created
         // its own branch, committed, and possibly pushed/created a PR).
-        const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
+        const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"])
         if (current !== expectedBranch) {
           console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
           return { dirty: true, uncommittedChanges: false, switched: true }
         }
 
-        const ret = await $`git status --porcelain`
+        const ret = await gitStatus(["status", "--porcelain"])
         const status = ret.stdout.toString().trim()
         if (status.length > 0) {
           return { dirty: true, uncommittedChanges: true, switched: false }
         }
-        const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
+        const head = await gitText(["rev-parse", "HEAD"])
         return {
           dirty: head !== originalHead,
           uncommittedChanges: false,
@@ -1180,11 +1194,11 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
       // Falls back to fetching from origin when local refs are missing
       // (common in shallow clones from actions/checkout).
       async function hasNewCommits(base: string, head: string) {
-        const result = await $`git rev-list --count ${base}..${head}`.nothrow()
+        const result = await gitStatus(["rev-list", "--count", `${base}..${head}`])
         if (result.exitCode !== 0) {
           console.log(`rev-list failed, fetching origin/${base}...`)
-          await $`git fetch origin ${base} --depth=1`.nothrow()
-          const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
+          await gitStatus(["fetch", "origin", base, "--depth=1"])
+          const retry = await gitStatus(["rev-list", "--count", `origin/${base}..${head}`])
           if (retry.exitCode !== 0) return true // assume dirty if we can't tell
           return parseInt(retry.stdout.toString().trim()) > 0
         }

+ 34 - 13
packages/opencode/src/cli/cmd/pr.ts

@@ -1,7 +1,8 @@
 import { UI } from "../ui"
 import { cmd } from "./cmd"
 import { Instance } from "@/project/instance"
-import { $ } from "bun"
+import { Process } from "@/util/process"
+import { git } from "@/util/git"
 
 export const PrCommand = cmd({
   command: "pr <number>",
@@ -27,21 +28,35 @@ export const PrCommand = cmd({
         UI.println(`Fetching and checking out PR #${prNumber}...`)
 
         // Use gh pr checkout with custom branch name
-        const result = await $`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow()
+        const result = await Process.run(
+          ["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
+          {
+            nothrow: true,
+          },
+        )
 
-        if (result.exitCode !== 0) {
+        if (result.code !== 0) {
           UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
           process.exit(1)
         }
 
         // Fetch PR info for fork handling and session link detection
-        const prInfoResult =
-          await $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow()
+        const prInfoResult = await Process.text(
+          [
+            "gh",
+            "pr",
+            "view",
+            `${prNumber}`,
+            "--json",
+            "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
+          ],
+          { nothrow: true },
+        )
 
         let sessionId: string | undefined
 
-        if (prInfoResult.exitCode === 0) {
-          const prInfoText = prInfoResult.text()
+        if (prInfoResult.code === 0) {
+          const prInfoText = prInfoResult.text
           if (prInfoText.trim()) {
             const prInfo = JSON.parse(prInfoText)
 
@@ -52,15 +67,19 @@ export const PrCommand = cmd({
               const remoteName = forkOwner
 
               // Check if remote already exists
-              const remotes = (await $`git remote`.nothrow().text()).trim()
+              const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
               if (!remotes.split("\n").includes(remoteName)) {
-                await $`git remote add ${remoteName} https://github.com/${forkOwner}/${forkName}.git`.nothrow()
+                await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
+                  cwd: Instance.worktree,
+                })
                 UI.println(`Added fork remote: ${remoteName}`)
               }
 
               // Set upstream to the fork so pushes go there
               const headRefName = prInfo.headRefName
-              await $`git branch --set-upstream-to=${remoteName}/${headRefName} ${localBranchName}`.nothrow()
+              await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
+                cwd: Instance.worktree,
+              })
             }
 
             // Check for opencode session link in PR body
@@ -71,9 +90,11 @@ export const PrCommand = cmd({
                 UI.println(`Found opencode session: ${sessionUrl}`)
                 UI.println(`Importing session...`)
 
-                const importResult = await $`opencode import ${sessionUrl}`.nothrow()
-                if (importResult.exitCode === 0) {
-                  const importOutput = importResult.text().trim()
+                const importResult = await Process.text(["opencode", "import", sessionUrl], {
+                  nothrow: true,
+                })
+                if (importResult.code === 0) {
+                  const importOutput = importResult.text.trim()
                   // Extract session ID from the output (format: "Imported session: <session-id>")
                   const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
                   if (sessionIdMatch) {

+ 32 - 15
packages/opencode/src/cli/cmd/tui/util/clipboard.ts

@@ -1,9 +1,9 @@
-import { $ } from "bun"
 import { platform, release } from "os"
 import clipboardy from "clipboardy"
 import { lazy } from "../../../../util/lazy.js"
 import { tmpdir } from "os"
 import path from "path"
+import fs from "fs/promises"
 import { Filesystem } from "../../../../util/filesystem"
 import { Process } from "../../../../util/process"
 import { which } from "../../../../util/which"
@@ -34,23 +34,38 @@ export namespace Clipboard {
     if (os === "darwin") {
       const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
       try {
-        await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
-          .nothrow()
-          .quiet()
+        await Process.run(
+          [
+            "osascript",
+            "-e",
+            'set imageData to the clipboard as "PNGf"',
+            "-e",
+            `set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
+            "-e",
+            "set eof fileRef to 0",
+            "-e",
+            "write imageData to fileRef",
+            "-e",
+            "close access fileRef",
+          ],
+          { nothrow: true },
+        )
         const buffer = await Filesystem.readBytes(tmpfile)
         return { data: buffer.toString("base64"), mime: "image/png" }
       } catch {
       } finally {
-        await $`rm -f "${tmpfile}"`.nothrow().quiet()
+        await fs.rm(tmpfile, { force: true }).catch(() => {})
       }
     }
 
     if (os === "win32" || release().includes("WSL")) {
       const script =
         "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
-      const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
-      if (base64) {
-        const imageBuffer = Buffer.from(base64.trim(), "base64")
+      const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
+        nothrow: true,
+      })
+      if (base64.text) {
+        const imageBuffer = Buffer.from(base64.text.trim(), "base64")
         if (imageBuffer.length > 0) {
           return { data: imageBuffer.toString("base64"), mime: "image/png" }
         }
@@ -58,13 +73,15 @@ export namespace Clipboard {
     }
 
     if (os === "linux") {
-      const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
-      if (wayland && wayland.byteLength > 0) {
-        return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
+      const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
+      if (wayland.stdout.byteLength > 0) {
+        return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
       }
-      const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
-      if (x11 && x11.byteLength > 0) {
-        return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
+      const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
+        nothrow: true,
+      })
+      if (x11.stdout.byteLength > 0) {
+        return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
       }
     }
 
@@ -81,7 +98,7 @@ export namespace Clipboard {
       console.log("clipboard: using osascript")
       return async (text: string) => {
         const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
-        await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
+        await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
       }
     }
 

+ 8 - 11
packages/opencode/src/cli/cmd/uninstall.ts

@@ -3,11 +3,11 @@ import { UI } from "../ui"
 import * as prompts from "@clack/prompts"
 import { Installation } from "../../installation"
 import { Global } from "../../global"
-import { $ } from "bun"
 import fs from "fs/promises"
 import path from "path"
 import os from "os"
 import { Filesystem } from "../../util/filesystem"
+import { Process } from "../../util/process"
 
 interface UninstallArgs {
   keepConfig: boolean
@@ -192,16 +192,13 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
     const cmd = cmds[method]
     if (cmd) {
       spinner.start(`Running ${cmd.join(" ")}...`)
-      const result =
-        method === "choco"
-          ? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow()
-          : await $`${cmd}`.quiet().nothrow()
-      if (result.exitCode !== 0) {
-        spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1)
-        if (
-          method === "choco" &&
-          result.stdout.toString("utf8").includes("not running from an elevated command shell")
-        ) {
+      const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
+        nothrow: true,
+      })
+      if (result.code !== 0) {
+        spinner.stop(`Package manager uninstall failed: exit code ${result.code}`, 1)
+        const text = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}`
+        if (method === "choco" && text.includes("not running from an elevated command shell")) {
           prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
         } else {
           prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)

+ 27 - 25
packages/opencode/src/file/index.ts

@@ -1,6 +1,5 @@
 import { BusEvent } from "@/bus/bus-event"
 import z from "zod"
-import { $ } from "bun"
 import { formatPatch, structuredPatch } from "diff"
 import path from "path"
 import fs from "fs"
@@ -11,6 +10,7 @@ import { Instance } from "../project/instance"
 import { Ripgrep } from "./ripgrep"
 import fuzzysort from "fuzzysort"
 import { Global } from "../global"
+import { git } from "@/util/git"
 
 export namespace File {
   const log = Log.create({ service: "file" })
@@ -418,11 +418,11 @@ export namespace File {
     const project = Instance.project
     if (project.vcs !== "git") return []
 
-    const diffOutput = await $`git -c core.fsmonitor=false -c core.quotepath=false diff --numstat HEAD`
-      .cwd(Instance.directory)
-      .quiet()
-      .nothrow()
-      .text()
+    const diffOutput = (
+      await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
+        cwd: Instance.directory,
+      })
+    ).text()
 
     const changedFiles: Info[] = []
 
@@ -439,12 +439,14 @@ export namespace File {
       }
     }
 
-    const untrackedOutput =
-      await $`git -c core.fsmonitor=false -c core.quotepath=false ls-files --others --exclude-standard`
-        .cwd(Instance.directory)
-        .quiet()
-        .nothrow()
-        .text()
+    const untrackedOutput = (
+      await git(
+        ["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"],
+        {
+          cwd: Instance.directory,
+        },
+      )
+    ).text()
 
     if (untrackedOutput.trim()) {
       const untrackedFiles = untrackedOutput.trim().split("\n")
@@ -465,12 +467,14 @@ export namespace File {
     }
 
     // Get deleted files
-    const deletedOutput =
-      await $`git -c core.fsmonitor=false -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
-        .cwd(Instance.directory)
-        .quiet()
-        .nothrow()
-        .text()
+    const deletedOutput = (
+      await git(
+        ["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"],
+        {
+          cwd: Instance.directory,
+        },
+      )
+    ).text()
 
     if (deletedOutput.trim()) {
       const deletedFiles = deletedOutput.trim().split("\n")
@@ -541,16 +545,14 @@ export namespace File {
     const content = (await Filesystem.readText(full).catch(() => "")).trim()
 
     if (project.vcs === "git") {
-      let diff = await $`git -c core.fsmonitor=false diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
+      let diff = (await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })).text()
       if (!diff.trim()) {
-        diff = await $`git -c core.fsmonitor=false diff --staged ${file}`
-          .cwd(Instance.directory)
-          .quiet()
-          .nothrow()
-          .text()
+        diff = (
+          await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory })
+        ).text()
       }
       if (diff.trim()) {
-        const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
+        const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
         const patch = structuredPatch(file, file, original, content, "old", "new", {
           context: Infinity,
           ignoreWhitespace: true,

+ 8 - 6
packages/opencode/src/file/ripgrep.ts

@@ -5,7 +5,7 @@ import fs from "fs/promises"
 import z from "zod"
 import { NamedError } from "@opencode-ai/util/error"
 import { lazy } from "../util/lazy"
-import { $ } from "bun"
+
 import { Filesystem } from "../util/filesystem"
 import { Process } from "../util/process"
 import { which } from "../util/which"
@@ -338,7 +338,7 @@ export namespace Ripgrep {
     limit?: number
     follow?: boolean
   }) {
-    const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
+    const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
     if (input.follow) args.push("--follow")
 
     if (input.glob) {
@@ -354,14 +354,16 @@ export namespace Ripgrep {
     args.push("--")
     args.push(input.pattern)
 
-    const command = args.join(" ")
-    const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
-    if (result.exitCode !== 0) {
+    const result = await Process.text(args, {
+      cwd: input.cwd,
+      nothrow: true,
+    })
+    if (result.code !== 0) {
       return []
     }
 
     // Handle both Unix (\n) and Windows (\r\n) line endings
-    const lines = result.text().trim().split(/\r?\n/).filter(Boolean)
+    const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
     // Parse JSON lines from ripgrep output
 
     return lines

+ 5 - 8
packages/opencode/src/file/watcher.ts

@@ -11,9 +11,9 @@ import { createWrapper } from "@parcel/watcher/wrapper"
 import { lazy } from "@/util/lazy"
 import { withTimeout } from "@/util/timeout"
 import type ParcelWatcher from "@parcel/watcher"
-import { $ } from "bun"
 import { Flag } from "@/flag/flag"
 import { readdir } from "fs/promises"
+import { git } from "@/util/git"
 
 const SUBSCRIBE_TIMEOUT_MS = 10_000
 
@@ -88,13 +88,10 @@ export namespace FileWatcher {
       }
 
       if (Instance.project.vcs === "git") {
-        const vcsDir = await $`git rev-parse --git-dir`
-          .quiet()
-          .nothrow()
-          .cwd(Instance.worktree)
-          .text()
-          .then((x) => path.resolve(Instance.worktree, x.trim()))
-          .catch(() => undefined)
+        const result = await git(["rev-parse", "--git-dir"], {
+          cwd: Instance.worktree,
+        })
+        const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined
         if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
           const gitDirContents = await readdir(vcsDir).catch(() => [])
           const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")

+ 80 - 38
packages/opencode/src/installation/index.ts

@@ -1,11 +1,12 @@
 import { BusEvent } from "@/bus/bus-event"
 import path from "path"
-import { $ } from "bun"
 import z from "zod"
 import { NamedError } from "@opencode-ai/util/error"
 import { Log } from "../util/log"
 import { iife } from "@/util/iife"
 import { Flag } from "../flag/flag"
+import { Process } from "@/util/process"
+import { buffer } from "node:stream/consumers"
 
 declare global {
   const OPENCODE_VERSION: string
@@ -15,6 +16,38 @@ declare global {
 export namespace Installation {
   const log = Log.create({ service: "installation" })
 
+  async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
+    return Process.text(cmd, {
+      cwd: opts.cwd,
+      env: opts.env,
+      nothrow: true,
+    }).then((x) => x.text)
+  }
+
+  async function upgradeCurl(target: string) {
+    const body = await fetch("https://opencode.ai/install").then((res) => {
+      if (!res.ok) throw new Error(res.statusText)
+      return res.text()
+    })
+    const proc = Process.spawn(["bash"], {
+      stdin: "pipe",
+      stdout: "pipe",
+      stderr: "pipe",
+      env: {
+        ...process.env,
+        VERSION: target,
+      },
+    })
+    if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
+    proc.stdin.end(body)
+    const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
+    return {
+      code,
+      stdout,
+      stderr,
+    }
+  }
+
   export type Method = Awaited<ReturnType<typeof method>>
 
   export const Event = {
@@ -65,31 +98,31 @@ export namespace Installation {
     const checks = [
       {
         name: "npm" as const,
-        command: () => $`npm list -g --depth=0`.throws(false).quiet().text(),
+        command: () => text(["npm", "list", "-g", "--depth=0"]),
       },
       {
         name: "yarn" as const,
-        command: () => $`yarn global list`.throws(false).quiet().text(),
+        command: () => text(["yarn", "global", "list"]),
       },
       {
         name: "pnpm" as const,
-        command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(),
+        command: () => text(["pnpm", "list", "-g", "--depth=0"]),
       },
       {
         name: "bun" as const,
-        command: () => $`bun pm ls -g`.throws(false).quiet().text(),
+        command: () => text(["bun", "pm", "ls", "-g"]),
       },
       {
         name: "brew" as const,
-        command: () => $`brew list --formula opencode`.throws(false).quiet().text(),
+        command: () => text(["brew", "list", "--formula", "opencode"]),
       },
       {
         name: "scoop" as const,
-        command: () => $`scoop list opencode`.throws(false).quiet().text(),
+        command: () => text(["scoop", "list", "opencode"]),
       },
       {
         name: "choco" as const,
-        command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(),
+        command: () => text(["choco", "list", "--limit-output", "opencode"]),
       },
     ]
 
@@ -121,61 +154,70 @@ export namespace Installation {
   )
 
   async function getBrewFormula() {
-    const tapFormula = await $`brew list --formula anomalyco/tap/opencode`.throws(false).quiet().text()
+    const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
     if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
-    const coreFormula = await $`brew list --formula opencode`.throws(false).quiet().text()
+    const coreFormula = await text(["brew", "list", "--formula", "opencode"])
     if (coreFormula.includes("opencode")) return "opencode"
     return "opencode"
   }
 
   export async function upgrade(method: Method, target: string) {
-    let cmd
+    let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
     switch (method) {
       case "curl":
-        cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({
-          ...process.env,
-          VERSION: target,
-        })
+        result = await upgradeCurl(target)
         break
       case "npm":
-        cmd = $`npm install -g opencode-ai@${target}`
+        result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
         break
       case "pnpm":
-        cmd = $`pnpm install -g opencode-ai@${target}`
+        result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
         break
       case "bun":
-        cmd = $`bun install -g opencode-ai@${target}`
+        result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
         break
       case "brew": {
         const formula = await getBrewFormula()
-        if (formula.includes("/")) {
-          cmd =
-            $`brew tap anomalyco/tap && cd "$(brew --repo anomalyco/tap)" && git pull --ff-only && brew upgrade ${formula}`.env(
-              {
-                HOMEBREW_NO_AUTO_UPDATE: "1",
-                ...process.env,
-              },
-            )
-          break
-        }
-        cmd = $`brew upgrade ${formula}`.env({
+        const env = {
           HOMEBREW_NO_AUTO_UPDATE: "1",
           ...process.env,
-        })
+        }
+        if (formula.includes("/")) {
+          const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
+          if (tap.code !== 0) {
+            result = tap
+            break
+          }
+          const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
+          if (repo.code !== 0) {
+            result = repo
+            break
+          }
+          const dir = repo.text.trim()
+          if (dir) {
+            const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
+            if (pull.code !== 0) {
+              result = pull
+              break
+            }
+          }
+        }
+        result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
         break
       }
+
       case "choco":
-        cmd = $`echo Y | choco upgrade opencode --version=${target}`
+        result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
         break
       case "scoop":
-        cmd = $`scoop install opencode@${target}`
+        result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
         break
       default:
         throw new Error(`Unknown method: ${method}`)
     }
-    const result = await cmd.quiet().throws(false)
-    if (result.exitCode !== 0) {
-      const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8")
+    if (!result || result.code !== 0) {
+      const stderr =
+        method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
       throw new UpgradeFailedError({
         stderr: stderr,
       })
@@ -186,7 +228,7 @@ export namespace Installation {
       stdout: result.stdout.toString(),
       stderr: result.stderr.toString(),
     })
-    await $`${process.execPath} --version`.nothrow().quiet().text()
+    await Process.text([process.execPath, "--version"], { nothrow: true })
   }
 
   export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
@@ -199,7 +241,7 @@ export namespace Installation {
     if (detectedMethod === "brew") {
       const formula = await getBrewFormula()
       if (formula.includes("/")) {
-        const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text()
+        const infoJson = await text(["brew", "info", "--json=v2", formula])
         const info = JSON.parse(infoJson)
         const version = info.formulae?.[0]?.versions?.stable
         if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
@@ -215,7 +257,7 @@ export namespace Installation {
 
     if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
       const registry = await iife(async () => {
-        const r = (await $`npm config get registry`.quiet().nothrow().text()).trim()
+        const r = (await text(["npm", "config", "get", "registry"])).trim()
         const reg = r || "https://registry.npmjs.org"
         return reg.endsWith("/") ? reg.slice(0, -1) : reg
       })

+ 62 - 51
packages/opencode/src/lsp/server.ts

@@ -4,7 +4,6 @@ import os from "os"
 import { Global } from "../global"
 import { Log } from "../util/log"
 import { BunProc } from "../bun"
-import { $ } from "bun"
 import { text } from "node:stream/consumers"
 import fs from "fs/promises"
 import { Filesystem } from "../util/filesystem"
@@ -13,6 +12,7 @@ import { Flag } from "../flag/flag"
 import { Archive } from "../util/archive"
 import { Process } from "../util/process"
 import { which } from "../util/which"
+import { Module } from "@opencode-ai/util/module"
 
 export namespace LSPServer {
   const log = Log.create({ service: "lsp.server" })
@@ -21,6 +21,8 @@ export namespace LSPServer {
       .stat(p)
       .then(() => true)
       .catch(() => false)
+  const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true })
+  const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true })
 
   export interface Handle {
     process: ChildProcessWithoutNullStreams
@@ -97,7 +99,7 @@ export namespace LSPServer {
     ),
     extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
     async spawn(root) {
-      const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
+      const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
       log.info("typescript server", { tsserver })
       if (!tsserver) return
       const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
@@ -172,7 +174,7 @@ export namespace LSPServer {
     root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
     extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
     async spawn(root) {
-      const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {})
+      const eslint = Module.resolve("eslint", Instance.directory)
       if (!eslint) return
       log.info("spawning eslint server")
       const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
@@ -205,8 +207,8 @@ export namespace LSPServer {
         await fs.rename(extractedPath, finalPath)
 
         const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
-        await $`${npmCmd} install`.cwd(finalPath).quiet()
-        await $`${npmCmd} run compile`.cwd(finalPath).quiet()
+        await Process.run([npmCmd, "install"], { cwd: finalPath })
+        await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
 
         log.info("installed VS Code ESLint server", { serverPath })
       }
@@ -340,7 +342,7 @@ export namespace LSPServer {
       let args = ["lsp-proxy", "--stdio"]
 
       if (!bin) {
-        const resolved = await Bun.resolve("biome", root).catch(() => undefined)
+        const resolved = Module.resolve("biome", root)
         if (!resolved) return
         bin = BunProc.which()
         args = ["x", "biome", "lsp-proxy", "--stdio"]
@@ -602,10 +604,11 @@ export namespace LSPServer {
             recursive: true,
           })
 
-          await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
-            .quiet()
-            .cwd(path.join(Global.Path.bin, "elixir-ls-master"))
-            .env({ MIX_ENV: "prod", ...process.env })
+          const cwd = path.join(Global.Path.bin, "elixir-ls-master")
+          const env = { MIX_ENV: "prod", ...process.env }
+          await Process.run(["mix", "deps.get"], { cwd, env })
+          await Process.run(["mix", "compile"], { cwd, env })
+          await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env })
 
           log.info(`installed elixir-ls`, {
             path: elixirLsPath,
@@ -706,7 +709,7 @@ export namespace LSPServer {
             })
           if (!ok) return
         } else {
-          await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
+          await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin })
         }
 
         await fs.rm(tempPath, { force: true })
@@ -719,7 +722,7 @@ export namespace LSPServer {
         }
 
         if (platform !== "win32") {
-          await $`chmod +x ${bin}`.quiet().nothrow()
+          await fs.chmod(bin, 0o755).catch(() => {})
         }
 
         log.info(`installed zls`, { bin })
@@ -831,11 +834,11 @@ export namespace LSPServer {
       // This is specific to macOS where sourcekit-lsp is typically installed with Xcode
       if (!which("xcrun")) return
 
-      const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
+      const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"])
 
-      if (lspLoc.exitCode !== 0) return
+      if (lspLoc.code !== 0) return
 
-      const bin = lspLoc.text().trim()
+      const bin = lspLoc.text.trim()
 
       return {
         process: spawn(bin, {
@@ -1010,7 +1013,7 @@ export namespace LSPServer {
         if (!ok) return
       }
       if (tar) {
-        await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow()
+        await run(["tar", "-xf", archive], { cwd: Global.Path.bin })
       }
       await fs.rm(archive, { force: true })
 
@@ -1021,7 +1024,7 @@ export namespace LSPServer {
       }
 
       if (platform !== "win32") {
-        await $`chmod +x ${bin}`.quiet().nothrow()
+        await fs.chmod(bin, 0o755).catch(() => {})
       }
 
       await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
@@ -1082,7 +1085,7 @@ export namespace LSPServer {
     extensions: [".astro"],
     root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
     async spawn(root) {
-      const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
+      const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
       if (!tsserver) {
         log.info("typescript not found, required for Astro language server")
         return
@@ -1161,13 +1164,10 @@ export namespace LSPServer {
         log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
         return
       }
-      const javaMajorVersion = await $`java -version`
-        .quiet()
-        .nothrow()
-        .then(({ stderr }) => {
-          const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString())
-          return !m ? undefined : parseInt(m[1])
-        })
+      const javaMajorVersion = await run(["java", "-version"]).then((result) => {
+        const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString())
+        return !m ? undefined : parseInt(m[1])
+      })
       if (javaMajorVersion == null || javaMajorVersion < 21) {
         log.error("JDTLS requires at least Java 21.")
         return
@@ -1184,27 +1184,27 @@ export namespace LSPServer {
         const archiveName = "release.tar.gz"
 
         log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
-        const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow()
-        if (curlResult.exitCode !== 0) {
-          log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() })
+        const download = await fetch(releaseURL)
+        if (!download.ok || !download.body) {
+          log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText })
           return
         }
+        await Filesystem.writeStream(path.join(distPath, archiveName), download.body)
 
         log.info("Extracting JDTLS archive")
-        const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow()
-        if (tarResult.exitCode !== 0) {
-          log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() })
+        const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath })
+        if (tarResult.code !== 0) {
+          log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() })
           return
         }
 
         await fs.rm(path.join(distPath, archiveName), { force: true })
         log.info("JDTLS download and extraction completed")
       }
-      const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
-        .cwd(launcherDir)
-        .quiet()
-        .nothrow()
-        .then(({ stdout }) => stdout.toString().trim())
+      const jarFileName =
+        (await fs.readdir(launcherDir).catch(() => []))
+          .find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item))
+          ?.trim() ?? ""
       const launcherJar = path.join(launcherDir, jarFileName)
       if (!(await pathExists(launcherJar))) {
         log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
@@ -1317,7 +1317,15 @@ export namespace LSPServer {
 
         await fs.mkdir(distPath, { recursive: true })
         const archivePath = path.join(distPath, "kotlin-ls.zip")
-        await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
+        const download = await fetch(releaseURL)
+        if (!download.ok || !download.body) {
+          log.error("Failed to download Kotlin Language Server", {
+            status: download.status,
+            statusText: download.statusText,
+          })
+          return
+        }
+        await Filesystem.writeStream(archivePath, download.body)
         const ok = await Archive.extractZip(archivePath, distPath)
           .then(() => true)
           .catch((error) => {
@@ -1327,7 +1335,7 @@ export namespace LSPServer {
         if (!ok) return
         await fs.rm(archivePath, { force: true })
         if (process.platform !== "win32") {
-          await $`chmod +x ${launcherScript}`.quiet().nothrow()
+          await fs.chmod(launcherScript, 0o755).catch(() => {})
         }
         log.info("Installed Kotlin Language Server", { path: launcherScript })
       }
@@ -1491,10 +1499,9 @@ export namespace LSPServer {
             })
           if (!ok) return
         } else {
-          const ok = await $`tar -xzf ${tempPath} -C ${installDir}`
-            .quiet()
-            .then(() => true)
-            .catch((error) => {
+          const ok = await run(["tar", "-xzf", tempPath, "-C", installDir])
+            .then((result) => result.code === 0)
+            .catch((error: unknown) => {
               log.error("Failed to extract lua-language-server archive", { error })
               return false
             })
@@ -1512,11 +1519,15 @@ export namespace LSPServer {
         }
 
         if (platform !== "win32") {
-          const ok = await $`chmod +x ${bin}`.quiet().catch((error) => {
-            log.error("Failed to set executable permission for lua-language-server binary", {
-              error,
+          const ok = await fs
+            .chmod(bin, 0o755)
+            .then(() => true)
+            .catch((error: unknown) => {
+              log.error("Failed to set executable permission for lua-language-server binary", {
+                error,
+              })
+              return false
             })
-          })
           if (!ok) return
         }
 
@@ -1730,7 +1741,7 @@ export namespace LSPServer {
         }
 
         if (platform !== "win32") {
-          await $`chmod +x ${bin}`.quiet().nothrow()
+          await fs.chmod(bin, 0o755).catch(() => {})
         }
 
         log.info(`installed terraform-ls`, { bin })
@@ -1813,7 +1824,7 @@ export namespace LSPServer {
           if (!ok) return
         }
         if (ext === "tar.gz") {
-          await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
+          await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin })
         }
 
         await fs.rm(tempPath, { force: true })
@@ -1826,7 +1837,7 @@ export namespace LSPServer {
         }
 
         if (platform !== "win32") {
-          await $`chmod +x ${bin}`.quiet().nothrow()
+          await fs.chmod(bin, 0o755).catch(() => {})
         }
 
         log.info("installed texlab", { bin })
@@ -2018,7 +2029,7 @@ export namespace LSPServer {
             })
           if (!ok) return
         } else {
-          await $`tar -xzf ${tempPath} --strip-components=1`.cwd(Global.Path.bin).quiet().nothrow()
+          await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin })
         }
 
         await fs.rm(tempPath, { force: true })
@@ -2031,7 +2042,7 @@ export namespace LSPServer {
         }
 
         if (platform !== "win32") {
-          await $`chmod +x ${bin}`.quiet().nothrow()
+          await fs.chmod(bin, 0o755).catch(() => {})
         }
 
         log.info("installed tinymist", { bin })

+ 8 - 8
packages/opencode/src/project/vcs.ts

@@ -1,11 +1,11 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
-import { $ } from "bun"
 import path from "path"
 import z from "zod"
 import { Log } from "@/util/log"
 import { Instance } from "./instance"
 import { FileWatcher } from "@/file/watcher"
+import { git } from "@/util/git"
 
 const log = Log.create({ service: "vcs" })
 
@@ -29,13 +29,13 @@ export namespace Vcs {
   export type Info = z.infer<typeof Info>
 
   async function currentBranch() {
-    return $`git rev-parse --abbrev-ref HEAD`
-      .quiet()
-      .nothrow()
-      .cwd(Instance.worktree)
-      .text()
-      .then((x) => x.trim())
-      .catch(() => undefined)
+    const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
+      cwd: Instance.worktree,
+    })
+    if (result.exitCode !== 0) return
+    const text = result.text().trim()
+    if (!text) return
+    return text
   }
 
   const state = Instance.state(

+ 206 - 87
packages/opencode/src/snapshot/index.ts

@@ -1,4 +1,3 @@
-import { $ } from "bun"
 import path from "path"
 import fs from "fs/promises"
 import { Filesystem } from "../util/filesystem"
@@ -9,12 +8,17 @@ import z from "zod"
 import { Config } from "../config/config"
 import { Instance } from "../project/instance"
 import { Scheduler } from "../scheduler"
+import { Process } from "@/util/process"
 
 export namespace Snapshot {
   const log = Log.create({ service: "snapshot" })
   const hour = 60 * 60 * 1000
   const prune = "7.days"
 
+  function args(git: string, cmd: string[]) {
+    return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
+  }
+
   export function init() {
     Scheduler.register({
       id: "snapshot.cleanup",
@@ -34,13 +38,13 @@ export namespace Snapshot {
       .then(() => true)
       .catch(() => false)
     if (!exists) return
-    const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
-      .quiet()
-      .cwd(Instance.directory)
-      .nothrow()
-    if (result.exitCode !== 0) {
+    const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
+      cwd: Instance.directory,
+      nothrow: true,
+    })
+    if (result.code !== 0) {
       log.warn("cleanup failed", {
-        exitCode: result.exitCode,
+        exitCode: result.code,
         stderr: result.stderr.toString(),
         stdout: result.stdout.toString(),
       })
@@ -55,27 +59,27 @@ export namespace Snapshot {
     if (cfg.snapshot === false) return
     const git = gitdir()
     if (await fs.mkdir(git, { recursive: true })) {
-      await $`git init`
-        .env({
+      await Process.run(["git", "init"], {
+        env: {
           ...process.env,
           GIT_DIR: git,
           GIT_WORK_TREE: Instance.worktree,
-        })
-        .quiet()
-        .nothrow()
+        },
+        nothrow: true,
+      })
+
       // Configure git to not convert line endings on Windows
-      await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
-      await $`git --git-dir ${git} config core.longpaths true`.quiet().nothrow()
-      await $`git --git-dir ${git} config core.symlinks true`.quiet().nothrow()
-      await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow()
+      await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
+      await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
+      await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
+      await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
       log.info("initialized")
     }
     await add(git)
-    const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
-      .quiet()
-      .cwd(Instance.directory)
-      .nothrow()
-      .text()
+    const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
+      cwd: Instance.directory,
+      nothrow: true,
+    }).then((x) => x.text)
     log.info("tracking", { hash, cwd: Instance.directory, git })
     return hash.trim()
   }
@@ -89,19 +93,32 @@ export namespace Snapshot {
   export async function patch(hash: string): Promise<Patch> {
     const git = gitdir()
     await add(git)
-    const result =
-      await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
-        .quiet()
-        .cwd(Instance.directory)
-        .nothrow()
+    const result = await Process.text(
+      [
+        "git",
+        "-c",
+        "core.autocrlf=false",
+        "-c",
+        "core.longpaths=true",
+        "-c",
+        "core.symlinks=true",
+        "-c",
+        "core.quotepath=false",
+        ...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
+      ],
+      {
+        cwd: Instance.directory,
+        nothrow: true,
+      },
+    )
 
     // If git diff fails, return empty patch
-    if (result.exitCode !== 0) {
-      log.warn("failed to get diff", { hash, exitCode: result.exitCode })
+    if (result.code !== 0) {
+      log.warn("failed to get diff", { hash, exitCode: result.code })
       return { hash, files: [] }
     }
 
-    const files = result.text()
+    const files = result.text
     return {
       hash,
       files: files
@@ -116,20 +133,37 @@ export namespace Snapshot {
   export async function restore(snapshot: string) {
     log.info("restore", { commit: snapshot })
     const git = gitdir()
-    const result =
-      await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
-        .quiet()
-        .cwd(Instance.worktree)
-        .nothrow()
-
-    if (result.exitCode !== 0) {
+    const result = await Process.run(
+      ["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
+      {
+        cwd: Instance.worktree,
+        nothrow: true,
+      },
+    )
+    if (result.code === 0) {
+      const checkout = await Process.run(
+        ["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
+        {
+          cwd: Instance.worktree,
+          nothrow: true,
+        },
+      )
+      if (checkout.code === 0) return
       log.error("failed to restore snapshot", {
         snapshot,
-        exitCode: result.exitCode,
-        stderr: result.stderr.toString(),
-        stdout: result.stdout.toString(),
+        exitCode: checkout.code,
+        stderr: checkout.stderr.toString(),
+        stdout: checkout.stdout.toString(),
       })
+      return
     }
+
+    log.error("failed to restore snapshot", {
+      snapshot,
+      exitCode: result.code,
+      stderr: result.stderr.toString(),
+      stdout: result.stdout.toString(),
+    })
   }
 
   export async function revert(patches: Patch[]) {
@@ -139,19 +173,37 @@ export namespace Snapshot {
       for (const file of item.files) {
         if (files.has(file)) continue
         log.info("reverting", { file, hash: item.hash })
-        const result =
-          await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
-            .quiet()
-            .cwd(Instance.worktree)
-            .nothrow()
-        if (result.exitCode !== 0) {
+        const result = await Process.run(
+          [
+            "git",
+            "-c",
+            "core.longpaths=true",
+            "-c",
+            "core.symlinks=true",
+            ...args(git, ["checkout", item.hash, "--", file]),
+          ],
+          {
+            cwd: Instance.worktree,
+            nothrow: true,
+          },
+        )
+        if (result.code !== 0) {
           const relativePath = path.relative(Instance.worktree, file)
-          const checkTree =
-            await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
-              .quiet()
-              .cwd(Instance.worktree)
-              .nothrow()
-          if (checkTree.exitCode === 0 && checkTree.text().trim()) {
+          const checkTree = await Process.text(
+            [
+              "git",
+              "-c",
+              "core.longpaths=true",
+              "-c",
+              "core.symlinks=true",
+              ...args(git, ["ls-tree", item.hash, "--", relativePath]),
+            ],
+            {
+              cwd: Instance.worktree,
+              nothrow: true,
+            },
+          )
+          if (checkTree.code === 0 && checkTree.text.trim()) {
             log.info("file existed in snapshot but checkout failed, keeping", {
               file,
             })
@@ -168,23 +220,36 @@ export namespace Snapshot {
   export async function diff(hash: string) {
     const git = gitdir()
     await add(git)
-    const result =
-      await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
-        .quiet()
-        .cwd(Instance.worktree)
-        .nothrow()
+    const result = await Process.text(
+      [
+        "git",
+        "-c",
+        "core.autocrlf=false",
+        "-c",
+        "core.longpaths=true",
+        "-c",
+        "core.symlinks=true",
+        "-c",
+        "core.quotepath=false",
+        ...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
+      ],
+      {
+        cwd: Instance.worktree,
+        nothrow: true,
+      },
+    )
 
-    if (result.exitCode !== 0) {
+    if (result.code !== 0) {
       log.warn("failed to get diff", {
         hash,
-        exitCode: result.exitCode,
+        exitCode: result.code,
         stderr: result.stderr.toString(),
         stdout: result.stdout.toString(),
       })
       return ""
     }
 
-    return result.text().trim()
+    return result.text.trim()
   }
 
   export const FileDiff = z
@@ -205,12 +270,24 @@ export namespace Snapshot {
     const result: FileDiff[] = []
     const status = new Map<string, "added" | "deleted" | "modified">()
 
-    const statuses =
-      await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
-        .quiet()
-        .cwd(Instance.directory)
-        .nothrow()
-        .text()
+    const statuses = await Process.text(
+      [
+        "git",
+        "-c",
+        "core.autocrlf=false",
+        "-c",
+        "core.longpaths=true",
+        "-c",
+        "core.symlinks=true",
+        "-c",
+        "core.quotepath=false",
+        ...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
+      ],
+      {
+        cwd: Instance.directory,
+        nothrow: true,
+      },
+    ).then((x) => x.text)
 
     for (const line of statuses.trim().split("\n")) {
       if (!line) continue
@@ -220,26 +297,57 @@ export namespace Snapshot {
       status.set(file, kind)
     }
 
-    for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
-      .quiet()
-      .cwd(Instance.directory)
-      .nothrow()
-      .lines()) {
+    for (const line of await Process.lines(
+      [
+        "git",
+        "-c",
+        "core.autocrlf=false",
+        "-c",
+        "core.longpaths=true",
+        "-c",
+        "core.symlinks=true",
+        "-c",
+        "core.quotepath=false",
+        ...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
+      ],
+      {
+        cwd: Instance.directory,
+        nothrow: true,
+      },
+    )) {
       if (!line) continue
       const [additions, deletions, file] = line.split("\t")
       const isBinaryFile = additions === "-" && deletions === "-"
       const before = isBinaryFile
         ? ""
-        : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
-            .quiet()
-            .nothrow()
-            .text()
+        : await Process.text(
+            [
+              "git",
+              "-c",
+              "core.autocrlf=false",
+              "-c",
+              "core.longpaths=true",
+              "-c",
+              "core.symlinks=true",
+              ...args(git, ["show", `${from}:${file}`]),
+            ],
+            { nothrow: true },
+          ).then((x) => x.text)
       const after = isBinaryFile
         ? ""
-        : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
-            .quiet()
-            .nothrow()
-            .text()
+        : await Process.text(
+            [
+              "git",
+              "-c",
+              "core.autocrlf=false",
+              "-c",
+              "core.longpaths=true",
+              "-c",
+              "core.symlinks=true",
+              ...args(git, ["show", `${to}:${file}`]),
+            ],
+            { nothrow: true },
+          ).then((x) => x.text)
       const added = isBinaryFile ? 0 : parseInt(additions)
       const deleted = isBinaryFile ? 0 : parseInt(deletions)
       result.push({
@@ -261,10 +369,22 @@ export namespace Snapshot {
 
   async function add(git: string) {
     await syncExclude(git)
-    await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .`
-      .quiet()
-      .cwd(Instance.directory)
-      .nothrow()
+    await Process.run(
+      [
+        "git",
+        "-c",
+        "core.autocrlf=false",
+        "-c",
+        "core.longpaths=true",
+        "-c",
+        "core.symlinks=true",
+        ...args(git, ["add", "."]),
+      ],
+      {
+        cwd: Instance.directory,
+        nothrow: true,
+      },
+    )
   }
 
   async function syncExclude(git: string) {
@@ -281,11 +401,10 @@ export namespace Snapshot {
   }
 
   async function excludes() {
-    const file = await $`git rev-parse --path-format=absolute --git-path info/exclude`
-      .quiet()
-      .cwd(Instance.worktree)
-      .nothrow()
-      .text()
+    const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
+      cwd: Instance.worktree,
+      nothrow: true,
+    }).then((x) => x.text)
     if (!file.trim()) return
     const exists = await fs
       .stat(file.trim())

+ 9 - 12
packages/opencode/src/storage/storage.ts

@@ -5,10 +5,10 @@ import { Global } from "../global"
 import { Filesystem } from "../util/filesystem"
 import { lazy } from "../util/lazy"
 import { Lock } from "../util/lock"
-import { $ } from "bun"
 import { NamedError } from "@opencode-ai/util/error"
 import z from "zod"
 import { Glob } from "../util/glob"
+import { git } from "@/util/git"
 
 export namespace Storage {
   const log = Log.create({ service: "storage" })
@@ -49,18 +49,15 @@ export namespace Storage {
           }
           if (!worktree) continue
           if (!(await Filesystem.isDir(worktree))) continue
-          const [id] = await $`git rev-list --max-parents=0 --all`
-            .quiet()
-            .nothrow()
-            .cwd(worktree)
+          const result = await git(["rev-list", "--max-parents=0", "--all"], {
+            cwd: worktree,
+          })
+          const [id] = result
             .text()
-            .then((x) =>
-              x
-                .split("\n")
-                .filter(Boolean)
-                .map((x) => x.trim())
-                .toSorted(),
-            )
+            .split("\n")
+            .filter(Boolean)
+            .map((x) => x.trim())
+            .toSorted()
           if (!id) continue
           projectID = id
 

+ 2 - 7
packages/opencode/src/tool/bash.ts

@@ -7,8 +7,8 @@ import { Log } from "../util/log"
 import { Instance } from "../project/instance"
 import { lazy } from "@/util/lazy"
 import { Language } from "web-tree-sitter"
+import fs from "fs/promises"
 
-import { $ } from "bun"
 import { Filesystem } from "@/util/filesystem"
 import { fileURLToPath } from "url"
 import { Flag } from "@/flag/flag.ts"
@@ -116,12 +116,7 @@ export const BashTool = Tool.define("bash", async () => {
         if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
           for (const arg of command.slice(1)) {
             if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
-            const resolved = await $`realpath ${arg}`
-              .cwd(cwd)
-              .quiet()
-              .nothrow()
-              .text()
-              .then((x) => x.trim())
+            const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "")
             log.info("resolved path", { arg, resolved })
             if (resolved) {
               const normalized =

+ 5 - 4
packages/opencode/src/util/archive.ts

@@ -1,5 +1,5 @@
-import { $ } from "bun"
 import path from "path"
+import { Process } from "./process"
 
 export namespace Archive {
   export async function extractZip(zipPath: string, destDir: string) {
@@ -8,9 +8,10 @@ export namespace Archive {
       const winDestDir = path.resolve(destDir)
       // $global:ProgressPreference suppresses PowerShell's blue progress bar popup
       const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
-      await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet()
-    } else {
-      await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet()
+      await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
+      return
     }
+
+    await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
   }
 }

+ 30 - 6
packages/opencode/src/util/process.ts

@@ -25,6 +25,10 @@ export namespace Process {
     stderr: Buffer
   }
 
+  export interface TextResult extends Result {
+    text: string
+  }
+
   export class RunFailedError extends Error {
     readonly cmd: string[]
     readonly code: number
@@ -114,13 +118,33 @@ export namespace Process {
 
     if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
 
-    const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
-    const out = {
-      code,
-      stdout,
-      stderr,
-    }
+    const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
+      .then(([code, stdout, stderr]) => ({
+        code,
+        stdout,
+        stderr,
+      }))
+      .catch((err: unknown) => {
+        if (!opts.nothrow) throw err
+        return {
+          code: 1,
+          stdout: Buffer.alloc(0),
+          stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
+        }
+      })
     if (out.code === 0 || opts.nothrow) return out
     throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
   }
+
+  export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
+    const out = await run(cmd, opts)
+    return {
+      ...out,
+      text: out.stdout.toString(),
+    }
+  }
+
+  export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
+    return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
+  }
 }

+ 40 - 31
packages/opencode/src/worktree/index.ts

@@ -1,4 +1,3 @@
-import { $ } from "bun"
 import fs from "fs/promises"
 import path from "path"
 import z from "zod"
@@ -11,6 +10,8 @@ import { Database, eq } from "../storage/db"
 import { ProjectTable } from "../project/project.sql"
 import { fn } from "../util/fn"
 import { Log } from "../util/log"
+import { Process } from "../util/process"
+import { git } from "../util/git"
 import { BusEvent } from "@/bus/bus-event"
 import { GlobalBus } from "@/bus/global"
 
@@ -248,14 +249,14 @@ export namespace Worktree {
   }
 
   async function sweep(root: string) {
-    const first = await $`git clean -ffdx`.quiet().nothrow().cwd(root)
+    const first = await git(["clean", "-ffdx"], { cwd: root })
     if (first.exitCode === 0) return first
 
     const entries = failed(first)
     if (!entries.length) return first
 
     await prune(root, entries)
-    return $`git clean -ffdx`.quiet().nothrow().cwd(root)
+    return git(["clean", "-ffdx"], { cwd: root })
   }
 
   async function canonical(input: string) {
@@ -274,7 +275,9 @@ export namespace Worktree {
       if (await exists(directory)) continue
 
       const ref = `refs/heads/${branch}`
-      const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
+      const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
+        cwd: Instance.worktree,
+      })
       if (branchCheck.exitCode === 0) continue
 
       return Info.parse({ name, branch, directory })
@@ -285,9 +288,9 @@ export namespace Worktree {
 
   async function runStartCommand(directory: string, cmd: string) {
     if (process.platform === "win32") {
-      return $`cmd /c ${cmd}`.nothrow().cwd(directory)
+      return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
     }
-    return $`bash -lc ${cmd}`.nothrow().cwd(directory)
+    return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
   }
 
   type StartKind = "project" | "worktree"
@@ -297,7 +300,7 @@ export namespace Worktree {
     if (!text) return true
 
     const ran = await runStartCommand(directory, text)
-    if (ran.exitCode === 0) return true
+    if (ran.code === 0) return true
 
     log.error("worktree start command failed", {
       kind,
@@ -344,10 +347,9 @@ export namespace Worktree {
   }
 
   export async function createFromInfo(info: Info, startCommand?: string) {
-    const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
-      .quiet()
-      .nothrow()
-      .cwd(Instance.worktree)
+    const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
+      cwd: Instance.worktree,
+    })
     if (created.exitCode !== 0) {
       throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
     }
@@ -359,7 +361,7 @@ export namespace Worktree {
 
     return () => {
       const start = async () => {
-        const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
+        const populated = await git(["reset", "--hard"], { cwd: info.directory })
         if (populated.exitCode !== 0) {
           const message = errorText(populated) || "Failed to populate worktree"
           log.error("worktree checkout failed", { directory: info.directory, message })
@@ -476,10 +478,10 @@ export namespace Worktree {
 
     const stop = async (target: string) => {
       if (!(await exists(target))) return
-      await $`git fsmonitor--daemon stop`.quiet().nothrow().cwd(target)
+      await git(["fsmonitor--daemon", "stop"], { cwd: target })
     }
 
-    const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
+    const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
     if (list.exitCode !== 0) {
       throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
     }
@@ -496,9 +498,11 @@ export namespace Worktree {
     }
 
     await stop(entry.path)
-    const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
+    const removed = await git(["worktree", "remove", "--force", entry.path], {
+      cwd: Instance.worktree,
+    })
     if (removed.exitCode !== 0) {
-      const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
+      const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
       if (next.exitCode !== 0) {
         throw new RemoveFailedError({
           message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
@@ -515,7 +519,7 @@ export namespace Worktree {
 
     const branch = entry.branch?.replace(/^refs\/heads\//, "")
     if (branch) {
-      const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
+      const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
       if (deleted.exitCode !== 0) {
         throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
       }
@@ -535,7 +539,7 @@ export namespace Worktree {
       throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
     }
 
-    const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
+    const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
     if (list.exitCode !== 0) {
       throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
     }
@@ -568,7 +572,7 @@ export namespace Worktree {
       throw new ResetFailedError({ message: "Worktree not found" })
     }
 
-    const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
+    const remoteList = await git(["remote"], { cwd: Instance.worktree })
     if (remoteList.exitCode !== 0) {
       throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
     }
@@ -587,18 +591,19 @@ export namespace Worktree {
           : ""
 
     const remoteHead = remote
-      ? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
+      ? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
       : { exitCode: 1, stdout: undefined, stderr: undefined }
 
     const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
     const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
     const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
 
-    const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
-    const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
-      .quiet()
-      .nothrow()
-      .cwd(Instance.worktree)
+    const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
+      cwd: Instance.worktree,
+    })
+    const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
+      cwd: Instance.worktree,
+    })
     const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
 
     const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
@@ -607,7 +612,7 @@ export namespace Worktree {
     }
 
     if (remoteBranch) {
-      const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
+      const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
       if (fetch.exitCode !== 0) {
         throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
       }
@@ -619,7 +624,7 @@ export namespace Worktree {
 
     const worktreePath = entry.path
 
-    const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
+    const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
     if (resetToTarget.exitCode !== 0) {
       throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
     }
@@ -629,22 +634,26 @@ export namespace Worktree {
       throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
     }
 
-    const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
+    const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
     if (update.exitCode !== 0) {
       throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
     }
 
-    const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath)
+    const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
+      cwd: worktreePath,
+    })
     if (subReset.exitCode !== 0) {
       throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
     }
 
-    const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath)
+    const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
+      cwd: worktreePath,
+    })
     if (subClean.exitCode !== 0) {
       throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
     }
 
-    const status = await $`git -c core.fsmonitor=false status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
+    const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
     if (status.exitCode !== 0) {
       throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
     }

+ 59 - 0
packages/opencode/test/util/module.test.ts

@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { Module } from "@opencode-ai/util/module"
+import { Filesystem } from "../../src/util/filesystem"
+import { tmpdir } from "../fixture/fixture"
+
+describe("util.module", () => {
+  test("resolves package subpaths from the provided dir", async () => {
+    await using tmp = await tmpdir()
+    const root = path.join(tmp.path, "proj")
+    const file = path.join(root, "node_modules/typescript/lib/tsserver.js")
+    await Filesystem.write(file, "export {}\n")
+    await Filesystem.writeJson(path.join(root, "node_modules/typescript/package.json"), { name: "typescript" })
+
+    expect(Module.resolve("typescript/lib/tsserver.js", root)).toBe(file)
+  })
+
+  test("resolves packages through ancestor node_modules", async () => {
+    await using tmp = await tmpdir()
+    const root = path.join(tmp.path, "proj")
+    const cwd = path.join(root, "apps/web")
+    const file = path.join(root, "node_modules/eslint/lib/api.js")
+    await Filesystem.write(file, "export {}\n")
+    await Filesystem.writeJson(path.join(root, "node_modules/eslint/package.json"), {
+      name: "eslint",
+      main: "lib/api.js",
+    })
+    await Filesystem.write(path.join(cwd, ".keep"), "")
+
+    expect(Module.resolve("eslint", cwd)).toBe(file)
+  })
+
+  test("resolves relative to the provided dir", async () => {
+    await using tmp = await tmpdir()
+    const a = path.join(tmp.path, "a")
+    const b = path.join(tmp.path, "b")
+    const left = path.join(a, "node_modules/biome/index.js")
+    const right = path.join(b, "node_modules/biome/index.js")
+    await Filesystem.write(left, "export {}\n")
+    await Filesystem.write(right, "export {}\n")
+    await Filesystem.writeJson(path.join(a, "node_modules/biome/package.json"), {
+      name: "biome",
+      main: "index.js",
+    })
+    await Filesystem.writeJson(path.join(b, "node_modules/biome/package.json"), {
+      name: "biome",
+      main: "index.js",
+    })
+
+    expect(Module.resolve("biome", a)).toBe(left)
+    expect(Module.resolve("biome", b)).toBe(right)
+    expect(Module.resolve("biome", a)).not.toBe(Module.resolve("biome", b))
+  })
+
+  test("returns undefined when resolution fails", async () => {
+    await using tmp = await tmpdir()
+    expect(Module.resolve("missing-package", tmp.path)).toBeUndefined()
+  })
+})

+ 10 - 0
packages/util/src/module.ts

@@ -0,0 +1,10 @@
+import { createRequire } from "node:module"
+import path from "node:path"
+
+export namespace Module {
+  export function resolve(id: string, dir: string) {
+    try {
+      return createRequire(path.join(dir, "package.json")).resolve(id)
+    } catch {}
+  }
+}