diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 34e2269d0..7a911919f 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -159,6 +159,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): return false } +/** + * Build a deduplicated list of plugin-registered auth providers that are not + * already present in models.dev, respecting enabled/disabled provider lists. + * Pure function with no side effects; safe to test without mocking. + */ +export function resolvePluginProviders(input: { + hooks: Hooks[] + existingProviders: Record + disabled: Set + enabled?: Set + providerNames: Record +}): Array<{ id: string; name: string }> { + const seen = new Set() + const result: Array<{ id: string; name: string }> = [] + + for (const hook of input.hooks) { + if (!hook.auth) continue + const id = hook.auth.provider + if (seen.has(id)) continue + seen.add(id) + if (Object.hasOwn(input.existingProviders, id)) continue + if (input.disabled.has(id)) continue + if (input.enabled && !input.enabled.has(id)) continue + result.push({ + id, + name: input.providerNames[id] ?? id, + }) + } + + return result +} + export const AuthCommand = cmd({ command: "auth", describe: "manage credentials", @@ -277,6 +309,15 @@ export const AuthLoginCommand = cmd({ openrouter: 5, vercel: 6, } + const pluginProviders = resolvePluginProviders({ + hooks: await Plugin.list(), + existingProviders: providers, + disabled, + enabled, + providerNames: Object.fromEntries( + Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name]), + ), + }) let provider = await prompts.autocomplete({ message: "Select provider", maxItems: 8, @@ -298,6 +339,11 @@ export const AuthLoginCommand = cmd({ }[x.id], })), ), + ...pluginProviders.map((x) => ({ + label: x.name, + value: x.id, + hint: "plugin", + })), { value: "other", label: "Other", diff --git a/packages/opencode/test/cli/plugin-auth-picker.test.ts b/packages/opencode/test/cli/plugin-auth-picker.test.ts new file mode 100644 index 000000000..3ce9094e9 --- /dev/null +++ b/packages/opencode/test/cli/plugin-auth-picker.test.ts @@ -0,0 +1,120 @@ +import { test, expect, describe } from "bun:test" +import { resolvePluginProviders } from "../../src/cli/cmd/auth" +import type { Hooks } from "@opencode-ai/plugin" + +function hookWithAuth(provider: string): Hooks { + return { + auth: { + provider, + methods: [], + }, + } +} + +function hookWithoutAuth(): Hooks { + return {} +} + +describe("resolvePluginProviders", () => { + test("returns plugin providers not in models.dev", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([{ id: "portkey", name: "portkey" }]) + }) + + test("skips providers already in models.dev", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("anthropic")], + existingProviders: { anthropic: {} }, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([]) + }) + + test("deduplicates across plugins", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey"), hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([{ id: "portkey", name: "portkey" }]) + }) + + test("respects disabled_providers", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(["portkey"]), + providerNames: {}, + }) + expect(result).toEqual([]) + }) + + test("respects enabled_providers when provider is absent", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + enabled: new Set(["anthropic"]), + providerNames: {}, + }) + expect(result).toEqual([]) + }) + + test("includes provider when in enabled set", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + enabled: new Set(["portkey"]), + providerNames: {}, + }) + expect(result).toEqual([{ id: "portkey", name: "portkey" }]) + }) + + test("resolves name from providerNames", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + providerNames: { portkey: "Portkey AI" }, + }) + expect(result).toEqual([{ id: "portkey", name: "Portkey AI" }]) + }) + + test("falls back to id when no name configured", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([{ id: "portkey", name: "portkey" }]) + }) + + test("skips hooks without auth", () => { + const result = resolvePluginProviders({ + hooks: [hookWithoutAuth(), hookWithAuth("portkey"), hookWithoutAuth()], + existingProviders: {}, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([{ id: "portkey", name: "portkey" }]) + }) + + test("returns empty for no hooks", () => { + const result = resolvePluginProviders({ + hooks: [], + existingProviders: {}, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([]) + }) +})