From eeb8f7bdd32ff85b71195af6255f63fd1873e60e Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Mon, 29 Dec 2025 17:55:34 -0700 Subject: [PATCH 1/2] feat: add multi-account support for auth providers - Add support for multiple accounts per provider with active account switching - New Auth data structure: ProviderAuth with accounts map and active field - Migrate legacy single-auth format automatically on read - CLI: auth list shows accounts per provider, login accepts --account flag - CLI: auth logout allows removing specific accounts - TUI: New DialogAccounts, DialogAccountName, DialogAuthMethod components - Server: New /auth/:providerID/accounts endpoints (list, set, setActive, remove) - Regenerate SDK with new auth.accounts.* methods --- packages/opencode/src/auth/index.ts | 167 ++++++++++++--- packages/opencode/src/cli/cmd/auth.ts | 123 +++++++---- .../cli/cmd/tui/component/dialog-provider.tsx | 199 ++++++++++++++---- packages/opencode/src/provider/auth.ts | 29 ++- packages/opencode/src/server/server.ts | 132 +++++++++++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 154 ++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 124 +++++++++++ 7 files changed, 810 insertions(+), 118 deletions(-) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index b9c8a78caf9..971520659f0 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -32,39 +32,158 @@ export namespace Auth { export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" }) export type Info = z.infer - const filepath = path.join(Global.Path.data, "auth.json") + export const ProviderAuth = z + .object({ + accounts: z.record(z.string(), Info), + active: z.string(), + }) + .meta({ ref: "ProviderAuth" }) + export type ProviderAuth = z.infer - export async function get(providerID: string) { - const auth = await all() - return auth[providerID] - } + export const AccountInfo = z + .object({ + name: z.string(), + type: z.enum(["oauth", "api", "wellknown"]), + active: z.boolean(), + }) + .meta({ ref: "AccountInfo" }) + export type AccountInfo = z.infer - export async function all(): Promise> { + const filepath = path.join(Global.Path.data, "auth.json") + + async function readRaw(): Promise> { const file = Bun.file(filepath) const data = await file.json().catch(() => ({}) as Record) - return Object.entries(data).reduce( - (acc, [key, value]) => { - const parsed = Info.safeParse(value) - if (!parsed.success) return acc - acc[key] = parsed.data - return acc - }, - {} as Record, - ) - } - export async function set(key: string, info: Info) { - const file = Bun.file(filepath) - const data = await all() - await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2)) - await fs.chmod(file.name!, 0o600) + const result: Record = {} + + for (const [providerID, value] of Object.entries(data)) { + const providerAuth = ProviderAuth.safeParse(value) + if (providerAuth.success) { + result[providerID] = providerAuth.data + continue + } + + const legacyAuth = Info.safeParse(value) + if (legacyAuth.success) { + result[providerID] = { + accounts: { default: legacyAuth.data }, + active: "default", + } + continue + } + } + + return result } - export async function remove(key: string) { + async function writeRaw(data: Record) { const file = Bun.file(filepath) - const data = await all() - delete data[key] await Bun.write(file, JSON.stringify(data, null, 2)) await fs.chmod(file.name!, 0o600) } + + export async function get(providerID: string): Promise { + const data = await readRaw() + const provider = data[providerID] + if (!provider) return undefined + return provider.accounts[provider.active] + } + + export async function getAccount(providerID: string, accountName: string): Promise { + const data = await readRaw() + const provider = data[providerID] + if (!provider) return undefined + return provider.accounts[accountName] + } + + export async function listAccounts(providerID: string): Promise { + const data = await readRaw() + const provider = data[providerID] + if (!provider) return [] + return Object.entries(provider.accounts).map(([name, auth]) => ({ + name, + type: auth.type, + active: name === provider.active, + })) + } + + export async function all(): Promise> { + const data = await readRaw() + const result: Record = {} + for (const [providerID, provider] of Object.entries(data)) { + const activeAuth = provider.accounts[provider.active] + if (activeAuth) result[providerID] = activeAuth + } + return result + } + + export async function allProviders(): Promise> { + return readRaw() + } + + export async function setAccount(providerID: string, accountName: string, info: Info) { + const data = await readRaw() + const existing = data[providerID] + if (existing) { + existing.accounts[accountName] = info + if (Object.keys(existing.accounts).length === 1) { + existing.active = accountName + } + } else { + data[providerID] = { + accounts: { [accountName]: info }, + active: accountName, + } + } + await writeRaw(data) + } + + export async function setActive(providerID: string, accountName: string): Promise { + const data = await readRaw() + const provider = data[providerID] + if (!provider || !provider.accounts[accountName]) return false + provider.active = accountName + await writeRaw(data) + return true + } + + export async function removeAccount(providerID: string, accountName: string): Promise { + const data = await readRaw() + const provider = data[providerID] + if (!provider || !provider.accounts[accountName]) return false + + delete provider.accounts[accountName] + + if (Object.keys(provider.accounts).length === 0) { + delete data[providerID] + } else if (provider.active === accountName) { + provider.active = Object.keys(provider.accounts)[0] + } + + await writeRaw(data) + return true + } + + export async function set(providerID: string, info: Info) { + await setAccount(providerID, "default", info) + } + + export async function remove(providerID: string) { + const data = await readRaw() + delete data[providerID] + await writeRaw(data) + } + + export async function getActive(providerID: string): Promise { + const data = await readRaw() + const provider = data[providerID] + return provider?.active + } + + export async function hasAccounts(providerID: string): Promise { + const data = await readRaw() + const provider = data[providerID] + return provider ? Object.keys(provider.accounts).length > 0 : false + } } diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 658329fb6ef..9db000c3cbd 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -14,11 +14,7 @@ import type { Hooks } from "@opencode-ai/plugin" type PluginAuth = NonNullable -/** - * Handle plugin-based authentication flow. - * Returns true if auth was handled, false if it should fall through to default handling. - */ -async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise { +async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, accountName: string): Promise { let index = 0 if (plugin.auth.methods.length > 1) { const method = await prompts.select({ @@ -35,7 +31,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } const method = plugin.auth.methods[index] - // Handle prompts for all auth types await new Promise((resolve) => setTimeout(resolve, 10)) const inputs: Record = {} if (method.prompts) { @@ -83,7 +78,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { + await Auth.setAccount(saveProvider, accountName, { type: "oauth", refresh, access, @@ -92,7 +87,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): }) } if ("key" in result) { - await Auth.set(saveProvider, { + await Auth.setAccount(saveProvider, accountName, { type: "api", key: result.key, }) @@ -115,7 +110,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { + await Auth.setAccount(saveProvider, accountName, { type: "oauth", refresh, access, @@ -124,7 +119,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): }) } if ("key" in result) { - await Auth.set(saveProvider, { + await Auth.setAccount(saveProvider, accountName, { type: "api", key: result.key, }) @@ -145,7 +140,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } if (result.type === "success") { const saveProvider = result.provider ?? provider - await Auth.set(saveProvider, { + await Auth.setAccount(saveProvider, accountName, { type: "api", key: result.key, }) @@ -170,24 +165,33 @@ export const AuthCommand = cmd({ export const AuthListCommand = cmd({ command: "list", aliases: ["ls"], - describe: "list providers", + describe: "list providers and accounts", async handler() { UI.empty() const authPath = path.join(Global.Path.data, "auth.json") const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = Object.entries(await Auth.all()) + + const providers = await Auth.allProviders() const database = await ModelsDev.get() - for (const [providerID, result] of results) { + let totalAccounts = 0 + for (const [providerID, provider] of Object.entries(providers)) { const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + const accountCount = Object.keys(provider.accounts).length + totalAccounts += accountCount + + prompts.log.info(`${name}`) + for (const [accountName, auth] of Object.entries(provider.accounts)) { + const isActive = accountName === provider.active + const activeIndicator = isActive ? " (active)" : "" + prompts.log.message(` ${accountName}${activeIndicator} ${UI.Style.TEXT_DIM}${auth.type}`) + } } - prompts.outro(`${results.length} credentials`) + prompts.outro(`${Object.keys(providers).length} provider(s), ${totalAccounts} account(s)`) - // Environment variables section const activeEnvVars: Array<{ provider: string; envVar: string }> = [] for (const [providerID, provider] of Object.entries(database)) { @@ -218,10 +222,16 @@ export const AuthLoginCommand = cmd({ command: "login [url]", describe: "log in to a provider", builder: (yargs) => - yargs.positional("url", { - describe: "opencode auth provider", - type: "string", - }), + yargs + .positional("url", { + describe: "opencode auth provider", + type: "string", + }) + .option("account", { + describe: "account name", + type: "string", + alias: "a", + }), async handler(args) { await Instance.provide({ directory: process.cwd(), @@ -229,6 +239,7 @@ export const AuthLoginCommand = cmd({ UI.empty() prompts.intro("Add credential") if (args.url) { + const accountName = args.account ?? "default" const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any) prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Bun.spawn({ @@ -242,12 +253,12 @@ export const AuthLoginCommand = cmd({ return } const token = await new Response(proc.stdout).text() - await Auth.set(args.url, { + await Auth.setAccount(args.url, accountName, { type: "wellknown", key: wellknown.auth.env, token: token.trim(), }) - prompts.log.success("Logged into " + args.url) + prompts.log.success(`Logged into ${args.url} as "${accountName}"`) prompts.outro("Done") return } @@ -306,9 +317,24 @@ export const AuthLoginCommand = cmd({ if (prompts.isCancel(provider)) throw new UI.CancelledError() + const existingAccounts = await Auth.listAccounts(provider) + let accountName = args.account + if (!accountName) { + if (existingAccounts.length > 0) { + prompts.log.info(`Existing accounts: ${existingAccounts.map((a) => a.name).join(", ")}`) + } + const inputName = await prompts.text({ + message: "Account name", + placeholder: existingAccounts.length === 0 ? "default" : "e.g. work, personal", + defaultValue: existingAccounts.length === 0 ? "default" : undefined, + }) + if (prompts.isCancel(inputName)) throw new UI.CancelledError() + accountName = inputName?.trim() || "default" + } + const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) if (plugin && plugin.auth) { - const handled = await handlePluginAuth({ auth: plugin.auth }, provider) + const handled = await handlePluginAuth({ auth: plugin.auth }, provider, accountName) if (handled) return } @@ -321,10 +347,9 @@ export const AuthLoginCommand = cmd({ provider = provider.replace(/^@ai-sdk\//, "") if (prompts.isCancel(provider)) throw new UI.CancelledError() - // Check if a plugin provides auth for this custom provider const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) if (customPlugin && customPlugin.auth) { - const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider) + const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, accountName) if (handled) return } @@ -354,11 +379,12 @@ export const AuthLoginCommand = cmd({ validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) if (prompts.isCancel(key)) throw new UI.CancelledError() - await Auth.set(provider, { + await Auth.setAccount(provider, accountName, { type: "api", key, }) + prompts.log.success(`Logged in as "${accountName}"`) prompts.outro("Done") }, }) @@ -367,25 +393,44 @@ export const AuthLoginCommand = cmd({ export const AuthLogoutCommand = cmd({ command: "logout", - describe: "log out from a configured provider", + describe: "log out from a configured provider or account", async handler() { UI.empty() - const credentials = await Auth.all().then((x) => Object.entries(x)) + const providers = await Auth.allProviders() prompts.intro("Remove credential") - if (credentials.length === 0) { + + if (Object.keys(providers).length === 0) { prompts.log.error("No credentials found") return } + const database = await ModelsDev.get() - const providerID = await prompts.select({ - message: "Select provider", - options: credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })), + + type AccountOption = { providerID: string; accountName: string } + const options: Array<{ label: string; value: AccountOption }> = [] + + for (const [providerID, provider] of Object.entries(providers)) { + const name = database[providerID]?.name || providerID + for (const [accountName, auth] of Object.entries(provider.accounts)) { + const isActive = accountName === provider.active + const activeIndicator = isActive ? " (active)" : "" + options.push({ + label: `${name} / ${accountName}${activeIndicator} ${UI.Style.TEXT_DIM}(${auth.type})`, + value: { providerID, accountName }, + }) + } + } + + const selected = await prompts.select({ + message: "Select account to remove", + options, }) - if (prompts.isCancel(providerID)) throw new UI.CancelledError() - await Auth.remove(providerID) - prompts.outro("Logout successful") + if (prompts.isCancel(selected)) throw new UI.CancelledError() + + await Auth.removeAccount(selected.providerID, selected.accountName) + prompts.log.success( + `Removed ${selected.accountName} from ${database[selected.providerID]?.name || selected.providerID}`, + ) + prompts.outro("Done") }, }) 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 d976485319f..2176430d99b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -20,6 +20,145 @@ const PROVIDER_PRIORITY: Record = { openrouter: 5, } +interface AccountInfo { + name: string + type: "oauth" | "api" | "wellknown" + active: boolean +} + +function DialogAccounts(props: { providerID: string; providerName: string }) { + const sdk = useSDK() + const dialog = useDialog() + const sync = useSync() + const [accounts, setAccounts] = createSignal([]) + + onMount(async () => { + const result = await sdk.client.auth.accounts.list({ providerID: props.providerID }) + if (result.data) setAccounts(result.data) + }) + + const options = createMemo(() => { + const accountOptions = accounts().map((account) => ({ + title: account.name, + value: account.name, + description: account.active ? "(Active)" : undefined, + async onSelect() { + if (account.active) { + dialog.replace(() => ) + return + } + await sdk.client.auth.accounts.setActive({ + providerID: props.providerID, + accountName: account.name, + }) + await sdk.client.instance.dispose() + await sync.bootstrap() + dialog.replace(() => ) + }, + })) + + return [ + ...accountOptions, + { + title: "+ Add account", + value: "__add__", + async onSelect() { + dialog.replace(() => ) + }, + }, + ] + }) + + return +} + +function DialogAccountName(props: { providerID: string; providerName: string }) { + const dialog = useDialog() + + return ( + { + const accountName = value?.trim() || "default" + dialog.replace(() => ( + + )) + }} + /> + ) +} + +function DialogAuthMethod(props: { providerID: string; providerName: string; accountName: string }) { + const sync = useSync() + const dialog = useDialog() + const sdk = useSDK() + + const methods = createMemo(() => { + return sync.data.provider_auth[props.providerID] ?? [{ type: "api", label: "API key" }] + }) + + onMount(async () => { + const m = methods() + if (m.length === 1) { + await startAuth(0, m[0]) + } + }) + + async function startAuth(index: number, method: { type: string; label: string }) { + if (method.type === "oauth") { + const result = await sdk.client.provider.oauth.authorize({ + providerID: props.providerID, + method: index, + accountName: props.accountName, + }) + if (result.data?.method === "code") { + dialog.replace(() => ( + + )) + } + if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) + } + } + if (method.type === "api") { + dialog.replace(() => ( + + )) + } + } + + const options = createMemo(() => + methods().map((method, index) => ({ + title: method.label, + value: index, + async onSelect() { + await startAuth(index, method) + }, + })), + ) + + return ( + 1} fallback={null}> + + + ) +} + export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() @@ -37,51 +176,15 @@ export function createDialogProviderOptions() { }[provider.id], category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", async onSelect() { - const methods = sync.data.provider_auth[provider.id] ?? [ - { - type: "api", - label: "API key", - }, - ] - let index: number | null = 0 - if (methods.length > 1) { - index = await new Promise((resolve) => { - dialog.replace( - () => ( - ({ - title: x.label, - value: index, - }))} - onSelect={(option) => resolve(option.value)} - /> - ), - () => resolve(null), - ) - }) - } - if (index == null) return - const method = methods[index] - if (method.type === "oauth") { - const result = await sdk.client.provider.oauth.authorize({ - providerID: provider.id, - method: index, - }) - if (result.data?.method === "code") { - dialog.replace(() => ( - - )) - } - if (result.data?.method === "auto") { - dialog.replace(() => ( - - )) - } - } - if (method.type === "api") { - return dialog.replace(() => ) + const accountsResult = await sdk.client.auth.accounts.list({ providerID: provider.id }) + const accounts = accountsResult.data ?? [] + + if (accounts.length > 0) { + dialog.replace(() => ) + return } + + dialog.replace(() => ) }, })), ) @@ -99,6 +202,7 @@ interface AutoMethodProps { providerID: string title: string authorization: ProviderAuthAuthorization + accountName: string } function AutoMethod(props: AutoMethodProps) { const { theme } = useTheme() @@ -110,6 +214,7 @@ function AutoMethod(props: AutoMethodProps) { const result = await sdk.client.provider.oauth.callback({ providerID: props.providerID, method: props.index, + accountName: props.accountName, }) if (result.error) { dialog.clear() @@ -142,6 +247,7 @@ interface CodeMethodProps { title: string providerID: string authorization: ProviderAuthAuthorization + accountName: string } function CodeMethod(props: CodeMethodProps) { const { theme } = useTheme() @@ -159,6 +265,7 @@ function CodeMethod(props: CodeMethodProps) { providerID: props.providerID, method: props.index, code: value, + accountName: props.accountName, }) if (!error) { await sdk.client.instance.dispose() @@ -184,6 +291,7 @@ function CodeMethod(props: CodeMethodProps) { interface ApiMethodProps { providerID: string title: string + accountName: string } function ApiMethod(props: ApiMethodProps) { const dialog = useDialog() @@ -209,8 +317,9 @@ function ApiMethod(props: ApiMethodProps) { } onConfirm={async (value) => { if (!value) return - sdk.client.auth.set({ + await sdk.client.auth.accounts.set({ providerID: props.providerID, + accountName: props.accountName, auth: { type: "api", key: value, diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index d06253ab4ad..1c89ad0776c 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -3,10 +3,15 @@ import { Plugin } from "../plugin" import { map, filter, pipe, fromEntries, mapValues } from "remeda" import z from "zod" import { fn } from "@/util/fn" -import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin" +import type { AuthOuathResult } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "@/auth" +interface PendingAuth { + result: AuthOuathResult + accountName: string +} + export namespace ProviderAuth { const state = Instance.state(async () => { const methods = pipe( @@ -15,7 +20,7 @@ export namespace ProviderAuth { map((x) => [x.auth!.provider, x.auth!] as const), fromEntries(), ) - return { methods, pending: {} as Record } + return { methods, pending: {} as Record } }) export const Method = z @@ -55,13 +60,15 @@ export namespace ProviderAuth { z.object({ providerID: z.string(), method: z.number(), + accountName: z.string().optional(), }), async (input): Promise => { const auth = await state().then((s) => s.methods[input.providerID]) const method = auth.methods[input.method] if (method.type === "oauth") { const result = await method.authorize() - await state().then((s) => (s.pending[input.providerID] = result)) + const accountName = input.accountName ?? "default" + await state().then((s) => (s.pending[input.providerID] = { result, accountName })) return { url: result.url, method: result.method, @@ -76,10 +83,14 @@ export namespace ProviderAuth { providerID: z.string(), method: z.number(), code: z.string().optional(), + accountName: z.string().optional(), }), async (input) => { - const match = await state().then((s) => s.pending[input.providerID]) - if (!match) throw new OauthMissing({ providerID: input.providerID }) + const pending = await state().then((s) => s.pending[input.providerID]) + if (!pending) throw new OauthMissing({ providerID: input.providerID }) + + const { result: match, accountName: pendingAccountName } = pending + const accountName = input.accountName ?? pendingAccountName let result if (match.method === "code") { @@ -93,13 +104,13 @@ export namespace ProviderAuth { if (result?.type === "success") { if ("key" in result) { - await Auth.set(input.providerID, { + await Auth.setAccount(input.providerID, accountName, { type: "api", key: result.key, }) } if ("refresh" in result) { - await Auth.set(input.providerID, { + await Auth.setAccount(input.providerID, accountName, { type: "oauth", access: result.access, refresh: result.refresh, @@ -117,9 +128,11 @@ export namespace ProviderAuth { z.object({ providerID: z.string(), key: z.string(), + accountName: z.string().optional(), }), async (input) => { - await Auth.set(input.providerID, { + const accountName = input.accountName ?? "default" + await Auth.setAccount(input.providerID, accountName, { type: "api", key: input.key, }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e25d9ded473..cf6d119cd3e 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1704,14 +1704,16 @@ export namespace Server { "json", z.object({ method: z.number().meta({ description: "Auth method index" }), + accountName: z.string().optional().meta({ description: "Account name for multi-account support" }), }), ), async (c) => { const providerID = c.req.valid("param").providerID - const { method } = c.req.valid("json") + const { method, accountName } = c.req.valid("json") const result = await ProviderAuth.authorize({ providerID, method, + accountName, }) return c.json(result) }, @@ -1745,15 +1747,17 @@ export namespace Server { z.object({ method: z.number().meta({ description: "Auth method index" }), code: z.string().optional().meta({ description: "OAuth authorization code" }), + accountName: z.string().optional().meta({ description: "Account name for multi-account support" }), }), ), async (c) => { const providerID = c.req.valid("param").providerID - const { method, code } = c.req.valid("json") + const { method, code, accountName } = c.req.valid("json") await ProviderAuth.callback({ providerID, method, code, + accountName, }) return c.json(true) }, @@ -2569,6 +2573,130 @@ export namespace Server { return c.json(true) }, ) + .get( + "/auth/:providerID/accounts", + describeRoute({ + summary: "List accounts", + description: "List all accounts for a provider", + operationId: "auth.accounts.list", + responses: { + 200: { + description: "List of accounts", + content: { + "application/json": { + schema: resolver(z.array(Auth.AccountInfo)), + }, + }, + }, + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + const accounts = await Auth.listAccounts(providerID) + return c.json(accounts) + }, + ) + .put( + "/auth/:providerID/accounts/:accountName", + describeRoute({ + summary: "Set account credentials", + description: "Set authentication credentials for a named account", + operationId: "auth.accounts.set", + responses: { + 200: { + description: "Successfully set account credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + accountName: z.string(), + }), + ), + validator("json", Auth.Info), + async (c) => { + const { providerID, accountName } = c.req.valid("param") + const info = c.req.valid("json") + await Auth.setAccount(providerID, accountName, info) + return c.json(true) + }, + ) + .post( + "/auth/:providerID/accounts/:accountName/active", + describeRoute({ + summary: "Set active account", + description: "Set the active account for a provider", + operationId: "auth.accounts.setActive", + responses: { + 200: { + description: "Successfully set active account", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + accountName: z.string(), + }), + ), + async (c) => { + const { providerID, accountName } = c.req.valid("param") + const success = await Auth.setActive(providerID, accountName) + return c.json(success) + }, + ) + .delete( + "/auth/:providerID/accounts/:accountName", + describeRoute({ + summary: "Remove account", + description: "Remove a named account from a provider", + operationId: "auth.accounts.remove", + responses: { + 200: { + description: "Successfully removed account", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + accountName: z.string(), + }), + ), + async (c) => { + const { providerID, accountName } = c.req.valid("param") + const success = await Auth.removeAccount(providerID, accountName) + return c.json(success) + }, + ) .get( "/event", describeRoute({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 797896ace9a..1a413989933 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -8,6 +8,13 @@ import type { AppLogErrors, AppLogResponses, Auth as Auth2, + AuthAccountsListResponses, + AuthAccountsRemoveErrors, + AuthAccountsRemoveResponses, + AuthAccountsSetActiveErrors, + AuthAccountsSetActiveResponses, + AuthAccountsSetErrors, + AuthAccountsSetResponses, AuthSetErrors, AuthSetResponses, CommandListResponses, @@ -1672,6 +1679,7 @@ export class Oauth extends HeyApiClient { providerID: string directory?: string method?: number + accountName?: string }, options?: Options, ) { @@ -1683,6 +1691,7 @@ export class Oauth extends HeyApiClient { { in: "path", key: "providerID" }, { in: "query", key: "directory" }, { in: "body", key: "method" }, + { in: "body", key: "accountName" }, ], }, ], @@ -1714,6 +1723,7 @@ export class Oauth extends HeyApiClient { directory?: string method?: number code?: string + accountName?: string }, options?: Options, ) { @@ -1726,6 +1736,7 @@ export class Oauth extends HeyApiClient { { in: "query", key: "directory" }, { in: "body", key: "method" }, { in: "body", key: "code" }, + { in: "body", key: "accountName" }, ], }, ], @@ -2028,6 +2039,147 @@ export class App extends HeyApiClient { } } +export class Accounts extends HeyApiClient { + /** + * List accounts + * + * List all accounts for a provider + */ + public list( + parameters: { + providerID: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/auth/{providerID}/accounts", + ...options, + ...params, + }) + } + + /** + * Remove account + * + * Remove a named account from a provider + */ + public remove( + parameters: { + providerID: string + accountName: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "path", key: "accountName" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete( + { + url: "/auth/{providerID}/accounts/{accountName}", + ...options, + ...params, + }, + ) + } + + /** + * Set account credentials + * + * Set authentication credentials for a named account + */ + public set( + parameters: { + providerID: string + accountName: string + directory?: string + auth?: Auth2 + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "path", key: "accountName" }, + { in: "query", key: "directory" }, + { key: "auth", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put({ + url: "/auth/{providerID}/accounts/{accountName}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Set active account + * + * Set the active account for a provider + */ + public setActive( + parameters: { + providerID: string + accountName: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "path", key: "accountName" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + AuthAccountsSetActiveResponses, + AuthAccountsSetActiveErrors, + ThrowOnError + >({ + url: "/auth/{providerID}/accounts/{accountName}/active", + ...options, + ...params, + }) + } +} + export class Auth extends HeyApiClient { /** * Remove MCP OAuth @@ -2194,6 +2346,8 @@ export class Auth extends HeyApiClient { }, }) } + + accounts = new Accounts({ client: this.client }) } export class Mcp extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5c4cc69423d..57ac1ef3426 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1947,6 +1947,12 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth +export type AccountInfo = { + name: string + type: "oauth" | "api" | "wellknown" + active: boolean +} + export type GlobalHealthData = { body?: never path?: never @@ -3519,6 +3525,10 @@ export type ProviderOauthAuthorizeData = { * Auth method index */ method: number + /** + * Account name for multi-account support + */ + accountName?: string } path: { /** @@ -3560,6 +3570,10 @@ export type ProviderOauthCallbackData = { * OAuth authorization code */ code?: string + /** + * Account name for multi-account support + */ + accountName?: string } path: { /** @@ -4340,6 +4354,116 @@ export type AuthSetResponses = { export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] +export type AuthAccountsListData = { + body?: never + path: { + providerID: string + } + query?: { + directory?: string + } + url: "/auth/{providerID}/accounts" +} + +export type AuthAccountsListResponses = { + /** + * List of accounts + */ + 200: Array +} + +export type AuthAccountsListResponse = AuthAccountsListResponses[keyof AuthAccountsListResponses] + +export type AuthAccountsRemoveData = { + body?: never + path: { + providerID: string + accountName: string + } + query?: { + directory?: string + } + url: "/auth/{providerID}/accounts/{accountName}" +} + +export type AuthAccountsRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthAccountsRemoveError = AuthAccountsRemoveErrors[keyof AuthAccountsRemoveErrors] + +export type AuthAccountsRemoveResponses = { + /** + * Successfully removed account + */ + 200: boolean +} + +export type AuthAccountsRemoveResponse = AuthAccountsRemoveResponses[keyof AuthAccountsRemoveResponses] + +export type AuthAccountsSetData = { + body?: Auth + path: { + providerID: string + accountName: string + } + query?: { + directory?: string + } + url: "/auth/{providerID}/accounts/{accountName}" +} + +export type AuthAccountsSetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthAccountsSetError = AuthAccountsSetErrors[keyof AuthAccountsSetErrors] + +export type AuthAccountsSetResponses = { + /** + * Successfully set account credentials + */ + 200: boolean +} + +export type AuthAccountsSetResponse = AuthAccountsSetResponses[keyof AuthAccountsSetResponses] + +export type AuthAccountsSetActiveData = { + body?: never + path: { + providerID: string + accountName: string + } + query?: { + directory?: string + } + url: "/auth/{providerID}/accounts/{accountName}/active" +} + +export type AuthAccountsSetActiveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthAccountsSetActiveError = AuthAccountsSetActiveErrors[keyof AuthAccountsSetActiveErrors] + +export type AuthAccountsSetActiveResponses = { + /** + * Successfully set active account + */ + 200: boolean +} + +export type AuthAccountsSetActiveResponse = AuthAccountsSetActiveResponses[keyof AuthAccountsSetActiveResponses] + export type EventSubscribeData = { body?: never path?: never From 88d657222dc025fd91fe367b275730dbb67e7c82 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Tue, 30 Dec 2025 09:53:44 -0700 Subject: [PATCH 2/2] feat: support git dependencies in plugin loading - Add detection for github:, git://, git+ URL schemes in BunProc.install - Extract package name from git URLs for correct module path resolution - Skip @version suffix for git dependencies to prevent resolution errors - Update default anthropic-auth plugin to use GitHub fork --- packages/opencode/src/bun/index.ts | 22 +++++++++++++++------- packages/opencode/src/plugin/index.ts | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 55bbf7b4170..d7c86934914 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -64,17 +64,25 @@ export namespace BunProc { // Use lock to ensure only one install at a time using _ = await Lock.write("bun-install") - const mod = path.join(Global.Path.cache, "node_modules", pkg) + // Check if this is a git dependency (github:, git://, git+https://, etc.) + const isGitDep = pkg.startsWith("github:") || pkg.startsWith("git://") || pkg.startsWith("git+") + + // For git deps, extract the package name from the URL for the module path + // e.g., "github:user/repo#branch" -> "repo" + const pkgName = isGitDep ? (pkg.split("/").pop()?.split("#")[0] ?? pkg) : pkg + const mod = path.join(Global.Path.cache, "node_modules", pkgName) + const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json")) const parsed = await pkgjson.json().catch(async () => { const result = { dependencies: {} } await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2)) return result }) - if (parsed.dependencies[pkg] === version) return mod + if (parsed.dependencies[pkgName] === pkg) return mod - // Build command arguments - const args = ["add", "--force", "--exact", "--cwd", Global.Path.cache, pkg + "@" + version] + // Build command arguments - don't append @version for git dependencies + const pkgSpec = isGitDep ? pkg : pkg + "@" + version + const args = ["add", "--force", "--exact", "--cwd", Global.Path.cache, pkgSpec] // Let Bun handle registry resolution: // - If .npmrc files exist, Bun will use them automatically @@ -98,8 +106,8 @@ export namespace BunProc { // Resolve actual version from installed package when using "latest" // This ensures subsequent starts use the cached version until explicitly updated - let resolvedVersion = version - if (version === "latest") { + let resolvedVersion: string = isGitDep ? pkg : version + if (!isGitDep && version === "latest") { const installedPkgJson = Bun.file(path.join(mod, "package.json")) const installedPkg = await installedPkgJson.json().catch(() => null) if (installedPkg?.version) { @@ -107,7 +115,7 @@ export namespace BunProc { } } - parsed.dependencies[pkg] = resolvedVersion + parsed.dependencies[pkgName] = resolvedVersion await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2)) return mod } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b492c7179e6..2862d02f9b6 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -29,7 +29,7 @@ export namespace Plugin { const plugins = [...(config.plugin ?? [])] if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { plugins.push("opencode-copilot-auth@0.0.9") - plugins.push("opencode-anthropic-auth@0.0.5") + plugins.push("github:coleleavitt/opencode-anthropic-auth#master") } for (let plugin of plugins) { log.info("loading plugin", { path: plugin })