Skip to content

Commit 10e0f75

Browse files
committed
feat(opencode): add plugin management CLI commands
Add `opencode plugin` subcommand with: - list: List installed plugins with current/latest version comparison - update [name]: Update all or specific plugin to latest version - add <name>: Add a plugin to config - remove <name>: Remove a plugin from config Implementation details: - Use Config.getPluginName() for consistent name extraction - URL-encode scoped packages for npm registry fetches - Normalize user input (e.g., 'foo@1.0' matches 'foo') - Validate config.plugin is array before processing - Atomic config writes (temp file + rename) - Proper error handling and status reporting - Fix spinner lifecycle (stop before re-start) - Show 'latest unknown' when registry fetch fails Fixes #6159
1 parent 7cba1ff commit 10e0f75

File tree

2 files changed

+376
-0
lines changed

2 files changed

+376
-0
lines changed
Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
import { cmd } from "./cmd"
2+
import * as prompts from "@clack/prompts"
3+
import { UI } from "../ui"
4+
import { Config } from "../../config/config"
5+
import { Global } from "../../global"
6+
import { BunProc } from "../../bun"
7+
import path from "path"
8+
import fs from "fs/promises"
9+
10+
async function fetchLatestVersion(pkg: string): Promise<string | null> {
11+
try {
12+
const encodedPkg = encodeURIComponent(pkg).replace("%40", "@")
13+
const response = await fetch(`https://registry.npmjs.org/${encodedPkg}/latest`, {
14+
signal: AbortSignal.timeout(10000),
15+
})
16+
if (!response.ok) return null
17+
const data = (await response.json()) as { version?: string }
18+
return data.version ?? null
19+
} catch {
20+
return null
21+
}
22+
}
23+
24+
async function getInstalledVersion(pkg: string): Promise<string | null> {
25+
try {
26+
const pkgJsonPath = path.join(Global.Path.cache, "node_modules", pkg, "package.json")
27+
const content = await fs.readFile(pkgJsonPath, "utf-8")
28+
const data = JSON.parse(content) as { version?: string }
29+
return data.version ?? null
30+
} catch {
31+
return null
32+
}
33+
}
34+
35+
function extractVersionFromSpecifier(specifier: string): string | null {
36+
if (specifier.startsWith("file://")) return null
37+
const lastAt = specifier.lastIndexOf("@")
38+
if (lastAt > 0) {
39+
const version = specifier.substring(lastAt + 1)
40+
return version.length > 0 ? version : null
41+
}
42+
return null
43+
}
44+
45+
function getPluginsArray(config: Config.Info): string[] {
46+
if (!config.plugin) return []
47+
if (!Array.isArray(config.plugin)) return []
48+
return config.plugin.filter((p): p is string => typeof p === "string")
49+
}
50+
51+
async function readGlobalConfig(): Promise<{ filePath: string; data: Config.Info }> {
52+
const filePath = path.join(Global.Path.config, "opencode.json")
53+
try {
54+
const text = await fs.readFile(filePath, "utf-8")
55+
const data = JSON.parse(text) as Config.Info
56+
return { filePath, data }
57+
} catch (err: unknown) {
58+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
59+
return { filePath, data: {} }
60+
}
61+
throw err
62+
}
63+
}
64+
65+
async function writeGlobalConfig(filePath: string, data: Config.Info): Promise<void> {
66+
await fs.mkdir(path.dirname(filePath), { recursive: true })
67+
const tempPath = `${filePath}.tmp.${process.pid}`
68+
await fs.writeFile(tempPath, JSON.stringify(data, null, 2))
69+
await fs.rename(tempPath, filePath)
70+
}
71+
72+
export const PluginCommand = cmd({
73+
command: "plugin",
74+
describe: "manage opencode plugins",
75+
builder: (yargs) =>
76+
yargs
77+
.command(PluginListCommand)
78+
.command(PluginUpdateCommand)
79+
.command(PluginAddCommand)
80+
.command(PluginRemoveCommand)
81+
.demandCommand(),
82+
async handler() {},
83+
})
84+
85+
export const PluginListCommand = cmd({
86+
command: "list",
87+
aliases: ["ls"],
88+
describe: "list installed plugins with version information",
89+
async handler() {
90+
UI.empty()
91+
prompts.intro("Installed Plugins")
92+
93+
const { data: config } = await readGlobalConfig()
94+
const plugins = getPluginsArray(config)
95+
96+
if (plugins.length === 0) {
97+
prompts.log.warn("No plugins configured")
98+
prompts.outro("Add plugins with: opencode plugin add <name>")
99+
return
100+
}
101+
102+
const spinner = prompts.spinner()
103+
spinner.start("Checking versions...")
104+
105+
const results: Array<{
106+
specifier: string
107+
name: string
108+
installed: string | null
109+
latest: string | null
110+
isLocal: boolean
111+
}> = []
112+
113+
for (const specifier of plugins) {
114+
const name = Config.getPluginName(specifier)
115+
const isLocal = specifier.startsWith("file://")
116+
117+
if (isLocal) {
118+
results.push({ specifier, name, installed: null, latest: null, isLocal: true })
119+
} else {
120+
const [installed, latest] = await Promise.all([getInstalledVersion(name), fetchLatestVersion(name)])
121+
results.push({ specifier, name, installed, latest, isLocal: false })
122+
}
123+
}
124+
125+
spinner.stop("Version check complete")
126+
127+
let updatesAvailable = 0
128+
let fetchFailed = 0
129+
for (const r of results) {
130+
let icon: string
131+
let info: string
132+
133+
if (r.isLocal) {
134+
icon = "○"
135+
info = `${UI.Style.TEXT_DIM}local file`
136+
} else if (!r.installed) {
137+
icon = "?"
138+
info = `${UI.Style.TEXT_DIM}not installed`
139+
} else if (!r.latest) {
140+
icon = "!"
141+
info = `${r.installed} ${UI.Style.TEXT_DIM}(latest unknown)`
142+
fetchFailed++
143+
} else if (r.installed !== r.latest) {
144+
icon = "⬆"
145+
info = `${r.installed}${r.latest}`
146+
updatesAvailable++
147+
} else {
148+
icon = "✓"
149+
info = `${UI.Style.TEXT_DIM}${r.installed}`
150+
}
151+
152+
prompts.log.info(`${icon} ${r.name} ${info}`)
153+
}
154+
155+
let summary = `${plugins.length} plugin(s)`
156+
if (updatesAvailable > 0) {
157+
summary += `, ${updatesAvailable} update(s) available`
158+
}
159+
if (fetchFailed > 0) {
160+
summary += `, ${fetchFailed} check(s) failed`
161+
}
162+
if (updatesAvailable > 0) {
163+
summary += `. Run: opencode plugin update`
164+
}
165+
prompts.outro(summary)
166+
},
167+
})
168+
169+
export const PluginUpdateCommand = cmd({
170+
command: "update [name]",
171+
describe: "update plugins to their latest versions",
172+
builder: (yargs) =>
173+
yargs.positional("name", {
174+
describe: "specific plugin to update (updates all if not specified)",
175+
type: "string",
176+
}),
177+
async handler(args) {
178+
UI.empty()
179+
prompts.intro("Update Plugins")
180+
181+
const { filePath, data: config } = await readGlobalConfig()
182+
const plugins = getPluginsArray(config)
183+
184+
if (plugins.length === 0) {
185+
prompts.log.warn("No plugins configured")
186+
prompts.outro("Add plugins with: opencode plugin add <name>")
187+
return
188+
}
189+
190+
let targetIndices: number[]
191+
if (args.name) {
192+
const targetName = Config.getPluginName(args.name)
193+
const idx = plugins.findIndex((p) => Config.getPluginName(p) === targetName)
194+
if (idx === -1) {
195+
prompts.log.error(`Plugin not found: ${targetName}`)
196+
prompts.outro("Done")
197+
return
198+
}
199+
targetIndices = [idx]
200+
} else {
201+
targetIndices = plugins.map((_, i) => i)
202+
}
203+
204+
const spinner = prompts.spinner()
205+
let updatedCount = 0
206+
let skippedCount = 0
207+
const updatedPlugins = [...plugins]
208+
209+
for (const idx of targetIndices) {
210+
const specifier = plugins[idx]
211+
const name = Config.getPluginName(specifier)
212+
213+
if (specifier.startsWith("file://")) {
214+
prompts.log.info(`○ ${name} ${UI.Style.TEXT_DIM}skipped (local file)`)
215+
continue
216+
}
217+
218+
spinner.start(`Checking ${name}...`)
219+
220+
const [installed, latest] = await Promise.all([getInstalledVersion(name), fetchLatestVersion(name)])
221+
222+
if (!latest) {
223+
spinner.stop(`! ${name} ${UI.Style.TEXT_DIM}failed to fetch latest version`)
224+
skippedCount++
225+
continue
226+
}
227+
228+
if (installed === latest) {
229+
spinner.stop(`✓ ${name} ${UI.Style.TEXT_DIM}already at ${latest}`)
230+
continue
231+
}
232+
233+
spinner.stop(` ${name} ${installed ?? "?"}${latest}`)
234+
spinner.start(`Installing ${name}@${latest}...`)
235+
236+
try {
237+
await BunProc.install(name, latest)
238+
updatedPlugins[idx] = `${name}@${latest}`
239+
spinner.stop(`✓ ${name} updated to ${latest}`)
240+
updatedCount++
241+
} catch (error) {
242+
spinner.stop(`✗ ${name} ${UI.Style.TEXT_DIM}update failed`)
243+
prompts.log.error(error instanceof Error ? error.message : String(error))
244+
}
245+
}
246+
247+
if (updatedCount > 0) {
248+
config.plugin = updatedPlugins
249+
await writeGlobalConfig(filePath, config)
250+
}
251+
252+
let summary = `${updatedCount} plugin(s) updated`
253+
if (skippedCount > 0) {
254+
summary += `, ${skippedCount} skipped (registry unreachable)`
255+
}
256+
prompts.outro(summary)
257+
},
258+
})
259+
260+
export const PluginAddCommand = cmd({
261+
command: "add <name>",
262+
describe: "add a plugin",
263+
builder: (yargs) =>
264+
yargs.positional("name", {
265+
describe: "plugin package name (e.g., oh-my-opencode or oh-my-opencode@2.0.0)",
266+
type: "string",
267+
demandOption: true,
268+
}),
269+
async handler(args) {
270+
UI.empty()
271+
prompts.intro("Add Plugin")
272+
273+
const { filePath, data: config } = await readGlobalConfig()
274+
const plugins = getPluginsArray(config)
275+
276+
const inputName = Config.getPluginName(args.name)
277+
const inputVersion = extractVersionFromSpecifier(args.name)
278+
const isLocal = args.name.startsWith("file://")
279+
280+
const existingIdx = plugins.findIndex((p) => Config.getPluginName(p) === inputName)
281+
282+
if (existingIdx !== -1) {
283+
const existingVersion = extractVersionFromSpecifier(plugins[existingIdx])
284+
prompts.log.warn(`Plugin ${inputName} is already installed (${existingVersion ?? "unknown version"})`)
285+
286+
const confirm = await prompts.confirm({ message: "Replace with new version?" })
287+
if (prompts.isCancel(confirm) || !confirm) {
288+
prompts.outro("Cancelled")
289+
return
290+
}
291+
}
292+
293+
let finalSpecifier: string
294+
295+
if (isLocal) {
296+
finalSpecifier = args.name
297+
prompts.log.info(`Adding local plugin: ${args.name}`)
298+
} else {
299+
const spinner = prompts.spinner()
300+
const targetVersion = inputVersion ?? "latest"
301+
spinner.start(`Installing ${inputName}@${targetVersion}...`)
302+
303+
try {
304+
await BunProc.install(inputName, targetVersion)
305+
const installed = await getInstalledVersion(inputName)
306+
finalSpecifier = `${inputName}@${installed ?? targetVersion}`
307+
spinner.stop(`✓ Installed ${finalSpecifier}`)
308+
} catch (error) {
309+
spinner.stop(`✗ Failed to install ${inputName}`)
310+
prompts.log.error(error instanceof Error ? error.message : String(error))
311+
prompts.outro("Installation failed")
312+
return
313+
}
314+
}
315+
316+
if (existingIdx !== -1) {
317+
plugins[existingIdx] = finalSpecifier
318+
} else {
319+
plugins.push(finalSpecifier)
320+
}
321+
322+
config.plugin = plugins
323+
await writeGlobalConfig(filePath, config)
324+
325+
prompts.outro("Plugin added successfully")
326+
},
327+
})
328+
329+
export const PluginRemoveCommand = cmd({
330+
command: "remove <name>",
331+
aliases: ["rm"],
332+
describe: "remove a plugin",
333+
builder: (yargs) =>
334+
yargs.positional("name", {
335+
describe: "plugin package name to remove",
336+
type: "string",
337+
demandOption: true,
338+
}),
339+
async handler(args) {
340+
UI.empty()
341+
prompts.intro("Remove Plugin")
342+
343+
const { filePath, data: config } = await readGlobalConfig()
344+
const plugins = getPluginsArray(config)
345+
346+
const targetName = Config.getPluginName(args.name)
347+
const idx = plugins.findIndex((p) => Config.getPluginName(p) === targetName)
348+
349+
if (idx === -1) {
350+
prompts.log.error(`Plugin not found: ${targetName}`)
351+
prompts.log.info("Installed plugins:")
352+
for (const p of plugins) {
353+
prompts.log.info(` - ${Config.getPluginName(p)}`)
354+
}
355+
prompts.outro("Done")
356+
return
357+
}
358+
359+
const name = Config.getPluginName(plugins[idx])
360+
361+
const confirm = await prompts.confirm({ message: `Remove ${name}?` })
362+
if (prompts.isCancel(confirm) || !confirm) {
363+
prompts.outro("Cancelled")
364+
return
365+
}
366+
367+
plugins.splice(idx, 1)
368+
config.plugin = plugins
369+
await writeGlobalConfig(filePath, config)
370+
371+
prompts.log.success(`Removed ${name}`)
372+
prompts.outro("Plugin removed successfully")
373+
},
374+
})

packages/opencode/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ServeCommand } from "./cli/cmd/serve"
1616
import { DebugCommand } from "./cli/cmd/debug"
1717
import { StatsCommand } from "./cli/cmd/stats"
1818
import { McpCommand } from "./cli/cmd/mcp"
19+
import { PluginCommand } from "./cli/cmd/plugin"
1920
import { GithubCommand } from "./cli/cmd/github"
2021
import { ExportCommand } from "./cli/cmd/export"
2122
import { ImportCommand } from "./cli/cmd/import"
@@ -80,6 +81,7 @@ const cli = yargs(hideBin(process.argv))
8081
.completion("completion", "generate shell completion script")
8182
.command(AcpCommand)
8283
.command(McpCommand)
84+
.command(PluginCommand)
8385
.command(TuiThreadCommand)
8486
.command(TuiSpawnCommand)
8587
.command(AttachCommand)

0 commit comments

Comments
 (0)