opencode-plugin-browser 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tui.tsx ADDED
@@ -0,0 +1,959 @@
1
+ import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
2
+ import { createSignal, For, Show } from "solid-js"
3
+ import { readdirSync, readFileSync, statSync } from "node:fs"
4
+ import { basename, dirname, join } from "node:path"
5
+ import {
6
+ asArray,
7
+ createCachedResource,
8
+ createCachedStore,
9
+ createToggle,
10
+ showToast,
11
+ workspaceDirectory,
12
+ type Toggle,
13
+ } from "opencode-plugin-kit"
14
+ import { CollapsibleGroup, CollapsibleSection } from "opencode-plugin-kit/collapsible"
15
+ import { describeBuiltin } from "./builtins.js"
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Data model
19
+ // ---------------------------------------------------------------------------
20
+
21
+ type Kind = "npm" | "local" | "builtin"
22
+
23
+ interface Entry {
24
+ name: string
25
+ kind: Kind
26
+ /** Project dir (local + uninstalled workspace candidates). */
27
+ dir?: string
28
+ /** npm version, when known. */
29
+ version?: string
30
+ /** npm registry reports an update available. */
31
+ outdated?: boolean
32
+ /** Activation state from the plugin registry ("active"/"failed"/…). */
33
+ status?: string
34
+ /** Description from the project's package.json (local plugins). */
35
+ description?: string
36
+ /** Known from config but not seen by the server registry (TUI-only). */
37
+ configOnly?: boolean
38
+ }
39
+
40
+ export const GROUP_ORDER: Kind[] = ["npm", "local", "builtin"]
41
+
42
+ // Groups that start collapsed (built-ins are numerous). npm and local open.
43
+ export const DEFAULT_COLLAPSED: Partial<Record<Kind, boolean>> = {
44
+ builtin: true,
45
+ }
46
+
47
+ // A directory is a plugin project when its package.json depends on the
48
+ // OpenCode plugin API and isn't a private monorepo root or the shared kit
49
+ // library (which has no plugin dependency).
50
+ export function isPluginProject(dir: string): { name: string; description?: string } | null {
51
+ try {
52
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))
53
+ if (pkg.private === true) return null
54
+ const deps = {
55
+ ...pkg.dependencies,
56
+ ...pkg.peerDependencies,
57
+ }
58
+ if ("@opencode-ai/plugin" in deps) {
59
+ return {
60
+ name: String(pkg.name ?? basename(dir)),
61
+ description: pkg.description ? String(pkg.description) : undefined,
62
+ }
63
+ }
64
+ } catch {
65
+ // Not a readable package.json — not a plugin project.
66
+ }
67
+ return null
68
+ }
69
+
70
+ // Scan the workspace root (the location's directory) for sibling plugin
71
+ // projects. This is the "known plugins" universe beyond the registry:
72
+ // anything here that isn't installed shows up as uninstalled.
73
+ export function scanWorkspace(root: string): Entry[] {
74
+ try {
75
+ return readdirSync(root, { withFileTypes: true })
76
+ .filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules")
77
+ .map((d) => join(root, d.name))
78
+ .filter((dir) =>
79
+ statSync(join(dir, "package.json"), {
80
+ throwIfNoEntry: false,
81
+ })?.isFile(),
82
+ )
83
+ .map((dir) => ({ meta: isPluginProject(dir), dir }))
84
+ .filter((e) => e.meta)
85
+ .map(({ meta, dir }) => ({
86
+ name: meta!.name,
87
+ dir,
88
+ kind: "local" as const,
89
+ status: "uninstalled",
90
+ description: meta!.description,
91
+ }))
92
+ } catch {
93
+ return []
94
+ }
95
+ }
96
+
97
+ // Strip a trailing npm version from a specifier without touching scoped
98
+ // names: "pkg@1.2" → "pkg", "@scope/pkg@1.2" → "@scope/pkg", "@scope/pkg" →
99
+ // unchanged.
100
+ export function stripVersion(spec: string): string {
101
+ const m = spec.match(/^(.+[^@])@([^@]+)$/)
102
+ return m ? m[1] : spec
103
+ }
104
+
105
+ // A config `plugins` entry is either a local path ("./x", "/abs/x") or a
106
+ // package specifier ("pkg", "@scope/pkg", "pkg@1.2", a git URL).
107
+ export function configEntry(entry: string, docDir: string): Entry | null {
108
+ const e = String(entry ?? "").trim()
109
+ if (!e) return null
110
+ if (e.startsWith(".") || e.startsWith("/")) {
111
+ const abs = e.startsWith("/") ? e : join(docDir, e)
112
+ const meta = isPluginProject(abs)
113
+ return {
114
+ name: meta?.name ?? basename(abs),
115
+ dir: abs,
116
+ kind: "local",
117
+ configOnly: true,
118
+ description: meta?.description,
119
+ }
120
+ }
121
+ const name =
122
+ e.startsWith("http") || e.includes("://") ? stripVersion(basename(e).replace(/\.git$/, "")) : stripVersion(e)
123
+ // A specifier that survives the branches above always yields a non-empty
124
+ // name; the empty fallback is defensive only.
125
+ /* v8 ignore next */
126
+ return name ? { name, kind: "npm", configOnly: true } : null
127
+ }
128
+
129
+ // Load every plugin from two complementary sources:
130
+ // 1. the plugin registry (client.plugin.list()) — activation state and the
131
+ // built-ins; the server only activates plugins with server features, so
132
+ // TUI-only plugins are absent here
133
+ // 2. the config documents (client.config.get()) — every configured plugin,
134
+ // local or npm, loaded or not
135
+ // and merge with the workspace scan for uninstalled candidates.
136
+ export async function loadEntries(ctx: any, root: string): Promise<Entry[]> {
137
+ const [regOut, cfgOut] = await Promise.all([
138
+ ctx.client.plugin.list().catch(() => undefined),
139
+ ctx.client.config.get().catch(() => undefined),
140
+ ])
141
+ const registry = asArray<any>(regOut)
142
+
143
+ const localDirs = new Set(
144
+ registry.map((p) => (p.source?.type === "local" && p.source.path ? dirname(p.source.path) : "")).filter(Boolean),
145
+ )
146
+ const ids = new Set(registry.map((p) => String(p.id ?? "")).filter(Boolean))
147
+ const targets = new Set(
148
+ registry
149
+ .map((p) => (p.source?.type === "package" && p.source.target ? stripVersion(String(p.source.target)) : ""))
150
+ .filter(Boolean),
151
+ )
152
+
153
+ const entries: Entry[] = registry.map((p): Entry => {
154
+ const src = p.source ?? {}
155
+ const kind: Kind = src.type === "package" ? "npm" : src.type === "local" ? "local" : "builtin"
156
+ const dir = kind === "local" ? dirname(src.path) : undefined
157
+ const name =
158
+ kind === "builtin"
159
+ ? String(p.id ?? src.type)
160
+ : basename(dir ?? stripVersion(String(src.target ?? p.id ?? "plugin")))
161
+ return {
162
+ name,
163
+ kind,
164
+ dir,
165
+ version: kind === "npm" && src.version ? String(src.version) : undefined,
166
+ outdated: src.outdated === true,
167
+ status: String(p.state?.status ?? "") || undefined,
168
+ }
169
+ })
170
+
171
+ // Config-declared plugins the registry doesn't know: local paths resolve
172
+ // against each config document's directory; npm specifiers keep their
173
+ // package name. Skip anything the registry already covers.
174
+ //
175
+ // The service may run from a different location than this workspace, so
176
+ // config.get() can omit the project's opencode.json entirely — read the
177
+ // project config files directly as well, and collect every declared
178
+ // plugin (registry + config docs + project files) into coverage sets the
179
+ // uninstalled scan checks against.
180
+ const cfgData: any = (cfgOut as any)?.data ?? cfgOut
181
+ const docs: any[] = Array.isArray(cfgData) ? cfgData : asArray<any>(cfgData)
182
+
183
+ // Coverage: dirs/names of every plugin declared as installed anywhere.
184
+ const coveredDirs = new Set(localDirs)
185
+ const coveredNames = new Set<string>([...ids, ...targets])
186
+
187
+ const pushConfigEntries = (plugins: string[], docDir: string) => {
188
+ for (const raw of plugins) {
189
+ const e = configEntry(raw, docDir)
190
+ if (!e) continue
191
+ if (e.dir) coveredDirs.add(e.dir)
192
+ coveredNames.add(e.name)
193
+ const known =
194
+ (e.dir && localDirs.has(e.dir)) ||
195
+ ids.has(e.name) ||
196
+ targets.has(e.name) ||
197
+ entries.some((x) => (e.dir ? x.dir === e.dir : false) || (x.kind === e.kind && x.name === e.name))
198
+ if (known) continue
199
+ entries.push(e)
200
+ }
201
+ }
202
+
203
+ for (const doc of docs) {
204
+ const plugins = (doc?.info as any)?.plugins
205
+ if (!Array.isArray(plugins)) continue
206
+ pushConfigEntries(plugins, doc?.path ? dirname(String(doc.path)) : root)
207
+ }
208
+
209
+ // The project's own config files, regardless of where the service runs.
210
+ // Tolerant parse: jsonc (comments, trailing commas) is allowed here too.
211
+ for (const file of ["opencode.json", "opencode.jsonc"]) {
212
+ const path = join(root, file)
213
+ try {
214
+ const parsed = tolerantParse(readFileSync(path, "utf8")) as any
215
+ if (parsed && Array.isArray(parsed.plugins)) {
216
+ pushConfigEntries(parsed.plugins, root)
217
+ }
218
+ } catch {
219
+ // No readable project config here.
220
+ }
221
+ }
222
+
223
+ // Backfill descriptions for local entries the registry knows but the
224
+ // config/scan didn't cover (one package.json read per project).
225
+ for (const e of entries) {
226
+ if (e.kind === "local" && e.dir && !e.description) {
227
+ try {
228
+ const pkg = JSON.parse(readFileSync(join(e.dir, "package.json"), "utf8"))
229
+ if (pkg.description) e.description = String(pkg.description)
230
+ } catch {
231
+ // No readable package.json — leave the description empty.
232
+ }
233
+ }
234
+ }
235
+
236
+ // Uninstalled workspace candidates.
237
+ const seen = new Set(entries.map((e) => e.dir ?? e.name))
238
+ for (const cand of scanWorkspace(root)) {
239
+ const key = cand.dir!
240
+ if (coveredDirs.has(key) || coveredNames.has(cand.name)) {
241
+ const existing = entries.find((e) => e.dir === key || e.name === cand.name)
242
+ if (existing) {
243
+ existing.dir = existing.dir ?? key
244
+ existing.description = existing.description ?? cand.description
245
+ // Config-declared but unregistered by the server: not uninstalled.
246
+ if (existing.status === "uninstalled") {
247
+ existing.status = undefined // registered: now just installed
248
+ }
249
+ }
250
+ continue
251
+ }
252
+ // Any prior entry with this dir would have matched coveredDirs above, so
253
+ // `seen.has(key)` is always false here; the guard is defensive only.
254
+ /* v8 ignore start */
255
+ if (!seen.has(key)) {
256
+ seen.add(key)
257
+ entries.push(cand)
258
+ }
259
+ /* v8 ignore stop */
260
+ }
261
+
262
+ return entries.toSorted((a, b) => {
263
+ const ka = GROUP_ORDER.indexOf(a.kind)
264
+ const kb = GROUP_ORDER.indexOf(b.kind)
265
+ if (ka !== kb) return ka - kb
266
+ return a.name.localeCompare(b.name)
267
+ })
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Config toggle — install (register) or uninstall (remove) a plugin by
272
+ // editing the `plugins` array of the config file that declares it.
273
+ // ---------------------------------------------------------------------------
274
+
275
+ /** The config document that owns a `plugins` array, preferring the project's
276
+ * own opencode.json; falls back to `<root>/opencode.json`. */
277
+ export function configDocPath(docs: any[], root: string): string {
278
+ const withPlugins = docs.filter((d) => Array.isArray((d?.info as any)?.plugins) && d.path)
279
+ const project = withPlugins.find((d) => dirname(String(d.path)) === root)
280
+ if (project) return String(project.path)
281
+ if (withPlugins.length > 0) return String(withPlugins[0].path)
282
+ // No doc with plugins: edit the project's own config — jsonc if that is
283
+ // what exists, else the default opencode.json.
284
+ const jsonc = join(root, "opencode.jsonc")
285
+ if (statSync(jsonc, { throwIfNoEntry: false })?.isFile()) return jsonc
286
+ return join(root, "opencode.json")
287
+ }
288
+
289
+ /** Resolve a config plugin specifier to the entry it would match, if any. */
290
+ export function specMatches(spec: string, docDir: string, e: Entry): boolean {
291
+ const s = String(spec ?? "").trim()
292
+ if (!s) return false
293
+ if (e.kind === "local" || e.dir) {
294
+ if (s.startsWith(".") || s.startsWith("/")) {
295
+ const abs = s.startsWith("/") ? s : join(docDir, s)
296
+ return e.dir ? abs === e.dir : basename(abs) === e.name
297
+ }
298
+ return false
299
+ }
300
+ return stripVersion(s) === e.name
301
+ }
302
+
303
+ export function readConfig(path: string): {
304
+ ok: boolean
305
+ plugins: string[]
306
+ rest: any
307
+ } {
308
+ let raw: string
309
+ try {
310
+ raw = readFileSync(path, "utf8")
311
+ } catch {
312
+ return { ok: false, plugins: [], rest: {} }
313
+ }
314
+ const parsed = tolerantParse(raw)
315
+ if (parsed === undefined || typeof parsed !== "object" || parsed === null) {
316
+ return { ok: false, plugins: [], rest: {} }
317
+ }
318
+ const obj = parsed as Record<string, unknown>
319
+ return {
320
+ ok: true,
321
+ plugins: Array.isArray(obj.plugins) ? obj.plugins.map(String) : [],
322
+ rest: obj,
323
+ }
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Tolerant JSONC config handling
328
+ //
329
+ // OpenCode configs may be JSONC (comments, trailing commas), and users
330
+ // legitimately keep comments in them. Two pieces:
331
+ // 1. tolerantParse — reads JSONC the way the server does.
332
+ // 2. textual plugins-array editing — splices the `plugins` array in place
333
+ // so every comment and every byte of formatting outside it survives.
334
+ // ---------------------------------------------------------------------------
335
+
336
+ /** Strip // and block comments plus trailing commas from JSONC text (string
337
+ * contents are preserved verbatim) so JSON.parse can read the result. */
338
+ export function cleanJsonc(text: string): string {
339
+ let out = ""
340
+ let i = 0
341
+ while (i < text.length) {
342
+ const c = text[i]
343
+ if (c === '"') {
344
+ // Copy the string (with escapes) verbatim.
345
+ let j = i + 1
346
+ while (j < text.length) {
347
+ if (text[j] === "\\") j += 2
348
+ else if (text[j] === '"') {
349
+ j++
350
+ break
351
+ } else j++
352
+ }
353
+ out += text.slice(i, j)
354
+ i = j
355
+ continue
356
+ }
357
+ if (c === "/" && text[i + 1] === "/") {
358
+ while (i < text.length && text[i] !== "\n") i++
359
+ continue // the newline itself is copied on the next iteration
360
+ }
361
+ if (c === "/" && text[i + 1] === "*") {
362
+ i += 2
363
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
364
+ i += 2
365
+ out += " "
366
+ continue
367
+ }
368
+ if (c === ",") {
369
+ // Drop a trailing comma: skip whitespace/comments after it and check
370
+ // whether the next significant character closes the current scope.
371
+ let j = i + 1
372
+ for (;;) {
373
+ while (j < text.length && /\s/.test(text[j])) j++
374
+ if (text[j] === "/" && text[j + 1] === "/") {
375
+ while (j < text.length && text[j] !== "\n") j++
376
+ continue
377
+ }
378
+ if (text[j] === "/" && text[j + 1] === "*") {
379
+ j += 2
380
+ while (j < text.length && !(text[j] === "*" && text[j + 1] === "/")) j++
381
+ j += 2
382
+ continue
383
+ }
384
+ break
385
+ }
386
+ if (text[j] === "}" || text[j] === "]") {
387
+ i++ // drop the comma
388
+ continue
389
+ }
390
+ }
391
+ out += c
392
+ i++
393
+ }
394
+ return out
395
+ }
396
+
397
+ /** Parse JSONC (or plain JSON). Returns undefined for unparseable input. */
398
+ export function tolerantParse(text: string): unknown {
399
+ try {
400
+ return JSON.parse(cleanJsonc(text))
401
+ } catch {
402
+ return undefined
403
+ }
404
+ }
405
+
406
+ interface StrTok {
407
+ t: "str"
408
+ v: string
409
+ start: number
410
+ end: number
411
+ }
412
+ interface PTok {
413
+ t: "p"
414
+ ch: string
415
+ start: number
416
+ }
417
+ type Tok = StrTok | PTok
418
+
419
+ /** Tokenize JSONC: strings (with offsets) and punctuation; comments skipped. */
420
+ function scanTokens(text: string): Tok[] {
421
+ const toks: Tok[] = []
422
+ let i = 0
423
+ while (i < text.length) {
424
+ const c = text[i]
425
+ if (c === '"') {
426
+ let j = i + 1
427
+ while (j < text.length) {
428
+ if (text[j] === "\\") j += 2
429
+ else if (text[j] === '"') {
430
+ j++
431
+ break
432
+ } else j++
433
+ }
434
+ toks.push({ t: "str", v: text.slice(i + 1, j - 1), start: i, end: j })
435
+ i = j
436
+ continue
437
+ }
438
+ if (c === "/" && text[i + 1] === "/") {
439
+ while (i < text.length && text[i] !== "\n") i++
440
+ continue
441
+ }
442
+ if (c === "/" && text[i + 1] === "*") {
443
+ i += 2
444
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
445
+ i += 2
446
+ continue
447
+ }
448
+ if ("{}[],:".includes(c)) toks.push({ t: "p", ch: c, start: i })
449
+ i++
450
+ }
451
+ return toks
452
+ }
453
+
454
+ function indentOf(text: string, pos: number): string {
455
+ const lineStart = text.lastIndexOf("\n", pos - 1) + 1
456
+ // `[ \t]*` always matches (possibly empty), so exec never returns null.
457
+ return /^[ \t]*/.exec(text.slice(lineStart, pos))![0]
458
+ }
459
+
460
+ /** Locate the top-level `plugins` key. Returns the token index of its "[",
461
+ * or null when the key is absent. `nonArray` flags `"plugins": <not-array>`.
462
+ * Only the root-level key matches — nested objects that happen to have a
463
+ * `plugins` key (e.g. under `mcp`) are ignored. */
464
+ function findPluginsArray(toks: Tok[]): { bracket: number; nonArray?: boolean } | null {
465
+ // The document is an object, so its keys live at brace depth 1; anything
466
+ // nested sits deeper.
467
+ let depth = 0
468
+ for (let i = 0; i < toks.length - 1; i++) {
469
+ const t = toks[i]
470
+ if (t.t === "p") {
471
+ if (t.ch === "{" || t.ch === "[") depth++
472
+ else if (t.ch === "}" || t.ch === "]") depth--
473
+ continue
474
+ }
475
+ if (depth !== 1 || t.t !== "str" || t.v !== "plugins") continue
476
+ const colon = toks[i + 1]
477
+ if (colon.t !== "p" || colon.ch !== ":") continue
478
+ const value = toks[i + 2]
479
+ if (value.t === "p" && value.ch === "[") return { bracket: i + 2 }
480
+ return { bracket: -1, nonArray: true }
481
+ }
482
+ return null
483
+ }
484
+
485
+ /** Collect the string items of the plugins array. Returns null when the array
486
+ * holds anything besides strings and commas (not a plugin list) — callers
487
+ * fall back to a full rewrite. */
488
+ function arrayItems(toks: Tok[], open: number, close: number): StrTok[] | null {
489
+ const items: StrTok[] = []
490
+ for (let i = open + 1; i < close; i++) {
491
+ const t = toks[i]
492
+ if (t.t === "str") items.push(t)
493
+ else if (t.ch !== ",") return null
494
+ }
495
+ return items
496
+ }
497
+
498
+ function matchingClose(toks: Tok[], open: number): number {
499
+ let depth = 0
500
+ for (let i = open; i < toks.length; i++) {
501
+ const t = toks[i]
502
+ if (t.t !== "p") continue
503
+ if (t.ch === "[" || t.ch === "{") depth++
504
+ else if (t.ch === "]" || t.ch === "}") {
505
+ depth--
506
+ if (depth === 0) return i
507
+ }
508
+ }
509
+ return -1
510
+ }
511
+
512
+ /** Insert a plugin specifier into the `plugins` array textually. Returns the
513
+ * updated document, the original text when the spec is already present, or
514
+ * null when there is no plugins array (the caller may insert one). */
515
+ export function addPluginSpec(raw: string, spec: string): string | null {
516
+ const toks = scanTokens(raw)
517
+ const found = findPluginsArray(toks)
518
+ if (!found || found.nonArray || found.bracket < 0) return null
519
+ const close = matchingClose(toks, found.bracket)
520
+ if (close < 0) return null
521
+ const items = arrayItems(toks, found.bracket, close)
522
+ if (items === null) return null
523
+ const q = JSON.stringify(spec)
524
+
525
+ if (items.length === 0) {
526
+ const openTok = toks[found.bracket] as PTok
527
+ const closeTok = toks[close] as PTok
528
+ if (!raw.slice(openTok.start, closeTok.start).includes("\n")) {
529
+ return raw.slice(0, openTok.start + 1) + q + raw.slice(closeTok.start)
530
+ }
531
+ const ind = indentOf(raw, closeTok.start)
532
+ return raw.slice(0, openTok.start + 1) + `\n${ind} ${q}\n${ind}` + raw.slice(closeTok.start)
533
+ }
534
+
535
+ if (items.some((t) => t.v === spec)) return raw
536
+ const last = items[items.length - 1]
537
+ // Inline array: append without a newline.
538
+ if (!raw.slice(toks[found.bracket].start, last.start).includes("\n")) {
539
+ return raw.slice(0, last.end) + `,${q}` + raw.slice(last.end)
540
+ }
541
+ const ind = indentOf(raw, last.start)
542
+ // A trailing comma (legal JSONC) right after the last item → insert after it.
543
+ let after = last.end
544
+ while (after < raw.length && (raw[after] === " " || raw[after] === "\t")) after++
545
+ if (raw[after] === ",") {
546
+ return raw.slice(0, after + 1) + `\n${ind}${q}` + raw.slice(after + 1)
547
+ }
548
+ return raw.slice(0, last.end) + `,\n${ind}${q}` + raw.slice(last.end)
549
+ }
550
+
551
+ /** Insert a `plugins` array (with one specifier) into a document that has no
552
+ * plugins key, textually — preserving any comments. */
553
+ export function addPluginsKey(raw: string, spec: string): string | null {
554
+ const toks = scanTokens(raw)
555
+ let closeTok: PTok | undefined
556
+ for (const t of toks) if (t.t === "p" && t.ch === "}") closeTok = t
557
+ if (!closeTok) return null
558
+ const q = JSON.stringify(spec)
559
+ // Does the object already have members (a "," or ":" before the brace)?
560
+ let needsComma = false
561
+ for (const t of toks) {
562
+ if (t.t === "p" && t.ch === "}" && t.start === closeTok.start) break
563
+ if (t.t === "p" && (t.ch === "," || t.ch === ":")) needsComma = true
564
+ }
565
+ const ind = indentOf(raw, closeTok.start)
566
+ const prefix = needsComma ? "," : ""
567
+ return raw.slice(0, closeTok.start) + `${prefix}\n${ind} "plugins": [${q}]\n${ind}` + raw.slice(closeTok.start)
568
+ }
569
+
570
+ /** Remove the first plugin specifier matching `matches` from the `plugins`
571
+ * array, textually. Returns the text and whether anything was removed, or
572
+ * null when there is no plugins array. */
573
+ export function removePluginSpec(
574
+ raw: string,
575
+ matches: (spec: string) => boolean,
576
+ ): { text: string; removed: boolean } | null {
577
+ const toks = scanTokens(raw)
578
+ const found = findPluginsArray(toks)
579
+ if (!found || found.nonArray || found.bracket < 0) return null
580
+ const close = matchingClose(toks, found.bracket)
581
+ if (close < 0) return null
582
+ const items = arrayItems(toks, found.bracket, close)
583
+ if (items === null) return null
584
+
585
+ // Find the first matching item's token index directly.
586
+ let at = -1
587
+ for (let i = found.bracket + 1; i < close; i++) {
588
+ const t = toks[i]
589
+ if (t.t === "str" && matches(t.v)) {
590
+ at = i
591
+ break
592
+ }
593
+ }
594
+ if (at < 0) return { text: raw, removed: false }
595
+ const item = toks[at] as StrTok
596
+ // Trim whitespace back to (and including) the item's newline, so a removed
597
+ // multiline item doesn't leave a blank indented line behind.
598
+ let cutStart = item.start
599
+ while (cutStart > 0 && (raw[cutStart - 1] === " " || raw[cutStart - 1] === "\t")) cutStart--
600
+ if (raw[cutStart - 1] === "\n") cutStart--
601
+ const next = toks[at + 1]
602
+ if (next && next.t === "p" && next.ch === ",") {
603
+ // Drop the item and its trailing comma, collapsing the whitespace after
604
+ // the comma to a single space so inline arrays keep a separator. When the
605
+ // removed item was the first, no separator is needed after the "[".
606
+ let end = (next as PTok).start + 1
607
+ let ws = ""
608
+ const isFirst = !toks.slice(found.bracket + 1, at).some((t) => t.t === "str")
609
+ if (!isFirst) {
610
+ while (end < raw.length && (raw[end] === " " || raw[end] === "\t")) {
611
+ ws ||= " "
612
+ end++
613
+ }
614
+ }
615
+ return {
616
+ text: raw.slice(0, cutStart) + ws + raw.slice(end),
617
+ removed: true,
618
+ }
619
+ }
620
+ // Last item: drop the preceding comma (if any) together with the item.
621
+ if (at > found.bracket + 1) {
622
+ const prev = toks[at - 1]
623
+ if (prev.t === "p" && prev.ch === ",") cutStart = prev.start
624
+ }
625
+ return { text: raw.slice(0, cutStart) + raw.slice(item.end), removed: true }
626
+ }
627
+
628
+ /** Apply an install/uninstall edit to the raw config text, preserving every
629
+ * comment and every byte of formatting outside the `plugins` array. Falls
630
+ * back to a full rewrite only when the document shape defeats the textual
631
+ * edit. */
632
+ function editPluginsText(
633
+ raw: string,
634
+ edit: { add: string; remove?: undefined } | { add?: undefined; remove: (spec: string) => boolean },
635
+ parsed: any,
636
+ next: string[],
637
+ ): string {
638
+ if (edit.add !== undefined) {
639
+ const spliced = addPluginSpec(raw, edit.add)
640
+ if (spliced !== null) return spliced
641
+ const keyed = addPluginsKey(raw, edit.add)
642
+ if (keyed !== null) return keyed
643
+ } else {
644
+ const result = removePluginSpec(raw, edit.remove)
645
+ if (result !== null) return result.text
646
+ }
647
+ const base = typeof parsed === "object" && parsed !== null ? parsed : {}
648
+ return `${JSON.stringify({ ...base, plugins: next }, null, 2)}\n`
649
+ }
650
+
651
+ // Toggle a plugin's installed/uninstalled status by editing the config's
652
+ // `plugins` array. The edit is textual, so comments and formatting in JSONC
653
+ // configs survive. Returns a user-facing message; throws on failure.
654
+ export async function toggleInstalled(ctx: any, root: string, e: Entry): Promise<string> {
655
+ const cfgOut = await ctx.client.config.get().catch((_err: any) => {
656
+ return undefined
657
+ })
658
+ const cfgData: any = (cfgOut as any)?.data ?? cfgOut
659
+ const docs = Array.isArray(cfgData) ? cfgData : asArray<any>(cfgData)
660
+ const path = configDocPath(docs, root)
661
+ const docDir = dirname(path)
662
+ let raw = ""
663
+ try {
664
+ raw = readFileSync(path, "utf8")
665
+ } catch {
666
+ raw = ""
667
+ }
668
+ const parsed = tolerantParse(raw)
669
+ if (parsed === undefined) throw new Error(`Cannot parse ${path}`)
670
+ const plugins = Array.isArray((parsed as any)?.plugins) ? (parsed as any).plugins.map(String) : []
671
+ const installing = e.status === "uninstalled"
672
+
673
+ let next: string[]
674
+ let edit: { add: string; remove?: undefined } | { add?: undefined; remove: (spec: string) => boolean }
675
+ if (installing) {
676
+ const spec = e.dir ? `./${basename(e.dir)}` : e.name // npm specifier; OpenCode resolves it on next start
677
+ if (plugins.includes(spec)) {
678
+ return `${e.name} registered — restart the TUI to load it`
679
+ }
680
+ next = [...plugins, spec]
681
+ edit = { add: spec }
682
+ } else {
683
+ next = plugins.filter((spec: string) => !specMatches(spec, docDir, e))
684
+ if (next.length === plugins.length) throw new Error(`No config entry matches ${e.name}`)
685
+ edit = { remove: (spec) => specMatches(spec, docDir, e) }
686
+ }
687
+
688
+ const fs = await import("node:fs")
689
+ fs.writeFileSync(path, editPluginsText(raw, edit, parsed, next))
690
+ return installing
691
+ ? `${e.name} registered — restart the TUI to load it`
692
+ : `${e.name} removed — restart the TUI to unload it`
693
+ }
694
+
695
+ // ---------------------------------------------------------------------------
696
+ // Widget — the top-level section and each group render through the kit's
697
+ // CollapsibleSection/CollapsibleGroup (same pattern as the built-in
698
+ // opencode.sidebar.mcp widget and the skill lister).
699
+ // ---------------------------------------------------------------------------
700
+
701
+ export const STATUS_GLYPH: Record<string, string> = {
702
+ active: "✓",
703
+ inactive: "○",
704
+ failed: "✗",
705
+ }
706
+
707
+ export const GROUP_TITLE: Record<Kind, string> = {
708
+ npm: "NPM",
709
+ local: "LOCAL",
710
+ builtin: "BUILT-IN",
711
+ }
712
+
713
+ export function PluginList(props: { sessionID?: string }) {
714
+ const ctx = usePlugin()
715
+ const theme = ctx.theme
716
+
717
+ // Built-in categories: sub-groups inside the BUILT-IN section, derived
718
+ // from the plugin id (opencode.<category>.<name>); everything without a
719
+ // known category lands in "core".
720
+ const BUILTIN_CATEGORIES: Array<[string, string]> = [
721
+ ["tool", "TOOLS"],
722
+ ["provider", "PROVIDERS"],
723
+ ["config", "CONFIG"],
724
+ ["websearch", "WEB SEARCH"],
725
+ ["prompt", "PROMPT ADAPTERS"],
726
+ ]
727
+ const builtinCategory = (id: string): string => {
728
+ const parts = id.split(".")
729
+ return parts[0] === "opencode" && parts.length >= 2 ? parts[1] : ""
730
+ }
731
+ const builtinSubGroups = (items: Entry[]) => {
732
+ const groups: Array<{ key: string; title: string; items: Entry[] }> = []
733
+ const seen = new Map<string, Entry[]>()
734
+ for (const [cat, title] of BUILTIN_CATEGORIES) {
735
+ const items2 = items.filter((e) => builtinCategory(e.name) === cat)
736
+ if (items2.length) {
737
+ seen.set(cat, items2)
738
+ groups.push({ key: `builtin:${cat}`, title, items: items2 })
739
+ }
740
+ }
741
+ const rest = items.filter((e) => !seen.has(builtinCategory(e.name)))
742
+ if (rest.length) groups.push({ key: "builtin:core", title: "CORE", items: rest })
743
+ return groups
744
+ }
745
+
746
+ // Built-ins are collapsed out of the summary count when hidden via
747
+ // /plugins-builtins (view toggle only — they still run).
748
+ const list = () => {
749
+ const all = entries.data() ?? []
750
+ return builtins.value() ? all : all.filter((e) => e.kind !== "builtin")
751
+ }
752
+
753
+ // Click-to-inspect (built-ins have no hover tooltip support in OpenTUI):
754
+ // selecting a row reveals its details inline beneath it.
755
+ const [selected, setSelected] = createSignal<string | null>(null)
756
+ const selectRow = (e: Entry) => setSelected((cur) => (cur === e.name ? null : e.name))
757
+
758
+ const entries = createCachedResource(
759
+ () => props.sessionID,
760
+ async () => loadEntries(ctx, workspaceDirectory(ctx)),
761
+ { cache },
762
+ )
763
+
764
+ const groups = () =>
765
+ GROUP_ORDER.map((kind) => ({
766
+ kind,
767
+ items: list().filter((e) => e.kind === kind),
768
+ })).filter((g) => g.items.length > 0)
769
+
770
+ // Collapsed summary, omitting empty groups: "2 npm · 4 local · 86 builtin".
771
+ const summary = () =>
772
+ GROUP_ORDER.map((kind) => {
773
+ const n = list().filter((e) => e.kind === kind).length
774
+ return n > 0 ? `${n} ${kind}` : ""
775
+ })
776
+ .filter(Boolean)
777
+ .join(" · ")
778
+
779
+ // Click a plugin row to flip its status: uninstalled → registered in the
780
+ // project config; installed → removed from it. A restart applies the
781
+ // change (plugins load at startup).
782
+ const [note, setNote] = createSignal<{ ok: boolean; text: string } | null>(null)
783
+ const onToggle = async (e: Entry) => {
784
+ // Built-in rows route to selectRow() and render no control, so this
785
+ // guard is defensive only.
786
+ /* v8 ignore next */
787
+ if (e.kind === "builtin") return
788
+ const root = workspaceDirectory(ctx)
789
+ try {
790
+ const message = await toggleInstalled(ctx, root, e)
791
+ setNote({ ok: true, text: message })
792
+ showToast(ctx, message)
793
+ await entries.refetchNow()
794
+ } catch (err: any) {
795
+ // toggleInstalled only ever throws Errors; the ?? fallbacks are
796
+ // defensive only.
797
+ /* v8 ignore next */
798
+ const text = String(err?.message ?? err ?? "toggle failed")
799
+ setNote({ ok: false, text })
800
+ showToast(ctx, text, "error")
801
+ }
802
+ }
803
+
804
+ const row = (e: Entry) => {
805
+ const uninstalled = e.status === "uninstalled"
806
+ const fg = uninstalled ? theme.text.subdued : theme.text.default
807
+ const glyph = uninstalled ? "○" : (STATUS_GLYPH[e.status ?? ""] ?? "•")
808
+ const version = e.version ? ` ${e.version}` : ""
809
+ const flag = e.outdated ? " ⚠ update" : ""
810
+ // MCPs show "connected"; plugins show their install state, right-aligned.
811
+ const state = uninstalled ? "uninstalled" : e.status === "failed" ? "failed" : "installed"
812
+ return {
813
+ glyph,
814
+ text: `${e.name}${version}${flag}`,
815
+ state,
816
+ fg,
817
+ stateFg: state === "failed" ? theme.text.default : theme.text.subdued,
818
+ // Explicit per-row control: [–] removes the config entry, [+] registers
819
+ // it. Built-ins aren't config-managed, so no control.
820
+ control: e.kind === "builtin" ? "" : uninstalled ? "[+]" : "[–]",
821
+ }
822
+ }
823
+
824
+ // One plugin row + its optional inline description (built-ins only).
825
+ const RowBlock = (p: { e: Entry }) => {
826
+ const r = row(p.e)
827
+ const e = p.e
828
+ return (
829
+ <box flexDirection="column">
830
+ <box flexDirection="row" gap={1} minWidth={0} onMouseDown={() => selectRow(e)}>
831
+ <text fg={r.fg} flexShrink={0}>
832
+ {r.glyph}
833
+ </text>
834
+ <text fg={r.fg} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
835
+ {r.text}
836
+ </text>
837
+ <text fg={r.stateFg} flexShrink={0}>
838
+ {r.state}
839
+ </text>
840
+ <Show when={r.control}>
841
+ <text fg={theme.text.subdued} flexShrink={0} onMouseDown={() => onToggle(e)}>
842
+ {r.control}
843
+ </text>
844
+ </Show>
845
+ </box>
846
+ <Show when={selected() === e.name}>
847
+ <box flexDirection="column" marginLeft={3} onMouseDown={() => selectRow(e)}>
848
+ <text fg={theme.text.subdued} wrapMode="none">
849
+ {e.kind === "builtin"
850
+ ? describeBuiltin(e.name)
851
+ : (e.description ?? "No description — check the project's package.json.")}
852
+ </text>
853
+ </box>
854
+ </Show>
855
+ </box>
856
+ )
857
+ }
858
+
859
+ return (
860
+ <Show when={!entries.data.error} fallback={<text>⚠ plugins unavailable</text>}>
861
+ <Show when={list().length > 0}>
862
+ <CollapsibleSection
863
+ title="PLUGINS"
864
+ count={list().length}
865
+ summary={summary()}
866
+ pinned={
867
+ <Show when={note()}>
868
+ {(n) => (
869
+ <text fg={n().ok ? theme.text.subdued : theme.text.default} wrapMode="none" truncate>
870
+ {n().ok ? "" : "✗ "}
871
+ {n().text}
872
+ </text>
873
+ )}
874
+ </Show>
875
+ }
876
+ >
877
+ <For each={groups()}>
878
+ {(g) => (
879
+ <CollapsibleGroup
880
+ title={GROUP_TITLE[g.kind]}
881
+ count={g.items.length}
882
+ defaultCollapsed={DEFAULT_COLLAPSED[g.kind]}
883
+ >
884
+ {(collapsed) => (
885
+ <>
886
+ <Show when={g.kind === "builtin" && !collapsed()}>
887
+ <text fg={theme.text.subdued} wrapMode="none" truncate>
888
+ built-ins can't be disabled individually;{" "}
889
+ {process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
890
+ ? "OPENCODE_DISABLE_DEFAULT_PLUGINS is on (all off)"
891
+ : "OPENCODE_DISABLE_DEFAULT_PLUGINS turns all off"}
892
+ </text>
893
+ </Show>
894
+ <Show when={g.kind === "builtin"} fallback={<For each={g.items}>{(e) => <RowBlock e={e} />}</For>}>
895
+ <For each={builtinSubGroups(g.items)}>
896
+ {(sub) => (
897
+ <CollapsibleGroup title={sub.title} count={sub.items.length}>
898
+ <For each={sub.items}>{(e) => <RowBlock e={e} />}</For>
899
+ </CollapsibleGroup>
900
+ )}
901
+ </For>
902
+ </Show>
903
+ </>
904
+ )}
905
+ </CollapsibleGroup>
906
+ )}
907
+ </For>
908
+ </CollapsibleSection>
909
+ </Show>
910
+ </Show>
911
+ )
912
+ }
913
+
914
+ // The cache must exist before the component renders, so create it at module
915
+ // scope and hydrate it from setup() (which owns the storage context).
916
+ type EntryCache = ReturnType<typeof createCachedStore<Entry[] | null>>
917
+ let cache: EntryCache
918
+
919
+ // Built-ins visibility: shown by default; /plugins-builtins toggles the
920
+ // view (kit createToggle). This is a view toggle only — built-ins always
921
+ // run. Created in setup() because it needs the storage context, then read
922
+ // from the component via module scope (same pattern as `cache` above).
923
+ let builtins: Toggle
924
+
925
+ export default Plugin.define({
926
+ id: "plugin-manager.cli",
927
+ setup(context: any) {
928
+ cache = createCachedStore<Entry[] | null>(context, "plugin-manager", {
929
+ initial: null,
930
+ staleAfterMs: 60_000,
931
+ })
932
+
933
+ // The kit's createToggle persists { value } under "builtins"; the legacy
934
+ // { show } shape it can't read falls back to the visible-by-default
935
+ // initial — the same outcome the previous one-time reset enforced.
936
+ builtins = createToggle(context, {
937
+ storageKey: "builtins",
938
+ initial: true,
939
+ command: {
940
+ id: "plugins.builtins",
941
+ group: "Plugins",
942
+ name: "plugins-builtins",
943
+ description: "Show or hide the built-in plugins group in the sidebar",
944
+ title: (value) => `Plugins: built-ins (${value ? "visible" : "hidden"})`,
945
+ },
946
+ toast: (value) => `Built-in plugins ${value ? "visible" : "hidden"} (they still run)`,
947
+ })
948
+ builtins.registerCommand()
949
+
950
+ // Placed with `after` so it renders below the built-in sidebar.content
951
+ // appends (Context, MCP). Ordering among plugins on the same slot follows
952
+ // the opencode.json `plugins` array — register this plugin after
953
+ // opencode-skill-lister so the section sits under MCP and Skills.
954
+ return context.ui.slot({
955
+ after: "sidebar.content",
956
+ render: ({ sessionID }: { sessionID?: string }) => <PluginList sessionID={sessionID} />,
957
+ })
958
+ },
959
+ })