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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ Order irrelevant.
the check. `setup` = install command, mandatory (tested). Use when the preset
is inert without it.
- `@include: <name|path>` — repeatable. Makes the module a **bundle**.
- `@part-of: <bundle>` — preset inert alone. `list` hides it unless `--all`;
install/remove by name still work. Bundle must `@include` it (tested).
Not for members useful alone (`permissions-*`).

## Bundles (`@include`)

Expand Down
29 changes: 18 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,24 +57,20 @@ something outside your opencode config say so in their description.
| `mcp-litellm-passthrough` | MCP | replace | Install `mcp-litellm` first — re-running it replaces `mcp.litellm` and drops these headers. Adds one `x-mcp-<alias>-<header>` passthrough header to the `mcp.litellm` server so an upstream MCP server authenticates as you (run once per header) |
| `mcp-playwright` | MCP | replace | Add the Playwright MCP server (local stdio via npx; pins `@playwright/mcp` 0.0.80) |
| `mcp-vscode` | MCP | replace | Requires the `JuehangQin.vscode-mcp-server` extension installed, enabled and toggled active in VS Code first — this preset does not install it. Adds the VS Code MCP server via that extension (loopback HTTP, default port 3000) |
| `plugin-litellm-pricing` | Plugin | append | Install `provider-litellm` too — without a `litellm` provider pointing at your proxy the plugin does nothing. Adds `opencode-plugin-litellm-pricing`: discovers a LiteLLM proxy's models at runtime and adds them to the picker with the proxy's own per-model pricing instead of `$0` (pins `opencode-plugin-litellm-pricing` 0.9.0) |
| `provider-litellm` | Provider | replace | Point the `litellm` provider at your proxy URL and key for `plugin-litellm-pricing`, which prices the models against that same proxy (prompts for base URL and API key; no models list) |
| `litellm-pricing` | Provider | bundle | LiteLLM proxy models in the picker with the proxy's own per-model pricing instead of `$0`: points the `litellm` provider at your proxy (prompts for base URL and API key; no models list) and adds `opencode-plugin-litellm-pricing` (pins 0.9.0), which fills in the models at runtime |
| `plugin-superpowers` | Plugin | append | Add the Superpowers OpenCode plugin from `obra/superpowers` (brainstorming, plans, TDD, review workflows; pins tag `v6.3.0`) |
| `privacy-share-disabled` | Privacy | replace | In the bundle. Sets `share` to "disabled" so opencode never publishes a session, automatically or on command |
| `agent-runaway-guard` | Agent | merge | Adds step limits to built-in agents to prevent runaway tool loops |
| `default-agent-plan` | Agent | replace | Sets the default agent to "plan" so opencode always starts in plan mode instead of build mode |
| `opencode-planify-german` | Bundle | — | The whole planify setup: `plugin-opencode-planify-german`, `instructions-opencode-planify-german`, `skill-opencode-planify-german`. Install all three or nothing happens — the rules name a tool that would not exist, the plugin would never be called |
| `plugin-opencode-planify-german` | Plugin | append | Needs `instructions-opencode-planify-german` too, or nothing tells the agent to use the tool — install the `opencode-planify-german` bundle for both. Adds the `opencode-planify-german` plugin from `trick77/opencode-planify-german`, which registers the `plan_render` tool: takes a plan as JSON, validates it against a schema, renders a self-contained HTML file to `docs/plans/<TICKET>-<slug>.html` and opens it in the system's default browser (installs it from npm, pinned to `0.3.2`; restart opencode after installing — plugins are resolved at start) |
| `instructions-opencode-planify-german` | Instructions | append | Needs `plugin-opencode-planify-german` — these rules name the `plan_render` tool it provides. Answer in German with Swiss orthography (never the eszett character, always `ss`; umlauts as real characters, never `ae`/`oe`/`ue`), German code comments with German domain nouns in identifiers, and every plan built as JSON and handed to `plan_render` instead of hand-written HTML or Markdown, with `<TICKET>` taken from the current branch name (fetches the rules file from `trick77/opencode-planify-german`, sha256-verified). Replaces the former `instructions-swiss-rules` |
| `skill-opencode-planify-german` | Skill | append | Registers the `planify` skill — the plan JSON schema field by field, the writing rules per field, when a plan gets a diagram, and a complete example. Fetches the skill files from `trick77/opencode-planify-german` into the cache, sha256-verified, and appends that dir to `skills.paths`; no clone needed. Uninstall by deleting the one `skills.paths` entry by hand |
| `opencode-planify-german` | Bundle | — | The whole planify setup, which does nothing in parts: the `opencode-planify-german` plugin from npm (pinned to `0.3.2`), registering a `plan_render` tool that validates a plan JSON against a schema and renders a self-contained HTML file to `docs/plans/<TICKET>-<slug>.html`; the rules file that makes opencode answer in German with Swiss orthography (never the eszett character, always `ss`) and send every plan through `plan_render`; and the `planify` skill documenting the plan schema. Rules file and skill are fetched sha256-verified. Restart opencode after installing — plugins are resolved at start |
| `skill-diagram-design` | Skill | append | Needs a clone of the repo on disk first — `git clone https://github.com/cathrynlavery/diagram-design ~/src/diagram-design`. Registers the `diagram-design` skill (editorial diagram types as self-contained HTML + SVG) by appending your clone's `skills/` dir to `skills.paths`; answer with that clone's `skills/` dir (`--set skillsDir=/Users/you/src/diagram-design/skills`); the install refuses if the dir is not there. Tracks `main` — the repo ships no tags. One-way: `remove` cannot undo an append preset that prompts, so uninstall by deleting the one `skills.paths` entry by hand |
| `tui-disable-mouse` | TUI | replace | Disables TUI mouse capture so native terminal selection and scrolling keep working |

