From a433766a31fbf5c0597aec757de59a3d882c02fa Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 14 Aug 2025 16:24:46 -0400 Subject: [PATCH] allow plugins to create custom auth providers --- packages/opencode/src/auth/anthropic.ts | 84 ----- packages/opencode/src/auth/copilot.ts | 19 -- packages/opencode/src/auth/index.ts | 38 ++- packages/opencode/src/cli/cmd/auth.ts | 365 +++++++++------------ packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/plugin/index.ts | 26 +- packages/opencode/src/provider/provider.ts | 114 +------ packages/opencode/src/server/server.ts | 32 ++ packages/plugin/src/index.ts | 55 +++- packages/sdk/js/src/gen/sdk.gen.ts | 20 ++ packages/sdk/js/src/gen/types.gen.ts | 56 ++++ 11 files changed, 372 insertions(+), 438 deletions(-) delete mode 100644 packages/opencode/src/auth/anthropic.ts delete mode 100644 packages/opencode/src/auth/copilot.ts diff --git a/packages/opencode/src/auth/anthropic.ts b/packages/opencode/src/auth/anthropic.ts deleted file mode 100644 index d3228cb8..00000000 --- a/packages/opencode/src/auth/anthropic.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { generatePKCE } from "@openauthjs/openauth/pkce" -import { Auth } from "./index" - -export namespace AuthAnthropic { - const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - - export async function authorize(mode: "max" | "console") { - const pkce = await generatePKCE() - - const url = new URL( - `https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`, - import.meta.url, - ) - url.searchParams.set("code", "true") - url.searchParams.set("client_id", CLIENT_ID) - url.searchParams.set("response_type", "code") - url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback") - url.searchParams.set("scope", "org:create_api_key user:profile user:inference") - url.searchParams.set("code_challenge", pkce.challenge) - url.searchParams.set("code_challenge_method", "S256") - url.searchParams.set("state", pkce.verifier) - return { - url: url.toString(), - verifier: pkce.verifier, - } - } - - export async function exchange(code: string, verifier: string) { - const splits = code.split("#") - const result = await fetch("https://console.anthropic.com/v1/oauth/token", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - code: splits[0], - state: splits[1], - grant_type: "authorization_code", - client_id: CLIENT_ID, - redirect_uri: "https://console.anthropic.com/oauth/code/callback", - code_verifier: verifier, - }), - }) - if (!result.ok) throw new ExchangeFailed() - const json = await result.json() - return { - refresh: json.refresh_token as string, - access: json.access_token as string, - expires: Date.now() + json.expires_in * 1000, - } - } - - export async function access() { - const info = await Auth.get("anthropic") - if (!info || info.type !== "oauth") return - if (info.access && info.expires > Date.now()) return info.access - const response = await fetch("https://console.anthropic.com/v1/oauth/token", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: info.refresh, - client_id: CLIENT_ID, - }), - }) - if (!response.ok) return - const json = await response.json() - await Auth.set("anthropic", { - type: "oauth", - refresh: json.refresh_token as string, - access: json.access_token as string, - expires: Date.now() + json.expires_in * 1000, - }) - return json.access_token as string - } - - export class ExchangeFailed extends Error { - constructor() { - super("Exchange failed") - } - } -} diff --git a/packages/opencode/src/auth/copilot.ts b/packages/opencode/src/auth/copilot.ts deleted file mode 100644 index 7a9b70f0..00000000 --- a/packages/opencode/src/auth/copilot.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Global } from "../global" -import { lazy } from "../util/lazy" -import path from "path" - -export const AuthCopilot = lazy(async () => { - const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts")) - const exists = await file.exists() - const response = fetch("https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts") - .then((x) => Bun.write(file, x)) - .catch(() => {}) - - if (!exists) { - const worked = await response - if (!worked) return - } - const result = await import(file.name!).catch(() => {}) - if (!result) return - return result.AuthCopilot -}) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index ace51b26..a0914343 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -4,25 +4,31 @@ import fs from "fs/promises" import { z } from "zod" export namespace Auth { - export const Oauth = z.object({ - type: z.literal("oauth"), - refresh: z.string(), - access: z.string(), - expires: z.number(), - }) + export const Oauth = z + .object({ + type: z.literal("oauth"), + refresh: z.string(), + access: z.string(), + expires: z.number(), + }) + .openapi({ ref: "OAuth" }) - export const Api = z.object({ - type: z.literal("api"), - key: z.string(), - }) + export const Api = z + .object({ + type: z.literal("api"), + key: z.string(), + }) + .openapi({ ref: "ApiAuth" }) - export const WellKnown = z.object({ - type: z.literal("wellknown"), - key: z.string(), - token: z.string(), - }) + export const WellKnown = z + .object({ + type: z.literal("wellknown"), + key: z.string(), + token: z.string(), + }) + .openapi({ ref: "WellKnownAuth" }) - export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]) + export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).openapi({ ref: "Auth" }) export type Info = z.infer const filepath = path.join(Global.Path.data, "auth.json") diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index dab0bfd5..ab06d5bf 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -1,5 +1,3 @@ -import { AuthAnthropic } from "../../auth/anthropic" -import { AuthCopilot } from "../../auth/copilot" import { Auth } from "../../auth" import { cmd } from "./cmd" import * as prompts from "@clack/prompts" @@ -10,6 +8,8 @@ import { map, pipe, sortBy, values } from "remeda" import path from "path" import os from "os" import { Global } from "../../global" +import { Plugin } from "../../plugin" +import { App } from "../../app/app" export const AuthCommand = cmd({ command: "auth", @@ -75,242 +75,179 @@ export const AuthLoginCommand = cmd({ type: "string", }), async handler(args) { - UI.empty() - prompts.intro("Add credential") - if (args.url) { - const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json()) - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Bun.spawn({ - cmd: wellknown.auth.command, - stdout: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - prompts.log.error("Failed") + await App.provide({ cwd: 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 } - 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 = { - anthropic: 0, - "github-copilot": 1, - openai: 2, - google: 3, - openrouter: 4, - vercel: 5, - } - 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] === 0 ? "recommended" : undefined, - })), - ), - { - value: "other", - label: "Other", - }, - ], - }) - - if (prompts.isCancel(provider)) throw new UI.CancelledError() - - 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 === "anthropic") { - const method = await prompts.select({ - message: "Login method", + await ModelsDev.refresh().catch(() => {}) + const providers = await ModelsDev.get() + const priority: Record = { + anthropic: 0, + "github-copilot": 1, + openai: 2, + google: 3, + openrouter: 4, + vercel: 5, + } + 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] === 0 ? "recommended" : undefined, + })), + ), { - label: "Claude Pro/Max", - value: "max", - }, - { - label: "Create API Key", - value: "console", - }, - { - label: "Manually enter API Key", - value: "api", + value: "other", + label: "Other", }, ], }) - if (prompts.isCancel(method)) throw new UI.CancelledError() - if (method === "max") { - // some weird bug where program exits without this - await new Promise((resolve) => setTimeout(resolve, 10)) - const { url, verifier } = await AuthAnthropic.authorize("max") - prompts.note("Trying to open browser...") - try { - await open(url) - } catch (e) { - prompts.log.error( - "Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:", - ) - } - prompts.log.info(url) + if (prompts.isCancel(provider)) throw new UI.CancelledError() - 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() - - try { - const credentials = await AuthAnthropic.exchange(code, verifier) - await Auth.set("anthropic", { - type: "oauth", - refresh: credentials.refresh, - access: credentials.access, - expires: credentials.expires, + 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(), + })), + ], }) - prompts.log.success("Login successful") - } catch { - prompts.log.error("Invalid code") + if (prompts.isCancel(method)) throw new UI.CancelledError() + index = parseInt(method) } - prompts.outro("Done") - return - } + const method = plugin.auth.methods[index] + if (method.type === "oauth") { + await new Promise((resolve) => setTimeout(resolve, 10)) + const authorize = await method.authorize() - if (method === "console") { - // some weird bug where program exits without this - await new Promise((resolve) => setTimeout(resolve, 10)) - const { url, verifier } = await AuthAnthropic.authorize("console") - prompts.note("Trying to open browser...") - try { - await open(url) - } catch (e) { - prompts.log.error( - "Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:", - ) - } - prompts.log.info(url) - - 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() - - try { - const credentials = await AuthAnthropic.exchange(code, verifier) - const accessToken = credentials.access - const response = await fetch("https://api.anthropic.com/api/oauth/claude_cli/create_api_key", { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json, text/plain, */*", - }, - }) - if (!response.ok) { - throw new Error("Failed to create API key") + if (authorize.url) { + try { + await open(authorize.url) + } catch (e) {} + prompts.log.info("Go to: " + authorize.url) } - const json = await response.json() - await Auth.set("anthropic", { - type: "api", - key: json.raw_key, - }) - prompts.log.success("Login successful - API key created and saved") - } catch (error) { - prompts.log.error("Invalid code or failed to create API key") + 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") { + await Auth.set(provider, { + type: "oauth", + refresh: result.refresh, + access: result.access, + expires: result.expires, + }) + 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") { + await Auth.set(provider, { + type: "oauth", + refresh: result.refresh, + access: result.access, + expires: result.expires, + }) + 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 } - } - const copilot = await AuthCopilot() - if (provider === "github-copilot" && copilot) { - await new Promise((resolve) => setTimeout(resolve, 10)) - const deviceInfo = await copilot.authorize() - - prompts.note(`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`) - - const spinner = prompts.spinner() - spinner.start("Waiting for authorization...") - - while (true) { - await new Promise((resolve) => setTimeout(resolve, deviceInfo.interval * 1000)) - const response = await copilot.poll(deviceInfo.device) - if (response.status === "pending") continue - if (response.status === "success") { - await Auth.set("github-copilot", { - type: "oauth", - refresh: response.refresh, - access: response.access, - expires: response.expires, - }) - spinner.stop("Login successful") - break - } - if (response.status === "failed") { - spinner.stop("Failed to authorize", 1) - break - } + if (provider === "vercel") { + prompts.log.info("You can create an api key in the dashboard") } + 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") - return - } - - if (provider === "vercel") { - prompts.log.info("You can create an api key in the dashboard") - } - - 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/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 38253d72..0d8bffa9 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -4,6 +4,7 @@ export namespace Flag { export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"] + export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS") function truthy(key: string) { const value = process.env[key]?.toLowerCase() diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 3ffa3019..8fbd38c6 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -6,6 +6,7 @@ import { Log } from "../util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { Server } from "../server/server" import { BunProc } from "../bun" +import { Flag } from "../flag/flag" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -17,7 +18,17 @@ export namespace Plugin { }) const config = await Config.get() const hooks = [] - for (let plugin of config.plugin ?? []) { + const input = { + client, + app, + $: Bun.$, + } + const plugins = [...(config.plugin ?? [])] + if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { + plugins.push("opencode-copilot-auth") + plugins.push("opencode-anthropic-auth") + } + for (let plugin of plugins) { log.info("loading plugin", { path: plugin }) if (!plugin.startsWith("file://")) { const [pkg, version] = plugin.split("@") @@ -25,22 +36,19 @@ export namespace Plugin { } const mod = await import(plugin) for (const [_name, fn] of Object.entries(mod)) { - const init = await fn({ - client, - app, - $: Bun.$, - }) + const init = await fn(input) hooks.push(init) } } return { hooks, + input, } }) export async function trigger< - Name extends keyof Required, + Name extends Exclude, "auth" | "event">, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { @@ -56,6 +64,10 @@ export namespace Plugin { return output } + export async function list() { + return state().then((x) => x.hooks) + } + export function init() { Bus.subscribeAll(async (input) => { const hooks = await state().then((x) => x.hooks) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f32231c5..42bb1804 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -5,8 +5,7 @@ import { mergeDeep, sortBy } from "remeda" import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai" import { Log } from "../util/log" import { BunProc } from "../bun" -import { AuthAnthropic } from "../auth/anthropic" -import { AuthCopilot } from "../auth/copilot" +import { Plugin } from "../plugin" import { ModelsDev } from "./models" import { NamedError } from "../util/error" import { Auth } from "../auth" @@ -26,103 +25,13 @@ export namespace Provider { type Source = "env" | "config" | "custom" | "api" const CUSTOM_LOADERS: Record = { - async anthropic(provider) { - const access = await AuthAnthropic.access() - if (!access) - return { - autoload: false, - options: { - headers: { - "anthropic-beta": - "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", - }, - }, - } - for (const model of Object.values(provider.models)) { - model.cost = { - input: 0, - output: 0, - } - } + async anthropic() { return { - autoload: true, + autoload: false, options: { - apiKey: "", - async fetch(input: any, init: any) { - const access = await AuthAnthropic.access() - const headers = { - ...init.headers, - authorization: `Bearer ${access}`, - "anthropic-beta": - "oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", - } - delete headers["x-api-key"] - return fetch(input, { - ...init, - headers, - }) - }, - }, - } - }, - "github-copilot": async (provider) => { - const copilot = await AuthCopilot() - if (!copilot) return { autoload: false } - let info = await Auth.get("github-copilot") - if (!info || info.type !== "oauth") return { autoload: false } - - if (provider && provider.models) { - for (const model of Object.values(provider.models)) { - model.cost = { - input: 0, - output: 0, - } - } - } - - return { - autoload: true, - options: { - apiKey: "", - async fetch(input: any, init: any) { - const info = await Auth.get("github-copilot") - if (!info || info.type !== "oauth") return - if (!info.access || info.expires < Date.now()) { - const tokens = await copilot.access(info.refresh) - if (!tokens) throw new Error("GitHub Copilot authentication expired") - await Auth.set("github-copilot", { - type: "oauth", - ...tokens, - }) - info.access = tokens.access - } - let isAgentCall = false - let isVisionRequest = false - try { - const body = typeof init.body === "string" ? JSON.parse(init.body) : init.body - if (body?.messages) { - isAgentCall = body.messages.some((msg: any) => msg.role && ["tool", "assistant"].includes(msg.role)) - isVisionRequest = body.messages.some( - (msg: any) => - Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), - ) - } - } catch {} - const headers: Record = { - ...init.headers, - ...copilot.HEADERS, - Authorization: `Bearer ${info.access}`, - "Openai-Intent": "conversation-edits", - "X-Initiator": isAgentCall ? "agent" : "user", - } - if (isVisionRequest) { - headers["Copilot-Vision-Request"] = "true" - } - delete headers["x-api-key"] - return fetch(input, { - ...init, - headers, - }) + headers: { + "anthropic-beta": + "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", }, }, } @@ -350,6 +259,17 @@ export namespace Provider { } } + for (const plugin of await Plugin.list()) { + if (!plugin.auth) continue + const providerID = plugin.auth.provider + if (disabled.has(providerID)) continue + const auth = await Auth.get(providerID) + if (!auth) continue + if (!plugin.auth.loader) continue + const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) + mergeProvider(plugin.auth.provider, options ?? {}, "custom") + } + // load config for (const [providerID, provider] of configProviders) { mergeProvider(providerID, provider.options ?? {}, "config") diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 63d63b0a..2ed65cbb 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -20,6 +20,7 @@ import { callTui, TuiRoute } from "./tui" import { Permission } from "../permission" import { lazy } from "../util/lazy" import { Agent } from "../agent/agent" +import { Auth } from "../auth" const ERRORS = { 400: { @@ -1120,6 +1121,37 @@ export namespace Server { async (c) => c.json(await callTui(c)), ) .route("/tui/control", TuiRoute) + .put( + "/auth/:id", + describeRoute({ + description: "Set authentication credentials", + operationId: "auth.set", + responses: { + 200: { + description: "Successfully set authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...ERRORS, + }, + }), + zValidator( + "param", + z.object({ + id: z.string(), + }), + ), + zValidator("json", Auth.Info), + async (c) => { + const id = c.req.valid("param").id + const info = c.req.valid("json") + await Auth.set(id, info) + return c.json(true) + }, + ) return result }) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 7ea82d2a..1a6cbf12 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,4 +1,14 @@ -import type { Event, createOpencodeClient, App, Model, Provider, Permission, UserMessage, Part } from "@opencode-ai/sdk" +import type { + Event, + createOpencodeClient, + App, + Model, + Provider, + Permission, + UserMessage, + Part, + Auth, +} from "@opencode-ai/sdk" import type { BunShell } from "./shell" export type PluginInput = { @@ -10,6 +20,49 @@ export type Plugin = (input: PluginInput) => Promise export interface Hooks { event?: (input: { event: Event }) => Promise + auth?: { + provider: string + loader?: (auth: () => Promise, provider: Provider) => Promise> + methods: ( + | { + type: "oauth" + label: string + authorize(): Promise< + { url: string; instructions: string } & ( + | { + method: "auto" + callback(): Promise< + | { + type: "success" + refresh: string + access: string + expires: number + } + | { + type: "failed" + } + > + } + | { + method: "code" + callback(code: string): Promise< + | { + type: "success" + refresh: string + access: string + expires: number + } + | { + type: "failed" + } + > + } + ) + > + } + | { type: "api"; label: string } + )[] + } /** * Called when a new message is received */ diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index 8a26dfa7..5dd8552d 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -77,6 +77,9 @@ import type { TuiClearPromptResponses, TuiExecuteCommandData, TuiExecuteCommandResponses, + AuthSetData, + AuthSetResponses, + AuthSetErrors, } from "./types.gen.js" import { client as _heyApiClient } from "./client.gen.js" @@ -517,6 +520,22 @@ class Tui extends _HeyApiClient { } } +class Auth extends _HeyApiClient { + /** + * Set authentication credentials + */ + public set(options: Options) { + return (options.client ?? this._client).put({ + url: "/auth/{id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } +} + export class OpencodeClient extends _HeyApiClient { /** * Respond to a permission request @@ -544,4 +563,5 @@ export class OpencodeClient extends _HeyApiClient { find = new Find({ client: this._client }) file = new File({ client: this._client }) tui = new Tui({ client: this._client }) + auth = new Auth({ client: this._client }) } diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 3652ea58..d2b99046 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1105,6 +1105,35 @@ export type Agent = { } } +export type Auth = + | ({ + type: "oauth" + } & OAuth) + | ({ + type: "api" + } & ApiAuth) + | ({ + type: "wellknown" + } & WellKnownAuth) + +export type OAuth = { + type: "oauth" + refresh: string + access: string + expires: number +} + +export type ApiAuth = { + type: "api" + key: string +} + +export type WellKnownAuth = { + type: "wellknown" + key: string + token: string +} + export type EventSubscribeData = { body?: never path?: never @@ -1858,6 +1887,33 @@ export type TuiExecuteCommandResponses = { export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses] +export type AuthSetData = { + body?: Auth + path: { + id: string + } + query?: never + url: "/auth/{id}" +} + +export type AuthSetErrors = { + /** + * Bad request + */ + 400: _Error +} + +export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] + +export type AuthSetResponses = { + /** + * Successfully set authentication credentials + */ + 200: boolean +} + +export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] + export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) }