Dax Raad 8 maanden geleden
bovenliggende
commit
ef7f1f0761

+ 18 - 0
bun.lock

@@ -21,6 +21,7 @@
       "name": "opencode",
       "version": "0.0.0",
       "dependencies": {
+        "@clack/prompts": "0.11.0",
         "@flystorage/file-storage": "1.1.0",
         "@flystorage/local-fs": "1.1.0",
         "@hono/zod-validator": "0.5.0",
@@ -32,6 +33,7 @@
         "env-paths": "3.0.0",
         "hono": "4.7.10",
         "hono-openapi": "0.4.8",
+        "open": "10.1.2",
         "remeda": "2.22.3",
         "ts-lsp-client": "1.0.3",
         "turndown": "7.2.0",
@@ -163,6 +165,10 @@
 
     "@capsizecss/unpack": ["@capsizecss/[email protected]", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
 
+    "@clack/core": ["@clack/[email protected]", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="],
+
+    "@clack/prompts": ["@clack/[email protected]", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="],
+
     "@cloudflare/kv-asset-handler": ["@cloudflare/[email protected]", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="],
 
     "@cloudflare/unenv-preset": ["@cloudflare/[email protected]", "", { "peerDependencies": { "unenv": "2.0.0-rc.17", "workerd": "^1.20250508.0" }, "optionalPeers": ["workerd"] }, "sha512-MtUgNl+QkQyhQvv5bbWP+BpBC1N0me4CHHuP2H4ktmOMKdB/6kkz/lo+zqiA4mEazb4y+1cwyNjVrQ2DWeE4mg=="],
@@ -557,6 +563,8 @@
 
     "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="],
 
+    "bundle-name": ["[email protected]", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
+
     "bytes": ["[email protected]", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
 
     "call-bind": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
@@ -649,8 +657,14 @@
 
     "deep-extend": ["[email protected]", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
 
+    "default-browser": ["[email protected]", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg=="],
+
+    "default-browser-id": ["[email protected]", "", {}, "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA=="],
+
     "define-data-property": ["[email protected]", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
 
+    "define-lazy-prop": ["[email protected]", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
+
     "defu": ["[email protected]", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
 
     "depd": ["[email protected]", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
@@ -1167,6 +1181,8 @@
 
     "oniguruma-to-es": ["[email protected]", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="],
 
+    "open": ["[email protected]", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
+
     "openapi-types": ["[email protected]", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
 
     "opencode": ["opencode@workspace:packages/opencode"],
@@ -1325,6 +1341,8 @@
 
     "router": ["[email protected]", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
 
+    "run-applescript": ["[email protected]", "", {}, "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A=="],
+
     "safe-buffer": ["[email protected]", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
 
     "safe-regex-test": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],

+ 2 - 0
packages/opencode/package.json

@@ -21,6 +21,7 @@
     "typescript": "catalog:"
   },
   "dependencies": {
+    "@clack/prompts": "0.11.0",
     "@flystorage/file-storage": "1.1.0",
     "@flystorage/local-fs": "1.1.0",
     "@hono/zod-validator": "0.5.0",
@@ -32,6 +33,7 @@
     "env-paths": "3.0.0",
     "hono": "4.7.10",
     "hono-openapi": "0.4.8",
+    "open": "10.1.2",
     "remeda": "2.22.3",
     "ts-lsp-client": "1.0.3",
     "turndown": "7.2.0",

+ 2 - 3
packages/opencode/src/auth/anthropic.ts

@@ -2,11 +2,12 @@ import { generatePKCE } from "@openauthjs/openauth/pkce"
 import { Global } from "../global"
 import path from "path"
 import fs from "fs/promises"
-import type { BunFile } from "bun"
 
 export namespace AuthAnthropic {
   const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
 
+  const file = Bun.file(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)
@@ -47,13 +48,11 @@ export namespace AuthAnthropic {
       }),
     })
     if (!result.ok) throw new ExchangeFailed()
-    const file = Bun.file(path.join(Global.Path.data, "anthropic.json"))
     await Bun.write(file, result)
     await fs.chmod(file.name!, 0o600)
   }
 
   export async function access() {
-    const file = Bun.file(path.join(Global.Path.data, "anthropic.json"))
     if (!(await file.exists())) return
     const result = await file.json()
     const refresh = result.refresh_token

+ 20 - 0
packages/opencode/src/auth/keys.ts

@@ -0,0 +1,20 @@
+import path from "path"
+import { Global } from "../global"
+import fs from "fs/promises"
+
+export namespace AuthKeys {
+  const file = Bun.file(path.join(Global.Path.data, "auth", "keys.json"))
+
+  export async function get() {
+    return file
+      .json()
+      .catch(() => ({}))
+      .then((x) => x as Record<string, string>)
+  }
+
+  export async function set(key: string, value: string) {
+    const env = await get()
+    await Bun.write(file, JSON.stringify({ ...env, [key]: value }))
+    await fs.chmod(file.name!, 0o600)
+  }
+}

+ 4 - 6
packages/opencode/src/cli/cmd/login-anthropic.ts

@@ -3,18 +3,16 @@ import { UI } from "../ui"
 
 // Example: https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=MdFtFgFap23AWDSN0oa3-eaKjQRFE4CaEhXx8M9fHZg&code_challenge_method=S256&state=rKLtaDzm88GSwekyEqdi0wXX-YqIr13tSzYymSzpvfs
 
-
-
 export const LoginAnthropicCommand = {
   command: "anthropic",
   describe: "Login to Anthropic",
   handler: async () => {
     const { url, verifier } = await AuthAnthropic.authorize()
 
-    UI.print("Login to Anthropic")
-    UI.print("Open the following URL in your browser:")
-    UI.print(url)
-    UI.print("")
+    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)

+ 129 - 0
packages/opencode/src/cli/cmd/provider.ts

@@ -0,0 +1,129 @@
+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 = [
+  `█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀`,
+  `█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀`,
+  `▀▀▀▀ █▀▀▀ ▀▀▀ ▀  ▀ ▀▀▀ ▀▀▀▀ ▀▀▀  ▀▀▀`,
+]
+
+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() {
+    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")
+  },
+})
+
+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",
+        options: [
+          {
+            label: "Claude Pro/Max",
+            value: "oauth",
+          },
+          {
+            label: "API Key",
+            value: "api",
+          },
+        ],
+      })
+      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"),
+        })
+        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)
+
+    prompts.outro("Done")
+  },
+})

+ 12 - 6
packages/opencode/src/cli/ui.ts

@@ -16,24 +16,30 @@ export namespace UI {
     TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
   }
 
-
+  export function println(...message: string[]) {
+    print(...message)
+    Bun.stderr.write("\n")
+  }
 
   export function print(...message: string[]) {
+    blank = false
     Bun.stderr.write(message.join(" "))
-    Bun.stderr.write("\n")
   }
 
+  let blank = false
   export function empty() {
-    print("" + Style.TEXT_NORMAL)
+    if (blank) return
+    println("" + Style.TEXT_NORMAL)
+    blank = true
   }
 
   export async function input(prompt: string): Promise<string> {
-    const readline = require('readline')
+    const readline = require("readline")
     const rl = readline.createInterface({
       input: process.stdin,
-      output: process.stdout
+      output: process.stdout,
     })
-    
+
     return new Promise((resolve) => {
       rl.question(prompt, (answer: string) => {
         rl.close()

+ 2 - 7
packages/opencode/src/index.ts

@@ -11,11 +11,11 @@ import { Global } from "./global"
 import yargs from "yargs"
 import { hideBin } from "yargs/helpers"
 import { RunCommand } from "./cli/cmd/run"
-import { LoginAnthropicCommand } from "./cli/cmd/login-anthropic"
 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"
 
 await Log.init({ print: process.argv.includes("--print-logs") })
 
@@ -70,11 +70,6 @@ yargs(hideBin(process.argv))
   .command(RunCommand)
   .command(GenerateCommand)
   .command(ScrapCommand)
-  .command({
-    command: "login",
-    describe: "generate credentials for various providers",
-    builder: (yargs) => yargs.command(LoginAnthropicCommand).demandCommand(),
-    handler: () => {},
-  })
+  .command(ProviderCommand)
   .help()
   .parse()

+ 2 - 6
packages/opencode/src/provider/models.ts

@@ -4,13 +4,9 @@ import path from "path"
 
 export namespace ModelsDev {
   const log = Log.create({ service: "models.dev" })
-
-  function filepath() {
-    return path.join(Global.Path.data, "models.json")
-  }
+  const file = Bun.file(path.join(Global.Path.cache, "models.json"))
 
   export async function get() {
-    const file = Bun.file(filepath())
     if (await file.exists()) {
       refresh()
       return file.json()
@@ -24,6 +20,6 @@ export namespace ModelsDev {
     const result = await fetch("https://models.dev/api.json")
     if (!result.ok)
       throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
-    await Bun.write(filepath(), result)
+    await Bun.write(file, result)
   }
 }

+ 0 - 1
packages/opencode/src/provider/provider.ts

@@ -18,7 +18,6 @@ import { LspHoverTool } from "../tool/lsp-hover"
 import { PatchTool } from "../tool/patch"
 import { ReadTool } from "../tool/read"
 import type { Tool } from "../tool/tool"
-
 import { WriteTool } from "../tool/write"
 import { TodoReadTool, TodoWriteTool } from "../tool/todo"
 import { AuthAnthropic } from "../auth/anthropic"