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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Substitutions in body and `@path`: `{{cache}}`, `{{prompt:<name>}}`.
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.
Expand Down
4 changes: 4 additions & 0 deletions src/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions test/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading