From 2e689989c2115ea55ea1331063f9bdb155a51852 Mon Sep 17 00:00:00 2001 From: trick77 Date: Wed, 20 May 2026 06:33:49 +0200 Subject: [PATCH 1/2] Add Superpowers plugin preset --- AGENTS.md | 5 ++-- README.md | 7 +++++ presets/plugin-superpowers.conf | 11 ++++++++ src/merge.ts | 48 ++++++++++++++++++++++++++++++--- src/parse-conf.ts | 8 ++++-- src/ui.ts | 32 ++++++++++++++++++++++ test/merge.test.ts | 48 +++++++++++++++++++++++++++++++++ test/parse-conf.test.ts | 11 +++++--- 8 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 presets/plugin-superpowers.conf diff --git a/AGENTS.md b/AGENTS.md index de99eeb..d99bb3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Every `presets/*.conf` must start with these directives (order doesn't matter): - `@name`, `@description`, `@author`, `@version`, `@path` — required. -- `@mode` — `replace` (default) | `merge` | `merge-overwrite`. +- `@mode` — `replace` (default) | `merge` | `merge-overwrite` | `append`. - `@fetch: URL -> dest [sha256=hex]` — repeatable. - `@prompt: name | type | help | default` — repeatable; type ∈ `text`/`secret`. Help and default are optional. Default is @@ -32,7 +32,8 @@ matter): Body is JSONC. After parsing it must be valid JSON of the shape the leaf at `@path` expects (object/array/scalar all allowed for `replace`; -must be an object for `merge`/`merge-overwrite`). +must be an object for `merge`/`merge-overwrite`; must be an array for +`append`). Substitutions inside body and inside `@path`: `{{cache}}`, `{{prompt:}}`. diff --git a/README.md b/README.md index 4059b71..2f6d44b 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ on a readline for the next. | `mcp-intellij` | MCP | replace | Add the JetBrains IDE MCP server (loopback HTTP, default port 64342) | | `mcp-playwright` | MCP | replace | Add the Playwright MCP server (`@playwright/mcp`, local stdio via npx) | | `mcp-vscode` | MCP | replace | Add the VS Code MCP server via the `JuehangQin.vscode-mcp-server` extension (loopback HTTP, default port 3000) | +| `plugin-superpowers` | Plugin | append | Add the Superpowers OpenCode plugin from `obra/superpowers` (brainstorming, plans, TDD, review workflows) | | `permissions-git-safe` | Permissions | merge | Read-only git commands (status, diff, log, branch --list, fetch, etc.) | | `permissions-shell-safe` | Permissions | merge | Low-risk shell commands (ls, cat, grep, rg, jq, yq, etc.) | | `permissions-build-tools` | Permissions | merge | Build tools (node, npm, mvn, gradle, make, python, pip, cargo, go) | @@ -112,10 +113,16 @@ opencode-presets reset mcp.openrag-tom - `merge` — the preset's keys are added; existing keys (yours or someone else's) are never overwritten. Use this for permission rules so user edits stick around. +- `append` — the preset's array entries are appended if missing; + existing array entries are preserved. Use this for shared arrays + like `plugin`. Re-installing is always safe: a no-op produces no backup and no write. +Plugin changes are loaded by opencode at startup. After installing a +plugin preset such as `plugin-superpowers`, quit and restart opencode. + ## Where presets are found `opencode-presets list` searches dirs in this order: diff --git a/presets/plugin-superpowers.conf b/presets/plugin-superpowers.conf new file mode 100644 index 0000000..c6ce917 --- /dev/null +++ b/presets/plugin-superpowers.conf @@ -0,0 +1,11 @@ +// @name: plugin-superpowers +// @description: Adds the Superpowers OpenCode plugin from obra/superpowers. +// Registers the Superpowers skill set, including brainstorming, +// writing-plans, executing-plans, TDD, code review, and related +// workflow skills. Uses OpenCode's git-backed plugin install. +// @author: Jan +// @version: 0.1.0 +// @path: plugin +// @mode: append + +["superpowers@git+https://github.com/obra/superpowers.git"] diff --git a/src/merge.ts b/src/merge.ts index eb87a51..bc5414a 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -7,8 +7,10 @@ // missing from the target are added. Idempotent. // 'merge-overwrite' — object merge that DOES overwrite overlapping // keys. +// 'append' — additive array merge: existing entries are preserved +// and missing incoming entries are appended. Idempotent. -export type MergeMode = 'replace' | 'merge' | 'merge-overwrite'; +export type MergeMode = 'replace' | 'merge' | 'merge-overwrite' | 'append'; export interface ApplyStats { mode: MergeMode; @@ -59,6 +61,25 @@ export function applyAtPath( function combine(existing: Json, incoming: Json, mode: MergeMode): { value: Json; stats: ApplyStats } { const stats: ApplyStats = { mode, added: 0, preserved: 0, overwritten: 0, replaced: false }; + if (mode === 'append') { + if (!Array.isArray(incoming)) { + throw new Error('@mode: append requires the body to be a JSON array'); + } + if (existing !== undefined && !Array.isArray(existing)) { + throw new Error('@mode: append requires the existing value at path to be a JSON array'); + } + const target: Json[] = Array.isArray(existing) ? [...existing] : []; + for (const v of incoming) { + if (target.some(existingValue => deepEqual(existingValue, v))) { + stats.preserved++; + } else { + target.push(v); + stats.added++; + } + } + return { value: target, stats }; + } + if (mode === 'merge' || mode === 'merge-overwrite') { if (!isPlainObject(incoming)) { throw new Error(`@mode: ${mode} requires the body to be a JSON object`); @@ -88,7 +109,8 @@ function combine(existing: Json, incoming: Json, mode: MergeMode): { value: Json // Remove a value or selected keys at a dotted JSON path. // For 'replace' mode: deletes the whole leaf at `path`. // For merge modes: deletes each key from `body` at `path` only if the -// current value still matches `body[key]`. Empty parent objects pruned. +// current value still matches `body[key]`. For append mode: deletes matching +// array entries. Empty parent objects/arrays pruned. export function removeAtPath( root: Json, dottedPath: string, @@ -120,7 +142,27 @@ export function removeAtPath( return { next, stats }; } - if (mode === 'merge' || mode === 'merge-overwrite') { + if (mode === 'append') { + if (!Array.isArray(body)) { + throw new Error('remove in @mode: append requires the body to be a JSON array'); + } + const target = cursor[last]; + if (!Array.isArray(target)) { + stats.missing = true; + return { next, stats }; + } + const keptValues = target.filter(value => { + const remove = body.some(bodyValue => deepEqual(value, bodyValue)); + if (remove) stats.removed++; + return !remove; + }); + if (keptValues.length === 0) { + delete cursor[last]; + pruneEmpty(parents); + } else { + cursor[last] = keptValues; + } + } else if (mode === 'merge' || mode === 'merge-overwrite') { if (!isPlainObject(body)) { throw new Error(`remove in @mode: ${mode} requires the body to be a JSON object`); } diff --git a/src/parse-conf.ts b/src/parse-conf.ts index 66be486..5afd83e 100644 --- a/src/parse-conf.ts +++ b/src/parse-conf.ts @@ -76,8 +76,8 @@ export function parseConfString(raw: string, filePath = ''): ParsedConf meta[key] = value; break; case 'mode': - if (value !== 'replace' && value !== 'merge' && value !== 'merge-overwrite') { - throw parseError(filePath, i + 1, `@mode must be "replace", "merge", or "merge-overwrite", got "${value}"`); + if (value !== 'replace' && value !== 'merge' && value !== 'merge-overwrite' && value !== 'append') { + throw parseError(filePath, i + 1, `@mode must be "replace", "merge", "merge-overwrite", or "append", got "${value}"`); } meta.mode = value; break; @@ -120,6 +120,10 @@ export function parseConfString(raw: string, filePath = ''): ParsedConf throw parseError(filePath, 1, `@mode: ${meta.mode} requires the body to be a JSON object`); } + if (meta.mode === 'append' && !Array.isArray(body)) { + throw parseError(filePath, 1, '@mode: append requires the body to be a JSON array'); + } + return { meta, body }; } diff --git a/src/ui.ts b/src/ui.ts index b0f3ca1..9070f52 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -117,6 +117,10 @@ function installDiff(current: unknown, incoming: unknown, meta: ConfMeta): strin return c.dim('(no existing value — will create)') + '\n' + c.ok('+ ') + truncJson(incoming); } + if (meta.mode === 'append') { + const entries = Array.isArray(incoming) ? incoming.length : 0; + return c.dim(`(no existing value — will create with ${entries} entr${entries === 1 ? 'y' : 'ies'})`); + } const keys = Object.keys(incoming as object); return c.dim(`(no existing value — will create with ${keys.length} key${keys.length === 1 ? '' : 's'})`); } @@ -124,6 +128,18 @@ function installDiff(current: unknown, incoming: unknown, meta: ConfMeta): strin if (deepEqual(current, incoming)) return c.dim('(no change — value already matches)'); return c.warn('- ') + truncJson(current) + '\n' + c.ok('+ ') + truncJson(incoming); } + if (meta.mode === 'append') { + if (!Array.isArray(current)) { + return c.err('! cannot append: existing value at path is not an array'); + } + const inc = Array.isArray(incoming) ? incoming : []; + const willAdd = inc.filter(v => !current.some(existing => deepEqual(existing, v))); + const willPreserve = inc.length - willAdd.length; + const lines: string[] = []; + lines.push(c.dim(`append ${willAdd.length}, preserve ${willPreserve}`)); + if (willAdd.length > 0) lines.push(c.ok('+ ') + sampleValues(willAdd, 5)); + return lines.join('\n'); + } if (typeof current !== 'object' || current === null || Array.isArray(current)) { return c.err('! cannot merge: existing value at path is not an object'); } @@ -148,6 +164,16 @@ function removeDiff(current: unknown, incoming: unknown, meta: ConfMeta): string return c.warn('- ') + truncJson(current) + '\n' + c.dim('(entire value above will be deleted; parent objects pruned if empty)'); } + if (meta.mode === 'append') { + if (!Array.isArray(current)) { + return c.dim('(value at path is not an array — nothing matchable to remove)'); + } + const inc = Array.isArray(incoming) ? incoming : []; + const willRemove = current.filter(value => inc.some(incomingValue => deepEqual(value, incomingValue))); + const lines = [c.dim(`remove ${willRemove.length} matching entr${willRemove.length === 1 ? 'y' : 'ies'}`)]; + if (willRemove.length > 0) lines.push(c.warn('- ') + sampleValues(willRemove, 5)); + return lines.join('\n'); + } if (typeof current !== 'object' || current === null || Array.isArray(current)) { return c.dim('(value at path is not an object — nothing matchable to remove)'); } @@ -172,6 +198,12 @@ function sample(keys: string[], n: number): string { return shown + c.dim(` … (+${keys.length - n} more)`); } +function sampleValues(values: unknown[], n: number): string { + if (values.length <= n) return values.map(v => JSON.stringify(v)).join(', '); + const shown = values.slice(0, n).map(v => JSON.stringify(v)).join(', '); + return shown + c.dim(` … (+${values.length - n} more)`); +} + function truncJson(value: unknown, max = 200): string { const s = JSON.stringify(value, null, 2); if (s.length <= max) return s; diff --git a/test/merge.test.ts b/test/merge.test.ts index 9e873ce..f763094 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -76,6 +76,39 @@ describe('applyAtPath — merge-overwrite mode', () => { }); }); +describe('applyAtPath — append mode', () => { + test('creates an array at a fresh path', () => { + const { next, stats } = applyAtPath({}, 'plugin', ['a'], 'append'); + assert.deepEqual((next as any).plugin, ['a']); + assert.equal(stats.added, 1); + assert.equal(stats.preserved, 0); + }); + + test('appends missing values and preserves existing values', () => { + const root = { plugin: ['a'] }; + const { next, stats } = applyAtPath(root, 'plugin', ['a', 'b'], 'append'); + assert.deepEqual((next as any).plugin, ['a', 'b']); + assert.equal(stats.added, 1); + assert.equal(stats.preserved, 1); + }); + + test('deduplicates objects by deep equality', () => { + const root = { plugin: [['pkg', { enabled: true }]] }; + const { next, stats } = applyAtPath(root, 'plugin', [['pkg', { enabled: true }]], 'append'); + assert.deepEqual((next as any).plugin, [['pkg', { enabled: true }]]); + assert.equal(stats.added, 0); + assert.equal(stats.preserved, 1); + }); + + test('rejects non-array body', () => { + assert.throws(() => applyAtPath({}, 'plugin', { a: 1 }, 'append'), /JSON array/); + }); + + test('rejects existing non-array target', () => { + assert.throws(() => applyAtPath({ plugin: {} }, 'plugin', ['a'], 'append'), /existing value at path/); + }); +}); + describe('removeAtPath — replace mode', () => { test('deletes the leaf and prunes empty parents', () => { const root = { a: { b: { c: 'x' } }, other: 1 }; @@ -121,6 +154,21 @@ describe('removeAtPath — merge mode', () => { }); }); +describe('removeAtPath — append mode', () => { + test('removes matching array entries only', () => { + const root = { plugin: ['a', 'b', 'c'] }; + const { next, stats } = removeAtPath(root, 'plugin', ['b'], 'append'); + assert.deepEqual((next as any).plugin, ['a', 'c']); + assert.equal(stats.removed, 1); + }); + + test('prunes parent when removal empties array', () => { + const root = { plugin: ['a'] }; + const { next } = removeAtPath(root, 'plugin', ['a'], 'append'); + assert.deepEqual(next, {}); + }); +}); + describe('getAtPath', () => { test('reads a deep dotted path', () => { assert.equal(getAtPath({ a: { b: { c: 7 } } }, 'a.b.c'), 7); diff --git a/test/parse-conf.test.ts b/test/parse-conf.test.ts index 28dcff4..b76e6dc 100644 --- a/test/parse-conf.test.ts +++ b/test/parse-conf.test.ts @@ -48,9 +48,9 @@ describe('parseConfString — multi-line description', () => { }); describe('parseConfString — @mode', () => { - test('accepts replace, merge, merge-overwrite', () => { - for (const mode of ['replace', 'merge', 'merge-overwrite']) { - const body = mode === 'replace' ? '{}' : '{}'; // object body works for both + test('accepts replace, merge, merge-overwrite, append', () => { + for (const mode of ['replace', 'merge', 'merge-overwrite', 'append']) { + const body = mode === 'append' ? '[]' : '{}'; const src = minimalHeader + `// @mode: ${mode}\n\n${body}`; const { meta } = parseConfString(src); assert.equal(meta.mode, mode); @@ -67,6 +67,11 @@ describe('parseConfString — @mode', () => { assert.throws(() => parseConfString(src), /requires the body to be a JSON object/); }); + test('append mode requires array body', () => { + const src = minimalHeader + '// @mode: append\n\n{}'; + assert.throws(() => parseConfString(src), /requires the body to be a JSON array/); + }); + test('replace mode accepts array, scalar, object', () => { for (const body of ['[1,2,3]', 'false', '"hello"', '{"x": 1}']) { const src = minimalHeader + '\n' + body; From 776d37a272df7691e74926615099817af5889e91 Mon Sep 17 00:00:00 2001 From: trick77 Date: Wed, 20 May 2026 06:36:54 +0200 Subject: [PATCH 2/2] Refine append mode preview --- src/merge.ts | 2 ++ src/ui.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/merge.ts b/src/merge.ts index bc5414a..ddb65bd 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -148,6 +148,8 @@ export function removeAtPath( } const target = cursor[last]; if (!Array.isArray(target)) { + // Removal is best-effort for additive modes: wrong-shaped existing + // values are treated like missing values, matching merge mode below. stats.missing = true; return { next, stats }; } diff --git a/src/ui.ts b/src/ui.ts index 9070f52..faf3058 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -119,7 +119,9 @@ function installDiff(current: unknown, incoming: unknown, meta: ConfMeta): strin } if (meta.mode === 'append') { const entries = Array.isArray(incoming) ? incoming.length : 0; - return c.dim(`(no existing value — will create with ${entries} entr${entries === 1 ? 'y' : 'ies'})`); + const lines = [c.dim(`(no existing value — will create with ${entries} entr${entries === 1 ? 'y' : 'ies'})`)]; + if (Array.isArray(incoming) && incoming.length > 0) lines.push(c.ok('+ ') + sampleValues(incoming, 5)); + return lines.join('\n'); } const keys = Object.keys(incoming as object); return c.dim(`(no existing value — will create with ${keys.length} key${keys.length === 1 ? '' : 's'})`);