Compare commits

...

4 Commits

Author SHA1 Message Date
Shoubhit Dash 177370dae0 fix(tui): paste into custom form answers 2026-08-18 13:13:51 +00:00
Shoubhit Dash 47af7462ea feat(cli): improve session export flow (#43229) 2026-08-18 18:00:12 +05:30
Simon Klee a76ab93673 tui: update OpenTUI to 0.5.4 (#43220) 2026-08-18 13:56:34 +02:00
Shoubhit Dash dfb60e37a3 feat(cli): add interactive auth commands (#43127) 2026-08-18 16:37:56 +05:30
17 changed files with 1107 additions and 219 deletions
+18 -17
View File
@@ -119,6 +119,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -589,9 +590,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.3",
"@opentui/keymap": ">=0.5.3",
"@opentui/solid": ">=0.5.3",
"@opentui/core": ">=0.5.4",
"@opentui/keymap": ">=0.5.4",
"@opentui/solid": ">=0.5.4",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -1087,9 +1088,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.3",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -2111,27 +2112,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="],
"@opentui/core": ["@opentui/core@0.5.4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.4", "@opentui/core-darwin-x64": "0.5.4", "@opentui/core-linux-arm64": "0.5.4", "@opentui/core-linux-arm64-musl": "0.5.4", "@opentui/core-linux-x64": "0.5.4", "@opentui/core-linux-x64-musl": "0.5.4", "@opentui/core-win32-arm64": "0.5.4", "@opentui/core-win32-x64": "0.5.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-czcJKQ72QhTWvu1eWfKg4EPN1GLLND6cP9MOhDyqYzCdIZgxQSJVWYzz6c4/CQGOu/qQLRyce2y1efVu4lgQ0w=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oETbn6tg/0g+mOvGXy3iot+1Zv7CGr65U1lRaPJ7kpsKGnOxftCpDD1qA0I6eQwfPJa9k7h9D/9yGB3IbweGcg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-TIeqCNfAV8xvNAv6oVBYsoGBz/p8CxcYm668OQIeBPUO+irqbQ72vJJa/SwFZrUEAHFmsDELaB6y80oHU5Jm6g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-UrOsX3D5BOO9TI30WvRwEK/lyPPhY75+LwSqeRRe8SV2ON+Ez5QqY4oryTyYC6+yNahTJufz34n0YgwnQaxTNA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-8vFWd1dsPZj9fHQKlsGHue3ukp7MRjTSpmIrdneIruOcoK2etccHfd6bqIo4FcNr+HA0eI2FP4ZIL9xhE3r9/w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1RXzSl6d347O7mUXviXFWlFFyILr7qsVt4jwD3k0UiKtENeEDNi+glM1bKHbXwCdQjfA835QgFA2mbILybK+aQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YYREqUB3v5K0qWij3A5YWzJkkVXp/EqIiuHZKsAjrUAY/0+Bp8Hn0baLvvvkuklpG9whW+GfwIeUsmp4fxqmCA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-F9sB6suPJmwkLF2MwKEC+OtwP6cn5QspIm4MCh2hmu3mVqSz9GDpYID1X3sAh9/V79EVocqiOSqI3KeCTRouKQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-2/6dPPPJ9xL/bWz9jh+lZeV2g/tbxZ9N4FBIqwhnqSfIYy5jOnscsPZpcN4X6PstfJAo2yf0Ebk/tLTOzOS6TQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.3", "", { "dependencies": { "@opentui/core": "0.5.3" }, "peerDependencies": { "@opentui/react": "0.5.3", "@opentui/solid": "0.5.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-VdvUabUDWnmgolzp1qiO55wz1zv0mMu5Z6tv+UQk9JqZRfKt5Q8XTDi0uQzhBb6qeLsHvXtRFOsnn6EFAcLixA=="],
"@opentui/keymap": ["@opentui/keymap@0.5.4", "", { "dependencies": { "@opentui/core": "0.5.4" }, "peerDependencies": { "@opentui/react": "0.5.4", "@opentui/solid": "0.5.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WAB6EH6C7SicmK/ycvx+mUu00sQ755gToZqBx8vgLhS7qZ07s/WLi88Ys0D6AA0i8lOiSIPA4uqei1jkV8UnBQ=="],
"@opentui/solid": ["@opentui/solid@0.5.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-IB6YC3ajucQeo06BMSNYRD7LHf8ndm6G5ETcYsS+51Mli5yW0ioyUr+6+qgqVtaQyOcJvaD1KWmK23pXLhsiwg=="],
"@opentui/solid": ["@opentui/solid@0.5.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-1injDvieTyYXD3RLC2EclaJK8XZ2+qyJ7wGeQPSzpcYEEcanb7G79KVWFlN7YUzqVAhTIictZDoQ6SRvIwL4Ww=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
+3 -3
View File
@@ -47,9 +47,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.3",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
+1
View File
@@ -23,6 +23,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
+30 -9
View File
@@ -86,12 +86,37 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
],
}),
Spec.make("auth", {
description: "Manage authentication",
description: "manage AI providers and credentials",
commands: [
Spec.make("login", {
description: "Log in to a well-known authentication provider",
Spec.make("list", {
description: "list providers and credentials",
params: {
url: Argument.string("url").pipe(Argument.withDescription("Well-known provider URL")),
...ServerParams,
format: Flag.choice("format", ["default", "json"]).pipe(
Flag.withDescription("Output format"),
Flag.withDefault("default"),
),
},
}),
Spec.make("login", {
description: "log in to a provider",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID, name, or well-known provider URL"),
Argument.optional,
),
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
},
}),
Spec.make("logout", {
description: "log out from a configured provider",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID or name"),
Argument.optional,
),
},
}),
],
@@ -145,11 +170,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
description: "Export session data as JSON",
params: {
...ServerParams,
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to export to stdout"),
Flag.optional,
),
session: Argument.string("session").pipe(Argument.withDescription("Session ID to export"), Argument.optional),
sanitize: Flag.boolean("sanitize").pipe(
Flag.withDescription("Redact sensitive transcript and file data"),
Flag.withDefault(false),
@@ -0,0 +1,153 @@
import { confirm, log, multiselect, password, select, text, type Option } from "@clack/prompts"
import { Effect } from "effect"
import type { FormAnswer, FormField, FormFields } from "@opencode-ai/client"
import { openUrl, prompt, requireInteractive } from "../../../ui/prompt"
const skip = Symbol("skip")
const custom = Symbol("custom")
export const answerForm = Effect.fn("cli.auth.form")(function* (fields: FormFields | undefined) {
if (!fields) return undefined
yield* requireInteractive("Authentication form input requires an interactive terminal")
const answer: FormAnswer = {}
for (const field of fields) {
if (!active(field, answer)) continue
const value = yield* answerField(field)
if (value !== undefined) answer[field.key] = value
}
return answer
})
export const secret = Effect.fn("cli.auth.secret")(function* (message: string) {
yield* requireInteractive("API key input requires an interactive terminal")
return yield* prompt<string>(() => password({ message, validate: (value) => (!value ? "Required" : undefined) }))
})
const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField) {
const message = field.title ?? field.key
if (field.description) log.info(field.description)
if (field.type === "external") {
log.info(field.url)
yield* openUrl(field.url)
const acknowledged = yield* prompt<boolean>(() =>
confirm({ message: message || "Continue after completing this step?", initialValue: true }),
)
if (!acknowledged) return yield* Effect.fail(new Error(`${message || "External step"} is required`))
return true
}
if (field.type === "boolean") {
if (field.required) return yield* prompt<boolean>(() => confirm({ message, initialValue: field.default ?? true }))
const options: Array<Option<boolean | typeof skip>> = [
{ value: true, label: "Yes" },
{ value: false, label: "No" },
{ value: skip, label: "Skip" },
]
const value = yield* prompt<boolean | typeof skip>(() =>
select<boolean | typeof skip>({
message,
options,
initialValue: field.default ?? skip,
}),
)
if (value === skip) return undefined
return value
}
if (field.type === "multiselect") {
const options: Array<Option<string | typeof custom>> = field.options.map((option) => ({
value: option.value,
label: option.label,
hint: option.description,
}))
if (field.custom) options.push({ value: custom, label: "Type another value" })
const values = yield* prompt<Array<string | typeof custom>>(() =>
multiselect<string | typeof custom>({
message,
options,
initialValues: field.default,
required: field.required || (field.minItems ?? 0) > 0,
}),
)
const selected = values.filter((value): value is string => value !== custom)
if (values.includes(custom)) {
selected.push(yield* prompt<string>(() => text({ message: "Enter value", validate: required })))
}
if (field.minItems !== undefined && selected.length < field.minItems) {
return yield* Effect.fail(new Error(`Select at least ${field.minItems}`))
}
if (field.maxItems !== undefined && selected.length > field.maxItems) {
return yield* Effect.fail(new Error(`Select at most ${field.maxItems}`))
}
return selected
}
if (field.type === "string" && field.options) {
const options: Array<Option<string | typeof custom | typeof skip>> = field.options.map((option) => ({
value: option.value,
label: option.label,
hint: option.description,
}))
if (field.custom) options.push({ value: custom, label: "Type your own answer" })
if (!field.required) options.push({ value: skip, label: "Skip" })
const value = yield* prompt<string | typeof custom | typeof skip>(() =>
select<string | typeof custom | typeof skip>({ message, options, initialValue: field.default }),
)
if (value === skip) return undefined
if (value !== custom) return value
}
const value = yield* prompt<string>(() =>
text({
message,
placeholder: field.type === "string" ? field.placeholder : undefined,
initialValue: field.default === undefined ? undefined : String(field.default),
validate: (input) => validateText(field, input),
}),
)
if (!value && !field.required) return undefined
if (field.type === "string") return value
return Number(value)
})
function active(field: FormField, answer: FormAnswer) {
if (field.type === "external" || !field.when) return true
return field.when.every((condition) => {
const value = answer[condition.key]
if (value === undefined) return false
const matches = Array.isArray(value) ? value.includes(String(condition.value)) : value === condition.value
return condition.op === "eq" ? matches : !matches
})
}
function required(value: string | undefined) {
return value ? undefined : "Required"
}
function validateText(field: Exclude<FormField, { type: "boolean" | "external" | "multiselect" }>, value?: string) {
if (!value) return field.required ? "Required" : undefined
if (field.type === "number" || field.type === "integer") {
const number = Number(value)
if (!Number.isFinite(number)) return "Expected a number"
if (field.type === "integer" && !Number.isInteger(number)) return "Expected an integer"
if (typeof field.minimum === "number" && number < field.minimum) return `Must be at least ${field.minimum}`
if (typeof field.maximum === "number" && number > field.maximum) return `Must be at most ${field.maximum}`
return undefined
}
if (field.minLength !== undefined && value.length < field.minLength)
return `Must be at least ${field.minLength} characters`
if (field.maxLength !== undefined && value.length > field.maxLength)
return `Must be at most ${field.maxLength} characters`
if (field.pattern) {
try {
if (!new RegExp(field.pattern).test(value)) return "Invalid format"
} catch {
return "Invalid format"
}
}
if (field.format === "uri" && !URL.canParse(value)) return "Expected a URL"
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
if (field.format === "date") {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "Expected a date"
const date = new Date(`${value}T00:00:00.000Z`)
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return "Expected a date"
}
if (field.format === "date-time" && Number.isNaN(Date.parse(value))) return "Expected a date and time"
return undefined
}
@@ -0,0 +1,51 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { createClient, loadIntegrations } from "./shared"
import { errorMessage } from "../../../ui/prompt"
export default Runtime.handler(Commands.commands.auth.commands.list, (input) =>
list(input).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
const list = Effect.fn("cli.auth.list")(function* (input) {
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
const integrations = (yield* loadIntegrations(client)).filter((integration) => integration.connections.length > 0)
if (input.format === "json") {
process.stdout.write(
JSON.stringify(
integrations.map((integration) => ({
id: integration.id,
name: integration.name,
connections: integration.connections,
})),
null,
2,
) + EOL,
)
return
}
const rows = integrations.flatMap((integration) =>
integration.connections.map((connection) => ({
integration: integration.name,
source: connection.type === "credential" ? connection.label : connection.name,
type: connection.type === "credential" ? "stored" : "environment",
})),
)
if (rows.length === 0) {
process.stdout.write("No authenticated integrations" + EOL)
return
}
const width = Math.max(...rows.map((row) => row.integration.length)) + 2
process.stdout.write(
rows.map((row) => row.integration.padEnd(width) + row.source.padEnd(28) + row.type).join(EOL) + EOL,
)
})
+253 -38
View File
@@ -1,52 +1,267 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts"
import { Effect, Option } from "effect"
import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt"
import { answerForm, secret } from "./form"
import {
createClient,
connectMethods,
loadIntegrations,
location,
request,
resolveIntegration,
resolveMethod,
type ConnectMethod,
} from "./shared"
const location = { directory: process.cwd() }
const integrationPriority = new Map([
["opencode", 0],
["opencode-go", 1],
["openai", 2],
["github-copilot", 3],
["google", 4],
["anthropic", 5],
["openrouter", 6],
["vercel", 7],
])
export default Runtime.handler(
Commands.commands.auth.commands.login,
Effect.fn("cli.auth.login")(function* (input) {
process.stdout.write("Logging in..." + EOL + EOL)
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
yield* request(() => client.integration.wellknown.add({ url: input.url, location }))
const integrationID = input.url.replace(/\/+$/, "")
const started = yield* request(() =>
client.integration.command.connect({ integrationID, methodID: "login", location }),
)
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }),
).pipe(Effect.ignore),
)
const status = yield* wait(client, integrationID, started.data.attemptID)
if (status.status === "failed") return yield* Effect.fail(new Error(status.message))
if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired"))
process.stdout.write("Logged in" + EOL)
}),
Effect.fn("cli.auth.login")((input) =>
login({
target: Option.getOrUndefined(input.target),
method: Option.getOrUndefined(input.method),
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
),
)
const wait = (
const login = Effect.fn("cli.auth.login.run")(function* (input: {
target?: string
method?: string
server?: string
standalone: boolean
}) {
if (!input.target)
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
intro("Connect an integration")
const client = yield* createClient({ server: input.server, standalone: input.standalone })
const integration = yield* findIntegration(client, input.target)
const methods = connectMethods(integration)
if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`))
const method = yield* chooseMethod(methods, input.method)
const answer = method.type === "command" ? undefined : yield* answerForm(method.form)
yield* authenticate(client, integration, method, answer)
outro("Done")
})
const findIntegration = Effect.fn("cli.auth.login.integration")(function* (client: OpenCodeClient, target?: string) {
if (target && URL.canParse(target)) {
const protocol = new URL(target).protocol
if (protocol === "http:" || protocol === "https:") {
const progress = spinner()
progress.start("Discovering authentication provider...")
yield* request((signal) => client.integration.wellknown.add({ url: target, location }, { signal })).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Authentication provider discovered"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Discovery failed", 1))),
)
}
}
const integrations = yield* loadIntegrations(client)
if (target) return yield* resolveIntegration(integrations, target)
const available = integrations
.filter((integration) => connectMethods(integration).length > 0)
.toSorted(
(a, b) =>
(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)
})
const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) {
if (target) return yield* resolveMethod(methods, target)
if (methods.length === 1) return methods[0]
yield* requireInteractive("Pass --method when running without an interactive terminal")
const id = yield* prompt<string>(() =>
select({
message: "Select login method",
options: methods.map((method) => {
if (method.type === "key") return { value: "key", label: method.label ?? "API key" }
return { value: method.id, label: method.label }
}),
}),
)
return yield* resolveMethod(methods, id)
})
const authenticate = Effect.fn("cli.auth.login.authenticate")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: ConnectMethod,
answer?: FormAnswer,
) {
if (method.type === "key") return yield* keyLogin(client, integration, method, answer)
if (method.type === "command") return yield* commandLogin(client, integration, method)
return yield* oauthLogin(client, integration, method, answer)
})
const keyLogin = Effect.fn("cli.auth.login.key")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "key" }>,
answer?: FormAnswer,
) {
const key = yield* secret(method.label ?? `Enter your ${integration.name} API key`)
const progress = spinner()
progress.start("Saving credential...")
yield* request((signal) =>
client.integration.connect.key({ integrationID: integration.id, key, answer, location }, { signal }),
).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Connected to ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))),
)
})
const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "oauth" }>,
answer?: FormAnswer,
) {
const progress = spinner()
progress.start("Starting authorization...")
const started = yield* request((signal) =>
client.integration.oauth.connect(
{ integrationID: integration.id, methodID: method.id, answer, location },
{ signal },
),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
const attempt = started.data
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.oauth.cancel(
{ integrationID: integration.id, attemptID: attempt.attemptID, location },
{ signal: AbortSignal.timeout(5_000) },
),
).pipe(Effect.ignore),
)
progress.stop("Authorization started")
log.info(attempt.instructions)
log.info(attempt.url)
if (process.stdin.isTTY && process.stdout.isTTY) yield* openUrl(attempt.url)
if (attempt.mode === "code") {
yield* requireInteractive("This login requires an interactive terminal to enter the authorization code")
const code = yield* prompt<string>(() =>
text({ message: "Paste the authorization code", validate: (value) => (!value ? "Required" : undefined) }),
)
const completing = spinner()
completing.start("Completing authorization...")
yield* request((signal) =>
client.integration.oauth.complete(
{ integrationID: integration.id, attemptID: attempt.attemptID, code, location },
{ signal },
),
).pipe(
Effect.tap(() => Effect.sync(() => completing.stop(`Connected to ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => completing.stop("Authentication failed", 1))),
)
return
}
const waiting = spinner()
waiting.start("Waiting for authorization...")
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe(
Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))),
)
if (status.status === "complete") {
waiting.stop(`Connected to ${integration.name}`)
return
}
waiting.stop("Authentication failed", 1)
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
yield* Effect.fail(new Error("Authorization expired"))
})
const commandLogin = Effect.fn("cli.auth.login.command")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "command" }>,
) {
const progress = spinner()
progress.start("Starting authentication command...")
const started = yield* request((signal) =>
client.integration.command.connect({ integrationID: integration.id, methodID: method.id, location }, { signal }),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.command.cancel(
{
integrationID: integration.id,
attemptID: started.data.attemptID,
location,
},
{ signal: AbortSignal.timeout(5_000) },
),
).pipe(Effect.ignore),
)
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) =>
progress.message(message.trim() || "Waiting for authentication command..."),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
if (status.status === "complete") {
progress.stop(`Connected to ${integration.name}`)
return
}
progress.stop("Authentication failed", 1)
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
yield* Effect.fail(new Error("Authentication expired"))
})
const waitForOAuth = Effect.fn("cli.auth.login.oauth.wait")(function* (
client: OpenCodeClient,
integrationID: string,
attemptID: string,
shown = false,
): Effect.Effect<Exclude<IntegrationCommandStatusOutput["data"], { status: "pending" }>, unknown> =>
Effect.gen(function* () {
const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location }))
) {
while (true) {
const response = yield* request((signal) =>
client.integration.oauth.status({ integrationID, attemptID, location }, { signal }),
)
if (response.data.status !== "pending") return response.data
const output = response.data.message?.trim()
if (!shown && output) process.stdout.write(output + EOL + EOL)
yield* Effect.sleep(500)
return yield* wait(client, integrationID, attemptID, shown || !!output)
})
}
})
function request<A>(task: () => Promise<A>) {
return Effect.tryPromise({ try: task, catch: (cause) => cause })
}
const waitForCommand = Effect.fn("cli.auth.login.command.wait")(function* (
client: OpenCodeClient,
integrationID: string,
attemptID: string,
update: (message: string) => void,
) {
while (true) {
const response = yield* request((signal) =>
client.integration.command.status({ integrationID, attemptID, location }, { signal }),
)
if (response.data.status !== "pending") return response.data
if (response.data.message) update(response.data.message)
yield* Effect.sleep(500)
}
})
@@ -0,0 +1,83 @@
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import type { IntegrationInfo } from "@opencode-ai/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"
export default Runtime.handler(
Commands.commands.auth.commands.logout,
Effect.fn("cli.auth.logout")((input) =>
logout({
target: Option.getOrUndefined(input.target),
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
),
)
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
target?: 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")
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 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}`))),
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,64 @@
import { Effect } from "effect"
import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { ServerConnection } from "../../../services/server-connection"
export const location = { directory: process.cwd() }
export const createClient = Effect.fn("cli.auth.client")(function* (input: ServerConnection.Args) {
const server = yield* ServerConnection.resolve(input)
return OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
})
export function request<A>(run: (signal: AbortSignal) => Promise<A>) {
return Effect.tryPromise({ try: run, catch: (cause) => cause })
}
export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (client: OpenCodeClient) {
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
yield* request((signal) => client.model.default({ location }, { signal }))
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
Effect.map((response) => response.data),
)
})
export const resolveIntegration = Effect.fn("cli.auth.resolve-integration")(function* (
integrations: IntegrationInfo[],
target: string,
) {
const normalized = target.replace(/\/+$/, "")
const byID = integrations.find((integration) => integration.id === normalized)
if (byID) return byID
const matches = integrations.filter((integration) => integration.name.toLowerCase() === normalized.toLowerCase())
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
return yield* Effect.fail(
new Error(
`Integration name "${target}" is ambiguous: ${matches.map((integration) => integration.id).join(", ")}`,
),
)
}
return yield* Effect.fail(new Error(`Integration not found: ${target}`))
})
export type ConnectMethod = Exclude<IntegrationMethod, { type: "env" }>
export function connectMethods(integration: IntegrationInfo) {
return integration.methods
.filter((method): method is ConnectMethod => method.type !== "env")
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
}
export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) {
const normalized = target.toLowerCase()
const matches = methods.filter((method) => {
if (method.type === "key") return normalized === "key" || method.label?.toLowerCase() === normalized
return method.id === target || method.label.toLowerCase() === normalized
})
if (matches.length === 1) return matches[0]
if (matches.length > 1) return yield* Effect.fail(new Error(`Authentication method "${target}" is ambiguous`))
const available = methods.map((method) => (method.type === "key" ? "key" : method.id)).join(", ")
return yield* Effect.fail(
new Error(`Authentication method not found: ${target}${available ? `. Available: ${available}` : ""}`),
)
})
+78 -130
View File
@@ -1,140 +1,88 @@
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { autocomplete, cancel, intro, isCancel, log, outro } from "@clack/prompts"
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option } from "effect"
import { EOL, tmpdir } from "node:os"
import path from "node:path"
import { emitKeypressEvents, type Key } from "node:readline"
import { EOL } from "node:os"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { errorMessage } from "../../ui/prompt"
export default Runtime.handler(
Commands.commands.export,
Effect.fn("cli.export")(function* (input) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const requested = Option.getOrUndefined(input.session)
const selected = requested
? undefined
: yield* Effect.promise(async () => {
const location = await client.location.get({ location: { directory: process.cwd() } })
const page = await client.session.list({
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
order: "desc",
limit: 50,
Effect.fn("cli.export")((input) =>
Effect.gen(function* () {
const requested = Option.getOrUndefined(input.session)
if (!requested && !process.stdin.isTTY) {
yield* Effect.fail(new Error("Pass a session ID when running without an interactive terminal"))
}
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const sessionID = requested
? requested
: yield* Effect.gen(function* () {
intro("Export session", { output: process.stderr })
const location = yield* Effect.tryPromise({
try: () => client.location.get({ location: { directory: process.cwd() } }),
catch: (cause) => cause,
})
const page = yield* Effect.tryPromise({
try: () =>
client.session.list({
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
order: "desc",
limit: 50,
}),
catch: (cause) => cause,
})
if (page.data.length === 0) {
log.error("No sessions found", { output: process.stderr })
outro("Done", { output: process.stderr })
return undefined
}
const selected = yield* Effect.tryPromise({
try: () =>
autocomplete({
message: "Select session to export",
maxItems: 10,
options: page.data.map((session) => ({
label: session.title,
value: session.id,
hint: `${new Date(session.time.updated).toLocaleString()} - ${session.id.slice(-8)}`,
})),
output: process.stderr,
}),
catch: (cause) => cause,
})
if (isCancel(selected)) {
cancel("Cancelled", { output: process.stderr })
process.exitCode = 130
return undefined
}
outro("Exporting session...", { output: process.stderr })
return selected
})
if (page.data.length === 0) {
process.stderr.write(`No sessions found${EOL}`)
return undefined
}
return selectSession(page.data, input.sanitize)
})
const sessionID = requested ?? selected?.session.id
if (!sessionID) return
const data = yield* Effect.promise(() =>
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
)
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
}),
if (!sessionID) return
const data = yield* Effect.tryPromise({
try: () => client.session.export({ sessionID, sanitize: input.sanitize }),
catch: (cause) => cause,
})
process.stdout.write(JSON.stringify(data, null, 2) + EOL)
}).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
),
)
type Selection = { session: SessionInfo; sanitize: boolean }
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
const input = process.stdin
const output = process.stderr
const wasRaw = input.isRaw
const wasPaused = input.isPaused()
const date = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
const columns = output.columns ?? 100
const titleWidth = Math.max(8, Math.min(48, columns - 34))
let selected = 0
let offset = 0
let sanitize = initialSanitize
let height = 0
const render = () => {
const visible = sessions.slice(offset, offset + 10)
const lines = [" \x1b[36mExport session\x1b[0m", ""]
lines.push(
...visible.map((session) => {
const index = sessions.indexOf(session)
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
}),
"",
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
"",
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
)
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write(lines.join(EOL) + EOL)
height = lines.length
}
const clear = () => {
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write("\x1b[?25h")
input.removeListener("keypress", onKeypress)
input.setRawMode(wasRaw ?? false)
if (wasPaused) input.pause()
}
const onKeypress = (value: string | undefined, key: Key) => {
if (key.name === "up") {
selected = (selected - 1 + sessions.length) % sessions.length
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
if (selected < offset) offset = selected
}
if (key.name === "down") {
selected = (selected + 1) % sessions.length
if (selected === 0) offset = 0
if (selected >= offset + 10) offset = selected - 9
}
if (key.name === "space" || value === " ") sanitize = !sanitize
if (key.name === "return") return finish(sessions[selected])
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
render()
}
const finish = (session: SessionInfo) => {
clear()
resolveSelection?.({ session, sanitize })
}
const cancel = () => {
clear()
resolveSelection?.()
}
let resolveSelection: ((selection?: Selection) => void) | undefined
emitKeypressEvents(input)
input.setRawMode(true)
input.resume()
input.on("keypress", onKeypress)
output.write("\x1b[?25l")
render()
return new Promise<Selection | undefined>((resolve) => {
resolveSelection = resolve
})
}
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
const json = JSON.stringify(data, null, 2) + EOL
if (stdout) return json
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
await Bun.write(file, json)
return file + EOL
}
+2
View File
@@ -19,7 +19,9 @@ const Handlers = Runtime.handlers(Commands, {
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
list: () => import("./commands/handlers/auth/list"),
login: () => import("./commands/handlers/auth/login"),
logout: () => import("./commands/handlers/auth/logout"),
},
debug: {
agents: () => import("./commands/handlers/debug/agents"),
+48
View File
@@ -0,0 +1,48 @@
import { cancel, isCancel, log, outro } from "@clack/prompts"
import { Effect } from "effect"
const cancelled = Symbol("cancelled")
export function prompt<A>(run: () => Promise<A | symbol>) {
return Effect.tryPromise({ try: run, catch: (cause) => cause }).pipe(
Effect.flatMap((value) => (isCancel(value) ? Effect.fail(cancelled) : Effect.succeed(value))),
)
}
export function requireInteractive(message: string) {
if (process.stdin.isTTY && process.stdout.isTTY) return Effect.void
return Effect.fail(new Error(message))
}
export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) {
const { default: open } = yield* Effect.promise(() => import("open"))
yield* Effect.promise(() => open(url)).pipe(Effect.ignore)
})
export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
return effect.pipe(
Effect.catchIf(
(error) => error === cancelled,
() =>
Effect.sync(() => {
cancel("Cancelled")
process.exitCode = 130
}),
),
Effect.catch((error) =>
Effect.sync(() => {
log.error(errorMessage(error))
outro("Failed")
process.exitCode = 1
}),
),
)
}
export function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
return error.message
}
return String(error)
}
+252 -5
View File
@@ -1,20 +1,267 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
describe("auth command", () => {
test("registers login", async () => {
const [auth, login] = await Promise.all([cli(["auth", "--help"]), cli(["auth", "login", "--help"])])
test("registers authentication commands", async () => {
const [auth, list, login, logout] = await Promise.all([
cli(["auth", "--help"]),
cli(["auth", "list", "--help"]),
cli(["auth", "login", "--help"]),
cli(["auth", "logout", "--help"]),
])
expect(auth.exitCode).toBe(0)
expect(auth.stdout).toContain("list")
expect(auth.stdout).toContain("login")
expect(auth.stdout).toContain("Log in to a well-known authentication provider")
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("log out from a configured provider")
expect(auth.stdout).not.toContain("connect")
expect(list.exitCode).toBe(0)
expect(list.stdout).toContain("opencode auth list [flags]")
expect(list.stdout).toContain("--format")
expect(login.exitCode).toBe(0)
expect(login.stdout).toContain("opencode auth login [flags] <url>")
expect(login.stdout).toContain("Well-known provider URL")
expect(login.stdout).toContain("opencode auth login [flags] [<target>]")
expect(login.stdout).toContain("Integration ID, name, or well-known provider URL")
expect(login.stdout).toContain("--method")
expect(logout.exitCode).toBe(0)
expect(logout.stdout).toContain("opencode auth logout [flags] [<target>]")
})
test("lists stored and environment connections", async () => {
const requests: string[] = []
using server = authServer((request, url) => {
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "anthropic",
name: "Anthropic",
methods: [],
connections: [
{ type: "credential", id: "cred_test", label: "default" },
{ type: "env", name: "ANTHROPIC_API_KEY" },
],
},
{ id: "openai", name: "OpenAI", methods: [], connections: [] },
]),
)
}
return new Response("Not found", { status: 404 })
}, requests)
const result = await cli(["auth", "list", "--format", "json", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(JSON.parse(result.stdout)).toEqual([
{
id: "anthropic",
name: "Anthropic",
connections: [
{ type: "credential", id: "cred_test", label: "default" },
{ type: "env", name: "ANTHROPIC_API_KEY" },
],
},
])
expect(requests.indexOf("/api/model/default")).toBeLessThan(requests.indexOf("/api/integration"))
})
test("runs command authentication without interactive input", async () => {
const requests: Array<{ method: string; path: string }> = []
using server = authServer((request, url) => {
requests.push({ method: request.method, path: url.pathname })
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "company",
name: "Company",
methods: [{ id: "login", type: "command", label: "Company login", command: ["company", "login"] }],
connections: [],
},
]),
)
}
if (url.pathname === "/api/integration/company/connect/command" && request.method === "POST") {
return Response.json(located({ attemptID: "con_test", time: { created: 1, expires: 2 } }))
}
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "GET") {
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
}
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "DELETE") {
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "login", "company", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(result.stdout).toContain("Connected to Company")
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/company/connect/command" })
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/company/connect/command/con_test" })
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/company/connect/command/con_test" })
})
test("completes automatic OAuth authentication", async () => {
const requests: Array<{ method: string; path: string }> = []
using server = authServer((request, url) => {
requests.push({ method: request.method, path: url.pathname })
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "openai",
name: "OpenAI",
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
connections: [],
},
]),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
return Response.json(
located({
attemptID: "con_oauth",
url: "https://example.com/authorize",
instructions: "Authorize OpenAI",
mode: "auto",
time: { created: 1, expires: 2 },
}),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(result.stdout).toContain("https://example.com/authorize")
expect(result.stdout).toContain("Connected to OpenAI")
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/openai/connect/oauth" })
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/openai/connect/oauth/con_oauth" })
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/openai/connect/oauth/con_oauth" })
})
test("settles the OAuth spinner when status polling fails", async () => {
using server = authServer((request, url) => {
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "openai",
name: "OpenAI",
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
connections: [],
},
]),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
return Response.json(
located({
attemptID: "con_oauth",
url: "https://example.com/authorize",
instructions: "Authorize OpenAI",
mode: "auto",
time: { created: 1, expires: 2 },
}),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
return new Response("Unavailable", { status: 500 })
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Authentication failed")
expect(result.stdout).toContain("Failed")
expect(result.stdout).not.toContain("\n at ")
})
test("removes stored credentials", async () => {
const removed: string[] = []
using server = authServer((request, url) => {
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "anthropic",
name: "Anthropic",
methods: [{ type: "key" }],
connections: [{ type: "credential", id: "cred_test", label: "default" }],
},
]),
)
}
if (url.pathname === "/api/credential/cred_test" && request.method === "DELETE") {
removed.push("cred_test")
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "logout", "anthropic", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(result.stdout).toContain("Disconnected from Anthropic")
expect(removed).toEqual(["cred_test"])
})
test("requires a target outside an interactive terminal", async () => {
const result = await cli(["auth", "login"])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Pass an integration ID or name")
expect(result.stdout).not.toContain("Background service failed to start")
})
test("reports list connection failures without a stack trace", async () => {
using server = Bun.serve({ port: 0, fetch: () => new Response("Unavailable", { status: 503 }) })
const result = await cli(["auth", "list", "--server", server.url.toString()])
expect(result.exitCode).toBe(1)
expect(result.stdout).toBe("")
expect(result.stderr).toContain("did not provide a compatible V2 health response")
expect(result.stderr).not.toContain("\n at ")
})
})
function authServer(fetch: (request: Request, url: URL) => Response | Promise<Response>, requests?: string[]) {
return Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
requests?.push(url.pathname)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/model/default") return Response.json(located(null))
return fetch(request, url)
},
})
}
function health() {
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
}
function located<T>(data: T) {
return {
location: {
directory: process.cwd(),
project: { id: "project", directory: process.cwd(), canonical: process.cwd() },
},
data,
}
}
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, ".."),
+26 -12
View File
@@ -3,7 +3,6 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
import { writeExport } from "../src/commands/handlers/export"
const info = {
id: "ses_export_test",
@@ -72,7 +71,7 @@ test("export is raw by default and supports explicit sanitization", async () =>
})
try {
const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
const [stdout, , exitCode] = await run(["export", info.id, "--server", server.url.toString()])
const exported = JSON.parse(stdout)
expect(exitCode).toBe(0)
@@ -80,7 +79,6 @@ test("export is raw by default and supports explicit sanitization", async () =>
const [sanitized, , sanitizedExitCode] = await run([
"export",
"-s",
info.id,
"--sanitize",
"--server",
@@ -94,7 +92,7 @@ test("export is raw by default and supports explicit sanitization", async () =>
}
}, 15_000)
test("export reports an empty session list without a stack trace", async () => {
test("export requires a session outside an interactive terminal", async () => {
const server = Bun.serve({
port: 0,
fetch(request) {
@@ -114,23 +112,39 @@ test("export reports an empty session list without a stack trace", async () => {
try {
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
expect(exitCode).toBe(0)
expect(exitCode).toBe(1)
expect(stdout).toBe("")
expect(stderr).toBe(`No sessions found${os.EOL}`)
expect(stderr).toBe(`Pass a session ID when running without an interactive terminal${os.EOL}`)
} finally {
await server.stop(true)
}
})
test("interactive export writes a temporary JSON file", async () => {
const output = await writeExport(transfer, info.id, false)
const file = output.trim()
test("export reports a missing session without a stack trace", async () => {
const sessionID = "ses_missing"
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === `/api/session/${sessionID}/export`) {
return Response.json(
{ _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` },
{ status: 404 },
)
}
return new Response("Not found", { status: 404 })
},
})
try {
expect(path.dirname(file)).toBe(os.tmpdir())
expect(await Bun.file(file).json()).toEqual(transfer)
const [stdout, stderr, exitCode] = await run(["export", sessionID, "--server", server.url.toString()])
expect(exitCode).toBe(1)
expect(stdout).toBe("")
expect(stderr).toBe(`Session not found: ${sessionID}${os.EOL}`)
} finally {
await fs.rm(file, { force: true })
await server.stop(true)
}
})
+3 -3
View File
@@ -32,9 +32,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.3",
"@opentui/keymap": ">=0.5.3",
"@opentui/solid": ">=0.5.3",
"@opentui/core": ">=0.5.4",
"@opentui/keymap": ">=0.5.4",
"@opentui/solid": ">=0.5.4",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
+12
View File
@@ -523,6 +523,18 @@ export function FormPrompt(props: {
textarea?.setText("")
},
},
{
id: "prompt.paste",
title: "Paste into answer",
group: "Form",
async run(_input, event) {
event?.preventDefault()
event?.stopPropagation()
const content = await clipboard.read()
if (content?.mime !== "text/plain") return
textarea?.insertText(content.data)
},
},
{
bind: "escape",
title: textual() ? "Dismiss form" : "Close answer edit",
+30 -2
View File
@@ -14,7 +14,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"], height = 20) {
async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"], height = 20, pasted?: string) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -58,7 +58,7 @@ async function mountForm(root: string, width = 80, fields?: FormWithLocation["fi
}}
clipboard={{
async read() {
return undefined
return pasted ? { data: pasted, mime: "text/plain" } : undefined
},
write(text) {
copied.push(text)
@@ -217,6 +217,34 @@ test("pasting on a custom choice opens its editor without submitting", async ()
}
})
test("ctrl-v pastes clipboard text into a custom answer", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(
tmp.path,
80,
[
{
key: "target",
type: "string",
options: [{ value: "staging", label: "Staging" }],
custom: true,
},
],
20,
"production west",
)
try {
prompt.app.mockInput.pressArrow("down")
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
prompt.app.mockInput.pressKey("v", { ctrl: true })
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production west")
} finally {
prompt.app.renderer.destroy()
}
})
test("typing a custom multiselect answer selects it before commit", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, [