From 1d4e1233e5c47073067e985b53e5a0bd24c1614d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:42:04 +0200 Subject: [PATCH] feat(cli): share grouped auth picker with MCP auth (#51006) Co-authored-by: rekram1-node Co-authored-by: thdxr Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Filip Hejmowski --- bun.lock | 2 + packages/cli/package.json | 2 + packages/cli/src/commands/commands.ts | 10 +- .../cli/src/commands/handlers/auth/login.ts | 51 +++--- .../cli/src/commands/handlers/mcp/auth.ts | 150 +++++++++++------- packages/cli/src/ui/integration-picker.ts | 67 ++++++++ packages/cli/test/auth-login-choices.test.ts | 30 ++++ packages/cli/test/auth.test.ts | 8 +- packages/cli/test/mcp-auth-choices.test.ts | 62 ++++++++ 9 files changed, 298 insertions(+), 84 deletions(-) create mode 100644 packages/cli/src/ui/integration-picker.ts create mode 100644 packages/cli/test/auth-login-choices.test.ts create mode 100644 packages/cli/test/mcp-auth-choices.test.ts diff --git a/bun.lock b/bun.lock index 5762b5445ca..ca06993ae30 100644 --- a/bun.lock +++ b/bun.lock @@ -119,6 +119,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "1.2.1", + "@clack/core": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1", "@effect/platform-node": "catalog:", "@opencode-ai/pty": "0.1.13", @@ -135,6 +136,7 @@ "effect": "catalog:", "immer": "11.1.4", "jsonc-parser": "3.3.1", + "picocolors": "1.1.1", "solid-js": "catalog:", "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", diff --git a/packages/cli/package.json b/packages/cli/package.json index 21c3c38bf3e..3ec9d34f96e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "1.2.1", + "@clack/core": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1", "@effect/platform-node": "catalog:", "@opencode/client": "workspace:*", @@ -41,6 +42,7 @@ "effect": "catalog:", "immer": "11.1.4", "jsonc-parser": "3.3.1", + "picocolors": "1.1.1", "solid-js": "catalog:", "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 602dfd5eb34..12188859cfb 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -142,10 +142,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME ], }), Spec.make("auth", { - description: "manage AI providers and credentials", + description: "manage integrations and credentials", commands: [ Spec.make("list", { - description: "list providers and credentials", + description: "list integrations and credentials", params: { ...ServerParams, format: Flag.choice("format", ["default", "json"]).pipe( @@ -155,7 +155,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }, }), Spec.make("login", { - description: "log in to a provider", + description: "connect an integration", params: { ...ServerParams, target: Argument.string("target").pipe( @@ -228,7 +228,9 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }), Spec.make("auth", { description: "Authenticate with an OAuth-capable remote MCP server", - params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) }, + params: { + name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server"), Argument.optional), + }, }), Spec.make("logout", { description: "Remove stored OAuth credentials for an MCP server", diff --git a/packages/cli/src/commands/handlers/auth/login.ts b/packages/cli/src/commands/handlers/auth/login.ts index 63641ee3350..746f105346b 100644 --- a/packages/cli/src/commands/handlers/auth/login.ts +++ b/packages/cli/src/commands/handlers/auth/login.ts @@ -1,8 +1,9 @@ -import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts" +import { intro, log, outro, select, spinner, text } from "@clack/prompts" import { Effect, Option } from "effect" import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" +import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker" import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt" import { answerForm, secret } from "./form" import { @@ -21,10 +22,8 @@ const integrationPriority = new Map([ ["opencode", 1], ["openai", 2], ["github-copilot", 3], - ["google", 4], - ["anthropic", 5], - ["openrouter", 6], - ["vercel", 7], + ["anthropic", 4], + ["google", 5], ]) export default Runtime.handler( @@ -74,30 +73,35 @@ const findIntegration = Effect.fn("cli.auth.login.integration")(function* (clien } const integrations = yield* loadIntegrations(client) if (target) return yield* resolveIntegration(integrations, target) - const available = integrations + const choices = loginChoices(integrations) + if (choices.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available")) + const id = yield* prompt(() => selectIntegration(choices)) + return yield* resolveIntegration(integrations, id) +}) + +export function loginChoices(integrations: IntegrationInfo[]): IntegrationChoice[] { + return integrations .filter((integration) => connectMethods(integration).length > 0) .toSorted( (a, b) => + Number(b.metadata?.source === "mcp") - Number(a.metadata?.source === "mcp") || (integrationPriority.get(a.id) ?? integrationPriority.size) - (integrationPriority.get(b.id) ?? integrationPriority.size) || a.name.localeCompare(b.name) || a.id.localeCompare(b.id), ) - if (available.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available")) - const id = yield* prompt(() => - autocomplete({ - message: "Select integration", - maxItems: 8, - options: available.map((integration) => { - const option = { value: integration.id, label: integration.name, hint: integration.id } - if (integration.connections.length > 0) return { ...option, hint: "connected" } - if (integration.id === "opencode") return { ...option, hint: "recommended" } - return option - }), - }), - ) - return yield* resolveIntegration(available, id) -}) + .map((integration) => ({ + value: integration.id, + label: integration.name, + category: + integration.metadata?.source === "mcp" + ? "MCP" + : integrationPriority.has(integration.id) + ? "Popular" + : "Services", + connected: integration.connections.length > 0, + })) +} const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) { if (target) return yield* resolveMethod(methods, target) @@ -143,17 +147,18 @@ const keyLogin = Effect.fn("cli.auth.login.key")(function* ( ) }) -const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* ( +export const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* ( client: OpenCodeClient, integration: IntegrationInfo, method: Extract, answer?: FormAnswer, + label?: string, ) { const progress = spinner() progress.start("Starting authorization...") const started = yield* request((signal) => client.integration.oauth.connect( - { integrationID: integration.id, methodID: method.id, answer, location }, + { integrationID: integration.id, methodID: method.id, answer, label, location }, { signal }, ), ).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1)))) diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index bd8ab349c48..627c2ccf9e6 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -1,65 +1,109 @@ -import { EOL } from "node:os" -import { Effect } from "effect" -import { - OpenCode, - type IntegrationAttemptStatus, - type IntegrationOAuthMethod, - type OpenCodeClient, -} from "@opencode/client" +import { confirm, intro, log, outro } from "@clack/prompts" +import { Effect, Option } from "effect" +import { OpenCode, type IntegrationInfo, type IntegrationOAuthMethod, type McpServer } from "@opencode/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { Service } from "@opencode/client/effect/service" import { ServiceConfig } from "../../../services/service-config" -import { resolveIntegration } from "./resolve" +import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker" +import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt" +import { answerForm } from "../auth/form" +import { oauthLogin } from "../auth/login" +import { loadIntegrations, request } from "../auth/shared" const location = { directory: process.cwd() } export default Runtime.handler( Commands.commands.mcp.commands.auth, - Effect.fn("cli.mcp.auth")(function* (input) { - const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) - const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) - - const integration = yield* resolveIntegration(client, input.name, location) - if (!integration) - return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`)) - const method = integration.methods.find( - (candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth", - ) - if (!method) - return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`)) - - const started = yield* Effect.promise(() => - client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, location }), - ) - const attempt = started.data - if (attempt.mode === "code") - return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support")) - - process.stdout.write(attempt.instructions + EOL + attempt.url + EOL) - - const result = yield* poll(client, integration.id, attempt.attemptID) - if (result.status === "complete") { - process.stdout.write(`Authenticated with ${input.name}` + EOL) - return - } - const reason = result.status === "failed" ? `: ${result.message}` : "" - return yield* Effect.fail(new Error(`Authentication ${result.status}${reason}`)) - }), + Effect.fn("cli.mcp.auth")((input) => authenticate(Option.getOrUndefined(input.name)).pipe(handlePromptErrors)), ) -const poll = ( - client: OpenCodeClient, - integrationID: string, - attemptID: string, -): Effect.Effect> => - Effect.gen(function* () { - const status = yield* Effect.promise(() => - client.integration.oauth.status({ integrationID, attemptID, location }), - ).pipe(Effect.map((result) => result.data)) - if (status.status === "pending") { - yield* Effect.sleep("1 second") - return yield* poll(client, integrationID, attemptID) +const authenticate = Effect.fn("cli.mcp.auth.run")(function* (name?: string) { + if (!name) yield* requireInteractive("Pass an MCP server name when running without an interactive terminal") + intro("Authenticate an MCP server") + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + const integrations = yield* loadIntegrations(client) + const servers = yield* request((signal) => client.mcp.list({ location }, { signal })) + const choices = mcpAuthChoices(servers.data, integrations) + if (!name && choices.length === 0) { + log.warn("No OAuth-capable MCP servers configured") + log.info( + `Remote MCP servers support OAuth by default. Add one with \`opencode mcp add\` or in opencode.json:\n${exampleConfig}`, + ) + outro("Done") + return + } + + const server = name ? servers.data.find((item) => item.name === name) : undefined + if (name && !server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`)) + const integrationID = server + ? server.integrationID + : yield* prompt(() => selectIntegration(choices, "MCP server")) + const integration = integrations.find((item) => item.id === integrationID) + const method = integration?.methods.find( + (candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth", + ) + if (!integration || !method) + return yield* Effect.fail(new Error(`MCP server "${name}" is not an OAuth-capable remote server`)) + + if (integration.connections.length > 0) { + const status = servers.data.find((item) => item.integrationID === integration.id)?.status.status + if (status === "needs_auth") log.warn(`${integration.name} has expired credentials. Re-authenticating...`) + if (status !== "needs_auth" && process.stdin.isTTY && process.stdout.isTTY) { + const again = yield* prompt(() => + confirm({ message: `${integration.name} already has valid credentials. Re-authenticate?` }), + ) + if (!again) { + outro("Cancelled") + return + } } - return status - }) + } + + // Re-authenticating replaces the previous sign-in rather than adding an account. The new credential + // keeps the active one's label, and the old ones are only removed once it is stored, so a failed + // attempt keeps them. + const previous = integration.connections.filter((connection) => connection.type === "credential") + yield* oauthLogin(client, integration, method, yield* answerForm(method.form), previous[0]?.label) + yield* Effect.forEach( + previous, + (connection) => request((signal) => client.credential.remove({ credentialID: connection.id }, { signal })), + { discard: true }, + ) + outro("Done") +}) + +const exampleConfig = ` + "mcp": { + "my-server": { + "type": "remote", + "url": "https://example.com/mcp" + } + }` + +// Choices carry the server-owned integration ID so provider integrations with colliding names never match. +export function mcpAuthChoices(servers: McpServer[], integrations: IntegrationInfo[]): IntegrationChoice[] { + const byID = new Map(integrations.map((integration) => [integration.id, integration])) + return servers + .flatMap((server) => { + const integration = server.integrationID ? byID.get(server.integrationID) : undefined + if (!integration?.methods.some((method) => method.type === "oauth")) return [] + return [ + { + value: integration.id, + label: server.name, + category: "MCP" as const, + connected: integration.connections.length > 0, + hint: statusHint(server.status), + }, + ] + }) + .toSorted((a, b) => a.label.localeCompare(b.label) || a.value.localeCompare(b.value)) +} + +function statusHint(status: McpServer["status"]) { + if (status.status === "needs_auth") return "needs authentication" + if (status.status === "failed" || status.status === "disabled") return status.status + return undefined +} diff --git a/packages/cli/src/ui/integration-picker.ts b/packages/cli/src/ui/integration-picker.ts new file mode 100644 index 00000000000..35fd40f1a88 --- /dev/null +++ b/packages/cli/src/ui/integration-picker.ts @@ -0,0 +1,67 @@ +import { AutocompletePrompt } from "@clack/core" +import { S_BAR, S_BAR_END, S_RADIO_ACTIVE, S_RADIO_INACTIVE, symbol } from "@clack/prompts" +import color from "picocolors" + +export type IntegrationChoice = { + value: string + label: string + category: "MCP" | "Popular" | "Services" + connected: boolean + hint?: string +} + +export async function selectIntegration(choices: IntegrationChoice[], kind = "integration") { + const result = await new AutocompletePrompt({ + options: choices, + filter: (search, choice) => + [choice.label, choice.value, choice.category].some((value) => value.toLowerCase().includes(search.toLowerCase())), + validate: (value) => (value ? undefined : `Select an ${kind}`), + render() { + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} Select ${kind}` + if (this.state === "submit") { + const choice = choices.find((item) => item.value === this.value) + return `${title}\n${color.gray(S_BAR)} ${color.dim(choice?.label ?? "")}` + } + if (this.state === "cancel") + return `${title}\n${color.gray(S_BAR)} ${color.strikethrough(color.dim(this.userInput))}` + + // Leave room for the category headings as well as Clack's title and footer. + const maxItems = Math.min(8, Math.max(2, (process.stdout.rows ?? 24) - 14 - Number(this.state === "error"))) + const compact = (process.stdout.rows ?? 24) < 18 + const start = Math.min( + Math.max(0, this.cursor - Math.min(2, maxItems - 1)), + Math.max(0, this.filteredOptions.length - maxItems), + ) + const visible = this.filteredOptions.slice(start, start + maxItems) + const rows = visible.flatMap((choice, index) => [ + ...(index === 0 || visible[index - 1].category !== choice.category + ? [...(compact ? [] : [`${color.cyan(S_BAR)} `]), `${color.cyan(S_BAR)} ${color.bold(choice.category)}`] + : []), + `${color.cyan(S_BAR)} ${start + index === this.cursor ? color.green(S_RADIO_ACTIVE) : color.dim(S_RADIO_INACTIVE)} ${ + start + index === this.cursor ? choice.label : color.dim(choice.label) + }${choice.connected ? ` ${color.green("✓")}` : ""}${choice.hint ? ` ${color.dim(`(${choice.hint})`)}` : ""}`, + ]) + return [ + title, + `${color.cyan(S_BAR)} ${color.dim("Search:")} ${this.isNavigating ? color.dim(this.userInput) : this.userInputWithCursor}`, + ...(visible.length === 0 && this.userInput + ? [`${color.cyan(S_BAR)} ${color.yellow(`No ${kind}s found`)}`] + : []), + ...(this.state === "error" && visible.length > 0 + ? [`${color.yellow(S_BAR)} ${color.yellow(this.error)}`] + : []), + ...(start > 0 ? [`${color.cyan(S_BAR)} ${color.dim("…")}`] : []), + ...rows, + ...(start + maxItems < this.filteredOptions.length ? [`${color.cyan(S_BAR)} ${color.dim("…")}`] : []), + `${color.cyan(S_BAR)} ${color.dim( + (process.stdout.columns ?? 80) < 50 + ? "↑/↓ navigate • Enter select" + : "↑/↓ to select • Enter: confirm • Type: to search", + )}`, + color.cyan(S_BAR_END), + ].join("\n") + }, + }).prompt() + if (typeof result === "string" || typeof result === "symbol") return result + throw new Error(`No ${kind} selected`) +} diff --git a/packages/cli/test/auth-login-choices.test.ts b/packages/cli/test/auth-login-choices.test.ts new file mode 100644 index 00000000000..fc1f071861f --- /dev/null +++ b/packages/cli/test/auth-login-choices.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test" +import type { IntegrationInfo } from "@opencode/client" +import { loginChoices } from "../src/commands/handlers/auth/login" + +const integration = (value: Partial & Pick): IntegrationInfo => ({ + methods: [{ type: "key" }], + connections: [], + ...value, +}) + +test("groups the CLI choices like /connect while keeping stable login IDs", () => { + expect( + loginChoices([ + integration({ id: "mistral", name: "Mistral" }), + integration({ id: "openai", name: "OpenAI" }), + integration({ id: "linear", name: "Linear", metadata: { source: "mcp" } }), + integration({ id: "github", name: "GitHub", metadata: { source: "mcp" } }), + integration({ id: "opencode", name: "OpenCode Console" }), + integration({ id: "opencode-go", name: "OpenCode Go", connections: [{ type: "env", name: "GO_KEY" }] }), + integration({ id: "unused", name: "Unused", methods: [{ type: "env", names: ["UNUSED_KEY"] }] }), + ]), + ).toEqual([ + { value: "github", label: "GitHub", category: "MCP", connected: false }, + { value: "linear", label: "Linear", category: "MCP", connected: false }, + { value: "opencode-go", label: "OpenCode Go", category: "Popular", connected: true }, + { value: "opencode", label: "OpenCode Console", category: "Popular", connected: false }, + { value: "openai", label: "OpenAI", category: "Popular", connected: false }, + { value: "mistral", label: "Mistral", category: "Services", connected: false }, + ]) +}) diff --git a/packages/cli/test/auth.test.ts b/packages/cli/test/auth.test.ts index 1f8a9a4e4f6..07d77efe54a 100644 --- a/packages/cli/test/auth.test.ts +++ b/packages/cli/test/auth.test.ts @@ -20,12 +20,12 @@ describe("auth command", () => { expect(auth.stdout).toContain("list") expect(auth.stdout).toContain("login") expect(auth.stdout).toContain("logout") - expect(auth.stdout).toContain("manage AI providers and credentials") - expect(auth.stdout).toContain("list providers and credentials") - expect(auth.stdout).toContain("log in to a provider") + expect(auth.stdout).toContain("manage integrations and credentials") + expect(auth.stdout).toContain("list integrations and credentials") + expect(auth.stdout).toContain("connect an integration") expect(auth.stdout).toContain("log out of a saved account") expect(auth.stdout).toContain("switch the active account for an integration") - expect(auth.stdout).not.toContain("connect") + expect(auth.stdout).not.toMatch(/^ connect\s/m) expect(list.exitCode).toBe(0) expect(list.stdout).toContain("opencode auth list [flags]") expect(list.stdout).toContain("--format") diff --git a/packages/cli/test/mcp-auth-choices.test.ts b/packages/cli/test/mcp-auth-choices.test.ts new file mode 100644 index 00000000000..f585c968e23 --- /dev/null +++ b/packages/cli/test/mcp-auth-choices.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test" +import path from "node:path" +import type { IntegrationInfo, McpServer } from "@opencode/client" +import { mcpAuthChoices } from "../src/commands/handlers/mcp/auth" + +const server = ( + name: string, + integrationID?: string, + status: McpServer["status"] = { status: "pending" }, +): McpServer => ({ name, integrationID, status }) + +const integration = (id: string, methods: IntegrationInfo["methods"], connected = false): IntegrationInfo => ({ + id, + name: id, + methods, + connections: connected ? [{ type: "credential", method: "oauth", id: "cred_1", label: "Work" }] : [], +}) + +test("offers only OAuth-capable MCP servers by their server identity", () => { + expect( + mcpAuthChoices( + [ + server("Linear", "mcp_linear", { status: "needs_auth", error: "expired" }), + server("Local"), + server("API key only", "mcp_key"), + server("GitHub", "mcp_github"), + server("Sentry", "mcp_sentry", { status: "failed", error: "boom" }), + server("Unresolved", "mcp_missing"), + ], + [ + integration("mcp_linear", [{ type: "oauth", id: "login", label: "Linear" }], true), + integration("mcp_github", [{ type: "oauth", id: "login", label: "GitHub" }]), + integration("mcp_sentry", [{ type: "oauth", id: "login", label: "Sentry" }]), + integration("mcp_key", [{ type: "key" }]), + integration("Linear", [{ type: "oauth", id: "login", label: "A provider with a colliding name" }]), + ], + ), + ).toEqual([ + { value: "mcp_github", label: "GitHub", category: "MCP", connected: false, hint: undefined }, + { value: "mcp_linear", label: "Linear", category: "MCP", connected: true, hint: "needs authentication" }, + { value: "mcp_sentry", label: "Sentry", category: "MCP", connected: false, hint: "failed" }, + ]) +}) + +test("mcp auth accepts an optional server name and rejects no-name noninteractive calls before connecting", async () => { + const cli = (args: string[]) => + Bun.spawn([process.execPath, "run", "src/index.ts", "mcp", "auth", ...args], { + cwd: path.join(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", + }) + const help = cli(["--help"]) + expect(await new Response(help.stdout).text()).toContain("opencode mcp auth [flags] []") + expect(await help.exited).toBe(0) + + const missing = cli([]) + expect(await new Response(missing.stdout).text()).toContain( + "Pass an MCP server name when running without an interactive terminal", + ) + expect(await new Response(missing.stderr).text()).toBe("") + expect(await missing.exited).toBe(1) +})