feat(cli): add auth account switching and targeted logout (#48050)

This commit is contained in:
Dax
2026-09-08 21:27:21 -04:00
committed by GitHub
parent 18b05e86fb
commit 594635b5ae
5 changed files with 149 additions and 53 deletions
+19 -1
View File
@@ -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,
),
},
}),
],
@@ -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<string>(() =>
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<string>(() =>
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,
})),
}),
)
})
@@ -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<string>(() =>
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)
})
@@ -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")
})
+1
View File
@@ -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"),