### Bundles

A preset whose header is `@include` lines is a **bundle**: a list of other
presets, with no rules of its own. Two ship: `opencode-planify-german`, and
`permissions-recommended`, which is all of this:
presets, with no rules of its own. Three ship: `opencode-planify-german`,
`litellm-pricing`, and `permissions-recommended`, which is all of this:

```
permissions-recommended
Expand Down Expand Up @@ -124,6 +120,10 @@ Not in the bundle, on purpose:
into, production included. Worth an explicit decision.
- `permissions-webfetch-ask` — *adds* friction; wrong for a defaults bundle.

The members of `opencode-planify-german` and `litellm-pricing` do nothing on
their own, so they are marked `@part-of` their bundle and `list` hides them;
`list --all` shows them. They still install and remove by name.

### About the `deny` presets

A denied command is rejected outright — no prompt, and `--auto` only
Expand Down Expand Up @@ -181,9 +181,11 @@ opencode-presets reset mcp.openrag-tom

### Pricing a LiteLLM proxy

Pricing takes no configuration of its own. `plugin-litellm-pricing` reads each
model's cost, limits and capabilities from the proxy `provider-litellm` already
points at, so the base URL and key are the whole setup. The numbers are
Install the `litellm-pricing` bundle. Pricing takes no configuration of its
own: its plugin part (`plugin-litellm-pricing`) reads each model's cost, limits
and capabilities from the proxy its provider part (`provider-litellm`) points
at, so the base URL and key are the whole setup. Both parts only show in
`list --all`, but install by name. The numbers are
LiteLLM's own resolved ones, your config-level `model_info` overrides included,
so what opencode displays is what the gateway bills. A model the proxy reports
no cost for is injected without a `cost` block rather than with a wrong one, and
Expand All @@ -202,6 +204,7 @@ clears the now-unused `options.catalogURL` out of your config.

```sh
opencode-presets list # what's available
opencode-presets list --all # plus presets that only work in a bundle
opencode-presets install jdtls-lombok # apply one preset by name
opencode-presets install jdtls-lombok permissions-git-safe
opencode-presets remove jdtls-lombok # undo a preset
Expand Down Expand Up @@ -397,6 +400,10 @@ error: mcp-example requires "jq" on PATH.
then run this again — nothing was written.
```

`@part-of: <bundle>` marks a preset that does nothing without its siblings.
`list` hides it unless `--all`; install and remove by name still work. Only
on a preset with a body, never on a bundle.

`@pins: <name> <version>` records a third-party artifact the preset
installs at an exact version — the npm package behind an `mcp` command,
a plugin spec, a `@fetch`ed jar. Optional and repeatable. It's shown on
Expand Down
8 changes: 5 additions & 3 deletions bin/opencode-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,10 @@ async function main(): Promise<void> {
if (sub === 'list') {
const rest = argv.slice(1);
const long = rest.includes('-l') || rest.includes('--long');
const positional = rest.filter(a => a !== '-l' && a !== '--long');
const all = rest.includes('-a') || rest.includes('--all');
const positional = rest.filter(a => a !== '-l' && a !== '--long' && a !== '-a' && a !== '--all');
const dirs = positional[0] ? [resolve(positional[0])] : DEFAULT_PRESET_DIRS;
await listConfs(dirs, { long, repoRoot: REPO_ROOT });
await listConfs(dirs, { long, all, repoRoot: REPO_ROOT });
return;
}

Expand Down Expand Up @@ -426,7 +427,8 @@ async function loadJsonOrNull(path: string): Promise<Record<string, unknown> | n

function printUsage(): void {
console.log('Usage:');
console.log(' opencode-presets list [<dir>] [--long] list available .conf presets');
console.log(' opencode-presets list [<dir>] [--long] [--all] list available .conf presets');
console.log(' (--all includes bundle parts)');
console.log(' opencode-presets install [--reset <path>]... [--set NAME=VALUE]... <conf>...');
console.log(' apply one or more presets');
console.log(' (with optional pre-resets;');
Expand Down
1 change: 1 addition & 0 deletions presets/instructions-opencode-planify-german.conf
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// `instructions`, replacing no built-in prompt.
// @author: Jan <jan@trick77.com>
// @version: 0.3.2
// @part-of: opencode-planify-german
// @path: instructions
// @mode: append
// @fetch: https://raw.githubusercontent.com/trick77/opencode-planify-german/62caf977134dbe222b087f32e55051a3c6f79aaa/instructions/planify.md -> {{cache}}/opencode-planify-german-rules-0.3.2.md sha256=314ae512eb63c8026aa9a689b5205921020b29452d1c9a27f12046ed49a3008e
Expand Down
9 changes: 9 additions & 0 deletions presets/litellm-pricing.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// @name: litellm-pricing
// @description: Bundle: LiteLLM proxy models in opencode's picker with the
// proxy's own pricing — provider-litellm (proxy URL and key) plus
// plugin-litellm-pricing. Neither does anything alone. Not the MCP gateway:
// that is mcp-litellm, installed separately.
// @author: Jan <jan@trick77.com>
// @version: 0.1.0
// @include: provider-litellm
// @include: plugin-litellm-pricing
1 change: 1 addition & 0 deletions presets/plugin-litellm-pricing.conf
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// instead of $0.
// @author: Jan <jan@trick77.com>
// @version: 0.10.0
// @part-of: litellm-pricing
// @path: plugin
// @mode: append
// @pins: opencode-plugin-litellm-pricing 0.9.0
Expand Down
1 change: 1 addition & 0 deletions presets/plugin-opencode-planify-german.conf
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// it. Restart opencode afterwards — plugins resolve at start.
// @author: Jan <jan@trick77.com>
// @version: 0.3.2
// @part-of: opencode-planify-german
// @path: plugin
// @mode: append
// @pins: opencode-planify-german 0.3.2
Expand Down
1 change: 1 addition & 0 deletions presets/provider-litellm.conf
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// proxy. Declares no models — the plugin fills them in at runtime.
// @author: Jan <jan@trick77.com>
// @version: 0.8.0
// @part-of: litellm-pricing
// @path: provider.litellm
// @prompt: baseURL | text | LiteLLM proxy base URL (OpenAI-compatible /v1 endpoint) | http://localhost:4000/v1
// @prompt: apiKey | secret | LiteLLM proxy API key (virtual key or master key)
Expand Down
1 change: 1 addition & 0 deletions presets/skill-opencode-planify-german.conf
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// Uninstall by deleting the entry.
// @author: Jan <jan@trick77.com>
// @version: 0.3.2
// @part-of: opencode-planify-german
// @path: skills.paths
// @mode: append
// @fetch: https://raw.githubusercontent.com/trick77/opencode-planify-german/62caf977134dbe222b087f32e55051a3c6f79aaa/skills/planify/SKILL.md -> {{cache}}/opencode-planify-german-skills-0.3.2/planify/SKILL.md sha256=de90cc59bf8ffbedb447680a35a4a386ec20d59c91ff9a4fdd2886d28cc78e48
Expand Down
27 changes: 25 additions & 2 deletions src/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ interface Row {
source: string;
error?: string;
shadowed?: boolean;
partOf: string;
includes: string[];
}

export async function listConfs(dirs: string[], { long = false, repoRoot }: { long?: boolean; repoRoot: string }): Promise<void> {
export async function listConfs(dirs: string[], { long = false, all = false, repoRoot }: { long?: boolean; all?: boolean; repoRoot: string }): Promise<void> {
const allRows: Row[] = [];
let anyExists = false;

Expand Down Expand Up @@ -54,6 +56,8 @@ export async function listConfs(dirs: string[], { long = false, repoRoot }: { lo
pins: meta.pins,
file: f,
source: dir,
partOf: meta.partOf,
includes: meta.includes,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
Expand All @@ -67,6 +71,8 @@ export async function listConfs(dirs: string[], { long = false, repoRoot }: { lo
pins: [],
file: f,
source: dir,
partOf: '',
includes: [],
error: msg.replace(f + ':', '').trim(),
});
}
Expand All @@ -90,7 +96,23 @@ export async function listConfs(dirs: string[], { long = false, repoRoot }: { lo

allRows.sort((a, b) => a.name.localeCompare(b.name) || (a.shadowed ? 1 : -1));

printTable(allRows, dirs, long, repoRoot);
// A bundle part is inert on its own; listing it next to its bundle reads as
// a separate feature. The bundle row already names its members. Decided on
// the winning row per name, and only when that bundle is listed and really
// includes the part — a typo in @part-of must not make a preset vanish.
const winners = new Map(allRows.filter(r => r.ok && !r.shadowed).map(r => [r.name, r]));
const hiddenNames = new Set<string>();
for (const r of winners.values()) {
if (r.partOf && winners.get(r.partOf)?.includes.includes(r.name)) hiddenNames.add(r.name);
}
const rows = all ? allRows : allRows.filter(r => !hiddenNames.has(r.name));
printTable(rows, dirs, long, repoRoot);

const hidden = hiddenNames.size;
if (!all && hidden > 0) {
console.log('');
console.log(c.dim(`(${hidden} bundle part${hidden === 1 ? '' : 's'} hidden — list --all shows them)`));
}
}

function printTable(rows: Row[], dirs: string[], long: boolean, repoRoot: string): void {
Expand Down Expand Up @@ -124,6 +146,7 @@ function printTable(rows: Row[], dirs: string[], long: boolean, repoRoot: string

if (!r.ok) line = c.err(line) + ' ' + c.err('! ' + r.error);
else if (r.shadowed) line += c.dim(' (shadowed by earlier dir)');
if (r.ok && r.partOf) line += c.dim(` (part of ${r.partOf})`);
console.log(line);

if (long && r.ok && r.description) {
Expand Down
11 changes: 11 additions & 0 deletions src/parse-conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export interface ConfMeta {
// @include is a bundle: a pure list, with no @path and no body of its own,
// so it can never apply anything itself. See expand-includes.ts.
includes: string[];
// Bundle this preset is a part of: inert on its own, so `list` hides it
// unless --all. Still installable by name.
partOf: string;
}

export interface ParsedConf {
Expand Down Expand Up @@ -86,6 +89,7 @@ export function parseConfString(raw: string, filePath = '<inline>'): ParsedConf
pins: [],
requiresBin: [],
includes: [],
partOf: '',
};

let i = 0;
Expand Down Expand Up @@ -139,6 +143,10 @@ export function parseConfString(raw: string, filePath = '<inline>'): ParsedConf
if (!value) throw parseError(filePath, i + 1, '@include needs a preset name or path');
meta.includes.push(value);
break;
case 'part-of':
if (!value) throw parseError(filePath, i + 1, '@part-of needs the name of the bundle that includes this preset');
meta.partOf = value;
break;
default:
throw parseError(filePath, i + 1, `unknown header key @${key}`);
}
Expand Down Expand Up @@ -182,6 +190,9 @@ export function parseConfString(raw: string, filePath = '<inline>'): ParsedConf
throw parseError(filePath, 1, `@include presets must not set @${key} — put it on the preset that uses it`);
}
}
if (meta.partOf) {
throw parseError(filePath, 1, '@include presets must not set @part-of — only a preset with a body can be part of a bundle');
}
for (const k of REQUIRED) {
if (k === 'path') continue;
if (!meta[k]) throw parseError(filePath, 1, `missing required header @${k}`);
Expand Down
18 changes: 18 additions & 0 deletions test/builtin-presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,24 @@ test('every @include in a shipped preset points at another shipped preset', asyn
}
});

// @part-of hides a preset from `list`; if the bundle does not actually include
// it, the preset vanishes from every listing a user reads.
test('every @part-of names a shipped bundle that @includes the preset', async () => {
const files = await shippedPresets();
const metas = await Promise.all(files.map(async (f) => (await parseConf(f)).meta));
const byName = new Map(metas.map((m) => [m.name, m]));

for (const meta of metas) {
if (!meta.partOf) continue;
const bundle = byName.get(meta.partOf);
assert.ok(bundle, `${meta.name}: @part-of ${JSON.stringify(meta.partOf)} does not name a shipped preset`);
assert.ok(
bundle.includes.includes(meta.name),
`${meta.name}: @part-of ${bundle.name}, but ${bundle.name} does not @include it`,
);
}
});

// opencode evaluates permission rules with last-match-wins, and `merge` appends
// new keys at the end — so install order would silently decide the outcome of any
// deny/allow pair that can match the same command string. Keeping every shipped
Expand Down
2 changes: 1 addition & 1 deletion test/deny-warnings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ function meta(path: string): ConfMeta {
return {
name: 'm', description: '', author: '', version: '0.0.0',
target: 'config', path, mode: 'merge',
fetch: [], prompts: [], pins: [], requiresBin: [], includes: [],
fetch: [], prompts: [], pins: [], requiresBin: [], includes: [], partOf: '',
};
}

Expand Down
1 change: 1 addition & 0 deletions test/distribute-set-values.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function mod(name: string, prompts: string[]): BatchModule {
pins: [],
requiresBin: [],
includes: [],
partOf: '',
};
return { confPath: `${name}.conf`, meta, body: {} };
}
Expand Down
20 changes: 20 additions & 0 deletions test/parse-conf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,26 @@ describe('parseConfString — @include', () => {
});
});

describe('parseConfString — @part-of', () => {
test('records the bundle a preset belongs to', () => {
const src = minimalHeader + '// @part-of: pack\n\n{}';
assert.equal(parseConfString(src).meta.partOf, 'pack');
});

test('defaults to empty', () => {
assert.equal(parseConfString(minimalHeader + '\n{}').meta.partOf, '');
});

test('rejects an empty @part-of', () => {
assert.throws(() => parseConfString(minimalHeader + '// @part-of:\n\n{}'), /@part-of needs/);
});

test('rejects @part-of on a bundle', () => {
const src = '// @name: pack\n// @description: d\n// @author: a\n// @version: 0.1.0\n// @part-of: other\n// @include: a\n';
assert.throws(() => parseConfString(src), /must not set @part-of/);
});
});

describe('parseConfString — @pins', () => {
test('parses a scoped package name and version', () => {
const src = minimalHeader + '// @pins: @playwright/mcp 0.0.78\n\n{}';
Expand Down
Loading