summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-09-19 05:11:29 -0400
committerDax Raad <[email protected]>2025-09-19 05:11:29 -0400
commitae6154e1c3341d0e80f1f0bac317a8e3ab84ad6e (patch)
tree3d39bfc987e5f83104ae9183ec66358987ee902a
parent0e19ca21edebb8a18525568fbe88c78a55414d05 (diff)
downloadopencode-ae6154e1c3341d0e80f1f0bac317a8e3ab84ad6e.tar.gz
opencode-ae6154e1c3341d0e80f1f0bac317a8e3ab84ad6e.zip
ignore: rework bootstrap so server lazy starts it
-rw-r--r--packages/opencode/src/cli/bootstrap.ts23
-rw-r--r--packages/opencode/src/cli/cmd/agent.ts205
-rw-r--r--packages/opencode/src/cli/cmd/auth.ts335
-rw-r--r--packages/opencode/src/cli/cmd/github.ts353
-rw-r--r--packages/opencode/src/cli/cmd/models.ts15
-rw-r--r--packages/opencode/src/cli/cmd/tui.ts2
-rw-r--r--packages/opencode/src/project/bootstrap.ts13
-rw-r--r--packages/opencode/src/project/instance.ts28
-rw-r--r--packages/opencode/src/project/project.ts101
-rw-r--r--packages/opencode/src/server/server.ts9
-rw-r--r--packages/opencode/test/snapshot/snapshot.test.ts104
-rw-r--r--packages/opencode/test/tool/bash.test.ts46
-rw-r--r--packages/opencode/test/tool/tool.test.ts55
13 files changed, 671 insertions, 618 deletions
diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts
index 3bd152618..2114cbc56 100644
--- a/packages/opencode/src/cli/bootstrap.ts
+++ b/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
+ },
})
}
diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts
index ea54d0dd6..54f873f9c 100644
--- a/packages/opencode/src/cli/cmd/agent.ts
+++ b/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")
+ },
})
},
})
diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts
index 382232f5a..965983b93 100644
--- a/packages/opencode/src/cli/cmd/auth.ts
+++ b/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")
+ },
})
},
})
diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts
index 916ced6cb..e15243e76 100644
--- a/packages/opencode/src/cli/cmd/github.ts
+++ b/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}"`)
+ }
+ },
})
},
})
diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts
index fffe475ea..268ed5ff2 100644
--- a/packages/opencode/src/cli/cmd/models.ts
+++ b/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}`)
+ }
}
- }
+ },
})
},
})
diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts
index bbc229e6c..168933119 100644
--- a/packages/opencode/src/cli/cmd/tui.ts
+++ b/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
diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts
new file mode 100644
index 000000000..07e6ff0f7
--- /dev/null
+++ b/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()
+}
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index c2afee9bf..01ea87a3c 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/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
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index a1f79dccb..37a03cc2b 100644
--- a/packages/opencode/src/project/project.ts
+++ b/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) {
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 95cfa6b66..c8ef1fe8e 100644
--- a/packages/opencode/src/server/server.ts
+++ b/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())
diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts
index 525869485..b0a7abca4 100644
--- a/packages/opencode/test/snapshot/snapshot.test.ts
+++ b/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)
- })
+ }})
})
diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts
index cdbeec085..3a74cba44 100644
--- a/packages/opencode/test/tool/bash.test.ts
+++ b/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")
+ },
})
})
})
diff --git a/packages/opencode/test/tool/tool.test.ts b/packages/opencode/test/tool/tool.test.ts
index c0f6e524f..0560fa09a 100644
--- a/packages/opencode/test/tool/tool.test.ts
+++ b/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