Compare commits

...
9 changed files with 267 additions and 58 deletions
+2
View File
@@ -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",
@@ -136,6 +137,7 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"picocolors": "1.1.1",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+2
View File
@@ -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:*",
@@ -42,6 +43,7 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"picocolors": "1.1.1",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+6 -4
View File
@@ -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",
@@ -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<string>(() => 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<string>(() =>
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)
+76 -29
View File
@@ -1,15 +1,21 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { intro } from "@clack/prompts"
import { Effect, Option } from "effect"
import {
OpenCode,
type IntegrationInfo,
type IntegrationAttemptStatus,
type IntegrationOAuthMethod,
type McpServer,
type OpenCodeClient,
} 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 { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker"
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
import { loadIntegrations } from "../auth/shared"
import { resolveIntegration } from "./resolve"
const location = { directory: process.cwd() }
@@ -17,37 +23,78 @@ 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}`))
const name = Option.getOrUndefined(input.name)
if (!name) return yield* interactive().pipe(handlePromptErrors)
const client = yield* createClient()
return yield* authenticate(client, name)
}),
)
const createClient = Effect.fn("cli.mcp.auth.client")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
return OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
})
const interactive = Effect.fn("cli.mcp.auth.interactive")(function* () {
yield* requireInteractive("Pass an MCP server name when running without an interactive terminal")
intro("Connect an integration")
const client = yield* createClient()
const name = yield* chooseServer(client)
return yield* authenticate(client, name)
})
const chooseServer = Effect.fn("cli.mcp.auth.select")(function* (client: OpenCodeClient) {
const integrations = yield* loadIntegrations(client)
const servers = yield* Effect.promise(() => client.mcp.list({ location }))
const choices = mcpAuthChoices(servers.data, integrations)
if (choices.length === 0) return yield* Effect.fail(new Error("No OAuth-capable remote MCP servers available"))
return yield* prompt<string>(() => selectIntegration(choices, "MCP server"))
})
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: server.name,
label: server.name,
category: "MCP" as const,
connected: integration.connections.length > 0,
},
]
})
.toSorted((a, b) => a.label.localeCompare(b.label) || a.value.localeCompare(b.value))
}
const authenticate = Effect.fn("cli.mcp.auth.authenticate")(function* (client: OpenCodeClient, name: string) {
const integration = yield* resolveIntegration(client, name, location)
if (!integration) return yield* Effect.fail(new Error(`MCP server "${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 "${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 ${name}` + EOL)
return
}
const reason = result.status === "failed" ? `: ${result.message}` : ""
return yield* Effect.fail(new Error(`Authentication ${result.status}${reason}`))
})
const poll = (
client: OpenCodeClient,
integrationID: string,
+63
View File
@@ -0,0 +1,63 @@
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
}
export async function selectIntegration(choices: IntegrationChoice[], kind = "integration") {
const result = await new AutocompletePrompt<IntegrationChoice>({
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}\n`
if (this.state === "submit") {
const choice = choices.find((item) => item.value === this.value)
return `${title}${color.gray(S_BAR)} ${color.dim(choice?.label ?? "")}`
}
if (this.state === "cancel")
return `${title}${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 - 2), 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("✓")}` : ""}`,
])
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`)
}
@@ -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<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): 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 },
])
})
+4 -4
View File
@@ -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")
@@ -0,0 +1,59 @@
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): McpServer => ({
name,
integrationID,
status: { status: "pending" },
})
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"),
server("Local"),
server("API key only", "mcp_key"),
server("GitHub", "mcp_github"),
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_key", [{ type: "key" }]),
integration("Linear", [{ type: "oauth", id: "login", label: "A provider with a colliding name" }]),
],
),
).toEqual([
{ value: "GitHub", label: "GitHub", category: "MCP", connected: false },
{ value: "Linear", label: "Linear", category: "MCP", connected: true },
])
})
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] [<name>]")
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)
})