onboarding progress

This commit is contained in:
Dax Raad 2025-06-10 15:43:14 -04:00
parent a0062d4661
commit 5ab2ff9589
7 changed files with 381 additions and 130 deletions

View file

@ -1,15 +1,11 @@
import { App } from "../../app/app"
import { AuthAnthropic } from "../../auth/anthropic"
import { AuthKeys } from "../../auth/keys"
import { UI } from "../ui"
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import open from "open"
const OPENCODE = [
`█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀`,
`█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀`,
`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`,
]
import { VERSION } from "../version"
import { Provider } from "../../provider/provider"
export const ProviderCommand = cmd({
command: "provider",
@ -27,103 +23,96 @@ export const ProviderListCommand = cmd({
aliases: ["ls"],
describe: "list providers",
async handler() {
prompts.intro("Configured Providers")
const keys = await AuthKeys.get()
for (const key of Object.keys(keys)) {
prompts.log.success(key)
}
prompts.outro("3 providers configured")
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`)
})
},
})
const ProviderAddCommand = cmd({
export const ProviderAddCommand = cmd({
command: "add",
describe: "add credentials for various providers",
async handler() {
UI.empty()
for (const row of OPENCODE) {
UI.print(" ")
for (let i = 0; i < row.length; i++) {
const color =
i < 18 ? Bun.color("white", "ansi") : Bun.color("gray", "ansi")
const char = row[i]
UI.print(color + char)
}
UI.println()
}
UI.empty()
prompts.intro("Setup")
const keys = await AuthKeys.get()
const provider = await prompts.select({
message: "Configure a provider",
options: [
{
label: "Anthropic",
value: "anthropic",
hint: keys["anthropic"] ? "configured" : "",
},
{
label: "OpenAI",
value: "openai",
hint: keys["openai"] ? "configured" : "",
},
{
label: "Google",
value: "google",
hint: keys["google"] ? "configured" : "",
},
],
})
if (prompts.isCancel(provider)) return
if (provider === "anthropic") {
const method = await prompts.select({
message: "Login method",
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: "Claude Pro/Max",
value: "oauth",
label: "Anthropic",
value: "anthropic",
hint: providers["anthropic"] ? "configured" : "",
},
{
label: "API Key",
value: "api",
label: "OpenAI",
value: "openai",
hint: providers["openai"] ? "configured" : "",
},
{
label: "Google",
value: "google",
hint: providers["google"] ? "configured" : "",
},
],
})
if (prompts.isCancel(method)) return
if (prompts.isCancel(provider)) return
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 (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(code)) return
await AuthAnthropic.exchange(code, verifier)
.then(() => {
prompts.log.success("Login successful")
if (prompts.isCancel(method)) return
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"),
})
.catch(() => {
prompts.log.error("Invalid code")
})
prompts.outro("Done")
return
if (prompts.isCancel(code)) return
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",
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) return
await AuthKeys.set(provider, key)
prompts.outro("Done")
})
if (prompts.isCancel(key)) return
await AuthKeys.set(provider, key)
prompts.outro("Done")
},
})

View file

@ -41,11 +41,11 @@ export const RunCommand = {
? await Session.get(args.session)
: await Session.create()
UI.print(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
UI.empty()
UI.print(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
UI.print(
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://dev.opencode.ai/s?id=" +
session.id.slice(-8),
@ -53,7 +53,7 @@ export const RunCommand = {
UI.empty()
function printEvent(color: string, type: string, title: string) {
UI.print(
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL +
UI.Style.TEXT_DIM +
@ -95,7 +95,7 @@ export const RunCommand = {
if (part.type === "text") {
if (part.text.includes("\n")) {
UI.empty()
UI.print(part.text)
UI.println(part.text)
UI.empty()
return
}

View file

@ -0,0 +1,193 @@
import { createCli, type TrpcCliMeta } from "trpc-cli"
import { initTRPC } from "@trpc/server"
import { z } from "zod"
import { Server } from "../server/server"
import { AuthAnthropic } from "../auth/anthropic"
import { UI } from "./ui"
import { App } from "../app/app"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { Session } from "../session"
import { Share } from "../share/share"
import { Message } from "../session/message"
import { VERSION } from "./version"
import { LSP } from "../lsp"
import fs from "fs/promises"
import path from "path"
const t = initTRPC.meta<TrpcCliMeta>().create()
export const router = t.router({
generate: t.procedure
.meta({
description: "Generate OpenAPI and event specs",
})
.input(z.object({}))
.mutation(async () => {
const specs = await Server.openapi()
const dir = "gen"
await fs.rmdir(dir, { recursive: true }).catch(() => {})
await fs.mkdir(dir, { recursive: true })
await Bun.write(
path.join(dir, "openapi.json"),
JSON.stringify(specs, null, 2),
)
return "Generated OpenAPI specs in gen/ directory"
}),
run: t.procedure
.meta({
description: "Run OpenCode with a message",
})
.input(
z.object({
message: z.array(z.string()).default([]).describe("Message to send"),
session: z.string().optional().describe("Session ID to continue"),
}),
)
.mutation(
async ({ input }: { input: { message: string[]; session?: string } }) => {
const message = input.message.join(" ")
await App.provide(
{
cwd: process.cwd(),
version: "0.0.0",
},
async () => {
await Share.init()
const session = input.session
? await Session.get(input.session)
: await Session.create()
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://dev.opencode.ai/s?id=" +
session.id.slice(-8),
)
UI.empty()
function printEvent(color: string, type: string, title: string) {
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL +
UI.Style.TEXT_DIM +
` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
}
Bus.subscribe(Message.Event.PartUpdated, async (message) => {
const part = message.properties.part
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
if (part.toolInvocation.toolName === "opencode_todowrite")
return
const args = part.toolInvocation.args as any
const tool = part.toolInvocation.toolName
if (tool === "opencode_edit")
printEvent(UI.Style.TEXT_SUCCESS_BOLD, "Edit", args.filePath)
if (tool === "opencode_bash")
printEvent(
UI.Style.TEXT_WARNING_BOLD,
"Execute",
args.command,
)
if (tool === "opencode_read")
printEvent(UI.Style.TEXT_INFO_BOLD, "Read", args.filePath)
if (tool === "opencode_write")
printEvent(
UI.Style.TEXT_SUCCESS_BOLD,
"Create",
args.filePath,
)
if (tool === "opencode_list")
printEvent(UI.Style.TEXT_INFO_BOLD, "List", args.path)
if (tool === "opencode_glob")
printEvent(
UI.Style.TEXT_INFO_BOLD,
"Glob",
args.pattern + (args.path ? " in " + args.path : ""),
)
}
if (part.type === "text") {
if (part.text.includes("\n")) {
UI.empty()
UI.println(part.text)
UI.empty()
return
}
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
}
})
const { providerID, modelID } = await Provider.defaultModel()
await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message,
},
],
})
UI.empty()
},
)
return "Session completed"
},
),
scrap: t.procedure
.meta({
description: "Test command for scraping files",
})
.input(
z.object({
file: z.string().describe("File to process"),
}),
)
.mutation(async ({ input }: { input: { file: string } }) => {
await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
await LSP.touchFile(input.file, true)
await LSP.diagnostics()
})
return `Processed file: ${input.file}`
}),
login: t.router({
anthropic: t.procedure
.meta({
description: "Login to Anthropic",
})
.input(z.object({}))
.mutation(async () => {
const { url, verifier } = await AuthAnthropic.authorize()
UI.println("Login to Anthropic")
UI.println("Open the following URL in your browser:")
UI.println(url)
UI.println("")
const code = await UI.input("Paste the authorization code here: ")
await AuthAnthropic.exchange(code, verifier)
return "Successfully logged in to Anthropic"
}),
}),
})
export function createOpenCodeCli() {
return createCli({ router })
}

