summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/cli/cmd/providers.ts276
-rw-r--r--packages/opencode/src/cli/effect/prompt.ts24
2 files changed, 158 insertions, 142 deletions
diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts
index 44fa42015..749139e2d 100644
--- a/packages/opencode/src/cli/cmd/providers.ts
+++ b/packages/opencode/src/cli/cmd/providers.ts
@@ -1,9 +1,8 @@
import { Auth } from "../../auth"
-import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd"
-import { effectCmd } from "../effect-cmd"
-import * as prompts from "@clack/prompts"
+import { CliError, effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
+import * as Prompt from "../effect/prompt"
import { ModelsDev } from "@/provider/models"
import { map, pipe, sortBy, values } from "remeda"
@@ -14,44 +13,57 @@ import { Global } from "@opencode-ai/core/global"
import { Plugin } from "../../plugin"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "@/util/process"
+import { errorMessage } from "@/util/error"
import { text } from "node:stream/consumers"
-import { Effect } from "effect"
+import { Effect, Option } from "effect"
type PluginAuth = NonNullable<Hooks["auth"]>
-const put = (key: string, info: Auth.Info) =>
- AppRuntime.runPromise(
- Effect.gen(function* () {
- const auth = yield* Auth.Service
- yield* auth.set(key, info)
- }),
- )
-
-async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
- let index = 0
- if (methodName) {
+const promptValue = <Value>(value: Option.Option<Value>) => {
+ if (Option.isNone(value)) return Effect.die(new UI.CancelledError())
+ return Effect.succeed(value.value)
+}
+
+const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) {
+ const auth = yield* Auth.Service
+ yield* Effect.orDie(auth.set(key, info))
+})
+
+const cliTry = <Value>(message: string, fn: () => PromiseLike<Value>) =>
+ Effect.tryPromise({
+ try: fn,
+ catch: (error) => new CliError({ message: message + errorMessage(error) }),
+ })
+
+const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
+ plugin: { auth: PluginAuth },
+ provider: string,
+ methodName?: string,
+) {
+ const index = yield* Effect.gen(function* () {
+ if (!methodName) {
+ if (plugin.auth.methods.length <= 1) return 0
+ return yield* promptValue(
+ yield* Prompt.select({
+ message: "Login method",
+ options: plugin.auth.methods.map((x, index) => ({
+ label: x.label,
+ value: index,
+ })),
+ }),
+ )
+ }
const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase())
if (match === -1) {
- prompts.log.error(
+ return yield* fail(
`Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`,
)
- process.exit(1)
}
- index = match
- } else if (plugin.auth.methods.length > 1) {
- const method = await prompts.select({
- message: "Login method",
- options: plugin.auth.methods.map((x, index) => ({
- label: x.label,
- value: index.toString(),
- })),
- })
- if (prompts.isCancel(method)) throw new UI.CancelledError()
- index = parseInt(method)
- }
+ return match
+ })
const method = plugin.auth.methods[index]
- await new Promise((r) => setTimeout(r, 10))
+ yield* Effect.sleep("10 millis")
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
@@ -63,46 +75,44 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
if (prompt.condition && !prompt.condition(inputs)) continue
if (prompt.type === "select") {
- const value = await prompts.select({
+ const value = yield* Prompt.select({
message: prompt.message,
options: prompt.options,
})
- if (prompts.isCancel(value)) throw new UI.CancelledError()
- inputs[prompt.key] = value
- } else {
- const value = await prompts.text({
- message: prompt.message,
- placeholder: prompt.placeholder,
- validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
- })
- if (prompts.isCancel(value)) throw new UI.CancelledError()
- inputs[prompt.key] = value
+ inputs[prompt.key] = yield* promptValue(value)
+ continue
}
+ const value = yield* Prompt.text({
+ message: prompt.message,
+ placeholder: prompt.placeholder,
+ validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
+ })
+ inputs[prompt.key] = yield* promptValue(value)
}
}
if (method.type === "oauth") {
- const authorize = await method.authorize(inputs)
+ const authorize = yield* cliTry("Failed to authorize: ", () => method.authorize(inputs))
if (authorize.url) {
- prompts.log.info("Go to: " + authorize.url)
+ yield* Prompt.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
- prompts.log.info(authorize.instructions)
+ yield* Prompt.log.info(authorize.instructions)
}
- const spinner = prompts.spinner()
- spinner.start("Waiting for authorization...")
- const result = await authorize.callback()
+ const spinner = Prompt.spinner()
+ yield* spinner.start("Waiting for authorization...")
+ const result = yield* cliTry("Failed to authorize: ", () => authorize.callback())
if (result.type === "failed") {
- spinner.stop("Failed to authorize", 1)
+ yield* spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
- await put(saveProvider, {
+ yield* put(saveProvider, {
type: "oauth",
refresh,
access,
@@ -111,30 +121,30 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})
}
if ("key" in result) {
- await put(saveProvider, {
+ yield* put(saveProvider, {
type: "api",
key: result.key,
})
}
- spinner.stop("Login successful")
+ yield* spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
- const code = await prompts.text({
+ const code = yield* Prompt.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
- if (prompts.isCancel(code)) throw new UI.CancelledError()
- const result = await authorize.callback(code)
+ const authorizationCode = yield* promptValue(code)
+ const result = yield* cliTry("Failed to authorize: ", () => authorize.callback(authorizationCode))
if (result.type === "failed") {
- prompts.log.error("Failed to authorize")
+ yield* Prompt.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
- await put(saveProvider, {
+ yield* put(saveProvider, {
type: "oauth",
refresh,
access,
@@ -143,56 +153,57 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})
}
if ("key" in result) {
- await put(saveProvider, {
+ yield* put(saveProvider, {
type: "api",
key: result.key,
})
}
- prompts.log.success("Login successful")
+ yield* Prompt.log.success("Login successful")
}
}
- prompts.outro("Done")
+ yield* Prompt.outro("Done")
return true
}
if (method.type === "api") {
- const key = await prompts.password({
+ const key = yield* Prompt.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
- if (prompts.isCancel(key)) throw new UI.CancelledError()
+ const apiKey = yield* promptValue(key)
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
- if (!method.authorize) {
- await put(provider, {
+ const authorizeApi = method.authorize
+ if (!authorizeApi) {
+ yield* put(provider, {
type: "api",
- key,
+ key: apiKey,
...metadata,
})
- prompts.outro("Done")
+ yield* Prompt.outro("Done")
return true
}
- const result = await method.authorize(inputs)
+ const result = yield* cliTry("Failed to authorize: ", () => authorizeApi(inputs))
if (result.type === "failed") {
- prompts.log.error("Failed to authorize")
+ yield* Prompt.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
- await put(saveProvider, {
+ yield* put(saveProvider, {
type: "api",
- key: result.key ?? key,
+ key: result.key ?? apiKey,
...metadata,
})
- prompts.log.success("Login successful")
+ yield* Prompt.log.success("Login successful")
}
- prompts.outro("Done")
+ yield* Prompt.outro("Done")
return true
}
return false
-}
+})
export function resolvePluginProviders(input: {
hooks: Hooks[]
@@ -244,16 +255,16 @@ export const ProvidersListCommand = effectCmd({
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
- prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
+ yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
const database = yield* modelsDev.get()
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
- prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
+ yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
- prompts.outro(`${results.length} credentials`)
+ yield* Prompt.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
@@ -270,13 +281,13 @@ export const ProvidersListCommand = effectCmd({
if (activeEnvVars.length > 0) {
UI.empty()
- prompts.intro("Environment")
+ yield* Prompt.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
- prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
+ yield* Prompt.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
- prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
+ yield* Prompt.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
}),
})
@@ -301,36 +312,42 @@ export const ProvidersLoginCommand = effectCmd({
type: "string",
}),
handler: Effect.fn("Cli.providers.login")(function* (args) {
- const cfgSvc = yield* Config.Service
- const pluginSvc = yield* Plugin.Service
- const modelsDev = yield* ModelsDev.Service
const authSvc = yield* Auth.Service
UI.empty()
- prompts.intro("Add credential")
+ yield* Prompt.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
- const wellknown = (yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`).then((x) => x.json()))) as {
+ const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
+ fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
+ )) as {
auth: { command: string[]; env: string }
}
- prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
- const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" })
+ yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
+ const abort = new AbortController()
+ const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
if (!proc.stdout) {
- prompts.log.error("Failed")
- prompts.outro("Done")
+ yield* Prompt.log.error("Failed")
+ yield* Prompt.outro("Done")
return
}
- const [exit, token] = yield* Effect.promise(() => Promise.all([proc.exited, text(proc.stdout!)]))
+ const [exit, token] = yield* cliTry("Failed to run auth provider command: ", () =>
+ Promise.all([proc.exited, text(proc.stdout!)]),
+ ).pipe(Effect.ensuring(Effect.sync(() => abort.abort())))
if (exit !== 0) {
- prompts.log.error("Failed")
- prompts.outro("Done")
+ yield* Prompt.log.error("Failed")
+ yield* Prompt.outro("Done")
return
}
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
- prompts.log.success("Logged into " + url)
- prompts.outro("Done")
+ yield* Prompt.log.success("Logged into " + url)
+ yield* Prompt.outro("Done")
return
}
+
+ const cfgSvc = yield* Config.Service
+ const pluginSvc = yield* Plugin.Service
+ const modelsDev = yield* ModelsDev.Service
yield* Effect.ignore(modelsDev.refresh(true))
const config = yield* cfgSvc.get()
@@ -392,53 +409,46 @@ export const ProvidersLoginCommand = effectCmd({
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
- prompts.log.error(`Unknown provider "${input}"`)
- process.exit(1)
+ return yield* fail(`Unknown provider "${input}"`)
}
provider = match.value
} else {
- const selected = yield* Effect.promise(() =>
- prompts.autocomplete({
+ provider = yield* promptValue(
+ yield* Prompt.autocomplete({
message: "Select provider",
maxItems: 8,
options: [...options, { value: "other", label: "Other" }],
}),
)
- if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
- provider = selected as string
}
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
- const handled = yield* Effect.promise(() => handlePluginAuth({ auth: plugin.auth! }, provider, args.method))
+ const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method)
if (handled) return
}
if (provider === "other") {
- const custom = yield* Effect.promise(() =>
- prompts.text({
+ provider = (yield* promptValue(
+ yield* Prompt.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
}),
- )
- if (prompts.isCancel(custom)) yield* Effect.die(new UI.CancelledError())
- provider = (custom as string).replace(/^@ai-sdk\//, "")
+ )).replace(/^@ai-sdk\//, "")
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
- const handled = yield* Effect.promise(() =>
- handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method),
- )
+ const handled = yield* handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method)
if (handled) return
}
- prompts.log.warn(
+ yield* Prompt.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
- prompts.log.info(
+ yield* Prompt.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
@@ -448,29 +458,27 @@ export const ProvidersLoginCommand = effectCmd({
}
if (provider === "opencode") {
- prompts.log.info("Create an api key at https://opencode.ai/auth")
+ yield* Prompt.log.info("Create an api key at https://opencode.ai/auth")
}
if (provider === "vercel") {
- prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
+ yield* Prompt.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
- prompts.log.info(
+ yield* Prompt.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
- const key = yield* Effect.promise(() =>
- prompts.password({
- message: "Enter your API key",
- validate: (x) => (x && x.length > 0 ? undefined : "Required"),
- }),
- )
- if (prompts.isCancel(key)) yield* Effect.die(new UI.CancelledError())
- yield* Effect.orDie(authSvc.set(provider, { type: "api", key: key as string }))
+ const key = yield* Prompt.password({
+ message: "Enter your API key",
+ validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+ })
+ const apiKey = yield* promptValue(key)
+ yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey }))
- prompts.outro("Done")
+ yield* Prompt.outro("Done")
}),
})
@@ -485,24 +493,20 @@ export const ProvidersLogoutCommand = effectCmd({
UI.empty()
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
- prompts.intro("Remove credential")
+ yield* Prompt.intro("Remove credential")
if (credentials.length === 0) {
- prompts.log.error("No credentials found")
+ yield* Prompt.log.error("No credentials found")
return
}
const database = yield* modelsDev.get()
- const selected = yield* Effect.promise(() =>
- prompts.select({
- message: "Select provider",
- options: credentials.map(([key, value]) => ({
- label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
- value: key,
- })),
- }),
- )
- if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
- const providerID = selected as string
- yield* Effect.orDie(authSvc.remove(providerID))
- prompts.outro("Logout successful")
+ const selected = yield* Prompt.select({
+ message: "Select provider",
+ options: credentials.map(([key, value]) => ({
+ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
+ value: key,
+ })),
+ })
+ yield* Effect.orDie(authSvc.remove(yield* promptValue(selected)))
+ yield* Prompt.outro("Logout successful")
}),
})
diff --git a/packages/opencode/src/cli/effect/prompt.ts b/packages/opencode/src/cli/effect/prompt.ts
index 7f9cd8cfe..2713f1a5b 100644
--- a/packages/opencode/src/cli/effect/prompt.ts
+++ b/packages/opencode/src/cli/effect/prompt.ts
@@ -6,15 +6,27 @@ export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
export const log = {
info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
+ error: (msg: string) => Effect.sync(() => prompts.log.error(msg)),
+ warn: (msg: string) => Effect.sync(() => prompts.log.warn(msg)),
+ success: (msg: string) => Effect.sync(() => prompts.log.success(msg)),
+}
+
+const optional = <Value>(result: Value | symbol) => {
+ if (prompts.isCancel(result)) return Option.none<Value>()
+ return Option.some(result)
}
export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
- Effect.tryPromise(() => prompts.select(opts)).pipe(
- Effect.map((result) => {
- if (prompts.isCancel(result)) return Option.none<Value>()
- return Option.some(result)
- }),
- )
+ Effect.promise(() => prompts.select(opts)).pipe(Effect.map((result) => optional(result)))
+
+export const autocomplete = <Value>(opts: Parameters<typeof prompts.autocomplete<Value>>[0]) =>
+ Effect.promise(() => prompts.autocomplete(opts)).pipe(Effect.map((result) => optional(result)))
+
+export const text = (opts: Parameters<typeof prompts.text>[0]) =>
+ Effect.promise(() => prompts.text(opts)).pipe(Effect.map((result) => optional(result)))
+
+export const password = (opts: Parameters<typeof prompts.password>[0]) =>
+ Effect.promise(() => prompts.password(opts)).pipe(Effect.map((result) => optional(result)))
export const spinner = () => {
const s = prompts.spinner()