diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 02b2a9741d..31edcf114a 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -140,6 +140,8 @@ npm plugins can declare a version compatibility range in `package.json` using th - Root-worktree fallback (`worktree === "/"` uses `/.opencode`) is covered by regression tests. - `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call. - `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors. +- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`. +- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced. - Without `--force`, an already-configured npm package name is a no-op. - With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept. - Tuple targets in `oc-plugin` provide default options written into config. @@ -164,7 +166,7 @@ Top-level API groups exposed to `tui(api, options, meta)`: - `api.app.version` - `api.command.register(cb)` / `api.command.trigger(value)` - `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current` -- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog` +- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Prompt`, `ui.toast`, `ui.dialog` - `api.keybind.match`, `print`, `create` - `api.tuiConfig` - `api.kv.get`, `set`, `ready` @@ -210,6 +212,7 @@ Command behavior: - `ui.Dialog` is the base dialog wrapper. - `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components. +- `ui.Prompt` renders the same prompt component used by the host app. - `ui.toast(...)` shows a toast. - `ui.dialog` exposes the host dialog stack: - `replace(render, onClose?)` @@ -277,6 +280,7 @@ Current host slot names: - `app` - `home_logo` +- `home_prompt` with props `{ workspace_id? }` - `home_bottom` - `sidebar_title` with props `{ session_id, title, share_url? }` - `sidebar_content` with props `{ session_id }` @@ -289,7 +293,7 @@ Slot notes: - `api.slots.register(plugin)` does not return an unregister function. - Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on. - Plugin-provided `id` is not allowed. -- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode. +- The current host renders `home_logo` and `home_prompt` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode. - Plugins cannot define new slot names in this branch. ### Plugin control and lifecycle diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 9640a662bd..8c0a6ee274 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -94,6 +94,13 @@ function pluginSpec(item: unknown) { return item[0] } +function pluginList(data: unknown) { + if (!data || typeof data !== "object" || Array.isArray(data)) return + const item = data as { plugin?: unknown } + if (!Array.isArray(item.plugin)) return + return item.plugin +} + function parseTarget(item: unknown): Target | undefined { if (item === "server" || item === "tui") return { kind: item } if (!Array.isArray(item)) return @@ -118,9 +125,28 @@ function parseTargets(raw: unknown) { return [...map.values()] } -function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } { +function patch(text: string, path: Array, value: unknown, insert = false) { + return applyEdits( + text, + modify(text, path, value, { + formattingOptions: { + tabSize: 2, + insertSpaces: true, + }, + isArrayInsertion: insert, + }), + ) +} + +function patchPluginList( + text: string, + list: unknown[] | undefined, + spec: string, + next: unknown, + force = false, +): { mode: Mode; text: string } { const pkg = parsePluginSpecifier(spec).pkg - const rows = list.map((item, i) => ({ + const rows = (list ?? []).map((item, i) => ({ item, i, spec: pluginSpec(item), @@ -133,16 +159,22 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f }) if (!dup.length) { + if (!list) { + return { + mode: "add", + text: patch(text, ["plugin"], [next]), + } + } return { mode: "add", - list: [...list, next], + text: patch(text, ["plugin", list.length], next, true), } } if (!force) { return { mode: "noop", - list, + text, } } @@ -150,29 +182,37 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f if (!keep) { return { mode: "noop", - list, + text, } } if (dup.length === 1 && keep.spec === spec) { return { mode: "noop", - list, + text, } } - const idx = new Set(dup.map((item) => item.i)) + let out = text + if (typeof keep.item === "string") { + out = patch(out, ["plugin", keep.i], next) + } + if (Array.isArray(keep.item) && typeof keep.item[0] === "string") { + out = patch(out, ["plugin", keep.i, 0], spec) + } + + const del = dup + .map((item) => item.i) + .filter((i) => i !== keep.i) + .sort((a, b) => b - a) + + for (const i of del) { + out = patch(out, ["plugin", i], undefined) + } + return { mode: "replace", - list: rows.flatMap((row) => { - if (!idx.has(row.i)) return [row.item] - if (row.i !== keep.i) return [] - if (typeof row.item === "string") return [next] - if (Array.isArray(row.item) && typeof row.item[0] === "string") { - return [[spec, ...row.item.slice(1)]] - } - return [row.item] - }), + text: out, } } @@ -289,10 +329,9 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea } } - const list: unknown[] = - data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : [] + const list = pluginList(data) const item = target.opts ? [spec, target.opts] : spec - const out = patchPluginList(list, spec, item, force) + const out = patchPluginList(text, list, spec, item, force) if (out.mode === "noop") { return { ok: true, @@ -304,13 +343,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea } } - const edits = modify(text, ["plugin"], out.list, { - formattingOptions: { - tabSize: 2, - insertSpaces: true, - }, - }) - const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error) + const write = await dep.write(cfg, out.text).catch((error: unknown) => error) if (write instanceof Error) { return { ok: false, diff --git a/packages/opencode/test/plugin/install.test.ts b/packages/opencode/test/plugin/install.test.ts index e7d39bf87d..24440c10ea 100644 --- a/packages/opencode/test/plugin/install.test.ts +++ b/packages/opencode/test/plugin/install.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" +import { parse as parseJsonc } from "jsonc-parser" import { Filesystem } from "../../src/util/filesystem" import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug" import { tmpdir } from "../fixture/fixture" @@ -120,6 +121,99 @@ describe("plugin.install.task", () => { expect(tui.plugin).toEqual([["acme@1.2.3", { compact: true }]]) }) + test("preserves JSONC comments when adding plugins to server and tui config", async () => { + await using tmp = await tmpdir() + const target = await plugin(tmp.path, ["server", "tui"]) + const cfg = path.join(tmp.path, ".opencode") + const server = path.join(cfg, "opencode.jsonc") + const tui = path.join(cfg, "tui.jsonc") + await fs.mkdir(cfg, { recursive: true }) + await Bun.write( + server, + `{ + // server head + "plugin": [ + // server keep + "seed@1.0.0" + ], + // server tail + "model": "x" +} +`, + ) + await Bun.write( + tui, + `{ + // tui head + "plugin": [ + // tui keep + "seed@1.0.0" + ], + // tui tail + "theme": "opencode" +} +`, + ) + + const run = createPlugTask( + { + mod: "acme@1.2.3", + }, + deps(path.join(tmp.path, "global"), target), + ) + + const ok = await run(ctx(tmp.path)) + expect(ok).toBe(true) + + const serverText = await fs.readFile(server, "utf8") + const tuiText = await fs.readFile(tui, "utf8") + expect(serverText).toContain("// server head") + expect(serverText).toContain("// server keep") + expect(serverText).toContain("// server tail") + expect(tuiText).toContain("// tui head") + expect(tuiText).toContain("// tui keep") + expect(tuiText).toContain("// tui tail") + + const serverJson = parseJsonc(serverText) as { plugin?: unknown[] } + const tuiJson = parseJsonc(tuiText) as { plugin?: unknown[] } + expect(serverJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"]) + expect(tuiJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"]) + }) + + test("preserves JSONC comments when force replacing plugin version", async () => { + await using tmp = await tmpdir() + const target = await plugin(tmp.path, ["server"]) + const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc") + await fs.mkdir(path.dirname(cfg), { recursive: true }) + await Bun.write( + cfg, + `{ + "plugin": [ + // keep this note + "acme@1.0.0" + ] +} +`, + ) + + const run = createPlugTask( + { + mod: "acme@2.0.0", + force: true, + }, + deps(path.join(tmp.path, "global"), target), + ) + + const ok = await run(ctx(tmp.path)) + expect(ok).toBe(true) + + const text = await fs.readFile(cfg, "utf8") + expect(text).toContain("// keep this note") + + const json = parseJsonc(text) as { plugin?: unknown[] } + expect(json.plugin).toEqual(["acme@2.0.0"]) + }) + test("supports resolver target pointing to a file", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"])