Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:<name>}}`.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions presets/plugin-superpowers.conf
Original file line number Diff line number Diff line change
@@ -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 <jan@trick77.com>
// @version: 0.1.0
// @path: plugin
// @mode: append

["superpowers@git+https://github.com/obra/superpowers.git"]
50 changes: 47 additions & 3 deletions src/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -120,7 +142,29 @@ 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)) {
// 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 };
}
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`);
}
Expand Down
8 changes: 6 additions & 2 deletions src/parse-conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ export function parseConfString(raw: string, filePath = '<inline>'): 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;
Expand Down Expand Up @@ -120,6 +120,10 @@ export function parseConfString(raw: string, filePath = '<inline>'): 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 };
}

Expand Down
34 changes: 34 additions & 0 deletions src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,31 @@ 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;
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'})`);
}
if (meta.mode === 'replace') {
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');
}
Expand All @@ -148,6 +166,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)');
}
Expand All @@ -172,6 +200,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;
Expand Down
48 changes: 48 additions & 0 deletions test/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 8 additions & 3 deletions test/parse-conf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down