diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts new file mode 100644 index 00000000000..62210d57586 --- /dev/null +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -0,0 +1,344 @@ +import type { Argv } from "yargs" +import { UI } from "../ui" +import * as prompts from "@clack/prompts" +import { Installation } from "../../installation" +import { Global } from "../../global" +import { $ } from "bun" +import fs from "fs/promises" +import path from "path" +import os from "os" + +interface UninstallArgs { + keepConfig: boolean + keepData: boolean + dryRun: boolean + force: boolean +} + +interface RemovalTargets { + directories: Array<{ path: string; label: string; keep: boolean }> + shellConfig: string | null + binary: string | null +} + +export const UninstallCommand = { + command: "uninstall", + describe: "uninstall opencode and remove all related files", + builder: (yargs: Argv) => + yargs + .option("keep-config", { + alias: "c", + type: "boolean", + describe: "keep configuration files", + default: false, + }) + .option("keep-data", { + alias: "d", + type: "boolean", + describe: "keep session data and snapshots", + default: false, + }) + .option("dry-run", { + type: "boolean", + describe: "show what would be removed without removing", + default: false, + }) + .option("force", { + alias: "f", + type: "boolean", + describe: "skip confirmation prompts", + default: false, + }), + + handler: async (args: UninstallArgs) => { + UI.empty() + UI.println(UI.logo(" ")) + UI.empty() + prompts.intro("Uninstall OpenCode") + + const method = await Installation.method() + prompts.log.info(`Installation method: ${method}`) + + const targets = await collectRemovalTargets(args, method) + + await showRemovalSummary(targets, method) + + if (!args.force && !args.dryRun) { + const confirm = await prompts.confirm({ + message: "Are you sure you want to uninstall?", + initialValue: false, + }) + if (!confirm || prompts.isCancel(confirm)) { + prompts.outro("Cancelled") + return + } + } + + if (args.dryRun) { + prompts.log.warn("Dry run - no changes made") + prompts.outro("Done") + return + } + + await executeUninstall(method, targets) + + prompts.outro("Done") + }, +} + +async function collectRemovalTargets(args: UninstallArgs, method: Installation.Method): Promise { + const directories: RemovalTargets["directories"] = [ + { path: Global.Path.data, label: "Data", keep: args.keepData }, + { path: Global.Path.cache, label: "Cache", keep: false }, + { path: Global.Path.config, label: "Config", keep: args.keepConfig }, + { path: Global.Path.state, label: "State", keep: false }, + ] + + const shellConfig = method === "curl" ? await getShellConfigFile() : null + const binary = method === "curl" ? process.execPath : null + + return { directories, shellConfig, binary } +} + +async function showRemovalSummary(targets: RemovalTargets, method: Installation.Method) { + prompts.log.message("The following will be removed:") + + for (const dir of targets.directories) { + const exists = await fs + .access(dir.path) + .then(() => true) + .catch(() => false) + if (!exists) continue + + const size = await getDirectorySize(dir.path) + const sizeStr = formatSize(size) + const status = dir.keep ? UI.Style.TEXT_DIM + "(keeping)" : "" + const prefix = dir.keep ? "○" : "✓" + + prompts.log.info(` ${prefix} ${dir.label}: ${shortenPath(dir.path)} ${UI.Style.TEXT_DIM}(${sizeStr})${status}`) + } + + if (targets.binary) { + prompts.log.info(` ✓ Binary: ${shortenPath(targets.binary)}`) + } + + if (targets.shellConfig) { + prompts.log.info(` ✓ Shell PATH in ${shortenPath(targets.shellConfig)}`) + } + + if (method !== "curl" && method !== "unknown") { + const cmds: Record = { + npm: "npm uninstall -g opencode-ai", + pnpm: "pnpm uninstall -g opencode-ai", + bun: "bun remove -g opencode-ai", + yarn: "yarn global remove opencode-ai", + brew: "brew uninstall opencode", + } + prompts.log.info(` ✓ Package: ${cmds[method] || method}`) + } +} + +async function executeUninstall(method: Installation.Method, targets: RemovalTargets) { + const spinner = prompts.spinner() + const errors: string[] = [] + + for (const dir of targets.directories) { + if (dir.keep) { + prompts.log.step(`Skipping ${dir.label} (--keep-${dir.label.toLowerCase()})`) + continue + } + + const exists = await fs + .access(dir.path) + .then(() => true) + .catch(() => false) + if (!exists) continue + + spinner.start(`Removing ${dir.label}...`) + const err = await fs.rm(dir.path, { recursive: true, force: true }).catch((e) => e) + if (err) { + spinner.stop(`Failed to remove ${dir.label}`, 1) + errors.push(`${dir.label}: ${err.message}`) + continue + } + spinner.stop(`Removed ${dir.label}`) + } + + if (targets.shellConfig) { + spinner.start("Cleaning shell config...") + const err = await cleanShellConfig(targets.shellConfig).catch((e) => e) + if (err) { + spinner.stop("Failed to clean shell config", 1) + errors.push(`Shell config: ${err.message}`) + } else { + spinner.stop("Cleaned shell config") + } + } + + if (method !== "curl" && method !== "unknown") { + const cmds: Record = { + npm: ["npm", "uninstall", "-g", "opencode-ai"], + pnpm: ["pnpm", "uninstall", "-g", "opencode-ai"], + bun: ["bun", "remove", "-g", "opencode-ai"], + yarn: ["yarn", "global", "remove", "opencode-ai"], + brew: ["brew", "uninstall", "opencode"], + } + + const cmd = cmds[method] + if (cmd) { + spinner.start(`Running ${cmd.join(" ")}...`) + const result = await $`${cmd}`.quiet().nothrow() + if (result.exitCode !== 0) { + spinner.stop(`Package manager uninstall failed`, 1) + prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`) + errors.push(`Package manager: exit code ${result.exitCode}`) + } else { + spinner.stop("Package removed") + } + } + } + + if (method === "curl" && targets.binary) { + UI.empty() + prompts.log.message("To finish removing the binary, run:") + prompts.log.info(` rm "${targets.binary}"`) + + const binDir = path.dirname(targets.binary) + if (binDir.includes(".opencode")) { + prompts.log.info(` rmdir "${binDir}" 2>/dev/null`) + } + } + + if (errors.length > 0) { + UI.empty() + prompts.log.warn("Some operations failed:") + for (const err of errors) { + prompts.log.error(` ${err}`) + } + } + + UI.empty() + prompts.log.success("Thank you for using OpenCode!") +} + +async function getShellConfigFile(): Promise { + const shell = path.basename(process.env.SHELL || "bash") + const home = os.homedir() + const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, ".config") + + const configFiles: Record = { + fish: [path.join(xdgConfig, "fish", "config.fish")], + zsh: [ + path.join(home, ".zshrc"), + path.join(home, ".zshenv"), + path.join(xdgConfig, "zsh", ".zshrc"), + path.join(xdgConfig, "zsh", ".zshenv"), + ], + bash: [ + path.join(home, ".bashrc"), + path.join(home, ".bash_profile"), + path.join(home, ".profile"), + path.join(xdgConfig, "bash", ".bashrc"), + path.join(xdgConfig, "bash", ".bash_profile"), + ], + ash: [path.join(home, ".ashrc"), path.join(home, ".profile")], + sh: [path.join(home, ".profile")], + } + + const candidates = configFiles[shell] || configFiles.bash + + for (const file of candidates) { + const exists = await fs + .access(file) + .then(() => true) + .catch(() => false) + if (!exists) continue + + const content = await Bun.file(file) + .text() + .catch(() => "") + if (content.includes("# opencode") || content.includes(".opencode/bin")) { + return file + } + } + + return null +} + +async function cleanShellConfig(file: string) { + const content = await Bun.file(file).text() + const lines = content.split("\n") + + const filtered: string[] = [] + let skip = false + + for (const line of lines) { + const trimmed = line.trim() + + if (trimmed === "# opencode") { + skip = true + continue + } + + if (skip) { + skip = false + if (trimmed.includes(".opencode/bin") || trimmed.includes("fish_add_path")) { + continue + } + } + + if ( + (trimmed.startsWith("export PATH=") && trimmed.includes(".opencode/bin")) || + (trimmed.startsWith("fish_add_path") && trimmed.includes(".opencode")) + ) { + continue + } + + filtered.push(line) + } + + while (filtered.length > 0 && filtered[filtered.length - 1].trim() === "") { + filtered.pop() + } + + const output = filtered.join("\n") + "\n" + await Bun.write(file, output) +} + +async function getDirectorySize(dir: string): Promise { + let total = 0 + + const walk = async (current: string) => { + const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []) + + for (const entry of entries) { + const full = path.join(current, entry.name) + if (entry.isDirectory()) { + await walk(full) + continue + } + if (entry.isFile()) { + const stat = await fs.stat(full).catch(() => null) + if (stat) total += stat.size + } + } + } + + await walk(dir) + return total +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` +} + +function shortenPath(p: string): string { + const home = os.homedir() + if (p.startsWith(home)) { + return p.replace(home, "~") + } + return p +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5ddf68e10cd..6545f81857d 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -6,6 +6,7 @@ import { Log } from "./util/log" import { AuthCommand } from "./cli/cmd/auth" import { AgentCommand } from "./cli/cmd/agent" import { UpgradeCommand } from "./cli/cmd/upgrade" +import { UninstallCommand } from "./cli/cmd/uninstall" import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" import { Installation } from "./installation" @@ -86,6 +87,7 @@ const cli = yargs(hideBin(process.argv)) .command(AuthCommand) .command(AgentCommand) .command(UpgradeCommand) + .command(UninstallCommand) .command(ServeCommand) .command(WebCommand) .command(ModelsCommand)