summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/AGENTS.md4
-rw-r--r--packages/opencode/src/cli/cmd/upgrade.ts184
-rw-r--r--packages/opencode/src/cli/router.ts193
-rw-r--r--packages/opencode/src/index.ts4
4 files changed, 189 insertions, 196 deletions
diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md
index b5bf02915..ebd034008 100644
--- a/packages/opencode/AGENTS.md
+++ b/packages/opencode/AGENTS.md
@@ -1,4 +1,4 @@
-# OpenCode Agent Guidelines
+# opencode agent guidelines
## Build/Test Commands
@@ -19,7 +19,7 @@
## IMPORTANT
-- Try to keep things in one function unless composable or reusable
+- Try to keep things in one function unless composable or reusablte
- DO NOT do unnecessary destructuring of variables
- DO NOT use else statements unless necessary
- DO NOT use try catch if it can be avoided
diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts
new file mode 100644
index 000000000..95159ce73
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/upgrade.ts
@@ -0,0 +1,184 @@
+import type { Argv } from "yargs"
+import { UI } from "../ui"
+import { VERSION } from "../version"
+import path from "path"
+import fs from "fs/promises"
+import os from "os"
+import * as prompts from "@clack/prompts"
+import { Global } from "../../global"
+
+const API = "https://api.github.com/repos/sst/opencode"
+
+interface Release {
+ tag_name: string
+ name: string
+ assets: Array<{
+ name: string
+ browser_download_url: string
+ }>
+}
+
+function asset(): string {
+ const platform = os.platform()
+ const arch = os.arch()
+
+ if (platform === "darwin") {
+ return arch === "arm64"
+ ? "opencode-darwin-arm64.zip"
+ : "opencode-darwin-x64.zip"
+ }
+ if (platform === "linux") {
+ return arch === "arm64"
+ ? "opencode-linux-arm64.zip"
+ : "opencode-linux-x64.zip"
+ }
+ if (platform === "win32") {
+ return "opencode-windows-x64.zip"
+ }
+
+ throw new Error(`Unsupported platform: ${platform}-${arch}`)
+}
+
+function compare(current: string, latest: string): number {
+ const a = current.replace(/^v/, "")
+ const b = latest.replace(/^v/, "")
+
+ const aParts = a.split(".").map(Number)
+ const bParts = b.split(".").map(Number)
+
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
+ const aPart = aParts[i] || 0
+ const bPart = bParts[i] || 0
+
+ if (aPart < bPart) return -1
+ if (aPart > bPart) return 1
+ }
+
+ return 0
+}
+
+async function latest(): Promise<Release> {
+ const response = await fetch(`${API}/releases/latest`)
+ if (!response.ok) {
+ throw new Error(`Failed to fetch latest release: ${response.statusText}`)
+ }
+ return response.json()
+}
+
+async function specific(version: string): Promise<Release> {
+ const tag = version.startsWith("v") ? version : `v${version}`
+ const response = await fetch(`${API}/releases/tags/${tag}`)
+ if (!response.ok) {
+ throw new Error(`Failed to fetch release ${tag}: ${response.statusText}`)
+ }
+ return response.json()
+}
+
+async function download(url: string): Promise<string> {
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new Error(`Failed to download: ${response.statusText}`)
+ }
+
+ const buffer = await response.arrayBuffer()
+ const temp = path.join(Global.Path.cache, `opencode-update-${Date.now()}.zip`)
+
+ await Bun.write(temp, buffer)
+
+ const extractDir = path.join(
+ Global.Path.cache,
+ `opencode-extract-${Date.now()}`,
+ )
+ await fs.mkdir(extractDir, { recursive: true })
+
+ const proc = Bun.spawn(["unzip", "-o", temp, "-d", extractDir], {
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+
+ const result = await proc.exited
+ if (result !== 0) {
+ throw new Error("Failed to extract update")
+ }
+
+ await fs.unlink(temp)
+
+ const binary = path.join(extractDir, "opencode")
+ await fs.chmod(binary, 0o755)
+
+ return binary
+}
+
+export const UpgradeCommand = {
+ command: "upgrade [target]",
+ describe: "Upgrade opencode to the latest version or a specific version",
+ builder: (yargs: Argv) => {
+ return yargs.positional("target", {
+ describe: "Specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')",
+ type: "string",
+ })
+ },
+ handler: async (args: { target?: string }) => {
+ UI.empty()
+ UI.println(UI.logo(" "))
+ UI.empty()
+ prompts.intro("upgrade")
+
+ if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
+ prompts.log.error(
+ `opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
+ )
+ prompts.outro("Done")
+ return
+ }
+
+ const release = args.target ? await specific(args.target) : await latest()
+ const target = release.tag_name
+
+ prompts.log.info(`Upgrade ${VERSION} → ${target}`)
+
+ if (VERSION !== "dev" && compare(VERSION, target) >= 0) {
+ prompts.log.success(`Already up to date`)
+ prompts.outro("Done")
+ return
+ }
+
+ const name = asset()
+ const found = release.assets.find((a) => a.name === name)
+
+ if (!found) {
+ prompts.log.error(`No binary found for platform: ${name}`)
+ prompts.outro("Done")
+ return
+ }
+
+ const spinner = prompts.spinner()
+ spinner.start("Downloading update...")
+
+ let downloadPath: string
+ try {
+ downloadPath = await download(found.browser_download_url)
+ spinner.stop("Download complete")
+ } catch (downloadError) {
+ spinner.stop("Download failed")
+ prompts.log.error(
+ `Download failed: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}`,
+ )
+ prompts.outro("Done")
+ return
+ }
+
+ try {
+ await fs.rename(downloadPath, process.execPath)
+ prompts.log.success(`Successfully upgraded to ${target}`)
+ } catch (installError) {
+ prompts.log.error(
+ `Install failed: ${installError instanceof Error ? installError.message : String(installError)}`,
+ )
+ // Clean up downloaded file
+ await fs.unlink(downloadPath).catch(() => {})
+ }
+
+ prompts.outro("Done")
+ },
+}
diff --git a/packages/opencode/src/cli/router.ts b/packages/opencode/src/cli/router.ts
deleted file mode 100644
index 247b82bcb..000000000
--- a/packages/opencode/src/cli/router.ts
+++ /dev/null
@@ -1,193 +0,0 @@
-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 })
-}
-
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 74f50e343..0a82a00a6 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -13,6 +13,7 @@ import { VERSION } from "./cli/version"
import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log"
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
+import { UpgradeCommand } from "./cli/cmd/upgrade"
import { Provider } from "./provider/provider"
import { UI } from "./cli/ui"
@@ -33,7 +34,7 @@ const cli = yargs(hideBin(process.argv))
.usage("\n" + UI.logo())
.command({
command: "$0 [project]",
- describe: "Start OpenCode TUI",
+ describe: "Start opencode TUI",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
@@ -102,6 +103,7 @@ const cli = yargs(hideBin(process.argv))
.command(GenerateCommand)
.command(ScrapCommand)
.command(AuthCommand)
+ .command(UpgradeCommand)
.fail((msg, err) => {
if (
msg.startsWith("Unknown argument") ||