Sfoglia il codice sorgente

onboarding progress

Dax Raad 8 mesi fa
parent
commit
5ab2ff9589

+ 75 - 86
packages/opencode/src/cli/cmd/provider.ts

@@ -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: "OpenAI",
+            value: "openai",
+            hint: providers["openai"] ? "configured" : "",
           },
           {
-            label: "API Key",
-            value: "api",
+            label: "Google",
+            value: "google",
+            hint: providers["google"] ? "configured" : "",
           },
         ],
       })
-      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)
+      if (prompts.isCancel(provider)) return
 
-        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")
-          })
-          .catch(() => {
-            prompts.log.error("Invalid code")
+        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"),
           })
-        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",
-    })
-    if (prompts.isCancel(key)) return
-    await AuthKeys.set(provider, 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")
+      prompts.outro("Done")
+    })
   },
 })

+ 5 - 5
packages/opencode/src/cli/cmd/run.ts

@@ -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
             }

+ 193 - 0
packages/opencode/src/cli/router.ts

@@ -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 })
+}
+

+ 20 - 0
packages/opencode/src/cli/ui.ts

@@ -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({

+ 12 - 1
packages/opencode/src/index.ts

@@ -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()
 

+ 73 - 37
packages/opencode/src/provider/provider.ts

@@ -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,
     }
   }

+ 3 - 1
packages/opencode/src/server/server.ts

@@ -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(