diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 3bd15261..2114cbc5 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(directory: string, cb: () => Promise) { - 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 ea54d0dd..54f873f9 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 - } + if (prompts.isCancel(modeResult)) throw new UI.CancelledError() - 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: "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 = {} - for (const tool of availableTools) { - if (!selectedTools.includes(tool)) { - tools[tool] = false + const tools: Record = {} + 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 382232f5..965983b9 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") - 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 = { - 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, - })), - ), - { - value: "other", - label: "Other", - }, - ], - }) - - if (prompts.isCancel(provider)) throw new UI.CancelledError() - - 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(), - })), - ], + 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", }) - 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.url) { - prompts.log.info("Go to: " + authorize.url) - } - - 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 (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") - } - } - - 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 ("key" in result) { - await Auth.set(provider, { - type: "api", - key: result.key, - }) - } - prompts.log.success("Login successful") - } + 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 } - } - - 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"), + await ModelsDev.refresh().catch(() => {}) + const providers = await ModelsDev.get() + const priority: Record = { + 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, + })), + ), + { + value: "other", + label: "Other", + }, + ], + }) + + if (prompts.isCancel(provider)) throw new UI.CancelledError() + + 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.url) { + prompts.log.info("Go to: " + authorize.url) + } + + 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 (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") + } + } + + 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 ("key" in result) { + await Auth.set(provider, { + type: "api", + key: result.key, + }) + } + prompts.log.success("Login successful") + } + } + 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 === "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 === "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, }) - 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 === "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") - } - - 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") + }, }) }, }) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 916ced6c..e15243e7 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() + 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() + const providers = await ModelsDev.get() + const provider = await promptProvider() + const model = await promptModel() + //const key = await promptKey() - await addWorkflowFiles() - printNextSteps() + 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() - } - - // 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. git@github.com:sst/opencode.git - // ie. git@github.com:sst/opencode - // ie. ssh://git@github.com/sst/opencode.git - // ie. ssh://git@github.com/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 = { - 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}`) + 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") } - }) - // 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 + 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"), + ) + } - 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.`, - ) + 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() } - 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) + // 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. git@github.com:sst/opencode.git + // ie. git@github.com:sst/opencode + // ie. ssh://git@github.com/sst/opencode.git + // ie. ssh://git@github.com/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 addWorkflowFiles() { - const envStr = - provider === "amazon-bedrock" - ? "" - : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` + async function promptProvider() { + const priority: Record = { + 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, + })), + ), + }) - await Bun.write( - path.join(app.root, WORKFLOW_FILE), - ` + 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}`) + } + }) + + // 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 fffe475e..268ed5ff 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 bbc229e6..16893311 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 00000000..07e6ff0f --- /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 c2afee9b..01ea87a3 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("instance") +const cache = new Map() export const Instance = { - async provide(directory: string, cb: () => R): Promise { - const project = await Project.fromDirectory(directory) - return context.provide({ directory, worktree: project.worktree, project }, cb) + async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { + 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 a1f79dcc..37a03cc2 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 - const cache = new Map() 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(["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(["project", "global"], project) - return project - } - worktree = path.dirname( - await $`git rev-parse --path-format=absolute --git-common-dir` - .quiet() - .nothrow() - .cwd(worktree) - .text() - .then((x) => x.trim()), - ) + 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, - worktree, - vcs: "git", + id: "global", + worktree: "/", time: { created: Date.now(), }, } - await Storage.write(["project", id], project) + await Storage.write(["project", "global"], project) return project } - if (cache.has(directory)) { - return cache.get(directory)! + 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(["project", "global"], project) + return project } - const result = await fn() - cache.set(directory, result) - return result + 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(), + }, + } + await Storage.write(["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 95cfa6b6..c8ef1fe8 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 52586948..b0a7abca 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 cdbeec08..3a74cba4 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 c0f6e524..0560fa09 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