From 68f4aa220ea68c539bd1e316a4e96fdd76b93560 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:26:40 +1000 Subject: [PATCH 01/10] fix(plugin): parse package specifiers with npm-package-arg and sanitize win32 cache paths (#21135) --- bun.lock | 2 + packages/opencode/package.json | 2 + packages/opencode/src/npm/index.ts | 8 +- packages/opencode/src/plugin/shared.ts | 28 +++++-- packages/opencode/test/npm.test.ts | 18 ++++ packages/opencode/test/plugin/shared.test.ts | 88 ++++++++++++++++++++ 6 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/npm.test.ts create mode 100644 packages/opencode/test/plugin/shared.test.ts diff --git a/bun.lock b/bun.lock index cdf44a5d84a9..d4159c2495fb 100644 --- a/bun.lock +++ b/bun.lock @@ -371,6 +371,7 @@ "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.0.3", + "npm-package-arg": "13.0.2", "open": "10.1.2", "opencode-gitlab-auth": "2.0.1", "opencode-poe-auth": "0.0.1", @@ -412,6 +413,7 @@ "@types/bun": "catalog:", "@types/cross-spawn": "catalog:", "@types/mime-types": "3.0.1", + "@types/npm-package-arg": "6.1.4", "@types/npmcli__arborist": "6.3.3", "@types/semver": "^7.5.8", "@types/turndown": "5.0.5", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d7f12549c018..40a0fed2fa1b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -54,6 +54,7 @@ "@types/bun": "catalog:", "@types/cross-spawn": "catalog:", "@types/mime-types": "3.0.1", + "@types/npm-package-arg": "6.1.4", "@types/npmcli__arborist": "6.3.3", "@types/semver": "^7.5.8", "@types/turndown": "5.0.5", @@ -135,6 +136,7 @@ "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.0.3", + "npm-package-arg": "13.0.2", "open": "10.1.2", "opencode-gitlab-auth": "2.0.1", "opencode-poe-auth": "0.0.1", diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 69bb2ca5284e..3568ff20e245 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -11,6 +11,7 @@ import { Arborist } from "@npmcli/arborist" export namespace Npm { const log = Log.create({ service: "npm" }) + const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined export const InstallFailedError = NamedError.create( "NpmInstallFailedError", @@ -19,8 +20,13 @@ export namespace Npm { }), ) + export function sanitize(pkg: string) { + if (!illegal) return pkg + return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") + } + function directory(pkg: string) { - return path.join(Global.Path.cache, "packages", pkg) + return path.join(Global.Path.cache, "packages", sanitize(pkg)) } function resolveEntryPoint(name: string, dir: string) { diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index f92520d05dc2..6cda49786bc9 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -1,5 +1,6 @@ import path from "path" import { fileURLToPath, pathToFileURL } from "url" +import npa from "npm-package-arg" import semver from "semver" import { Npm } from "@/npm" import { Filesystem } from "@/util/filesystem" @@ -12,11 +13,24 @@ export function isDeprecatedPlugin(spec: string) { return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg)) } +function parse(spec: string) { + try { + return npa(spec) + } catch {} +} + export function parsePluginSpecifier(spec: string) { - const lastAt = spec.lastIndexOf("@") - const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec - const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest" - return { pkg, version } + const hit = parse(spec) + if (hit?.type === "alias" && !hit.name) { + const sub = (hit as npa.AliasResult).subSpec + if (sub?.name) { + const version = !sub.rawSpec || sub.rawSpec === "*" ? "latest" : sub.rawSpec + return { pkg: sub.name, version } + } + } + if (!hit?.name) return { pkg: spec, version: "" } + if (hit.raw === hit.name) return { pkg: hit.name, version: "latest" } + return { pkg: hit.name, version: hit.rawSpec } } export type PluginSource = "file" | "npm" @@ -190,9 +204,11 @@ export async function checkPluginCompatibility(target: string, opencodeVersion: } } -export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) { +export async function resolvePluginTarget(spec: string) { if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec) - const result = await Npm.add(parsed.pkg + "@" + parsed.version) + const hit = parse(spec) + const pkg = hit?.name && hit.raw === hit.name ? `${hit.name}@latest` : spec + const result = await Npm.add(pkg) return result.directory } diff --git a/packages/opencode/test/npm.test.ts b/packages/opencode/test/npm.test.ts new file mode 100644 index 000000000000..61e3ca6ddf02 --- /dev/null +++ b/packages/opencode/test/npm.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test" +import { Npm } from "../src/npm" + +const win = process.platform === "win32" + +describe("Npm.sanitize", () => { + test("keeps normal scoped package specs unchanged", () => { + expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") + expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0") + expect(Npm.sanitize("prettier")).toBe("prettier") + }) + + test("handles git https specs", () => { + const spec = "acme@git+https://github.com/opencode/acme.git" + const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec + expect(Npm.sanitize(spec)).toBe(expected) + }) +}) diff --git a/packages/opencode/test/plugin/shared.test.ts b/packages/opencode/test/plugin/shared.test.ts new file mode 100644 index 000000000000..98475b02f469 --- /dev/null +++ b/packages/opencode/test/plugin/shared.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import { parsePluginSpecifier } from "../../src/plugin/shared" + +describe("parsePluginSpecifier", () => { + test("parses standard npm package without version", () => { + expect(parsePluginSpecifier("acme")).toEqual({ + pkg: "acme", + version: "latest", + }) + }) + + test("parses standard npm package with version", () => { + expect(parsePluginSpecifier("acme@1.0.0")).toEqual({ + pkg: "acme", + version: "1.0.0", + }) + }) + + test("parses scoped npm package without version", () => { + expect(parsePluginSpecifier("@opencode/acme")).toEqual({ + pkg: "@opencode/acme", + version: "latest", + }) + }) + + test("parses scoped npm package with version", () => { + expect(parsePluginSpecifier("@opencode/acme@1.0.0")).toEqual({ + pkg: "@opencode/acme", + version: "1.0.0", + }) + }) + + test("parses package with git+https url", () => { + expect(parsePluginSpecifier("acme@git+https://github.com/opencode/acme.git")).toEqual({ + pkg: "acme", + version: "git+https://github.com/opencode/acme.git", + }) + }) + + test("parses scoped package with git+https url", () => { + expect(parsePluginSpecifier("@opencode/acme@git+https://github.com/opencode/acme.git")).toEqual({ + pkg: "@opencode/acme", + version: "git+https://github.com/opencode/acme.git", + }) + }) + + test("parses package with git+ssh url containing another @", () => { + expect(parsePluginSpecifier("acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({ + pkg: "acme", + version: "git+ssh://git@github.com/opencode/acme.git", + }) + }) + + test("parses scoped package with git+ssh url containing another @", () => { + expect(parsePluginSpecifier("@opencode/acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({ + pkg: "@opencode/acme", + version: "git+ssh://git@github.com/opencode/acme.git", + }) + }) + + test("parses unaliased git+ssh url", () => { + expect(parsePluginSpecifier("git+ssh://git@github.com/opencode/acme.git")).toEqual({ + pkg: "git+ssh://git@github.com/opencode/acme.git", + version: "", + }) + }) + + test("parses npm alias using the alias name", () => { + expect(parsePluginSpecifier("acme@npm:@opencode/acme@1.0.0")).toEqual({ + pkg: "acme", + version: "npm:@opencode/acme@1.0.0", + }) + }) + + test("parses bare npm protocol specifier using the target package", () => { + expect(parsePluginSpecifier("npm:@opencode/acme@1.0.0")).toEqual({ + pkg: "@opencode/acme", + version: "1.0.0", + }) + }) + + test("parses unversioned npm protocol specifier", () => { + expect(parsePluginSpecifier("npm:@opencode/acme")).toEqual({ + pkg: "@opencode/acme", + version: "latest", + }) + }) +}) From 9e156ea168fd5b21627e341085bba73018454cb1 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 6 Apr 2026 01:18:03 +0000 Subject: [PATCH 02/10] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index e1e8e12b7076..e09f3cf6d18d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-0jwPCu2Lod433GPQLHN8eEkhfpPviDFfkFJmuvkRdlE=", - "aarch64-linux": "sha256-Qi0IkGkaIBKZsPLTO8kaTbCVL0cEfVOm/Y/6VUVI9TY=", - "aarch64-darwin": "sha256-1eZBBLgYVkjg5RYN/etR1Mb5UjU3VelElBB5ug5hQdc=", - "x86_64-darwin": "sha256-jdXgA+kZb/foFHR40UiPif6rsA2GDVCCVHnJR3jBUGI=" + "x86_64-linux": "sha256-LRhPPrOKCGUSCEWTpAxPdWKTKVNkg82WrvD25cP3jts=", + "aarch64-linux": "sha256-sbNxkil47n+B7v6ds5EYFybLytXUyRlu0Cpka0ZmDx4=", + "aarch64-darwin": "sha256-5+99gtpIHGygMW3VBAexNhmaORgI8LCxPk/Gf1fW/ds=", + "x86_64-darwin": "sha256-LqnvZGGnQaRxIoowOr5gf6lFgDhbgQhVPiAcRTtU6fE=" } } From 4712c18a5833da85cd3946357662b148e78573f7 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Mon, 6 Apr 2026 04:14:11 +0200 Subject: [PATCH 03/10] feat(tui): make the mouse disablable (#6824, #7926) (#13748) --- packages/opencode/src/cli/cmd/tui/app.tsx | 3 +++ packages/opencode/src/config/tui-schema.ts | 1 + packages/opencode/src/flag/flag.ts | 1 + packages/web/src/content/docs/cli.mdx | 1 + packages/web/src/content/docs/config.mdx | 5 ++--- packages/web/src/content/docs/tui.mdx | 4 +++- 6 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 6b2633c371f3..d643c0704f4c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -125,6 +125,8 @@ import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { + const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true) + return { externalOutputMode: "passthrough", targetFps: 60, @@ -133,6 +135,7 @@ function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { useKittyKeyboard: { events: process.platform === "win32" }, autoFocus: false, openConsoleOnError: false, + useMouse: mouseEnabled, consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], onCopySelection: (text) => { diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/config/tui-schema.ts index b126d3c96a42..a373b4d8009b 100644 --- a/packages/opencode/src/config/tui-schema.ts +++ b/packages/opencode/src/config/tui-schema.ts @@ -22,6 +22,7 @@ export const TuiOptions = z.object({ .enum(["auto", "stacked"]) .optional() .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), + mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"), }) export const TuiInfo = z diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 1ac52dd17fa1..739009502b17 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -31,6 +31,7 @@ export namespace Flag { export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS") export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT") export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH") + export const OPENCODE_DISABLE_MOUSE = truthy("OPENCODE_DISABLE_MOUSE") export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE") export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT = OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT") diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index e2ba2404de94..579038ad03df 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -573,6 +573,7 @@ OpenCode can be configured using environment variables. | `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` | | `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` | | `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Disable fetching models from remote sources | +| `OPENCODE_DISABLE_MOUSE` | boolean | Disable mouse capture in the TUI | | `OPENCODE_FAKE_VCS` | string | Fake VCS provider for testing purposes | | `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Disable file time checking for optimization | | `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) | diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 39f3cd8ff03c..52ee1da0a383 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -272,7 +272,8 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings. "scroll_acceleration": { "enabled": true }, - "diff_style": "auto" + "diff_style": "auto", + "mouse": true } ``` @@ -280,8 +281,6 @@ Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file. Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible. -[Learn more about TUI configuration here](/docs/tui#configure). - --- ### Server diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 6dfa7b3125a3..e89fb4af39ef 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -368,7 +368,8 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). "scroll_acceleration": { "enabled": true }, - "diff_style": "auto" + "diff_style": "auto", + "mouse": true } ``` @@ -381,6 +382,7 @@ This is separate from `opencode.json`, which configures server/runtime behavior. - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. +- `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved. Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path. From f0f1e51c5c48111198612f9eca652aeffbad49d7 Mon Sep 17 00:00:00 2001 From: George Harker Date: Sun, 5 Apr 2026 19:29:34 -0700 Subject: [PATCH 04/10] fix(core): implement proper configOptions for acp (#21134) --- packages/opencode/src/acp/agent.ts | 84 ++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 96a97be75296..6e87e7642d65 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -21,6 +21,9 @@ import { type Role, type SessionInfo, type SetSessionModelRequest, + type SessionConfigOption, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, type SetSessionModeRequest, type SetSessionModeResponse, type ToolCallContent, @@ -601,6 +604,7 @@ export namespace ACP { return { sessionId, + configOptions: load.configOptions, models: load.models, modes: load.modes, _meta: load._meta, @@ -660,6 +664,11 @@ export namespace ACP { result.modes.currentModeId = lastUser.agent this.sessionManager.setMode(sessionId, lastUser.agent) } + result.configOptions = buildConfigOptions({ + currentModelId: result.models.currentModelId, + availableModels: result.models.availableModels, + modes: result.modes, + }) } for (const msg of messages ?? []) { @@ -1266,6 +1275,11 @@ export namespace ACP { availableModels, }, modes, + configOptions: buildConfigOptions({ + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + availableModels, + modes, + }), _meta: buildVariantMeta({ model, variant: this.sessionManager.getVariant(sessionId), @@ -1305,6 +1319,44 @@ export namespace ACP { this.sessionManager.setMode(params.sessionId, params.modeId) } + async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { + const session = this.sessionManager.get(params.sessionId) + const providers = await this.sdk.config + .providers({ directory: session.cwd }, { throwOnError: true }) + .then((x) => x.data!.providers) + const entries = sortProvidersByName(providers) + + if (params.configId === "model") { + if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string") + const selection = parseModelSelection(params.value, providers) + this.sessionManager.setModel(session.id, selection.model) + this.sessionManager.setVariant(session.id, selection.variant) + } else if (params.configId === "mode") { + if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string") + const availableModes = await this.loadAvailableModes(session.cwd) + if (!availableModes.some((mode) => mode.id === params.value)) { + throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` })) + } + this.sessionManager.setMode(session.id, params.value) + } else { + throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` })) + } + + const updatedSession = this.sessionManager.get(session.id) + const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd)) + const availableVariants = modelVariantsFromProviders(entries, model) + const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true) + const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const modeState = await this.resolveModeState(session.cwd, session.id) + const modes = modeState.currentModeId + ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } + : undefined + + return { + configOptions: buildConfigOptions({ currentModelId, availableModels, modes }), + } + } + async prompt(params: PromptRequest) { const sessionID = params.sessionId const session = this.sessionManager.get(sessionID) @@ -1760,4 +1812,36 @@ export namespace ACP { return { model: parsed, variant: undefined } } + + function buildConfigOptions(input: { + currentModelId: string + availableModels: ModelOption[] + modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined + }): SessionConfigOption[] { + const options: SessionConfigOption[] = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: input.currentModelId, + options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })), + }, + ] + if (input.modes) { + options.push({ + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: input.modes.currentModeId, + options: input.modes.availableModes.map((m) => ({ + value: m.id, + name: m.name, + ...(m.description ? { description: m.description } : {}), + })), + }) + } + return options + } } From 9965d385de42c902282dc1316235d75f036142fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Steenhuis?= Date: Mon, 6 Apr 2026 04:34:53 +0200 Subject: [PATCH 05/10] fix: pass both 'openai' and 'azure' providerOptions keys for @ai-sdk/azure (#20272) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c402238685f9..c1617da40bb9 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -936,6 +936,12 @@ export namespace ProviderTransform { } const key = sdkKey(model.api.npm) ?? model.providerID + // @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from + // providerOptions["openai"], but OpenAIResponsesLanguageModel checks + // "azure" first. Pass both so model options work on either code path. + if (model.api.npm === "@ai-sdk/azure") { + return { openai: options, azure: options } + } return { [key]: options } } From 77a462c93022b0545a952d903d52f74feaa7105d Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:38:35 +1000 Subject: [PATCH 06/10] fix(tui): default Ctrl+Z to undo on Windows (#21138) --- packages/opencode/src/cli/cmd/tui/app.tsx | 1 + .../tui/feature-plugins/home/tips-view.tsx | 4 +- packages/opencode/src/config/tui.ts | 10 +++- packages/opencode/test/config/tui.test.ts | 48 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index d643c0704f4c..93a5da037f32 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -761,6 +761,7 @@ function App(props: { onSnapshot?: () => Promise }) { keybind: "terminal_suspend", category: "System", hidden: true, + enabled: tuiConfig.keybinds?.terminal_suspend !== "none", onSelect: () => { process.once("SIGCONT", () => { renderer.resume() diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index 08e429617f05..a87e4ed2b73d 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -148,5 +148,7 @@ const TIPS = [ "Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs", "Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog", "Use {highlight}/rename{/highlight} to rename the current session", - "Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell", + ...(process.platform === "win32" + ? ["Press {highlight}Ctrl+Z{/highlight} to undo changes in your prompt"] + : ["Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell"]), ] diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index adfb3c781069..fa2022482d25 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -111,7 +111,15 @@ export namespace TuiConfig { } } - acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {}) + const keybinds = { ...(acc.result.keybinds ?? {}) } + if (process.platform === "win32") { + // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. + keybinds.terminal_suspend = "none" + keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join( + ",", + ) + } + acc.result.keybinds = Config.Keybinds.parse(keybinds) const deps: Promise[] = [] if (acc.result.plugin?.length) { diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index a8d98b66cdec..b761d59ea4e6 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -9,6 +9,7 @@ import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! +const wintest = process.platform === "win32" ? test : test.skip beforeEach(async () => { await Config.invalidate(true) @@ -441,6 +442,53 @@ test("merges keybind overrides across precedence layers", async () => { }) }) +wintest("defaults Ctrl+Z to input undo on Windows", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.keybinds?.terminal_suspend).toBe("none") + expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") + }, + }) +}) + +wintest("keeps explicit input undo overrides on Windows", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } })) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.keybinds?.terminal_suspend).toBe("none") + expect(config.keybinds?.input_undo).toBe("ctrl+y") + }, + }) +}) + +wintest("ignores terminal suspend bindings on Windows", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { terminal_suspend: "alt+z" } })) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.keybinds?.terminal_suspend).toBe("none") + expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") + }, + }) +}) + test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => { await using tmp = await tmpdir({ init: async (dir) => { From 342436dfc45062ef8383d356de24418a967eb512 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 6 Apr 2026 03:44:46 +0000 Subject: [PATCH 07/10] release: v1.3.16 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index d4159c2495fb..b34ef09a4130 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -80,7 +80,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -114,7 +114,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -141,7 +141,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -165,7 +165,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -189,7 +189,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -222,7 +222,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -254,7 +254,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -283,7 +283,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -299,7 +299,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.3.15", + "version": "1.3.16", "bin": { "opencode": "./bin/opencode", }, @@ -430,7 +430,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -464,7 +464,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "cross-spawn": "catalog:", }, @@ -479,7 +479,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -514,7 +514,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -562,7 +562,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "zod": "catalog:", }, @@ -573,7 +573,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 3d71ec1e7fc0..f9e3effb50a6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.3.15", + "version": "1.3.16", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 6b52b23dc23d..7b6be5eca00f 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.3.15", + "version": "1.3.16", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 3a1fa330f614..ac9bdc6235d4 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.3.15", + "version": "1.3.16", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index b630e41abc11..61b3dddd655a 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.3.15", + "version": "1.3.16", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index ff81ab887395..b7a5a6febe24 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.3.15", + "version": "1.3.16", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 64af9910e2b0..41ebacff8ce9 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.3.15", + "version": "1.3.16", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index eb0c8034cdb0..ca6b2dbeda93 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.3.15", + "version": "1.3.16", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index aee89a15047b..13fefec83559 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.3.15", + "version": "1.3.16", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index dc4a944ba101..6f1b0a7fe8f8 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.3.15" +version = "1.3.16" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.16/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.16/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.16/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.16/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.16/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 637adefec200..c853251ccdad 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.3.15", + "version": "1.3.16", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 40a0fed2fa1b..4bc5f2e06877 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.15", + "version": "1.3.16", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index f4bd4e401c77..b05d822c654f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.3.15", + "version": "1.3.16", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 04fb5194ad72..ff8255120b01 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.3.15", + "version": "1.3.16", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 3de4f3b61507..8b8587180982 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.3.15", + "version": "1.3.16", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 16268d9c746c..f769113006f6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.3.15", + "version": "1.3.16", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 7b3cf2159008..a84f4c713dc0 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.3.15", + "version": "1.3.16", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 24bb2354c3b0..7d898447d549 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.3.15", + "version": "1.3.16", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 36e9ec554015..564669fc26d8 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.3.15", + "version": "1.3.16", "publisher": "sst-dev", "repository": { "type": "git", From a8fd0159bee09a6d89cb53c86b3119a668c9ef23 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Apr 2026 23:51:34 -0400 Subject: [PATCH 08/10] zen: remove header check --- packages/console/app/src/routes/zen/util/rateLimiter.ts | 5 ++--- packages/console/core/src/subscription.ts | 2 -- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/rateLimiter.ts index 042ec51f2c88..160633981047 100644 --- a/packages/console/app/src/routes/zen/util/rateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/rateLimiter.ts @@ -17,9 +17,8 @@ export function createRateLimiter( const dict = i18n(localeFromRequest(request)) const limits = Subscription.getFreeLimits() - const headerExists = request.headers.has(limits.checkHeader) - const dailyLimit = !headerExists ? limits.fallbackValue : (rateLimit ?? limits.dailyRequests) - const isDefaultModel = headerExists && !rateLimit + const dailyLimit = rateLimit ?? limits.dailyRequests + const isDefaultModel = !rateLimit const ip = !rawIp.length ? "unknown" : rawIp const now = Date.now() diff --git a/packages/console/core/src/subscription.ts b/packages/console/core/src/subscription.ts index 9d6c3ce2b586..bee58184587a 100644 --- a/packages/console/core/src/subscription.ts +++ b/packages/console/core/src/subscription.ts @@ -9,8 +9,6 @@ export namespace Subscription { free: z.object({ promoTokens: z.number().int(), dailyRequests: z.number().int(), - checkHeader: z.string(), - fallbackValue: z.number().int(), }), lite: z.object({ rollingLimit: z.number().int(), From 70b636a360517ddb91658eff8ce0c2bbde45cb9f Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 6 Apr 2026 00:32:55 -0400 Subject: [PATCH 09/10] zen: normalize ipv6 --- packages/console/app/src/routes/zen/util/handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index db5977bc16fe..3e191918e569 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -90,7 +90,8 @@ export async function handler( const body = await input.request.json() const model = opts.parseModel(url, body) const isStream = opts.parseIsStream(url, body) - const ip = input.request.headers.get("x-real-ip") ?? "" + const rawIp = input.request.headers.get("x-real-ip") ?? "" + const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp const sessionId = input.request.headers.get("x-opencode-session") ?? "" const requestId = input.request.headers.get("x-opencode-request") ?? "" const projectId = input.request.headers.get("x-opencode-project") ?? "" From eaa272ef7f034137746d2ed5d13383d9ef20ca8d Mon Sep 17 00:00:00 2001 From: MC Date: Mon, 6 Apr 2026 01:26:04 -0400 Subject: [PATCH 10/10] fix: show clear error when Cloudflare provider env vars are missing (#20399) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline --- packages/opencode/src/auth/index.ts | 1 + .../cli/cmd/tui/component/dialog-provider.tsx | 12 +++- packages/opencode/src/plugin/cloudflare.ts | 67 +++++++++++++++++++ packages/opencode/src/plugin/index.ts | 10 ++- packages/opencode/src/provider/provider.ts | 45 +++++++++++-- packages/sdk/js/src/v2/gen/types.gen.ts | 3 + packages/sdk/openapi.json | 9 +++ 7 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/src/plugin/cloudflare.ts diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index b6d340cc8ddf..2a9fb6c19e93 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -24,6 +24,7 @@ export namespace Auth { export class Api extends Schema.Class("ApiAuth")({ type: Schema.Literal("api"), key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) {} export class WellKnown extends Schema.Class("WellKnownAuth")({ diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 8add73dd6e45..cb7abb8227ed 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -129,7 +129,15 @@ export function createDialogProviderOptions() { } } if (method.type === "api") { - return dialog.replace(() => ) + let metadata: Record | undefined + if (method.prompts?.length) { + const value = await PromptsMethod({ dialog, prompts: method.prompts }) + if (!value) return + metadata = value + } + return dialog.replace(() => ( + + )) } }, } @@ -249,6 +257,7 @@ function CodeMethod(props: CodeMethodProps) { interface ApiMethodProps { providerID: string title: string + metadata?: Record } function ApiMethod(props: ApiMethodProps) { const dialog = useDialog() @@ -293,6 +302,7 @@ function ApiMethod(props: ApiMethodProps) { auth: { type: "api", key: value, + ...(props.metadata ? { metadata: props.metadata } : {}), }, }) await sdk.client.instance.dispose() diff --git a/packages/opencode/src/plugin/cloudflare.ts b/packages/opencode/src/plugin/cloudflare.ts new file mode 100644 index 000000000000..e20a488a3647 --- /dev/null +++ b/packages/opencode/src/plugin/cloudflare.ts @@ -0,0 +1,67 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" + +export async function CloudflareWorkersAuthPlugin(_input: PluginInput): Promise { + const prompts = [ + ...(!process.env.CLOUDFLARE_ACCOUNT_ID + ? [ + { + type: "text" as const, + key: "accountId", + message: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + }, + ] + : []), + ] + + return { + auth: { + provider: "cloudflare-workers-ai", + methods: [ + { + type: "api", + label: "API key", + prompts, + }, + ], + }, + } +} + +export async function CloudflareAIGatewayAuthPlugin(_input: PluginInput): Promise { + const prompts = [ + ...(!process.env.CLOUDFLARE_ACCOUNT_ID + ? [ + { + type: "text" as const, + key: "accountId", + message: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + }, + ] + : []), + ...(!process.env.CLOUDFLARE_GATEWAY_ID + ? [ + { + type: "text" as const, + key: "gatewayId", + message: "Enter your Cloudflare AI Gateway ID", + placeholder: "e.g. my-gateway", + }, + ] + : []), + ] + + return { + auth: { + provider: "cloudflare-ai-gateway", + methods: [ + { + type: "api", + label: "Gateway API token", + prompts, + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index fb60fa096e88..df69c8eba708 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -10,6 +10,7 @@ import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./github-copilot/copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" +import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { Effect, Layer, ServiceMap, Stream } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -46,7 +47,14 @@ export namespace Plugin { export class Service extends ServiceMap.Service()("@opencode/Plugin") {} // Built-in plugins that are directly imported (not installed from npm) - const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin] + const INTERNAL_PLUGINS: PluginInstance[] = [ + CodexAuthPlugin, + CopilotAuthPlugin, + GitlabAuthPlugin, + PoeAuthPlugin, + CloudflareWorkersAuthPlugin, + CloudflareAIGatewayAuthPlugin, + ] function isServerPlugin(value: unknown): value is PluginInstance { return typeof value === "function" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 924d13312a68..9ca49bf8f106 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -672,13 +672,26 @@ export namespace Provider { } }), "cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) { - const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID") - if (!accountId) return { autoload: false } + // When baseURL is already configured (e.g. corporate config routing through a proxy/gateway), + // skip the account ID check because the URL is already fully specified. + if (input.options?.baseURL) return { autoload: false } + + const auth = yield* dep.auth(input.id) + const accountId = + Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined) + if (!accountId) + return { + autoload: false, + async getModel() { + throw new Error( + "CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=", + ) + }, + } const apiKey = yield* Effect.gen(function* () { const envToken = Env.get("CLOUDFLARE_API_KEY") if (envToken) return envToken - const auth = yield* dep.auth(input.id) if (auth?.type === "api") return auth.key return undefined }) @@ -702,16 +715,34 @@ export namespace Provider { } }), "cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) { - const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID") - const gateway = Env.get("CLOUDFLARE_GATEWAY_ID") + // When baseURL is already configured (e.g. corporate config), skip the ID checks. + if (input.options?.baseURL) return { autoload: false } - if (!accountId || !gateway) return { autoload: false } + const auth = yield* dep.auth(input.id) + const accountId = + Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined) + const gateway = + Env.get("CLOUDFLARE_GATEWAY_ID") || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined) + + if (!accountId || !gateway) { + const missing = [ + !accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined, + !gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined, + ].filter((x): x is string => Boolean(x)) + return { + autoload: false, + async getModel() { + throw new Error( + `${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=`).join(" && ")}`, + ) + }, + } + } // Get API token from env or auth - required for authenticated gateways const apiToken = yield* Effect.gen(function* () { const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN") if (envToken) return envToken - const auth = yield* dep.auth(input.id) if (auth?.type === "api") return auth.key return undefined }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 72e549e485ab..548ab8363e2c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1639,6 +1639,9 @@ export type OAuth = { export type ApiAuth = { type: "api" key: string + metadata?: { + [key: string]: string + } } export type WellKnownAuth = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1aa4010e7ade..e21c48e89a3a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11621,6 +11621,15 @@ }, "key": { "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } } }, "required": ["type", "key"]