diff --git a/AGENTS.md b/AGENTS.md index 840f828..4c2c9f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ Substitutions in body and `@path`: `{{cache}}`, `{{prompt:}}`. entries and opencode loads the plugin twice. An incoming `name@spec` entry therefore replaces every existing entry with the same package name, in place (`specName` in `src/merge.ts`), and stacked configs collapse on next install. +`[name@spec, {options}]` tuples count as the same package as the plain string. Non-`name@spec` entries (`{{cache}}` fetch dests, prompted dirs, git URLs with credentials) stay plain append — a heuristic there would delete unrelated entries. `remove` is untouched: it still deletes only exact matches. diff --git a/src/merge.ts b/src/merge.ts index 859b3fc..7302093 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -70,7 +70,11 @@ export function applyAtPath( // append behaviour: a `{{cache}}` fetch destination, a prompted directory, or a // git URL carrying credentials (`git+https://user@host/…`), whose trailing `@` // would otherwise split in the wrong place. +// opencode also accepts `[name@spec, {options}]` for a plugin with options; the +// spec inside names the same package, so a tuple and a plain string supersede +// each other in both directions. function specName(value: Json): string | null { + if (Array.isArray(value) && value.length === 2 && isPlainObject(value[1])) return specName(value[0]); if (typeof value !== 'string') return null; const at = value.lastIndexOf('@'); // at === 0 is a bare scope (`@scope/pkg`), which names no version. diff --git a/test/merge.test.ts b/test/merge.test.ts index 876c005..910a6f7 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -149,6 +149,32 @@ describe('applyAtPath — append mode', () => { assert.deepEqual((git.next as any).plugin, ['superpowers@git+https://github.com/obra/superpowers.git#v6.4.0']); }); + // `[name@spec, {options}]` is the same package as the plain string, so + // switching between a preset with plugin options and one without must not + // leave the plugin loading twice. + test('a plugin tuple with options and a plain spec supersede each other', () => { + const toTuple = applyAtPath( + { plugin: ['superpowers@6.3.0', 'pricing@0.8.1'] }, + 'plugin', + [['pricing@0.9.0', { token: 't' }]], + 'append' + ); + assert.deepEqual((toTuple.next as any).plugin, ['superpowers@6.3.0', ['pricing@0.9.0', { token: 't' }]]); + assert.equal(toTuple.stats.superseded, 1); + + const toPlain = applyAtPath({ plugin: [['pricing@0.9.0', { token: 't' }]] }, 'plugin', ['pricing@0.9.0'], 'append'); + assert.deepEqual((toPlain.next as any).plugin, ['pricing@0.9.0']); + assert.equal(toPlain.stats.superseded, 1); + }); + + test('a plugin tuple with changed options replaces the old tuple', () => { + const root = { plugin: [['pricing@0.9.0', { token: 'old' }]] }; + const { next, stats } = applyAtPath(root, 'plugin', [['pricing@0.9.0', { token: 'new' }]], 'append'); + assert.deepEqual((next as any).plugin, [['pricing@0.9.0', { token: 'new' }]]); + assert.equal(stats.added, 0); + assert.equal(stats.superseded, 1); + }); + // Only `name@spec` entries carry a package identity. A fetched skill path or // a prompted directory must keep the plain additive behaviour, or unrelated // entries sharing a prefix would silently delete each other.