Skip to content

Commit ec27759

Browse files
authored
feat: add uninstall command (#5208)
1 parent 9c938ee commit ec27759

File tree

2 files changed

+346
-0
lines changed

2 files changed

+346
-0
lines changed
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
import type { Argv } from "yargs"
2+
import { UI } from "../ui"
3+
import * as prompts from "@clack/prompts"
4+
import { Installation } from "../../installation"
5+
import { Global } from "../../global"
6+
import { $ } from "bun"
7+
import fs from "fs/promises"
8+
import path from "path"
9+
import os from "os"
10+
11+
interface UninstallArgs {
12+
keepConfig: boolean
13+
keepData: boolean
14+
dryRun: boolean
15+
force: boolean
16+
}
17+
18+
interface RemovalTargets {
19+
directories: Array<{ path: string; label: string; keep: boolean }>
20+
shellConfig: string | null
21+
binary: string | null
22+
}
23+
24+
export const UninstallCommand = {
25+
command: "uninstall",
26+
describe: "uninstall opencode and remove all related files",
27+
builder: (yargs: Argv) =>
28+
yargs
29+
.option("keep-config", {
30+
alias: "c",
31+
type: "boolean",
32+
describe: "keep configuration files",
33+
default: false,
34+
})
35+
.option("keep-data", {
36+
alias: "d",
37+
type: "boolean",
38+
describe: "keep session data and snapshots",
39+
default: false,
40+
})
41+
.option("dry-run", {
42+
type: "boolean",
43+
describe: "show what would be removed without removing",
44+
default: false,
45+
})
46+
.option("force", {
47+
alias: "f",
48+
type: "boolean",
49+
describe: "skip confirmation prompts",
50+
default: false,
51+
}),
52+
53+
handler: async (args: UninstallArgs) => {
54+
UI.empty()
55+
UI.println(UI.logo(" "))
56+
UI.empty()
57+
prompts.intro("Uninstall OpenCode")
58+
59+
const method = await Installation.method()
60+
prompts.log.info(`Installation method: ${method}`)
61+
62+
const targets = await collectRemovalTargets(args, method)
63+
64+
await showRemovalSummary(targets, method)
65+
66+
if (!args.force && !args.dryRun) {
67+
const confirm = await prompts.confirm({
68+
message: "Are you sure you want to uninstall?",
69+
initialValue: false,
70+
})
71+
if (!confirm || prompts.isCancel(confirm)) {
72+
prompts.outro("Cancelled")
73+
return
74+
}
75+
}
76+
77+
if (args.dryRun) {
78+
prompts.log.warn("Dry run - no changes made")
79+
prompts.outro("Done")
80+
return
81+
}
82+
83+
await executeUninstall(method, targets)
84+
85+
prompts.outro("Done")
86+
},
87+
}
88+
89+
async function collectRemovalTargets(args: UninstallArgs, method: Installation.Method): Promise<RemovalTargets> {
90+
const directories: RemovalTargets["directories"] = [
91+
{ path: Global.Path.data, label: "Data", keep: args.keepData },
92+
{ path: Global.Path.cache, label: "Cache", keep: false },
93+
{ path: Global.Path.config, label: "Config", keep: args.keepConfig },
94+
{ path: Global.Path.state, label: "State", keep: false },
95+
]
96+
97+
const shellConfig = method === "curl" ? await getShellConfigFile() : null
98+
const binary = method === "curl" ? process.execPath : null
99+
100+
return { directories, shellConfig, binary }
101+
}
102+
103+
async function showRemovalSummary(targets: RemovalTargets, method: Installation.Method) {
104+
prompts.log.message("The following will be removed:")
105+
106+
for (const dir of targets.directories) {
107+
const exists = await fs
108+
.access(dir.path)
109+
.then(() => true)
110+
.catch(() => false)
111+
if (!exists) continue
112+
113+
const size = await getDirectorySize(dir.path)
114+
const sizeStr = formatSize(size)
115+
const status = dir.keep ? UI.Style.TEXT_DIM + "(keeping)" : ""
116+
const prefix = dir.keep ? "○" : "✓"
117+
118+
prompts.log.info(` ${prefix} ${dir.label}: ${shortenPath(dir.path)} ${UI.Style.TEXT_DIM}(${sizeStr})${status}`)
119+
}
120+
121+
if (targets.binary) {
122+
prompts.log.info(` ✓ Binary: ${shortenPath(targets.binary)}`)
123+
}
124+
125+
if (targets.shellConfig) {
126+
prompts.log.info(` ✓ Shell PATH in ${shortenPath(targets.shellConfig)}`)
127+
}
128+
129+
if (method !== "curl" && method !== "unknown") {
130+
const cmds: Record<string, string> = {
131+
npm: "npm uninstall -g opencode-ai",
132+
pnpm: "pnpm uninstall -g opencode-ai",
133+
bun: "bun remove -g opencode-ai",
134+
yarn: "yarn global remove opencode-ai",
135+
brew: "brew uninstall opencode",
136+
}
137+
prompts.log.info(` ✓ Package: ${cmds[method] || method}`)
138+
}
139+
}
140+
141+
async function executeUninstall(method: Installation.Method, targets: RemovalTargets) {
142+
const spinner = prompts.spinner()
143+
const errors: string[] = []
144+
145+
for (const dir of targets.directories) {
146+
if (dir.keep) {
147+
prompts.log.step(`Skipping ${dir.label} (--keep-${dir.label.toLowerCase()})`)
148+
continue
149+
}
150+
151+
const exists = await fs
152+
.access(dir.path)
153+
.then(() => true)
154+
.catch(() => false)
155+
if (!exists) continue
156+
157+
spinner.start(`Removing ${dir.label}...`)
158+
const err = await fs.rm(dir.path, { recursive: true, force: true }).catch((e) => e)
159+
if (err) {
160+
spinner.stop(`Failed to remove ${dir.label}`, 1)
161+
errors.push(`${dir.label}: ${err.message}`)
162+
continue
163+
}
164+
spinner.stop(`Removed ${dir.label}`)
165+
}
166+
167+
if (targets.shellConfig) {
168+
spinner.start("Cleaning shell config...")
169+
const err = await cleanShellConfig(targets.shellConfig).catch((e) => e)
170+
if (err) {
171+
spinner.stop("Failed to clean shell config", 1)
172+
errors.push(`Shell config: ${err.message}`)
173+
} else {
174+
spinner.stop("Cleaned shell config")
175+
}
176+
}
177+
178+
if (method !== "curl" && method !== "unknown") {
179+
const cmds: Record<string, string[]> = {
180+
npm: ["npm", "uninstall", "-g", "opencode-ai"],
181+
pnpm: ["pnpm", "uninstall", "-g", "opencode-ai"],
182+
bun: ["bun", "remove", "-g", "opencode-ai"],
183+
yarn: ["yarn", "global", "remove", "opencode-ai"],
184+
brew: ["brew", "uninstall", "opencode"],
185+
}
186+
187+
const cmd = cmds[method]
188+
if (cmd) {
189+
spinner.start(`Running ${cmd.join(" ")}...`)
190+
const result = await $`${cmd}`.quiet().nothrow()
191+
if (result.exitCode !== 0) {
192+
spinner.stop(`Package manager uninstall failed`, 1)
193+
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)
194+
errors.push(`Package manager: exit code ${result.exitCode}`)
195+
} else {
196+
spinner.stop("Package removed")
197+
}
198+
}
199+
}
200+
201+
if (method === "curl" && targets.binary) {
202+
UI.empty()
203+
prompts.log.message("To finish removing the binary, run:")
204+
prompts.log.info(` rm "${targets.binary}"`)
205+
206+
const binDir = path.dirname(targets.binary)
207+
if (binDir.includes(".opencode")) {
208+
prompts.log.info(` rmdir "${binDir}" 2>/dev/null`)
209+
}
210+
}
211+
212+
if (errors.length > 0) {
213+
UI.empty()
214+
prompts.log.warn("Some operations failed:")
215+
for (const err of errors) {
216+
prompts.log.error(` ${err}`)
217+
}
218+
}
219+
220+
UI.empty()
221+
prompts.log.success("Thank you for using OpenCode!")
222+
}
223+
224+
async function getShellConfigFile(): Promise<string | null> {
225+
const shell = path.basename(process.env.SHELL || "bash")
226+
const home = os.homedir()
227+
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, ".config")
228+
229+
const configFiles: Record<string, string[]> = {
230+
fish: [path.join(xdgConfig, "fish", "config.fish")],
231+
zsh: [
232+
path.join(home, ".zshrc"),
233+
path.join(home, ".zshenv"),
234+
path.join(xdgConfig, "zsh", ".zshrc"),
235+
path.join(xdgConfig, "zsh", ".zshenv"),
236+
],
237+
bash: [
238+
path.join(home, ".bashrc"),
239+
path.join(home, ".bash_profile"),
240+
path.join(home, ".profile"),
241+
path.join(xdgConfig, "bash", ".bashrc"),
242+
path.join(xdgConfig, "bash", ".bash_profile"),
243+
],
244+
ash: [path.join(home, ".ashrc"), path.join(home, ".profile")],
245+
sh: [path.join(home, ".profile")],
246+
}
247+
248+
const candidates = configFiles[shell] || configFiles.bash
249+
250+
for (const file of candidates) {
251+
const exists = await fs
252+
.access(file)
253+
.then(() => true)
254+
.catch(() => false)
255+
if (!exists) continue
256+
257+
const content = await Bun.file(file)
258+
.text()
259+
.catch(() => "")
260+
if (content.includes("# opencode") || content.includes(".opencode/bin")) {
261+
return file
262+
}
263+
}
264+
265+
return null
266+
}
267+
268+
async function cleanShellConfig(file: string) {
269+
const content = await Bun.file(file).text()
270+
const lines = content.split("\n")
271+
272+
const filtered: string[] = []
273+
let skip = false
274+
275+
for (const line of lines) {
276+
const trimmed = line.trim()
277+
278+
if (trimmed === "# opencode") {
279+
skip = true
280+
continue
281+
}
282+
283+
if (skip) {
284+
skip = false
285+
if (trimmed.includes(".opencode/bin") || trimmed.includes("fish_add_path")) {
286+
continue
287+
}
288+
}
289+
290+
if (
291+
(trimmed.startsWith("export PATH=") && trimmed.includes(".opencode/bin")) ||
292+
(trimmed.startsWith("fish_add_path") && trimmed.includes(".opencode"))
293+
) {
294+
continue
295+
}
296+
297+
filtered.push(line)
298+
}
299+
300+
while (filtered.length > 0 && filtered[filtered.length - 1].trim() === "") {
301+
filtered.pop()
302+
}
303+
304+
const output = filtered.join("\n") + "\n"
305+
await Bun.write(file, output)
306+
}
307+
308+
async function getDirectorySize(dir: string): Promise<number> {
309+
let total = 0
310+
311+
const walk = async (current: string) => {
312+
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => [])
313+
314+
for (const entry of entries) {
315+
const full = path.join(current, entry.name)
316+
if (entry.isDirectory()) {
317+
await walk(full)
318+
continue
319+
}
320+
if (entry.isFile()) {
321+
const stat = await fs.stat(full).catch(() => null)
322+
if (stat) total += stat.size
323+
}
324+
}
325+
}
326+
327+
await walk(dir)
328+
return total
329+
}
330+
331+
function formatSize(bytes: number): string {
332+
if (bytes < 1024) return `${bytes} B`
333+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
334+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
335+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
336+
}
337+
338+
function shortenPath(p: string): string {
339+
const home = os.homedir()
340+
if (p.startsWith(home)) {
341+
return p.replace(home, "~")
342+
}
343+
return p
344+
}

packages/opencode/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Log } from "./util/log"
66
import { AuthCommand } from "./cli/cmd/auth"
77
import { AgentCommand } from "./cli/cmd/agent"
88
import { UpgradeCommand } from "./cli/cmd/upgrade"
9+
import { UninstallCommand } from "./cli/cmd/uninstall"
910
import { ModelsCommand } from "./cli/cmd/models"
1011
import { UI } from "./cli/ui"
1112
import { Installation } from "./installation"
@@ -86,6 +87,7 @@ const cli = yargs(hideBin(process.argv))
8687
.command(AuthCommand)
8788
.command(AgentCommand)
8889
.command(UpgradeCommand)
90+
.command(UninstallCommand)
8991
.command(ServeCommand)
9092
.command(WebCommand)
9193
.command(ModelsCommand)

0 commit comments

Comments
 (0)