View file

@ -1,4 +1,10 @@
export namespace UI {
const LOGO = [
`█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀`,
`█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀`,
`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`,
]
export const Style = {
TEXT_HIGHLIGHT: "\x1b[96m",
TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
@ -33,6 +39,20 @@ export namespace UI {
blank = true
}
export function logo() {
for (const row of LOGO) {
print(" ")
for (let i = 0; i < row.length; i++) {
const color =
i > 18 ? Bun.color("white", "ansi") : Bun.color("gray", "ansi")
const char = row[i]
print(color + char)
}
println()
}
empty()
}
export async function input(prompt: string): Promise<string> {
const readline = require("readline")
const rl = readline.createInterface({

View file

@ -15,7 +15,9 @@ import { GenerateCommand } from "./cli/cmd/generate"
import { VERSION } from "./cli/version"
import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log"
import { ProviderCommand } from "./cli/cmd/provider"
import { ProviderAddCommand, ProviderCommand } from "./cli/cmd/provider"
import { Provider } from "./provider/provider"
import { UI } from "./cli/ui"
await Log.init({ print: process.argv.includes("--print-logs") })
@ -31,6 +33,15 @@ yargs(hideBin(process.argv))
}),
handler: async (args) => {
await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
UI.empty()
UI.logo()
UI.empty()
await ProviderAddCommand.handler(args)
return
}
await Share.init()
const server = Server.listen()

View file

@ -23,6 +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"
export namespace Provider {
const log = Log.create({ service: "provider" })
@ -60,21 +61,32 @@ export namespace Provider {
})
export type Info = z.output<typeof Info>
type Autodetector = (provider: Info) => Promise<Record<string, any> | false>
type Autodetector = (provider: Info) => Promise<
| {
source: Source
options: Record<string, any>
}
| false
>
function env(...keys: string[]): Autodetector {
return async () => {
function env(...keys: string[]) {
const result: Autodetector = async () => {
for (const key of keys) {
if (process.env[key]) return {}
if (process.env[key])
return {
source: "env",
options: {},
}
}
return false
}
return result
}
const AUTODETECT: Record<
string,
(provider: Info) => Promise<Record<string, any> | false>
> = {
type Source = "oauth" | "env" | "config" | "global"
const AUTODETECT: Record<string, Autodetector> = {
async anthropic(provider) {
const access = await AuthAnthropic.access()
if (access) {
@ -88,10 +100,13 @@ export namespace Provider {
}
}
return {
apiKey: "",
headers: {
authorization: `Bearer ${access}`,
"anthropic-beta": "oauth-2025-04-20",
source: "oauth",
options: {
apiKey: "",
headers: {
authorization: `Bearer ${access}`,
"anthropic-beta": "oauth-2025-04-20",
},
},
}
}
@ -107,6 +122,7 @@ export namespace Provider {
const providers: {
[providerID: string]: {
source: Source
info: Provider.Info
options: Record<string, any>
}
@ -116,30 +132,52 @@ export namespace Provider {
log.info("loading")
function mergeProvider(
id: string,
options: Record<string, any>,
source: Source,
) {
const provider = providers[id]
if (!provider) {
providers[id] = {
source,
info: database[id] ?? {
id,
name: id,
models: [],
},
options,
}
return
}
provider.options = {
...provider.options,
...options,
}
provider.source = source
}
for (const [providerID, fn] of Object.entries(AUTODETECT)) {
const provider = database[providerID]
if (!provider) continue
const options = await fn(provider)
if (!options) continue
providers[providerID] = {
info: provider,
options,
}
const result = await fn(provider)
if (!result) continue
mergeProvider(providerID, result.options, result.source)
}
const keys = await AuthKeys.get()
for (const [providerID, key] of Object.entries(keys)) {
mergeProvider(
providerID,
{
apiKey: key,
},
"global",
)
}
for (const [providerID, options] of Object.entries(config.provider ?? {})) {
const existing = providers[providerID]
if (existing) {
existing.options = {
...existing.options,
...options,
}
continue
}
providers[providerID] = {
info: database[providerID],
options,
}
mergeProvider(providerID, options, "config")
}
for (const providerID of Object.keys(providers)) {
@ -153,10 +191,8 @@ export namespace Provider {
}
})
export async function active() {
return state().then((state) =>
mapValues(state.providers, (item) => item.info),
)
export async function list() {
return state().then((state) => state.providers)
}
async function getSDK(providerID: string) {
@ -242,12 +278,12 @@ export namespace Provider {
}
export async function defaultModel() {
const [provider] = await active().then((val) => Object.values(val))
const [provider] = await list().then((val) => Object.values(val))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.models))
const [model] = sort(Object.values(provider.info.models))
if (!model) throw new Error("no models found")
return {
providerID: provider.id,
providerID: provider.info.id,
modelID: model.id,
}
}

View file

@ -415,7 +415,9 @@ export namespace Server {
},
}),
async (c) => {
const providers = await Provider.active()
const providers = await Provider.list().then((x) =>
mapValues(x, (item) => item.info),
)
return c.json({
providers: Object.values(providers),
defaults: mapValues(