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

ignore: rework bootstrap so server lazy starts it

Dax Raad 5 месяцев назад
Родитель
Сommit
ae6154e1c3

+ 9 - 14
packages/opencode/src/cli/bootstrap.ts

@@ -1,19 +1,14 @@
-import { Format } from "../format"
-import { LSP } from "../lsp"
-import { Plugin } from "../plugin"
+import { InstanceBootstrap } from "../project/bootstrap"
 import { Instance } from "../project/instance"
-import { Share } from "../share/share"
-import { Snapshot } from "../snapshot"
 
 export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
-  return Instance.provide(directory, async () => {
-    await Plugin.init()
-    Share.init()
-    Format.init()
-    LSP.init()
-    Snapshot.init()
-    const result = await cb()
-    await Instance.dispose()
-    return result
+  return Instance.provide({
+    directory,
+    init: InstanceBootstrap,
+    fn: async () => {
+      const result = await cb()
+      await Instance.dispose()
+      return result
+    },
   })
 }

+ 104 - 101
packages/opencode/src/cli/cmd/agent.ts

@@ -11,121 +11,124 @@ const AgentCreateCommand = cmd({
   command: "create",
   describe: "create a new agent",
   async handler() {
-    await Instance.provide(process.cwd(), async () => {
-      UI.empty()
-      prompts.intro("Create agent")
-      const project = Instance.project
+    await Instance.provide({
+      directory: process.cwd(),
+      async fn() {
+        UI.empty()
+        prompts.intro("Create agent")
+        const project = Instance.project
 
-      let scope: "global" | "project" = "global"
-      if (project.vcs === "git") {
-        const scopeResult = await prompts.select({
-          message: "Location",
+        let scope: "global" | "project" = "global"
+        if (project.vcs === "git") {
+          const scopeResult = await prompts.select({
+            message: "Location",
+            options: [
+              {
+                label: "Current project",
+                value: "project" as const,
+                hint: Instance.worktree,
+              },
+              {
+                label: "Global",
+                value: "global" as const,
+                hint: Global.Path.config,
+              },
+            ],
+          })
+          if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
+          scope = scopeResult
+        }
+
+        const query = await prompts.text({
+          message: "Description",
+          placeholder: "What should this agent do?",
+          validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+        })
+        if (prompts.isCancel(query)) throw new UI.CancelledError()
+
+        const spinner = prompts.spinner()
+
+        spinner.start("Generating agent configuration...")
+        const generated = await Agent.generate({ description: query }).catch((error) => {
+          spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
+          throw new UI.CancelledError()
+        })
+        spinner.stop(`Agent ${generated.identifier} generated`)
+
+        const availableTools = [
+          "bash",
+          "read",
+          "write",
+          "edit",
+          "list",
+          "glob",
+          "grep",
+          "webfetch",
+          "task",
+          "todowrite",
+          "todoread",
+        ]
+
+        const selectedTools = await prompts.multiselect({
+          message: "Select tools to enable",
+          options: availableTools.map((tool) => ({
+            label: tool,
+            value: tool,
+          })),
+          initialValues: availableTools,
+        })
+        if (prompts.isCancel(selectedTools)) throw new UI.CancelledError()
+
+        const modeResult = await prompts.select({
+          message: "Agent mode",
           options: [
             {
-              label: "Current project",
-              value: "project" as const,
-              hint: Instance.worktree,
+              label: "All",
+              value: "all" as const,
+              hint: "Can function in both primary and subagent roles",
             },
             {
-              label: "Global",
-              value: "global" as const,
-              hint: Global.Path.config,
+              label: "Primary",
+              value: "primary" as const,
+              hint: "Acts as a primary/main agent",
+            },
+            {
+              label: "Subagent",
+              value: "subagent" as const,
+              hint: "Can be used as a subagent by other agents",
             },
           ],
+          initialValue: "all",
         })
-        if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
-        scope = scopeResult
-      }
-
-      const query = await prompts.text({
-        message: "Description",
-        placeholder: "What should this agent do?",
-        validate: (x) => (x && x.length > 0 ? undefined : "Required"),
-      })
-      if (prompts.isCancel(query)) throw new UI.CancelledError()
-
-      const spinner = prompts.spinner()
-
-      spinner.start("Generating agent configuration...")
-      const generated = await Agent.generate({ description: query }).catch((error) => {
-        spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
-        throw new UI.CancelledError()
-      })
-      spinner.stop(`Agent ${generated.identifier} generated`)
+        if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
 
-      const availableTools = [
-        "bash",
-        "read",
-        "write",
-        "edit",
-        "list",
-        "glob",
-        "grep",
-        "webfetch",
-        "task",
-        "todowrite",
-        "todoread",
-      ]
-
-      const selectedTools = await prompts.multiselect({
-        message: "Select tools to enable",
-        options: availableTools.map((tool) => ({
-          label: tool,
-          value: tool,
-        })),
-        initialValues: availableTools,
-      })
-      if (prompts.isCancel(selectedTools)) throw new UI.CancelledError()
-
-      const modeResult = await prompts.select({
-        message: "Agent mode",
-        options: [
-          {
-            label: "All",
-            value: "all" as const,
-            hint: "Can function in both primary and subagent roles",
-          },
-          {
-            label: "Primary",
-            value: "primary" as const,
-            hint: "Acts as a primary/main agent",
-          },
-          {
-            label: "Subagent",
-            value: "subagent" as const,
-            hint: "Can be used as a subagent by other agents",
-          },
-        ],
-        initialValue: "all",
-      })
-      if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
-
-      const tools: Record<string, boolean> = {}
-      for (const tool of availableTools) {
-        if (!selectedTools.includes(tool)) {
-          tools[tool] = false
+        const tools: Record<string, boolean> = {}
+        for (const tool of availableTools) {
+          if (!selectedTools.includes(tool)) {
+            tools[tool] = false
+          }
         }
-      }
 
-      const frontmatter: any = {
-        description: generated.whenToUse,
-        mode: modeResult,
-      }
-      if (Object.keys(tools).length > 0) {
-        frontmatter.tools = tools
-      }
+        const frontmatter: any = {
+          description: generated.whenToUse,
+          mode: modeResult,
+        }
+        if (Object.keys(tools).length > 0) {
+          frontmatter.tools = tools
+        }
 
-      const content = matter.stringify(generated.systemPrompt, frontmatter)
-      const filePath = path.join(
-        scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
-        `agent`,
-        `${generated.identifier}.md`,
-      )
+        const content = matter.stringify(generated.systemPrompt, frontmatter)
+        const filePath = path.join(
+          scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
+          `agent`,
+          `${generated.identifier}.md`,
+        )
 
-      await Bun.write(filePath, content)
+        await Bun.write(filePath, content)
 
-      prompts.log.success(`Agent created: ${filePath}`)
-      prompts.outro("Done")
+        prompts.log.success(`Agent created: ${filePath}`)
+        prompts.outro("Done")
+      },
     })
   },
 })

+ 169 - 166
packages/opencode/src/cli/cmd/auth.ts

@@ -74,196 +74,199 @@ export const AuthLoginCommand = cmd({
       type: "string",
     }),
   async handler(args) {
-    await Instance.provide(process.cwd(), async () => {
-      UI.empty()
-      prompts.intro("Add credential")
-      if (args.url) {
-        const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
-        prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
-        const proc = Bun.spawn({
-          cmd: wellknown.auth.command,
-          stdout: "pipe",
-        })
-        const exit = await proc.exited
-        if (exit !== 0) {
-          prompts.log.error("Failed")
+    await Instance.provide({
+      directory: process.cwd(),
+      async fn() {
+        UI.empty()
+        prompts.intro("Add credential")
+        if (args.url) {
+          const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
+          prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
+          const proc = Bun.spawn({
+            cmd: wellknown.auth.command,
+            stdout: "pipe",
+          })
+          const exit = await proc.exited
+          if (exit !== 0) {
+            prompts.log.error("Failed")
+            prompts.outro("Done")
+            return
+          }
+          const token = await new Response(proc.stdout).text()
+          await Auth.set(args.url, {
+            type: "wellknown",
+            key: wellknown.auth.env,
+            token: token.trim(),
+          })
+          prompts.log.success("Logged into " + args.url)
           prompts.outro("Done")
           return
         }
-        const token = await new Response(proc.stdout).text()
-        await Auth.set(args.url, {
-          type: "wellknown",
-          key: wellknown.auth.env,
-          token: token.trim(),
-        })
-        prompts.log.success("Logged into " + args.url)
-        prompts.outro("Done")
-        return
-      }
-      await ModelsDev.refresh().catch(() => {})
-      const providers = await ModelsDev.get()
-      const priority: Record<string, number> = {
-        opencode: 0,
-        anthropic: 1,
-        "github-copilot": 2,
-        openai: 3,
-        google: 4,
-        openrouter: 5,
-        vercel: 6,
-      }
-      let provider = await prompts.autocomplete({
-        message: "Select provider",
-        maxItems: 8,
-        options: [
-          ...pipe(
-            providers,
-            values(),
-            sortBy(
-              (x) => priority[x.id] ?? 99,
-              (x) => x.name ?? x.id,
+        await ModelsDev.refresh().catch(() => {})
+        const providers = await ModelsDev.get()
+        const priority: Record<string, number> = {
+          opencode: 0,
+          anthropic: 1,
+          "github-copilot": 2,
+          openai: 3,
+          google: 4,
+          openrouter: 5,
+          vercel: 6,
+        }
+        let provider = await prompts.autocomplete({
+          message: "Select provider",
+          maxItems: 8,
+          options: [
+            ...pipe(
+              providers,
+              values(),
+              sortBy(
+                (x) => priority[x.id] ?? 99,
+                (x) => x.name ?? x.id,
+              ),
+              map((x) => ({
+                label: x.name,
+                value: x.id,
+                hint: priority[x.id] <= 1 ? "recommended" : undefined,
+              })),
             ),
-            map((x) => ({
-              label: x.name,
-              value: x.id,
-              hint: priority[x.id] <= 1 ? "recommended" : undefined,
-            })),
-          ),
-          {
-            value: "other",
-            label: "Other",
-          },
-        ],
-      })
-
-      if (prompts.isCancel(provider)) throw new UI.CancelledError()
+            {
+              value: "other",
+              label: "Other",
+            },
+          ],
+        })
 
-      const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
-      if (plugin && plugin.auth) {
-        let index = 0
-        if (plugin.auth.methods.length > 1) {
-          const method = await prompts.select({
-            message: "Login method",
-            options: [
-              ...plugin.auth.methods.map((x, index) => ({
-                label: x.label,
-                value: index.toString(),
-              })),
-            ],
-          })
-          if (prompts.isCancel(method)) throw new UI.CancelledError()
-          index = parseInt(method)
-        }
-        const method = plugin.auth.methods[index]
-        if (method.type === "oauth") {
-          await new Promise((resolve) => setTimeout(resolve, 10))
-          const authorize = await method.authorize()
+        if (prompts.isCancel(provider)) throw new UI.CancelledError()
 
-          if (authorize.url) {
-            prompts.log.info("Go to: " + authorize.url)
+        const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
+        if (plugin && plugin.auth) {
+          let index = 0
+          if (plugin.auth.methods.length > 1) {
+            const method = await prompts.select({
+              message: "Login method",
+              options: [
+                ...plugin.auth.methods.map((x, index) => ({
+                  label: x.label,
+                  value: index.toString(),
+                })),
+              ],
+            })
+            if (prompts.isCancel(method)) throw new UI.CancelledError()
+            index = parseInt(method)
           }
+          const method = plugin.auth.methods[index]
+          if (method.type === "oauth") {
+            await new Promise((resolve) => setTimeout(resolve, 10))
+            const authorize = await method.authorize()
 
-          if (authorize.method === "auto") {
-            if (authorize.instructions) {
-              prompts.log.info(authorize.instructions)
+            if (authorize.url) {
+              prompts.log.info("Go to: " + authorize.url)
             }
-            const spinner = prompts.spinner()
-            spinner.start("Waiting for authorization...")
-            const result = await authorize.callback()
-            if (result.type === "failed") {
-              spinner.stop("Failed to authorize", 1)
-            }
-            if (result.type === "success") {
-              if ("refresh" in result) {
-                await Auth.set(provider, {
-                  type: "oauth",
-                  refresh: result.refresh,
-                  access: result.access,
-                  expires: result.expires,
-                })
+
+            if (authorize.method === "auto") {
+              if (authorize.instructions) {
+                prompts.log.info(authorize.instructions)
+              }
+              const spinner = prompts.spinner()
+              spinner.start("Waiting for authorization...")
+              const result = await authorize.callback()
+              if (result.type === "failed") {
+                spinner.stop("Failed to authorize", 1)
               }
-              if ("key" in result) {
-                await Auth.set(provider, {
-                  type: "api",
-                  key: result.key,
-                })
+              if (result.type === "success") {
+                if ("refresh" in result) {
+                  await Auth.set(provider, {
+                    type: "oauth",
+                    refresh: result.refresh,
+                    access: result.access,
+                    expires: result.expires,
+                  })
+                }
+                if ("key" in result) {
+                  await Auth.set(provider, {
+                    type: "api",
+                    key: result.key,
+                  })
+                }
+                spinner.stop("Login successful")
               }
-              spinner.stop("Login successful")
             }
-          }
 
-          if (authorize.method === "code") {
-            const code = await prompts.text({
-              message: "Paste the authorization code here: ",
-              validate: (x) => (x && x.length > 0 ? undefined : "Required"),
-            })
-            if (prompts.isCancel(code)) throw new UI.CancelledError()
-            const result = await authorize.callback(code)
-            if (result.type === "failed") {
-              prompts.log.error("Failed to authorize")
-            }
-            if (result.type === "success") {
-              if ("refresh" in result) {
-                await Auth.set(provider, {
-                  type: "oauth",
-                  refresh: result.refresh,
-                  access: result.access,
-                  expires: result.expires,
-                })
+            if (authorize.method === "code") {
+              const code = await prompts.text({
+                message: "Paste the authorization code here: ",
+                validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+              })
+              if (prompts.isCancel(code)) throw new UI.CancelledError()
+              const result = await authorize.callback(code)
+              if (result.type === "failed") {
+                prompts.log.error("Failed to authorize")
               }
-              if ("key" in result) {
-                await Auth.set(provider, {
-                  type: "api",
-                  key: result.key,
-                })
+              if (result.type === "success") {
+                if ("refresh" in result) {
+                  await Auth.set(provider, {
+                    type: "oauth",
+                    refresh: result.refresh,
+                    access: result.access,
+                    expires: result.expires,
+                  })
+                }
+                if ("key" in result) {
+                  await Auth.set(provider, {
+                    type: "api",
+                    key: result.key,
+                  })
+                }
+                prompts.log.success("Login successful")
               }
-              prompts.log.success("Login successful")
             }
+            prompts.outro("Done")
+            return
           }
-          prompts.outro("Done")
-          return
         }
-      }
 
-      if (provider === "other") {
-        provider = await prompts.text({
-          message: "Enter provider id",
-          validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
-        })
-        if (prompts.isCancel(provider)) throw new UI.CancelledError()
-        provider = provider.replace(/^@ai-sdk\//, "")
-        if (prompts.isCancel(provider)) throw new UI.CancelledError()
-        prompts.log.warn(
-          `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
-        )
-      }
+        if (provider === "other") {
+          provider = await prompts.text({
+            message: "Enter provider id",
+            validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
+          })
+          if (prompts.isCancel(provider)) throw new UI.CancelledError()
+          provider = provider.replace(/^@ai-sdk\//, "")
+          if (prompts.isCancel(provider)) throw new UI.CancelledError()
+          prompts.log.warn(
+            `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
+          )
+        }
 
-      if (provider === "amazon-bedrock") {
-        prompts.log.info(
-          "Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
-        )
-        prompts.outro("Done")
-        return
-      }
+        if (provider === "amazon-bedrock") {
+          prompts.log.info(
+            "Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
+          )
+          prompts.outro("Done")
+          return
+        }
 
-      if (provider === "opencode") {
-        prompts.log.info("Create an api key at https://opencode.ai/auth")
-      }
+        if (provider === "opencode") {
+          prompts.log.info("Create an api key at https://opencode.ai/auth")
+        }
 
-      if (provider === "vercel") {
-        prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
-      }
+        if (provider === "vercel") {
+          prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
+        }
 
-      const key = await prompts.password({
-        message: "Enter your API key",
-        validate: (x) => (x && x.length > 0 ? undefined : "Required"),
-      })
-      if (prompts.isCancel(key)) throw new UI.CancelledError()
-      await Auth.set(provider, {
-        type: "api",
-        key,
-      })
+        const key = await prompts.password({
+          message: "Enter your API key",
+          validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+        })
+        if (prompts.isCancel(key)) throw new UI.CancelledError()
+        await Auth.set(provider, {
+          type: "api",
+          key,
+        })
 
-      prompts.outro("Done")
+        prompts.outro("Done")
+      },
     })
   },
 })

+ 179 - 174
packages/opencode/src/cli/cmd/github.ts

@@ -21,190 +21,194 @@ export const GithubInstallCommand = cmd({
   command: "install",
   describe: "install the GitHub agent",
   async handler() {
-    await Instance.provide(process.cwd(), async () => {
-      UI.empty()
-      prompts.intro("Install GitHub agent")
-      const app = await getAppInfo()
-      await installGitHubApp()
-
-      const providers = await ModelsDev.get()
-      const provider = await promptProvider()
-      const model = await promptModel()
-      //const key = await promptKey()
-
-      await addWorkflowFiles()
-      printNextSteps()
-
-      function printNextSteps() {
-        let step2
-        if (provider === "amazon-bedrock") {
-          step2 =
-            "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
-        } else {
-          step2 = [
-            `    2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
-            "",
-            ...providers[provider].env.map((e) => `       - ${e}`),
-          ].join("\n")
-        }
+    await Instance.provide({
+      directory: process.cwd(),
+      async fn() {
+        UI.empty()
+        prompts.intro("Install GitHub agent")
+        const app = await getAppInfo()
+        await installGitHubApp()
+
+        const providers = await ModelsDev.get()
+        const provider = await promptProvider()
+        const model = await promptModel()
+        //const key = await promptKey()
+
+        await addWorkflowFiles()
+        printNextSteps()
+
+        function printNextSteps() {
+          let step2
+          if (provider === "amazon-bedrock") {
+            step2 =
+              "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
+          } else {
+            step2 = [
+              `    2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
+              "",
+              ...providers[provider].env.map((e) => `       - ${e}`),
+            ].join("\n")
+          }
 
-        prompts.outro(
-          [
-            "Next steps:",
-            "",
-            `    1. Commit the \`${WORKFLOW_FILE}\` file and push`,
-            step2,
-            "",
-            "    3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
-            "",
-            "   Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
-          ].join("\n"),
-        )
-      }
-
-      async function getAppInfo() {
-        const project = Instance.project
-        if (project.vcs !== "git") {
-          prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
-          throw new UI.CancelledError()
+          prompts.outro(
+            [
+              "Next steps:",
+              "",
+              `    1. Commit the \`${WORKFLOW_FILE}\` file and push`,
+              step2,
+              "",
+              "    3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
+              "",
+              "   Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
+            ].join("\n"),
+          )
         }
 
-        // Get repo info
-        const info = await $`git remote get-url origin`
-          .quiet()
-          .nothrow()
-          .text()
-          .then((text) => text.trim())
-        // match https or git pattern
-        // ie. https://github.com/sst/opencode.git
-        // ie. https://github.com/sst/opencode
-        // ie. [email protected]:sst/opencode.git
-        // ie. [email protected]:sst/opencode
-        // ie. ssh://[email protected]/sst/opencode.git
-        // ie. ssh://[email protected]/sst/opencode
-        const parsed = info.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
-        if (!parsed) {
-          prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
-          throw new UI.CancelledError()
-        }
-        const [, owner, repo] = parsed
-        return { owner, repo, root: Instance.worktree }
-      }
-
-      async function promptProvider() {
-        const priority: Record<string, number> = {
-          opencode: 0,
-          anthropic: 1,
-          "github-copilot": 2,
-          openai: 3,
-          google: 4,
-          openrouter: 5,
-          vercel: 6,
-        }
-        let provider = await prompts.select({
-          message: "Select provider",
-          maxItems: 8,
-          options: pipe(
-            providers,
-            values(),
-            sortBy(
-              (x) => priority[x.id] ?? 99,
-              (x) => x.name ?? x.id,
-            ),
-            map((x) => ({
-              label: x.name,
-              value: x.id,
-              hint: priority[x.id] <= 1 ? "recommended" : undefined,
-            })),
-          ),
-        })
-
-        if (prompts.isCancel(provider)) throw new UI.CancelledError()
-
-        return provider
-      }
-
-      async function promptModel() {
-        const providerData = providers[provider]!
-
-        const model = await prompts.select({
-          message: "Select model",
-          maxItems: 8,
-          options: pipe(
-            providerData.models,
-            values(),
-            sortBy((x) => x.name ?? x.id),
-            map((x) => ({
-              label: x.name ?? x.id,
-              value: x.id,
-            })),
-          ),
-        })
-
-        if (prompts.isCancel(model)) throw new UI.CancelledError()
-        return model
-      }
-
-      async function installGitHubApp() {
-        const s = prompts.spinner()
-        s.start("Installing GitHub app")
-
-        // Get installation
-        const installation = await getInstallation()
-        if (installation) return s.stop("GitHub app already installed")
-
-        // Open browser
-        const url = "https://github.com/apps/opencode-agent"
-        const command =
-          process.platform === "darwin"
-            ? `open "${url}"`
-            : process.platform === "win32"
-              ? `start "${url}"`
-              : `xdg-open "${url}"`
-
-        exec(command, (error) => {
-          if (error) {
-            prompts.log.warn(`Could not open browser. Please visit: ${url}`)
+        async function getAppInfo() {
+          const project = Instance.project
+          if (project.vcs !== "git") {
+            prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
+            throw new UI.CancelledError()
           }
-        })
 
-        // Wait for installation
-        s.message("Waiting for GitHub app to be installed")
-        const MAX_RETRIES = 120
-        let retries = 0
-        do {
-          const installation = await getInstallation()
-          if (installation) break
-
-          if (retries > MAX_RETRIES) {
-            s.stop(
-              `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
-            )
+          // Get repo info
+          const info = await $`git remote get-url origin`
+            .quiet()
+            .nothrow()
+            .text()
+            .then((text) => text.trim())
+          // match https or git pattern
+          // ie. https://github.com/sst/opencode.git
+          // ie. https://github.com/sst/opencode
+          // ie. [email protected]:sst/opencode.git
+          // ie. [email protected]:sst/opencode
+          // ie. ssh://[email protected]/sst/opencode.git
+          // ie. ssh://[email protected]/sst/opencode
+          const parsed = info.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
+          if (!parsed) {
+            prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
             throw new UI.CancelledError()
           }
+          const [, owner, repo] = parsed
+          return { owner, repo, root: Instance.worktree }
+        }
 
-          retries++
-          await new Promise((resolve) => setTimeout(resolve, 1000))
-        } while (true)
+        async function promptProvider() {
+          const priority: Record<string, number> = {
+            opencode: 0,
+            anthropic: 1,
+            "github-copilot": 2,
+            openai: 3,
+            google: 4,
+            openrouter: 5,
+            vercel: 6,
+          }
+          let provider = await prompts.select({
+            message: "Select provider",
+            maxItems: 8,
+            options: pipe(
+              providers,
+              values(),
+              sortBy(
+                (x) => priority[x.id] ?? 99,
+                (x) => x.name ?? x.id,
+              ),
+              map((x) => ({
+                label: x.name,
+                value: x.id,
+                hint: priority[x.id] <= 1 ? "recommended" : undefined,
+              })),
+            ),
+          })
 
-        s.stop("Installed GitHub app")
+          if (prompts.isCancel(provider)) throw new UI.CancelledError()
 
-        async function getInstallation() {
-          return await fetch(`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`)
-            .then((res) => res.json())
-            .then((data) => data.installation)
+          return provider
         }
-      }
 
-      async function addWorkflowFiles() {
-        const envStr =
-          provider === "amazon-bedrock"
-            ? ""
-            : `\n        env:${providers[provider].env.map((e) => `\n          ${e}: \${{ secrets.${e} }}`).join("")}`
+        async function promptModel() {
+          const providerData = providers[provider]!
+
+          const model = await prompts.select({
+            message: "Select model",
+            maxItems: 8,
+            options: pipe(
+              providerData.models,
+              values(),
+              sortBy((x) => x.name ?? x.id),
+              map((x) => ({
+                label: x.name ?? x.id,
+                value: x.id,
+              })),
+            ),
+          })
+
+          if (prompts.isCancel(model)) throw new UI.CancelledError()
+          return model
+        }
 
-        await Bun.write(
-          path.join(app.root, WORKFLOW_FILE),
-          `
+        async function installGitHubApp() {
+          const s = prompts.spinner()
+          s.start("Installing GitHub app")
+
+          // Get installation
+          const installation = await getInstallation()
+          if (installation) return s.stop("GitHub app already installed")
+
+          // Open browser
+          const url = "https://github.com/apps/opencode-agent"
+          const command =
+            process.platform === "darwin"
+              ? `open "${url}"`
+              : process.platform === "win32"
+                ? `start "${url}"`
+                : `xdg-open "${url}"`
+
+          exec(command, (error) => {
+            if (error) {
+              prompts.log.warn(`Could not open browser. Please visit: ${url}`)
+            }
+          })
+
+          // Wait for installation
+          s.message("Waiting for GitHub app to be installed")
+          const MAX_RETRIES = 120
+          let retries = 0
+          do {
+            const installation = await getInstallation()
+            if (installation) break
+
+            if (retries > MAX_RETRIES) {
+              s.stop(
+                `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
+              )
+              throw new UI.CancelledError()
+            }
+
+            retries++
+            await new Promise((resolve) => setTimeout(resolve, 1000))
+          } while (true)
+
+          s.stop("Installed GitHub app")
+
+          async function getInstallation() {
+            return await fetch(
+              `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
+            )
+              .then((res) => res.json())
+              .then((data) => data.installation)
+          }
+        }
+
+        async function addWorkflowFiles() {
+          const envStr =
+            provider === "amazon-bedrock"
+              ? ""
+              : `\n        env:${providers[provider].env.map((e) => `\n          ${e}: \${{ secrets.${e} }}`).join("")}`
+
+          await Bun.write(
+            path.join(app.root, WORKFLOW_FILE),
+            `
 name: opencode
 
 on:
@@ -231,10 +235,11 @@ jobs:
         with:
           model: ${provider}/${model}
 `.trim(),
-        )
+          )
 
-        prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
-      }
+          prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
+        }
+      },
     })
   },
 })

+ 9 - 6
packages/opencode/src/cli/cmd/models.ts

@@ -6,14 +6,17 @@ export const ModelsCommand = cmd({
   command: "models",
   describe: "list all available models",
   handler: async () => {
-    await Instance.provide(process.cwd(), async () => {
-      const providers = await Provider.list()
+    await Instance.provide({
+      directory: process.cwd(),
+      async fn() {
+        const providers = await Provider.list()
 
-      for (const [providerID, provider] of Object.entries(providers)) {
-        for (const modelID of Object.keys(provider.info.models)) {
-          console.log(`${providerID}/${modelID}`)
+        for (const [providerID, provider] of Object.entries(providers)) {
+          for (const modelID of Object.keys(provider.info.models)) {
+            console.log(`${providerID}/${modelID}`)
+          }
         }
-      }
+      },
     })
   },
 })

+ 1 - 1
packages/opencode/src/cli/cmd/tui.ts

@@ -1,7 +1,6 @@
 import { Global } from "../../global"
 import { Provider } from "../../provider/provider"
 import { Server } from "../../server/server"
-import { bootstrap } from "../bootstrap"
 import { UI } from "../ui"
 import { cmd } from "./cmd"
 import path from "path"
@@ -16,6 +15,7 @@ import { Ide } from "../../ide"
 import { Flag } from "../../flag/flag"
 import { Session } from "../../session"
 import { $ } from "bun"
+import { bootstrap } from "../bootstrap"
 
 declare global {
   const OPENCODE_TUI_PATH: string

+ 13 - 0
packages/opencode/src/project/bootstrap.ts

@@ -0,0 +1,13 @@
+import { Plugin } from "../plugin"
+import { Share } from "../share/share"
+import { Format } from "../format"
+import { LSP } from "../lsp"
+import { Snapshot } from "../snapshot"
+
+export async function InstanceBootstrap() {
+  await Plugin.init()
+  Share.init()
+  Format.init()
+  LSP.init()
+  Snapshot.init()
+}

+ 24 - 4
packages/opencode/src/project/instance.ts

@@ -2,12 +2,32 @@ import { Context } from "../util/context"
 import { Project } from "./project"
 import { State } from "./state"
 
-const context = Context.create<{ directory: string; worktree: string; project: Project.Info }>("path")
+interface Context {
+  directory: string
+  worktree: string
+  project: Project.Info
+}
+const context = Context.create<Context>("instance")
+const cache = new Map<string, Context>()
 
 export const Instance = {
-  async provide<R>(directory: string, cb: () => R): Promise<R> {
-    const project = await Project.fromDirectory(directory)
-    return context.provide({ directory, worktree: project.worktree, project }, cb)
+  async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
+    let existing = cache.get(input.directory)
+    if (!existing) {
+      const project = await Project.fromDirectory(input.directory)
+      existing = {
+        directory: input.directory,
+        worktree: project.worktree,
+        project,
+      }
+    }
+    return context.provide(existing, async () => {
+      if (!cache.has(input.directory)) {
+        await input.init?.()
+        cache.set(input.directory, existing)
+      }
+      return input.fn()
+    })
   },
   get directory() {
     return context.use().directory

+ 46 - 55
packages/opencode/src/project/project.ts

@@ -22,73 +22,64 @@ export namespace Project {
     })
   export type Info = z.infer<typeof Info>
 
-  const cache = new Map<string, Info>()
   export async function fromDirectory(directory: string) {
     log.info("fromDirectory", { directory })
-    const fn = async () => {
-      const matches = Filesystem.up({ targets: [".git"], start: directory })
-      const git = await matches.next().then((x) => x.value)
-      await matches.return()
-      if (!git) {
-        const project: Info = {
-          id: "global",
-          worktree: "/",
-          time: {
-            created: Date.now(),
-          },
-        }
-        await Storage.write<Info>(["project", "global"], project)
-        return project
-      }
-      let worktree = path.dirname(git)
-      const [id] = await $`git rev-list --max-parents=0 --all`
-        .quiet()
-        .nothrow()
-        .cwd(worktree)
-        .text()
-        .then((x) =>
-          x
-            .split("\n")
-            .filter(Boolean)
-            .map((x) => x.trim())
-            .toSorted(),
-        )
-      if (!id) {
-        const project: Info = {
-          id: "global",
-          worktree: "/",
-          time: {
-            created: Date.now(),
-          },
-        }
-        await Storage.write<Info>(["project", "global"], project)
-        return project
+    const matches = Filesystem.up({ targets: [".git"], start: directory })
+    const git = await matches.next().then((x) => x.value)
+    await matches.return()
+    if (!git) {
+      const project: Info = {
+        id: "global",
+        worktree: "/",
+        time: {
+          created: Date.now(),
+        },
       }
-      worktree = path.dirname(
-        await $`git rev-parse --path-format=absolute --git-common-dir`
-          .quiet()
-          .nothrow()
-          .cwd(worktree)
-          .text()
-          .then((x) => x.trim()),
+      await Storage.write<Info>(["project", "global"], project)
+      return project
+    }
+    let worktree = path.dirname(git)
+    const [id] = await $`git rev-list --max-parents=0 --all`
+      .quiet()
+      .nothrow()
+      .cwd(worktree)
+      .text()
+      .then((x) =>
+        x
+          .split("\n")
+          .filter(Boolean)
+          .map((x) => x.trim())
+          .toSorted(),
       )
+    if (!id) {
       const project: Info = {
-        id,
-        worktree,
-        vcs: "git",
+        id: "global",
+        worktree: "/",
         time: {
           created: Date.now(),
         },
       }
-      await Storage.write<Info>(["project", id], project)
+      await Storage.write<Info>(["project", "global"], project)
       return project
     }
-    if (cache.has(directory)) {
-      return cache.get(directory)!
+    worktree = path.dirname(
+      await $`git rev-parse --path-format=absolute --git-common-dir`
+        .quiet()
+        .nothrow()
+        .cwd(worktree)
+        .text()
+        .then((x) => x.trim()),
+    )
+    const project: Info = {
+      id,
+      worktree,
+      vcs: "git",
+      time: {
+        created: Date.now(),
+      },
     }
-    const result = await fn()
-    cache.set(directory, result)
-    return result
+    await Storage.write<Info>(["project", id], project)
+    return project
   }
 
   export async function setInitialized(projectID: string) {

+ 7 - 2
packages/opencode/src/server/server.ts

@@ -29,6 +29,7 @@ import { SessionPrompt } from "../session/prompt"
 import { SessionCompaction } from "../session/compaction"
 import { SessionRevert } from "../session/revert"
 import { lazy } from "../util/lazy"
+import { InstanceBootstrap } from "../project/bootstrap"
 
 const ERRORS = {
   400: {
@@ -90,8 +91,12 @@ export namespace Server {
       })
       .use(async (c, next) => {
         const directory = c.req.query("directory") ?? process.cwd()
-        return Instance.provide(directory, async () => {
-          return next()
+        return Instance.provide({
+          directory,
+          init: InstanceBootstrap,
+          async fn() {
+            return next()
+          },
         })
       })
       .use(cors())

+ 52 - 52
packages/opencode/test/snapshot/snapshot.test.ts

@@ -27,19 +27,19 @@ async function bootstrap() {
 
 test("tracks deleted files correctly", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
     await $`rm ${tmp.dir}/a.txt`.quiet()
 
     expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/a.txt`)
-  })
+  }})
 })
 
 test("revert should remove new files", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -48,12 +48,12 @@ test("revert should remove new files", async () => {
     await Snapshot.revert([await Snapshot.patch(before!)])
 
     expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(false)
-  })
+  }})
 })
 
 test("revert in subdirectory", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -65,12 +65,12 @@ test("revert in subdirectory", async () => {
     expect(await Bun.file(`${tmp.dir}/sub/file.txt`).exists()).toBe(false)
     // Note: revert currently only removes files, not directories
     // The empty subdirectory will remain
-  })
+  }})
 })
 
 test("multiple file operations", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -87,24 +87,24 @@ test("multiple file operations", async () => {
     // Note: revert currently only removes files, not directories
     // The empty directory will remain
     expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
-  })
+  }})
 })
 
 test("empty directory handling", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
     await $`mkdir ${tmp.dir}/empty`.quiet()
 
     expect((await Snapshot.patch(before!)).files.length).toBe(0)
-  })
+  }})
 })
 
 test("binary file handling", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -115,36 +115,36 @@ test("binary file handling", async () => {
 
     await Snapshot.revert([patch])
     expect(await Bun.file(`${tmp.dir}/image.png`).exists()).toBe(false)
-  })
+  }})
 })
 
 test("symlink handling", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
     await $`ln -s ${tmp.dir}/a.txt ${tmp.dir}/link.txt`.quiet()
 
     expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/link.txt`)
-  })
+  }})
 })
 
 test("large file handling", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
     await Bun.write(`${tmp.dir}/large.txt`, "x".repeat(1024 * 1024))
 
     expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/large.txt`)
-  })
+  }})
 })
 
 test("nested directory revert", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -154,12 +154,12 @@ test("nested directory revert", async () => {
     await Snapshot.revert([await Snapshot.patch(before!)])
 
     expect(await Bun.file(`${tmp.dir}/level1/level2/level3/deep.txt`).exists()).toBe(false)
-  })
+  }})
 })
 
 test("special characters in filenames", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -171,23 +171,23 @@ test("special characters in filenames", async () => {
     expect(files).toContain(`${tmp.dir}/file with spaces.txt`)
     expect(files).toContain(`${tmp.dir}/file-with-dashes.txt`)
     expect(files).toContain(`${tmp.dir}/file_with_underscores.txt`)
-  })
+  }})
 })
 
 test("revert with empty patches", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     // Should not crash with empty patches
     expect(Snapshot.revert([])).resolves.toBeUndefined()
 
     // Should not crash with patches that have empty file lists
     expect(Snapshot.revert([{ hash: "dummy", files: [] }])).resolves.toBeUndefined()
-  })
+  }})
 })
 
 test("patch with invalid hash", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -198,12 +198,12 @@ test("patch with invalid hash", async () => {
     const patch = await Snapshot.patch("invalid-hash-12345")
     expect(patch.files).toEqual([])
     expect(patch.hash).toBe("invalid-hash-12345")
-  })
+  }})
 })
 
 test("revert non-existent file", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -217,12 +217,12 @@ test("revert non-existent file", async () => {
         },
       ]),
     ).resolves.toBeUndefined()
-  })
+  }})
 })
 
 test("unicode filenames", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -244,12 +244,12 @@ test("unicode filenames", async () => {
 
     // Skip revert test due to git filename escaping issues
     // The functionality works but git uses escaped filenames internally
-  })
+  }})
 })
 
 test("very long filenames", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -263,12 +263,12 @@ test("very long filenames", async () => {
 
     await Snapshot.revert([patch])
     expect(await Bun.file(longFile).exists()).toBe(false)
-  })
+  }})
 })
 
 test("hidden files", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -280,12 +280,12 @@ test("hidden files", async () => {
     expect(patch.files).toContain(`${tmp.dir}/.hidden`)
     expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
     expect(patch.files).toContain(`${tmp.dir}/.config`)
-  })
+  }})
 })
 
 test("nested symlinks", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -297,12 +297,12 @@ test("nested symlinks", async () => {
     const patch = await Snapshot.patch(before!)
     expect(patch.files).toContain(`${tmp.dir}/sub/dir/link.txt`)
     expect(patch.files).toContain(`${tmp.dir}/sub-link`)
-  })
+  }})
 })
 
 test("file permissions and ownership changes", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -315,12 +315,12 @@ test("file permissions and ownership changes", async () => {
     // Note: git doesn't track permission changes on existing files by default
     // Only tracks executable bit when files are first added
     expect(patch.files.length).toBe(0)
-  })
+  }})
 })
 
 test("circular symlinks", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -329,12 +329,12 @@ test("circular symlinks", async () => {
 
     const patch = await Snapshot.patch(before!)
     expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
-  })
+  }})
 })
 
 test("gitignore changes", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -350,12 +350,12 @@ test("gitignore changes", async () => {
     expect(patch.files).toContain(`${tmp.dir}/normal.txt`)
     // Should not track ignored files (git won't see them)
     expect(patch.files).not.toContain(`${tmp.dir}/test.ignored`)
-  })
+  }})
 })
 
 test("concurrent file operations during patch", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -376,7 +376,7 @@ test("concurrent file operations during patch", async () => {
 
     // Should capture some or all of the concurrent files
     expect(patch.files.length).toBeGreaterThanOrEqual(0)
-  })
+  }})
 })
 
 test("snapshot state isolation between projects", async () => {
@@ -384,14 +384,14 @@ test("snapshot state isolation between projects", async () => {
   await using tmp1 = await bootstrap()
   await using tmp2 = await bootstrap()
 
-  await Instance.provide(tmp1.dir, async () => {
+  await Instance.provide({ directory: tmp1.dir, fn: async () => {
     const before1 = await Snapshot.track()
     await Bun.write(`${tmp1.dir}/project1.txt`, "project1 content")
     const patch1 = await Snapshot.patch(before1!)
     expect(patch1.files).toContain(`${tmp1.dir}/project1.txt`)
-  })
+  }})
 
-  await Instance.provide(tmp2.dir, async () => {
+  await Instance.provide({ directory: tmp2.dir, fn: async () => {
     const before2 = await Snapshot.track()
     await Bun.write(`${tmp2.dir}/project2.txt`, "project2 content")
     const patch2 = await Snapshot.patch(before2!)
@@ -399,12 +399,12 @@ test("snapshot state isolation between projects", async () => {
 
     // Ensure project1 files don't appear in project2
     expect(patch2.files).not.toContain(`${tmp1?.dir}/project1.txt`)
-  })
+  }})
 })
 
 test("track with no changes returns same hash", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const hash1 = await Snapshot.track()
     expect(hash1).toBeTruthy()
 
@@ -415,12 +415,12 @@ test("track with no changes returns same hash", async () => {
     // Track again
     const hash3 = await Snapshot.track()
     expect(hash3).toBe(hash1!)
-  })
+  }})
 })
 
 test("diff function with various changes", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -433,12 +433,12 @@ test("diff function with various changes", async () => {
     expect(diff).toContain("deleted")
     expect(diff).toContain("modified")
     // Note: git diff only shows changes to tracked files, not untracked files like new.txt
-  })
+  }})
 })
 
 test("restore function", async () => {
   await using tmp = await bootstrap()
-  await Instance.provide(tmp.dir, async () => {
+  await Instance.provide({ directory: tmp.dir, fn: async () => {
     const before = await Snapshot.track()
     expect(before).toBeTruthy()
 
@@ -454,5 +454,5 @@ test("restore function", async () => {
     expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent)
     expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(true) // New files should remain
     expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
-  })
+  }})
 })

+ 26 - 20
packages/opencode/test/tool/bash.test.ts

@@ -19,30 +19,36 @@ Log.init({ print: false })
 
 describe("tool.bash", () => {
   test("basic", async () => {
-    await Instance.provide(projectRoot, async () => {
-      const result = await bash.execute(
-        {
-          command: "echo 'test'",
-          description: "Echo test message",
-        },
-        ctx,
-      )
-      expect(result.metadata.exit).toBe(0)
-      expect(result.metadata.output).toContain("test")
+    await Instance.provide({
+      directory: projectRoot,
+      fn: async () => {
+        const result = await bash.execute(
+          {
+            command: "echo 'test'",
+            description: "Echo test message",
+          },
+          ctx,
+        )
+        expect(result.metadata.exit).toBe(0)
+        expect(result.metadata.output).toContain("test")
+      },
     })
   })
 
   test("cd ../ should fail outside of project root", async () => {
-    await Instance.provide(projectRoot, async () => {
-      expect(
-        bash.execute(
-          {
-            command: "cd ../",
-            description: "Try to cd to parent directory",
-          },
-          ctx,
-        ),
-      ).rejects.toThrow("This command references paths outside of")
+    await Instance.provide({
+      directory: projectRoot,
+      fn: async () => {
+        expect(
+          bash.execute(
+            {
+              command: "cd ../",
+              description: "Try to cd to parent directory",
+            },
+            ctx,
+          ),
+        ).rejects.toThrow("This command references paths outside of")
+      },
     })
   })
 })

+ 32 - 23
packages/opencode/test/tool/tool.test.ts

@@ -20,38 +20,47 @@ const fixturePath = path.join(__dirname, "../fixtures/example")
 
 describe("tool.glob", () => {
   test("truncate", async () => {
-    await Instance.provide(projectRoot, async () => {
-      let result = await glob.execute(
-        {
-          pattern: "**/*",
-          path: "../../node_modules",
-        },
-        ctx,
-      )
-      expect(result.metadata.truncated).toBe(true)
+    await Instance.provide({
+      directory: projectRoot,
+      fn: async () => {
+        let result = await glob.execute(
+          {
+            pattern: "**/*",
+            path: "../../node_modules",
+          },
+          ctx,
+        )
+        expect(result.metadata.truncated).toBe(true)
+      },
     })
   })
   test("basic", async () => {
-    await Instance.provide(projectRoot, async () => {
-      let result = await glob.execute(
-        {
-          pattern: "*.json",
-          path: undefined,
-        },
-        ctx,
-      )
-      expect(result.metadata).toMatchObject({
-        truncated: false,
-        count: 2,
-      })
+    await Instance.provide({
+      directory: projectRoot,
+      fn: async () => {
+        let result = await glob.execute(
+          {
+            pattern: "*.json",
+            path: undefined,
+          },
+          ctx,
+        )
+        expect(result.metadata).toMatchObject({
+          truncated: false,
+          count: 2,
+        })
+      },
     })
   })
 })
 
 describe("tool.ls", () => {
   test("basic", async () => {
-    const result = await Instance.provide(projectRoot, async () => {
-      return await list.execute({ path: fixturePath, ignore: [".git"] }, ctx)
+    const result = await Instance.provide({
+      directory: projectRoot,
+      fn: async () => {
+        return await list.execute({ path: fixturePath, ignore: [".git"] }, ctx)
+      },
     })
 
     // Normalize absolute path to relative for consistent snapshots