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 }