summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-06-11 00:21:46 -0400
committerDax Raad <[email protected]>2025-06-11 00:21:46 -0400
commit83eb61fd5f2b74efbeeeade5da9776a8c856f868 (patch)
tree9a4a2a615e32801d740b4c9e4595c2e45a631c7d
parentb8e7d06356ab63cfe0a2d1e286cdb11083a878e3 (diff)
downloadopencode-83eb61fd5f2b74efbeeeade5da9776a8c856f868.tar.gz
opencode-83eb61fd5f2b74efbeeeade5da9776a8c856f868.zip
Refactor authentication system to consolidate auth flow and remove provider-based commands
🤖 Generated with [OpenCode](https://opencode.ai) Co-Authored-By: OpenCode <[email protected]>
-rw-r--r--packages/opencode/src/app/app.ts1
-rw-r--r--packages/opencode/src/auth/anthropic.ts28
-rw-r--r--packages/opencode/src/auth/index.ts50
-rw-r--r--packages/opencode/src/auth/keys.ts22
-rw-r--r--packages/opencode/src/cli/cmd/auth.ts144
-rw-r--r--packages/opencode/src/cli/cmd/provider.ts120
-rw-r--r--packages/opencode/src/cli/cmd/run.ts4
-rw-r--r--packages/opencode/src/cli/ui.ts5
-rw-r--r--packages/opencode/src/index.ts153
-rw-r--r--packages/opencode/src/provider/provider.ts10
-rw-r--r--packages/opencode/src/util/error.ts5
-rw-r--r--packages/opencode/src/util/log.ts1
12 files changed, 308 insertions, 235 deletions
diff --git a/packages/opencode/src/app/app.ts b/packages/opencode/src/app/app.ts
index 7e8afc825..e240fa6bf 100644
--- a/packages/opencode/src/app/app.ts
+++ b/packages/opencode/src/app/app.ts
@@ -35,7 +35,6 @@ export namespace App {
async function create(input: { cwd: string; version: string }) {
log.info("creating", {
cwd: input.cwd,
- version: input.version,
})
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) =>
x ? path.dirname(x) : undefined,
diff --git a/packages/opencode/src/auth/anthropic.ts b/packages/opencode/src/auth/anthropic.ts
index 5884dfc25..ef029b1d0 100644
--- a/packages/opencode/src/auth/anthropic.ts
+++ b/packages/opencode/src/auth/anthropic.ts
@@ -1,13 +1,10 @@
import { generatePKCE } from "@openauthjs/openauth/pkce"
-import { Global } from "../global"
-import path from "path"
import fs from "fs/promises"
+import { Auth } from "./index"
export namespace AuthAnthropic {
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
- const filepath = path.join(Global.Path.data, "auth", "anthropic.json")
-
export async function authorize() {
const pkce = await generatePKCE()
const url = new URL("https://claude.ai/oauth/authorize", import.meta.url)
@@ -48,16 +45,17 @@ export namespace AuthAnthropic {
}),
})
if (!result.ok) throw new ExchangeFailed()
- const file = Bun.file(filepath)
- await Bun.write(file, result)
- await fs.chmod(file.name!, 0o600)
+ const json = await result.json()
+ await Auth.set("anthropic", {
+ type: "oauth",
+ refresh: json.refresh_token as string,
+ expires: Date.now() + json.expires_in * 1000,
+ })
}
export async function access() {
- const file = Bun.file(filepath)
- const result = await file.json().catch(() => ({}))
- if (!result) return
- const refresh = result.refresh_token
+ const info = await Auth.get("anthropic")
+ if (!info || info.type !== "oauth") return
const response = await fetch(
"https://console.anthropic.com/v1/oauth/token",
{
@@ -67,14 +65,18 @@ export namespace AuthAnthropic {
},
body: JSON.stringify({
grant_type: "refresh_token",
- refresh_token: refresh,
+ refresh_token: info.refresh,
client_id: CLIENT_ID,
}),
},
)
if (!response.ok) return
const json = await response.json()
- await Bun.write(file, JSON.stringify(json))
+ await Auth.set("anthropic", {
+ type: "oauth",
+ refresh: json.refresh_token as string,
+ expires: Date.now() + json.expires_in * 1000,
+ })
return json.access_token as string
}
diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts
new file mode 100644
index 000000000..b2b222fce
--- /dev/null
+++ b/packages/opencode/src/auth/index.ts
@@ -0,0 +1,50 @@
+import path from "path"
+import { Global } from "../global"
+import fs from "fs/promises"
+import { z } from "zod"
+
+export namespace Auth {
+ export const Oauth = z.object({
+ type: z.literal("oauth"),
+ refresh: z.string(),
+ expires: z.number(),
+ })
+
+ export const Api = z.object({
+ type: z.literal("api"),
+ key: z.string(),
+ })
+
+ export const Info = z.discriminatedUnion("type", [Oauth, Api])
+ export type Info = z.infer<typeof Info>
+
+ const filepath = path.join(Global.Path.data, "auth.json")
+
+ export async function get(providerID: string) {
+ const file = Bun.file(filepath)
+ return file
+ .json()
+ .catch(() => ({}))
+ .then((x) => x[providerID] as Info | undefined)
+ }
+
+ export async function all(): Promise<Record<string, Info>> {
+ const file = Bun.file(filepath)
+ return file.json().catch(() => ({}))
+ }
+
+ export async function set(key: string, info: Info) {
+ const file = Bun.file(filepath)
+ const data = await all()
+ await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2))
+ await fs.chmod(file.name!, 0o600)
+ }
+
+ export async function remove(key: string) {
+ const file = Bun.file(filepath)
+ const data = await all()
+ delete data[key]
+ await Bun.write(file, JSON.stringify(data, null, 2))
+ await fs.chmod(file.name!, 0o600)
+ }
+}
diff --git a/packages/opencode/src/auth/keys.ts b/packages/opencode/src/auth/keys.ts
deleted file mode 100644
index 67e2d52e4..000000000
--- a/packages/opencode/src/auth/keys.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import path from "path"
-import { Global } from "../global"
-import fs from "fs/promises"
-
-export namespace AuthKeys {
- const filepath = path.join(Global.Path.data, "auth", "keys.json")
-
- export async function get() {
- const file = Bun.file(filepath)
- return file
- .json()
- .catch(() => ({}))
- .then((x) => x as Record<string, string>)
- }
-
- export async function set(key: string, value: string) {
- const file = Bun.file(filepath)
- const env = await get()
- await Bun.write(file, JSON.stringify({ ...env, [key]: value }))
- await fs.chmod(file.name!, 0o600)
- }
-}
diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts
new file mode 100644
index 000000000..91104a48d
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/auth.ts
@@ -0,0 +1,144 @@
+import { AuthAnthropic } from "../../auth/anthropic"
+import { Auth } from "../../auth"
+import { cmd } from "./cmd"
+import * as prompts from "@clack/prompts"
+import open from "open"
+import { UI } from "../ui"
+import { ModelsDev } from "../../provider/models"
+
+export const AuthCommand = cmd({
+ command: "auth",
+ builder: (yargs) =>
+ yargs
+ .command(AuthLoginCommand)
+ .command(AuthLogoutCommand)
+ .command(AuthListCommand)
+ .demandCommand(),
+ async handler(args) {},
+})
+
+export const AuthListCommand = cmd({
+ command: "list",
+ aliases: ["ls"],
+ describe: "list providers",
+ async handler() {
+ UI.empty()
+ prompts.intro("Credentials")
+ const results = await Auth.all().then((x) => Object.entries(x))
+ const database = await ModelsDev.get()
+
+ for (const [providerID, result] of results) {
+ const name = database[providerID]?.name || providerID
+ prompts.log.info(`${name} ${Bun.color("gray", "ansi")}(${result.type})`)
+ }
+
+ prompts.outro(`${results.length} credentials`)
+ },
+})
+
+export const AuthLoginCommand = cmd({
+ command: "login",
+ describe: "login to a provider",
+ async handler() {
+ UI.empty()
+ prompts.intro("Add credential")
+ const provider = await prompts.select({
+ message: "Select provider",
+ maxItems: 2,
+ options: [
+ {
+ label: "Anthropic",
+ value: "anthropic",
+ },
+ {
+ label: "OpenAI",
+ value: "openai",
+ },
+ {
+ label: "Google",
+ value: "google",
+ },
+ ],
+ })
+ if (prompts.isCancel(provider)) throw new UI.CancelledError()
+
+ if (provider === "anthropic") {
+ const method = await prompts.select({
+ message: "Login method",
+ options: [
+ {
+ label: "Claude Pro/Max",
+ value: "oauth",
+ },
+ {
+ label: "API Key",
+ value: "api",
+ },
+ ],
+ })
+ if (prompts.isCancel(method)) throw new UI.CancelledError()
+
+ if (method === "oauth") {
+ // some weird bug where program exits without this
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ const { url, verifier } = await AuthAnthropic.authorize()
+ prompts.note("Opening browser...")
+ await open(url)
+ prompts.log.info(url)
+
+ const code = await prompts.text({
+ message: "Paste the authorization code here: ",
+ validate: (x) => (x.length > 0 ? undefined : "Required"),
+ })
+ if (prompts.isCancel(code)) throw new UI.CancelledError()
+
+ await AuthAnthropic.exchange(code, verifier)
+ .then(() => {
+ prompts.log.success("Login successful")
+ })
+ .catch(() => {
+ prompts.log.error("Invalid code")
+ })
+ prompts.outro("Done")
+ return
+ }
+ }
+
+ const key = await prompts.password({
+ message: "Enter your API key",
+ validate: (x) => (x.length > 0 ? undefined : "Required"),
+ })
+ if (prompts.isCancel(key)) throw new UI.CancelledError()
+ await Auth.set(provider, {
+ type: "api",
+ key,
+ })
+
+ prompts.outro("Done")
+ },
+})
+
+export const AuthLogoutCommand = cmd({
+ command: "logout",
+ describe: "logout from a configured provider",
+ async handler() {
+ UI.empty()
+ const credentials = await Auth.all().then((x) => Object.entries(x))
+ prompts.intro("Remove credential")
+ if (credentials.length === 0) {
+ prompts.log.error("No credentials found")
+ return
+ }
+ const database = await ModelsDev.get()
+ const providerID = await prompts.select({
+ message: "Select credential",
+ options: credentials.map(([key, value]) => ({
+ label: database[key]?.name || key,
+ value: key,
+ })),
+ })
+ if (prompts.isCancel(providerID)) throw new UI.CancelledError()
+ await Auth.remove(providerID)
+ prompts.outro("Logout successful")
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/provider.ts b/packages/opencode/src/cli/cmd/provider.ts
deleted file mode 100644
index 23011c9ae..000000000
--- a/packages/opencode/src/cli/cmd/provider.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { App } from "../../app/app"
-import { AuthAnthropic } from "../../auth/anthropic"
-import { AuthKeys } from "../../auth/keys"
-import { cmd } from "./cmd"
-import * as prompts from "@clack/prompts"
-import open from "open"
-import { VERSION } from "../version"
-import { Provider } from "../../provider/provider"
-import { UI } from "../ui"
-
-export const ProviderCommand = cmd({
- command: "provider",
- builder: (yargs) =>
- yargs
- .command(ProviderAddCommand)
- .command(ProviderListCommand)
- .demandCommand(),
- describe: "initialize opencode",
- async handler() {},
-})
-
-export const ProviderListCommand = cmd({
- command: "list",
- aliases: ["ls"],
- describe: "list providers",
- async handler() {
- await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
- prompts.intro("Providers")
- const providers = await Provider.list().then((x) => Object.values(x))
- for (const value of providers) {
- prompts.log.success(value.info.name + " (" + value.source + ")")
- }
- prompts.outro(`${providers.length} configured`)
- })
- },
-})
-
-export const ProviderAddCommand = cmd({
- command: "add",
- describe: "add credentials for various providers",
- async handler() {
- await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
- const providers = await Provider.list()
- prompts.intro("Add provider")
- const provider = await prompts.select({
- message: "Select",
- maxItems: 2,
- options: [
- {
- label: "Anthropic",
- value: "anthropic",
- hint: providers["anthropic"] ? "configured" : "",
- },
- {
- label: "OpenAI",
- value: "openai",
- hint: providers["openai"] ? "configured" : "",
- },
- {
- label: "Google",
- value: "google",
- hint: providers["google"] ? "configured" : "",
- },
- ],
- })
- if (prompts.isCancel(provider)) throw new UI.CancelledError({})
-
- if (provider === "anthropic") {
- const method = await prompts.select({
- message: "Login method",
- options: [
- {
- label: "Claude Pro/Max",
- value: "oauth",
- },
- {
- label: "API Key",
- value: "api",
- },
- ],
- })
- if (prompts.isCancel(method)) throw new UI.CancelledError({})
-
- if (method === "oauth") {
- // some weird bug where program exits without this
- await new Promise((resolve) => setTimeout(resolve, 10))
- const { url, verifier } = await AuthAnthropic.authorize()
- prompts.note("Opening browser...")
- await open(url)
- prompts.log.info(url)
-
- const code = await prompts.text({
- message: "Paste the authorization code here: ",
- validate: (x) => (x.length > 0 ? undefined : "Required"),
- })
- if (prompts.isCancel(code)) throw new UI.CancelledError({})
-
- await AuthAnthropic.exchange(code, verifier)
- .then(() => {
- prompts.log.success("Login successful")
- })
- .catch(() => {
- prompts.log.error("Invalid code")
- })
- prompts.outro("Done")
- return
- }
- }
-
- const key = await prompts.password({
- message: "Enter your API key",
- validate: (x) => (x.length > 0 ? undefined : "Required"),
- })
- if (prompts.isCancel(key)) throw new UI.CancelledError({})
- await AuthKeys.set(provider, key)
-
- prompts.outro("Done")
- })
- },
-})
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 124d500fd..522f35b89 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -33,7 +33,7 @@ export const RunCommand = {
await App.provide(
{
cwd: process.cwd(),
- version: "0.0.0",
+ version: VERSION,
},
async () => {
await Share.init()
@@ -47,7 +47,7 @@ export const RunCommand = {
UI.empty()
UI.println(
UI.Style.TEXT_INFO_BOLD +
- "~ https://dev.opencode.ai/s?id=" +
+ "~ https://dev.opencode.ai/s/" +
session.id.slice(-8),
)
UI.empty()
diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts
index fad8214ba..111457d6a 100644
--- a/packages/opencode/src/cli/ui.ts
+++ b/packages/opencode/src/cli/ui.ts
@@ -8,10 +8,7 @@ export namespace UI {
`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`,
]
- export const CancelledError = NamedError.create(
- "UICancelledError",
- z.object({}),
- )
+ export const CancelledError = NamedError.create("UICancelledError", z.void())
export const Style = {
TEXT_HIGHLIGHT: "\x1b[96m",
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 07ee6633c..676e3afd1 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -15,82 +15,101 @@ import { GenerateCommand } from "./cli/cmd/generate"
import { VERSION } from "./cli/version"
import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log"
-import { ProviderAddCommand, ProviderCommand } from "./cli/cmd/provider"
+import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
import { Provider } from "./provider/provider"
import { UI } from "./cli/ui"
-await Log.init({ print: process.argv.includes("--print-logs") })
-
-try {
- await yargs(hideBin(process.argv))
- .scriptName("opencode")
- .version(VERSION)
- .command({
- command: "$0",
- describe: "Start OpenCode TUI",
- handler: async (args) => {
- while (true) {
- const result = await App.provide(
- { cwd: process.cwd(), version: VERSION },
- async () => {
- const providers = await Provider.list()
- if (Object.keys(providers).length === 0) {
- return "needs_provider"
- }
+const cli = yargs(hideBin(process.argv))
+ .scriptName("opencode")
+ .version(VERSION)
+ .option("print-logs", {
+ describe: "Print logs to stderr",
+ type: "boolean",
+ })
+ .middleware(async (args) => {
+ await Log.init({ print: process.argv.includes("--print-logs") })
+ Log.Default.info("opencode", {
+ version: VERSION,
+ args: process.argv.slice(2),
+ })
+ })
+ .command({
+ command: "$0",
+ describe: "Start OpenCode TUI",
+ handler: async (args) => {
+ while (true) {
+ const result = await App.provide(
+ { cwd: process.cwd(), version: VERSION },
+ async () => {
+ const providers = await Provider.list()
+ if (Object.keys(providers).length === 0) {
+ return "needs_provider"
+ }
- await Share.init()
- const server = Server.listen()
+ await Share.init()
+ const server = Server.listen()
- let cmd = ["go", "run", "./main.go"]
- let cwd = new URL("../../tui/cmd/opencode", import.meta.url)
- .pathname
- if (Bun.embeddedFiles.length > 0) {
- const blob = Bun.embeddedFiles[0] as File
- const binary = path.join(Global.Path.cache, "tui", blob.name)
- const file = Bun.file(binary)
- if (!(await file.exists())) {
- await Bun.write(file, blob, { mode: 0o755 })
- await fs.chmod(binary, 0o755)
- }
- cwd = process.cwd()
- cmd = [binary]
+ let cmd = ["go", "run", "./main.go"]
+ let cwd = new URL("../../tui/cmd/opencode", import.meta.url)
+ .pathname
+ if (Bun.embeddedFiles.length > 0) {
+ const blob = Bun.embeddedFiles[0] as File
+ const binary = path.join(Global.Path.cache, "tui", blob.name)
+ const file = Bun.file(binary)
+ if (!(await file.exists())) {
+ await Bun.write(file, blob, { mode: 0o755 })
+ await fs.chmod(binary, 0o755)
}
- const proc = Bun.spawn({
- cmd,
- cwd,
- stdout: "inherit",
- stderr: "inherit",
- stdin: "inherit",
- env: {
- ...process.env,
- OPENCODE_SERVER: server.url.toString(),
- },
- onExit: () => {
- server.stop()
- },
- })
- await proc.exited
- await server.stop()
+ cwd = process.cwd()
+ cmd = [binary]
+ }
+ const proc = Bun.spawn({
+ cmd,
+ cwd,
+ stdout: "inherit",
+ stderr: "inherit",
+ stdin: "inherit",
+ env: {
+ ...process.env,
+ OPENCODE_SERVER: server.url.toString(),
+ },
+ onExit: () => {
+ server.stop()
+ },
+ })
+ await proc.exited
+ await server.stop()
- return "done"
- },
- )
- if (result === "done") break
- if (result === "needs_provider") {
- UI.logo()
- await ProviderAddCommand.handler(args)
- }
+ return "done"
+ },
+ )
+ if (result === "done") break
+ if (result === "needs_provider") {
+ UI.logo()
+ await AuthLoginCommand.handler(args)
}
- },
+ }
+ },
+ })
+ .command(RunCommand)
+ .command(GenerateCommand)
+ .command(ScrapCommand)
+ .command(AuthCommand)
+ .fail((msg, err) => {
+ if (
+ msg.startsWith("Unknown argument") ||
+ msg.startsWith("Not enough non-option arguments")
+ ) {
+ cli.showHelp("log")
+ }
+ Log.Default.error(msg, {
+ err,
})
- .command(RunCommand)
- .command(GenerateCommand)
- .command(ScrapCommand)
- .command(ProviderCommand)
- .fail((msg, err) => {
- Log.Default.error(msg)
- })
- .parse()
+ })
+ .strict()
+
+try {
+ await cli.parse()
} catch (e) {
Log.Default.error(e)
}
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index e894d7acf..2c4e4daff 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -23,7 +23,7 @@ import { TodoReadTool, TodoWriteTool } from "../tool/todo"
import { AuthAnthropic } from "../auth/anthropic"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
-import { AuthKeys } from "../auth/keys"
+import { Auth } from "../auth"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -84,7 +84,7 @@ export namespace Provider {
return result
}
- type Source = "oauth" | "env" | "config" | "global"
+ type Source = "oauth" | "env" | "config" | "api"
const AUTODETECT: Record<string, Autodetector> = {
async anthropic(provider) {
@@ -162,8 +162,10 @@ export namespace Provider {
mergeProvider(providerID, result.options, result.source)
}
- for (const [providerID, key] of Object.entries(await AuthKeys.get())) {
- mergeProvider(providerID, { apiKey: key }, "global")
+ for (const [providerID, info] of Object.entries(await Auth.all())) {
+ if (info.type === "api") {
+ mergeProvider(providerID, { apiKey: info.key }, "api")
+ }
}
for (const [providerID, options] of Object.entries(config.provider ?? {})) {
diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts
index 7960fffb7..99e465706 100644
--- a/packages/opencode/src/util/error.ts
+++ b/packages/opencode/src/util/error.ts
@@ -21,6 +21,8 @@ export abstract class NamedError extends Error {
ref: name,
})
+ public readonly name = name as Name
+
constructor(
public readonly data: z.input<Data>,
options?: ErrorOptions,
@@ -35,7 +37,7 @@ export abstract class NamedError extends Error {
}
schema() {
- return data
+ return result.Schema
}
toObject() {
@@ -45,6 +47,7 @@ export abstract class NamedError extends Error {
}
}
}
+ Object.defineProperty(result, "name", { value: name })
return result
}
diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts
index 07ecd4b63..5c25d7d02 100644
--- a/packages/opencode/src/util/log.ts
+++ b/packages/opencode/src/util/log.ts
@@ -28,7 +28,6 @@ export namespace Log {
writer.flush()
return true
}
- Default.info("initialized", { file: logpath })
}
async function cleanup(dir: string) {