From f98ad6e07888bccc9317fa55ef5e1557fbf8ad88 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Fri, 6 Mar 2026 21:43:39 +0100 Subject: [PATCH] plugin meta --- packages/opencode/src/config/tui.ts | 64 ++++++++++++++++++--- packages/opencode/test/config/tui.test.ts | 70 ++++++++++++++++++++++- 2 files changed, 125 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 596673cb43..eab42368c8 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -16,7 +16,36 @@ export namespace TuiConfig { export const Info = TuiInfo - export type Info = z.output + export type PluginMeta = { + scope: "global" | "local" + source: string + } + + type PluginEntry = { + item: Config.PluginSpec + meta: PluginMeta + } + + export type Info = z.output & { + plugin_meta?: Record + } + + function scope(file: string): PluginMeta["scope"] { + if (Instance.containsPath(file)) return "local" + return "global" + } + + function dedupePlugin(list: PluginEntry[]) { + const seen = new Set() + const result: PluginEntry[] = [] + for (const item of list.toReversed()) { + const name = Config.getPluginName(item.item) + if (seen.has(name)) continue + seen.add(name) + result.push(item) + } + return result.toReversed() + } function mergeInfo(target: Info, source: Info): Info { const merged = mergeDeep(target, source) @@ -44,35 +73,56 @@ export namespace TuiConfig { : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree) let result: Info = {} + const plugin: PluginEntry[] = [] + + const apply = async (file: string) => { + const data = await loadFile(file) + result = mergeInfo(result, data) + if (!data.plugin?.length) return + const level = scope(file) + for (const item of data.plugin) { + plugin.push({ + item, + meta: { + scope: level, + source: file, + }, + }) + } + } for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - result = mergeInfo(result, await loadFile(file)) + await apply(file) } if (custom) { - result = mergeInfo(result, await loadFile(custom)) + await apply(custom) log.debug("loaded custom tui config", { path: custom }) } for (const file of projectFiles) { - result = mergeInfo(result, await loadFile(file)) + await apply(file) } for (const dir of unique(directories)) { if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - result = mergeInfo(result, await loadFile(file)) + await apply(file) } } if (existsSync(managed)) { for (const file of ConfigPaths.fileInDirectory(managed, "tui")) { - result = mergeInfo(result, await loadFile(file)) + await apply(file) } } + const merged = dedupePlugin(plugin) result.keybinds = Config.Keybinds.parse(result.keybinds ?? {}) - result.plugin = Config.deduplicatePlugins(result.plugin ?? []) + result.plugin = merged.map((item) => item.item) + result.plugin_meta = merged.length + ? Object.fromEntries(merged.map((item) => [Config.getPluginName(item.item), item.meta])) + : undefined const deps: Promise[] = [] for (const dir of unique(directories)) { diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index b928c7c51d..14327d9ba2 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -458,9 +458,15 @@ test("applies file substitutions when first identical token is in a commented li test("loads managed tui config and gives it highest precedence", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2)) + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ theme: "project-theme", plugin: ["shared-plugin@1.0.0"] }, null, 2), + ) await fs.mkdir(managedConfigDir, { recursive: true }) - await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2)) + await Bun.write( + path.join(managedConfigDir, "tui.json"), + JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2), + ) }, }) @@ -469,6 +475,13 @@ test("loads managed tui config and gives it highest precedence", async () => { fn: async () => { const config = await TuiConfig.get() expect(config.theme).toBe("managed-theme") + expect(config.plugin).toEqual(["shared-plugin@2.0.0"]) + expect(config.plugin_meta).toEqual({ + "shared-plugin": { + scope: "global", + source: path.join(managedConfigDir, "tui.json"), + }, + }) }, }) }) @@ -526,6 +539,12 @@ test("supports tuple plugin specs with options in tui.json", async () => { fn: async () => { const config = await TuiConfig.get() expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) + expect(config.plugin_meta).toEqual({ + "acme-plugin": { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + }) }, }) }) @@ -559,6 +578,53 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a ["acme-plugin@2.0.0", { source: "project" }], ["second-plugin@3.0.0", { source: "project" }], ]) + expect(config.plugin_meta).toEqual({ + "acme-plugin": { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + "second-plugin": { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + }) + }, + }) +}) + +test("tracks global and local plugin metadata in merged tui config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(Global.Path.config, "tui.json"), + JSON.stringify({ + plugin: ["global-plugin@1.0.0"], + }), + ) + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + plugin: ["local-plugin@2.0.0"], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"]) + expect(config.plugin_meta).toEqual({ + "global-plugin": { + scope: "global", + source: path.join(Global.Path.config, "tui.json"), + }, + "local-plugin": { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + }) }, }) })