diff --git a/bun.lock b/bun.lock index 57b56660..650dc516 100644 --- a/bun.lock +++ b/bun.lock @@ -24,9 +24,9 @@ "@flystorage/file-storage": "1.1.0", "@flystorage/local-fs": "1.1.0", "@hono/zod-validator": "0.5.0", + "@openauthjs/openauth": "0.4.3", "@standard-schema/spec": "1.0.0", "ai": "catalog:", - "cac": "6.7.14", "decimal.js": "10.5.0", "diff": "8.0.2", "env-paths": "3.0.0", @@ -38,6 +38,7 @@ "vscode-jsonrpc": "8.2.1", "vscode-languageclient": "8", "xdg-basedir": "5.1.0", + "yargs": "18.0.0", "zod": "catalog:", "zod-openapi": "4.2.4", }, @@ -45,6 +46,7 @@ "@tsconfig/bun": "1.0.7", "@types/bun": "latest", "@types/turndown": "5.0.5", + "@types/yargs": "17.0.33", "typescript": "catalog:", }, }, @@ -286,14 +288,24 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], + "@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="], + "@opencode/function": ["@opencode/function@workspace:packages/function"], "@opencode/web": ["@opencode/web@workspace:packages/web"], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], + + "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], + + "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="], + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], + "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-365BEGl6ChOsauRjyVpBjXybflXAOvoMROw3TucAROHIcdBvXk9/2AmEvGFU0r75+vdQI4LJdJdpH4Y6Yqaj4A=="], "@pagefind/darwin-x64": ["@pagefind/darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-zlGHA23uuXmS8z3XxEGmbHpWDxXfPZ47QS06tGUq0HDcZjXjXHeLG+cboOy828QIV5FXsm9MjfkP5e4ZNbOkow=="], @@ -416,6 +428,10 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -434,6 +450,8 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -510,8 +528,6 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "call-bind": ["call-bind@1.0.8", "", { "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=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -542,6 +558,8 @@ "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -734,6 +752,8 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -1498,8 +1518,12 @@ "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="], @@ -1526,6 +1550,14 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="], + + "@openauthjs/openauth/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + + "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + + "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1582,6 +1614,8 @@ "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="], + "yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a879d2cd..0318c46d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -17,15 +17,16 @@ "@tsconfig/bun": "1.0.7", "@types/bun": "latest", "@types/turndown": "5.0.5", + "@types/yargs": "17.0.33", "typescript": "catalog:" }, "dependencies": { "@flystorage/file-storage": "1.1.0", "@flystorage/local-fs": "1.1.0", "@hono/zod-validator": "0.5.0", + "@openauthjs/openauth": "0.4.3", "@standard-schema/spec": "1.0.0", "ai": "catalog:", - "cac": "6.7.14", "decimal.js": "10.5.0", "diff": "8.0.2", "env-paths": "3.0.0", @@ -37,6 +38,7 @@ "vscode-jsonrpc": "8.2.1", "vscode-languageclient": "8", "xdg-basedir": "5.1.0", + "yargs": "18.0.0", "zod": "catalog:", "zod-openapi": "4.2.4" } diff --git a/packages/opencode/src/app/app.ts b/packages/opencode/src/app/app.ts index 5548a481..88f11526 100644 --- a/packages/opencode/src/app/app.ts +++ b/packages/opencode/src/app/app.ts @@ -37,7 +37,11 @@ export namespace App { x ? path.dirname(x) : undefined, ) - const data = path.join(Global.Path.data, git ?? "global") + const data = path.join( + Global.Path.data, + "project", + git ? git.split(path.sep).join("-") : "global", + ) const stateFile = Bun.file(path.join(data, APP_JSON)) const state = (await stateFile.json().catch(() => ({}))) as { initialized: number diff --git a/packages/opencode/src/auth/anthropic.ts b/packages/opencode/src/auth/anthropic.ts new file mode 100644 index 00000000..addc7bf1 --- /dev/null +++ b/packages/opencode/src/auth/anthropic.ts @@ -0,0 +1,66 @@ +// 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 + +import { generatePKCE } from "@openauthjs/openauth/pkce" +import { Global } from "../global" +import path from "path" + +export namespace AuthAnthropic { + export async function authorize() { + const pkce = await generatePKCE() + const url = new URL("https://claude.ai/oauth/authorize", import.meta.url) + url.searchParams.set("code", "true") + url.searchParams.set("client_id", "9d1c250a-e61b-44d9-88ed-5944d1962f5e") + url.searchParams.set("response_type", "code") + url.searchParams.set( + "redirect_uri", + "https://console.anthropic.com/oauth/code/callback", + ) + url.searchParams.set( + "scope", + "org:create_api_key user:profile user:inference", + ) + url.searchParams.set("code_challenge", pkce.challenge) + url.searchParams.set("code_challenge_method", "S256") + url.searchParams.set("state", pkce.verifier) + return { + url: url.toString(), + verifier: pkce.verifier, + } + } + + export async function exchange(code: string, verifier: string) { + const splits = code.split("#") + const result = await fetch("https://console.anthropic.com/v1/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: splits[0], + state: splits[1], + grant_type: "authorization_code", + client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + redirect_uri: "https://console.anthropic.com/oauth/code/callback", + code_verifier: verifier, + }), + }) + if (!result.ok) throw new ExchangeFailed() + await Bun.write(path.join(Global.Path.data, "anthropic.json"), result) + } + + export async function load() { + const file = Bun.file(path.join(Global.Path.data, "anthropic.json")) + if (!(await file.exists())) return + const result = await file.json() + return { + accessToken: result.access_token as string, + refreshToken: result.refresh_token as string, + } + } + + export class ExchangeFailed extends Error { + constructor() { + super("Exchange failed") + } + } +} diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts new file mode 100644 index 00000000..1390f271 --- /dev/null +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -0,0 +1,20 @@ +import { Server } from "../../server/server" +import fs from "fs/promises" +import path from "path" +import type { CommandModule } from "yargs" + +export const GenerateCommand = { + command: "generate", + describe: "Generate OpenAPI and event specs", + handler: 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), + ) + }, +} satisfies CommandModule + diff --git a/packages/opencode/src/cli/cmd/login-anthropic.ts b/packages/opencode/src/cli/cmd/login-anthropic.ts new file mode 100644 index 00000000..12291fbe --- /dev/null +++ b/packages/opencode/src/cli/cmd/login-anthropic.ts @@ -0,0 +1,22 @@ +import { AuthAnthropic } from "../../auth/anthropic" +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 + +import { generatePKCE } from "@openauthjs/openauth/pkce" + +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("") + + const code = await UI.input("Paste the authorization code here: ") + await AuthAnthropic.exchange(code, verifier) + }, +} diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts new file mode 100644 index 00000000..c28ae430 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run.ts @@ -0,0 +1,140 @@ +import type { Argv } from "yargs" +import { App } from "../../app/app" +import { version } from "bun" +import { Bus } from "../../bus" +import { Provider } from "../../provider/provider" +import { Session } from "../../session" +import { Share } from "../../share/share" +import { Message } from "../../session/message" + +export const RunCommand = { + command: "run [message..]", + describe: "Run OpenCode with a message", + builder: (yargs: Argv) => { + return yargs + .positional("message", { + describe: "Message to send", + type: "string", + array: true, + default: [], + }) + .option("session", { + describe: "Session ID to continue", + type: "string", + }) + }, + handler: async (args: { message: string[]; session?: string }) => { + const message = args.message.join(" ") + await App.provide({ cwd: process.cwd(), version }, async () => { + await Share.init() + const session = args.session + ? await Session.get(args.session) + : await Session.create() + + const styles = { + TEXT_HIGHLIGHT: "\x1b[96m", + TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m", + TEXT_DIM: "\x1b[90m", + TEXT_DIM_BOLD: "\x1b[90m\x1b[1m", + TEXT_NORMAL: "\x1b[0m", + TEXT_NORMAL_BOLD: "\x1b[1m", + TEXT_WARNING: "\x1b[93m", + TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m", + TEXT_DANGER: "\x1b[91m", + TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m", + TEXT_SUCCESS: "\x1b[92m", + TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m", + TEXT_INFO: "\x1b[94m", + TEXT_INFO_BOLD: "\x1b[94m\x1b[1m", + } + + let isEmpty = false + function stderr(...message: string[]) { + isEmpty = true + Bun.stderr.write(message.join(" ")) + Bun.stderr.write("\n") + } + + function empty() { + stderr("" + styles.TEXT_NORMAL) + isEmpty = true + } + + stderr(styles.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", version) + empty() + stderr(styles.TEXT_NORMAL_BOLD + "> ", message) + empty() + stderr( + styles.TEXT_INFO_BOLD + + "~ https://dev.opencode.ai/s?id=" + + session.id.slice(-8), + ) + empty() + + function printEvent(color: string, type: string, title: string) { + stderr( + color + `|`, + styles.TEXT_NORMAL + styles.TEXT_DIM + ` ${type.padEnd(7, " ")}`, + "", + styles.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 messages = await Session.messages(session.id) + const metadata = + messages[messages.length - 1].metadata.tool[ + part.toolInvocation.toolCallId + ] + const args = part.toolInvocation.args as any + const tool = part.toolInvocation.toolName + + if (tool === "opencode_edit") + printEvent(styles.TEXT_SUCCESS_BOLD, "Edit", args.filePath) + if (tool === "opencode_bash") + printEvent(styles.TEXT_WARNING_BOLD, "Execute", args.command) + if (tool === "opencode_read") + printEvent(styles.TEXT_INFO_BOLD, "Read", args.filePath) + if (tool === "opencode_write") + printEvent(styles.TEXT_SUCCESS_BOLD, "Create", args.filePath) + if (tool === "opencode_glob") + printEvent( + styles.TEXT_INFO_BOLD, + "Glob", + args.pattern + (args.path ? " in " + args.path : ""), + ) + } + + if (part.type === "text") { + if (part.text.includes("\n")) { + empty() + stderr(part.text) + empty() + return + } + printEvent(styles.TEXT_NORMAL_BOLD, "Text", part.text) + } + }) + + const { providerID, modelID } = await Provider.defaultModel() + const result = await Session.chat({ + sessionID: session.id, + providerID, + modelID, + parts: [ + { + type: "text", + text: message, + }, + ], + }) + empty() + }) + }, +} diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts new file mode 100644 index 00000000..d08163ba --- /dev/null +++ b/packages/opencode/src/cli/ui.ts @@ -0,0 +1,44 @@ +export namespace UI { + export const Style = { + TEXT_HIGHLIGHT: "\x1b[96m", + TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m", + TEXT_DIM: "\x1b[90m", + TEXT_DIM_BOLD: "\x1b[90m\x1b[1m", + TEXT_NORMAL: "\x1b[0m", + TEXT_NORMAL_BOLD: "\x1b[1m", + TEXT_WARNING: "\x1b[93m", + TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m", + TEXT_DANGER: "\x1b[91m", + TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m", + TEXT_SUCCESS: "\x1b[92m", + TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m", + TEXT_INFO: "\x1b[94m", + TEXT_INFO_BOLD: "\x1b[94m\x1b[1m", + } + + + + export function print(...message: string[]) { + Bun.stderr.write(message.join(" ")) + Bun.stderr.write("\n") + } + + export function empty() { + print("" + Style.TEXT_NORMAL) + } + + export async function input(prompt: string): Promise { + const readline = require('readline') + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }) + + return new Promise((resolve) => { + rl.question(prompt, (answer: string) => { + rl.close() + resolve(answer.trim()) + }) + }) + } +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 0ed49f6e..ef2daa95 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -3,208 +3,74 @@ import { App } from "./app/app" import { Server } from "./server/server" import fs from "fs/promises" import path from "path" -import { Bus } from "./bus" -import { Session } from "./session" -import cac from "cac" + import { Share } from "./share/share" -import { Message } from "./session/message" + import { Global } from "./global" -import { Provider } from "./provider/provider" + +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" declare global { const OPENCODE_VERSION: string } -const cli = cac("opencode") const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev" -cli.command("", "Start the opencode in interactive mode").action(async () => { - await App.provide({ cwd: process.cwd(), version }, async () => { - await Share.init() - const server = Server.listen() +yargs(hideBin(process.argv)) + .scriptName("opencode") + .version(version) + .command({ + command: "$0", + describe: "Start OpenCode TUI", + handler: async () => { + await App.provide({ cwd: process.cwd(), version }, async () => { + 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())) { - console.log("installing tui binary...") - await Bun.write(file, blob, { mode: 0o755 }) - await fs.chmod(binary, 0o755) - } - 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() - }) -}) - -cli.command("generate", "Generate OpenAPI and event specs").action(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), - ) -}) - -cli - .command("run [...message]", "Run a chat message") - .option("--session ", "Session ID") - .action(async (message: string[], options) => { - await App.provide({ cwd: process.cwd(), version }, async () => { - await Share.init() - const session = options.session - ? await Session.get(options.session) - : await Session.create() - - const styles = { - TEXT_HIGHLIGHT: "\x1b[96m", - TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m", - TEXT_DIM: "\x1b[90m", - TEXT_DIM_BOLD: "\x1b[90m\x1b[1m", - TEXT_NORMAL: "\x1b[0m", - TEXT_NORMAL_BOLD: "\x1b[1m", - TEXT_WARNING: "\x1b[93m", - TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m", - TEXT_DANGER: "\x1b[91m", - TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m", - TEXT_SUCCESS: "\x1b[92m", - TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m", - TEXT_INFO: "\x1b[94m", - TEXT_INFO_BOLD: "\x1b[94m\x1b[1m", - } - - let isEmpty = false - function stderr(...message: string[]) { - isEmpty = true - Bun.stderr.write(message.join(" ")) - Bun.stderr.write("\n") - } - - function empty() { - stderr("" + styles.TEXT_NORMAL) - isEmpty = true - } - - stderr(styles.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", version) - empty() - stderr(styles.TEXT_NORMAL_BOLD + "> ", message.join(" ")) - empty() - stderr( - styles.TEXT_INFO_BOLD + - "~ https://dev.opencode.ai/s?id=" + - session.id.slice(-8), - ) - empty() - - function printEvent(color: string, type: string, title: string) { - stderr( - color + `|`, - styles.TEXT_NORMAL + styles.TEXT_DIM + ` ${type.padEnd(7, " ")}`, - "", - styles.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 messages = await Session.messages(session.id) - const metadata = - messages[messages.length - 1].metadata.tool[ - part.toolInvocation.toolCallId - ] - const args = part.toolInvocation.args as any - const tool = part.toolInvocation.toolName - - if (tool === "opencode_edit") - printEvent(styles.TEXT_SUCCESS_BOLD, "Edit", args.filePath) - if (tool === "opencode_bash") - printEvent(styles.TEXT_WARNING_BOLD, "Execute", args.command) - if (tool === "opencode_read") - printEvent(styles.TEXT_INFO_BOLD, "Read", args.filePath) - if (tool === "opencode_write") - printEvent(styles.TEXT_SUCCESS_BOLD, "Create", args.filePath) - if (tool === "opencode_glob") - printEvent( - styles.TEXT_INFO_BOLD, - "Glob", - args.pattern + (args.path ? " in " + args.path : ""), - ) - } - - if (part.type === "text") { - if (part.text.includes("\n")) { - empty() - stderr(part.text) - empty() - return + 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())) { + console.log("installing tui binary...") + await Bun.write(file, blob, { mode: 0o755 }) + await fs.chmod(binary, 0o755) } - printEvent(styles.TEXT_NORMAL_BOLD, "Text", part.text) + cwd = process.cwd() + cmd = [binary] } - }) - - const { providerID, modelID } = await Provider.defaultModel() - const result = await Session.chat({ - sessionID: session.id, - providerID, - modelID, - parts: [ - { - type: "text", - text: message.join(" "), + 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() }) - empty() - }) + }, }) - -cli.command("init", "Run a chat message").action(async () => { - await App.provide({ cwd: process.cwd(), version }, async () => { - const { modelID, providerID } = await Provider.defaultModel() - console.log("Initializing...") - - const session = await Session.create() - - const unsub = Bus.subscribe(Session.Event.Updated, async (message) => { - if (message.properties.info.share?.url) - console.log("Share:", message.properties.info.share.url) - unsub() - }) - - await Session.initialize({ - sessionID: session.id, - modelID, - providerID, - }) + .command(RunCommand) + .command(GenerateCommand) + .command({ + command: "login", + describe: "generate credentials for various providers", + builder: (yargs) => yargs.command(LoginAnthropicCommand).demandCommand(), + handler: () => {}, }) -}) - -cli.version(typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev") -cli.help() -cli.parse() + .help() + .parse() diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4113954e..6c2b34e1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -21,6 +21,7 @@ import type { Tool } from "../tool/tool" import { MultiEditTool } from "../tool/multiedit" import { WriteTool } from "../tool/write" import { TodoReadTool, TodoWriteTool } from "../tool/todo" +import { AuthAnthropic } from "../auth/anthropic" export namespace Provider { const log = Log.create({ service: "provider" }) @@ -63,6 +64,25 @@ export namespace Provider { google: ["GOOGLE_GENERATIVE_AI_API_KEY"], // TODO: support GEMINI_API_KEY? } + const AUTODETECT2: Record< + string, + () => Promise | false> + > = { + anthropic: async () => { + const result = await AuthAnthropic.load() + if (result) + return { + apiKey: "", + headers: { + authorization: `Bearer ${result.accessToken}`, + "anthropic-beta": "oauth-2025-04-20", + }, + } + if (process.env["ANTHROPIC_API_KEY"]) return {} + return false + }, + } + const state = App.state("provider", async () => { log.info("loading config") const config = await Config.get() @@ -72,6 +92,21 @@ export namespace Provider { const sdk = new Map() log.info("loading") + + for (const [providerID, fn] of Object.entries(AUTODETECT2)) { + const provider = PROVIDER_DATABASE.find((x) => x.id === providerID) + if (!provider) continue + const result = await fn() + if (!result) continue + providers.set(providerID, { + ...provider, + options: { + ...provider.options, + ...result, + }, + }) + } + for (const item of PROVIDER_DATABASE) { if (!AUTODETECT[item.id].some((env) => process.env[env])) continue log.info("found", { providerID: item.id }) @@ -177,7 +212,7 @@ export namespace Provider { PatchTool, ReadTool, EditTool, - MultiEditTool, + // MultiEditTool, WriteTool, TodoWriteTool, TodoReadTool, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index d77da209..474131cc 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -16,6 +16,7 @@ import { z, ZodSchema } from "zod" import { Decimal } from "decimal.js" import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" +import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" import PROMPT_TITLE from "./prompt/title.txt" import PROMPT_SUMMARIZE from "./prompt/summarize.txt" import PROMPT_INITIALIZE from "../session/prompt/initialize.txt" @@ -207,6 +208,24 @@ export namespace Session { if (msgs.length === 0) { const app = App.info() + if (input.providerID === "anthropic") + msgs.push({ + id: Identifier.ascending("message"), + role: "system", + parts: [ + { + type: "text", + text: PROMPT_ANTHROPIC_SPOOF.trim(), + }, + ], + metadata: { + sessionID: input.sessionID, + time: { + created: Date.now(), + }, + tool: {}, + }, + }) const system: Message.Info = { id: Identifier.ascending("message"), role: "system", @@ -249,6 +268,15 @@ ${app.git ? await ListTool.execute({ path: app.path.cwd }, { sessionID: input.se generateText({ maxOutputTokens: 80, messages: convertToModelMessages([ + { + role: "system", + parts: [ + { + type: "text", + text: PROMPT_ANTHROPIC_SPOOF.trim(), + }, + ], + }, { role: "system", parts: [ diff --git a/packages/opencode/src/session/prompt/anthropic_spoof.txt b/packages/opencode/src/session/prompt/anthropic_spoof.txt new file mode 100644 index 00000000..aed6cc19 --- /dev/null +++ b/packages/opencode/src/session/prompt/anthropic_spoof.txt @@ -0,0 +1 @@ +You are Claude Code, Anthropic's official CLI for Claude.