diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 92ca9d46115..eb530f07176 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -123,13 +123,31 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME }, }), Spec.make("logout", { - description: "log out from a configured provider", + description: "log out of a saved account", params: { ...ServerParams, target: Argument.string("target").pipe( Argument.withDescription("Integration ID or name"), Argument.optional, ), + credential: Argument.string("credential").pipe( + Argument.withDescription("Credential ID or label (opens an account picker when omitted)"), + Argument.optional, + ), + }, + }), + Spec.make("switch", { + description: "switch the active account for an integration", + params: { + ...ServerParams, + target: Argument.string("target").pipe( + Argument.withDescription("Integration ID or name"), + Argument.optional, + ), + credential: Argument.string("credential").pipe( + Argument.withDescription("Credential ID or label (opens an account picker when omitted)"), + Argument.optional, + ), }, }), ], diff --git a/packages/cli/src/commands/handlers/auth/account.ts b/packages/cli/src/commands/handlers/auth/account.ts new file mode 100644 index 00000000000..45d76fed966 --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/account.ts @@ -0,0 +1,74 @@ +import { autocomplete } from "@clack/prompts" +import { Effect } from "effect" +import type { IntegrationInfo } from "@opencode/client" +import { prompt } from "../../../ui/prompt" +import { resolveIntegration } from "./shared" + +export const chooseIntegration = Effect.fn("cli.auth.account.integration")(function* ( + integrations: IntegrationInfo[], + target?: string, +) { + if (target) return yield* resolveIntegration(integrations, target) + const configured = integrations.filter((integration) => + integration.connections.some((connection) => connection.type === "credential"), + ) + if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found")) + const id = yield* prompt(() => + autocomplete({ + message: "Select integration", + maxItems: 8, + options: configured.map((integration) => ({ + value: integration.id, + label: integration.name, + hint: integration.connections + .filter((connection) => connection.type === "credential") + .map((connection) => connection.label) + .join(", "), + })), + }), + ) + return yield* resolveIntegration(configured, id) +}) + +export const chooseCredential = Effect.fn("cli.auth.account.credential")(function* ( + integration: IntegrationInfo, + action: "log out" | "switch to", + target?: string, +) { + const credentials = integration.connections.filter((connection) => connection.type === "credential") + if (credentials.length === 0) { + const environment = integration.connections + .filter((connection) => connection.type === "env") + .map((connection) => connection.name) + if (environment.length && action === "log out") { + yield* Effect.fail( + new Error( + `${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`, + ), + ) + } + yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`)) + } + if (target) { + const byID = credentials.find((credential) => credential.id === target) + if (byID) return byID.id + const matches = credentials.filter((credential) => credential.label.toLowerCase() === target.toLowerCase()) + if (matches.length === 1) return matches[0].id + if (matches.length > 1) + return yield* Effect.fail( + new Error(`Credential label "${target}" is ambiguous. Use an ID: ${matches.map((item) => item.id).join(", ")}`), + ) + return yield* Effect.fail(new Error(`Credential not found for ${integration.name}: ${target}`)) + } + return yield* prompt(() => + autocomplete({ + message: `Select ${integration.name} account to ${action}`, + maxItems: 8, + options: credentials.map((credential, index) => ({ + value: credential.id, + label: index === 0 ? `${credential.label} (active)` : credential.label, + hint: credential.id, + })), + }), + ) +}) diff --git a/packages/cli/src/commands/handlers/auth/logout.ts b/packages/cli/src/commands/handlers/auth/logout.ts index d883eb62c24..98c9026c3af 100644 --- a/packages/cli/src/commands/handlers/auth/logout.ts +++ b/packages/cli/src/commands/handlers/auth/logout.ts @@ -1,16 +1,17 @@ -import { autocomplete, intro, outro, spinner } from "@clack/prompts" +import { intro, outro, spinner } from "@clack/prompts" import { Effect, Option } from "effect" -import type { IntegrationInfo } from "@opencode/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt" -import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared" +import { handlePromptErrors, requireInteractive } from "../../../ui/prompt" +import { createClient, loadIntegrations, location, request } from "./shared" +import { chooseCredential, chooseIntegration } from "./account" export default Runtime.handler( Commands.commands.auth.commands.logout, Effect.fn("cli.auth.logout")((input) => logout({ target: Option.getOrUndefined(input.target), + credential: Option.getOrUndefined(input.credential), server: Option.getOrUndefined(input.server), standalone: input.standalone, }).pipe(handlePromptErrors), @@ -19,65 +20,24 @@ export default Runtime.handler( const logout = Effect.fn("cli.auth.logout.run")(function* (input: { target?: string + credential?: string server?: string standalone: boolean }) { if (!input.target) yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal") - intro("Remove credential") + if (!input.credential) + yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal") + intro("Log out of an account") const client = yield* createClient({ server: input.server, standalone: input.standalone }) const integrations = yield* loadIntegrations(client) const integration = yield* chooseIntegration(integrations, input.target) - const credentials = integration.connections.filter((connection) => connection.type === "credential") - if (credentials.length === 0) { - const environment = integration.connections - .filter((connection) => connection.type === "env") - .map((connection) => connection.name) - if (environment.length) { - yield* Effect.fail( - new Error( - `${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`, - ), - ) - } - yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`)) - } + const credentialID = yield* chooseCredential(integration, "log out", input.credential) const progress = spinner() progress.start("Removing credential...") - yield* Effect.forEach( - credentials, - (connection) => - request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })), - { concurrency: "unbounded", discard: true }, - ).pipe( - Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))), + yield* request((signal) => client.credential.remove({ credentialID, location }, { signal })).pipe( + Effect.tap(() => Effect.sync(() => progress.stop(`Removed account from ${integration.name}`))), Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))), ) outro("Done") }) - -const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* ( - integrations: IntegrationInfo[], - target?: string, -) { - if (target) return yield* resolveIntegration(integrations, target) - const configured = integrations.filter((integration) => - integration.connections.some((connection) => connection.type === "credential"), - ) - if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found")) - const id = yield* prompt(() => - autocomplete({ - message: "Select integration", - maxItems: 8, - options: configured.map((integration) => ({ - value: integration.id, - label: integration.name, - hint: integration.connections - .filter((connection) => connection.type === "credential") - .map((connection) => connection.label) - .join(", "), - })), - }), - ) - return yield* resolveIntegration(configured, id) -}) diff --git a/packages/cli/src/commands/handlers/auth/switch.ts b/packages/cli/src/commands/handlers/auth/switch.ts new file mode 100644 index 00000000000..4b411f75acd --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/switch.ts @@ -0,0 +1,43 @@ +import { intro, outro, spinner } from "@clack/prompts" +import { Effect, Option } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { handlePromptErrors, requireInteractive } from "../../../ui/prompt" +import { createClient, loadIntegrations, location, request } from "./shared" +import { chooseCredential, chooseIntegration } from "./account" + +export default Runtime.handler( + Commands.commands.auth.commands.switch, + Effect.fn("cli.auth.switch")((input) => + switchAccount({ + target: Option.getOrUndefined(input.target), + credential: Option.getOrUndefined(input.credential), + server: Option.getOrUndefined(input.server), + standalone: input.standalone, + }).pipe(handlePromptErrors), + ), +) + +const switchAccount = Effect.fn("cli.auth.switch.run")(function* (input: { + target?: string + credential?: string + server?: string + standalone: boolean +}) { + if (!input.target) + yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal") + if (!input.credential) + yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal") + intro("Switch account") + const client = yield* createClient({ server: input.server, standalone: input.standalone }) + const integrations = yield* loadIntegrations(client) + const integration = yield* chooseIntegration(integrations, input.target) + const credentialID = yield* chooseCredential(integration, "switch to", input.credential) + const progress = spinner() + progress.start("Switching account...") + yield* request((signal) => client.credential.activate({ credentialID, location }, { signal })).pipe( + Effect.tap(() => Effect.sync(() => progress.stop(`Switched account for ${integration.name}`))), + Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to switch account", 1))), + ) + outro("Done") +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1c1371085c9..39e5f7bd8a5 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,7 @@ const Handlers = Runtime.handlers(Commands, { list: () => import("./commands/handlers/auth/list"), login: () => import("./commands/handlers/auth/login"), logout: () => import("./commands/handlers/auth/logout"), + switch: () => import("./commands/handlers/auth/switch"), }, debug: { agents: () => import("./commands/handlers/debug/agents"),