From f9420d5624a7094cbba8adf5fec858644f074fcf Mon Sep 17 00:00:00 2001 From: trick77 Date: Sun, 27 Sep 2026 18:25:04 +0200 Subject: [PATCH 1/2] Hide bundle parts from list Presets that do nothing without their bundle siblings showed up as separate features. New @part-of header marks them; list hides them unless --all, README table drops them. Planify parts go under opencode-planify-german; provider-litellm and plugin-litellm-pricing under a new litellm-pricing bundle. --- AGENTS.md | 3 +++ README.md | 21 ++++++++++++------- bin/opencode-presets.ts | 8 ++++--- .../instructions-opencode-planify-german.conf | 1 + presets/litellm-pricing.conf | 9 ++++++++ presets/plugin-litellm-pricing.conf | 1 + presets/plugin-opencode-planify-german.conf | 1 + presets/provider-litellm.conf | 1 + presets/skill-opencode-planify-german.conf | 1 + src/list.ts | 17 +++++++++++++-- src/parse-conf.ts | 11 ++++++++++ test/builtin-presets.test.ts | 18 ++++++++++++++++ test/deny-warnings.test.ts | 2 +- test/distribute-set-values.test.ts | 1 + test/parse-conf.test.ts | 20 ++++++++++++++++++ 15 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 presets/litellm-pricing.conf diff --git a/AGENTS.md b/AGENTS.md index 6d5e030..840f828 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,9 @@ Order irrelevant. the check. `setup` = install command, mandatory (tested). Use when the preset is inert without it. - `@include: ` — repeatable. Makes the module a **bundle**. +- `@part-of: ` — 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`) diff --git a/README.md b/README.md index b7687ba..3e61a5b 100644 --- a/README.md +++ b/README.md @@ -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--
` 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/-.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 `` 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/-.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 @@ -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 @@ -202,6 +202,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 @@ -397,6 +398,10 @@ error: mcp-example requires "jq" on PATH. then run this again — nothing was written. ``` +`@part-of: ` 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: ` 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 diff --git a/bin/opencode-presets.ts b/bin/opencode-presets.ts index 1697bea..4f001a0 100755 --- a/bin/opencode-presets.ts +++ b/bin/opencode-presets.ts @@ -81,9 +81,10 @@ async function main(): Promise { 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; } @@ -426,7 +427,8 @@ async function loadJsonOrNull(path: string): Promise | n function printUsage(): void { console.log('Usage:'); - console.log(' opencode-presets list [] [--long] list available .conf presets'); + console.log(' opencode-presets list [] [--long] [--all] list available .conf presets'); + console.log(' (--all includes bundle parts)'); console.log(' opencode-presets install [--reset ]... [--set NAME=VALUE]... ...'); console.log(' apply one or more presets'); console.log(' (with optional pre-resets;'); diff --git a/presets/instructions-opencode-planify-german.conf b/presets/instructions-opencode-planify-german.conf index 94b0192..7861560 100644 --- a/presets/instructions-opencode-planify-german.conf +++ b/presets/instructions-opencode-planify-german.conf @@ -8,6 +8,7 @@ // `instructions`, replacing no built-in prompt. // @author: Jan // @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 diff --git a/presets/litellm-pricing.conf b/presets/litellm-pricing.conf new file mode 100644 index 0000000..a540940 --- /dev/null +++ b/presets/litellm-pricing.conf @@ -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 +// @version: 0.1.0 +// @include: provider-litellm +// @include: plugin-litellm-pricing diff --git a/presets/plugin-litellm-pricing.conf b/presets/plugin-litellm-pricing.conf index 52c0029..c27c9ec 100644 --- a/presets/plugin-litellm-pricing.conf +++ b/presets/plugin-litellm-pricing.conf @@ -6,6 +6,7 @@ // instead of $0. // @author: Jan // @version: 0.10.0 +// @part-of: litellm-pricing // @path: plugin // @mode: append // @pins: opencode-plugin-litellm-pricing 0.9.0 diff --git a/presets/plugin-opencode-planify-german.conf b/presets/plugin-opencode-planify-german.conf index b574363..3aab382 100644 --- a/presets/plugin-opencode-planify-german.conf +++ b/presets/plugin-opencode-planify-german.conf @@ -7,6 +7,7 @@ // it. Restart opencode afterwards — plugins resolve at start. // @author: Jan // @version: 0.3.2 +// @part-of: opencode-planify-german // @path: plugin // @mode: append // @pins: opencode-planify-german 0.3.2 diff --git a/presets/provider-litellm.conf b/presets/provider-litellm.conf index cab01d9..8bd6479 100644 --- a/presets/provider-litellm.conf +++ b/presets/provider-litellm.conf @@ -4,6 +4,7 @@ // proxy. Declares no models — the plugin fills them in at runtime. // @author: Jan // @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) diff --git a/presets/skill-opencode-planify-german.conf b/presets/skill-opencode-planify-german.conf index 624c2c4..c29eac0 100644 --- a/presets/skill-opencode-planify-german.conf +++ b/presets/skill-opencode-planify-german.conf @@ -7,6 +7,7 @@ // Uninstall by deleting the entry. // @author: Jan // @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 diff --git a/src/list.ts b/src/list.ts index 02d93dc..ceb1ebc 100644 --- a/src/list.ts +++ b/src/list.ts @@ -15,9 +15,10 @@ interface Row { source: string; error?: string; shadowed?: boolean; + partOf: string; } -export async function listConfs(dirs: string[], { long = false, repoRoot }: { long?: boolean; repoRoot: string }): Promise { +export async function listConfs(dirs: string[], { long = false, all = false, repoRoot }: { long?: boolean; all?: boolean; repoRoot: string }): Promise { const allRows: Row[] = []; let anyExists = false; @@ -54,6 +55,7 @@ export async function listConfs(dirs: string[], { long = false, repoRoot }: { lo pins: meta.pins, file: f, source: dir, + partOf: meta.partOf, }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -67,6 +69,7 @@ export async function listConfs(dirs: string[], { long = false, repoRoot }: { lo pins: [], file: f, source: dir, + partOf: '', error: msg.replace(f + ':', '').trim(), }); } @@ -90,7 +93,16 @@ 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. + const rows = all ? allRows : allRows.filter(r => !r.partOf); + printTable(rows, dirs, long, repoRoot); + + const hidden = allRows.filter(r => r.partOf && !r.shadowed).length; + 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 { @@ -124,6 +136,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) { diff --git a/src/parse-conf.ts b/src/parse-conf.ts index 45a30d2..ffddf6e 100644 --- a/src/parse-conf.ts +++ b/src/parse-conf.ts @@ -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 { @@ -86,6 +89,7 @@ export function parseConfString(raw: string, filePath = ''): ParsedConf pins: [], requiresBin: [], includes: [], + partOf: '', }; let i = 0; @@ -139,6 +143,10 @@ export function parseConfString(raw: string, filePath = ''): 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}`); } @@ -182,6 +190,9 @@ export function parseConfString(raw: string, filePath = ''): 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}`); diff --git a/test/builtin-presets.test.ts b/test/builtin-presets.test.ts index b164df0..be548dd 100644 --- a/test/builtin-presets.test.ts +++ b/test/builtin-presets.test.ts @@ -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 diff --git a/test/deny-warnings.test.ts b/test/deny-warnings.test.ts index 95656f9..67aee03 100644 --- a/test/deny-warnings.test.ts +++ b/test/deny-warnings.test.ts @@ -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: '', }; } diff --git a/test/distribute-set-values.test.ts b/test/distribute-set-values.test.ts index e65f475..7250ac6 100644 --- a/test/distribute-set-values.test.ts +++ b/test/distribute-set-values.test.ts @@ -19,6 +19,7 @@ function mod(name: string, prompts: string[]): BatchModule { pins: [], requiresBin: [], includes: [], + partOf: '', }; return { confPath: `${name}.conf`, meta, body: {} }; } diff --git a/test/parse-conf.test.ts b/test/parse-conf.test.ts index d8737fb..194174d 100644 --- a/test/parse-conf.test.ts +++ b/test/parse-conf.test.ts @@ -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{}'; From 4e8ba173195beb664f1c5cc9c2916a9d7f8fada8 Mon Sep 17 00:00:00 2001 From: trick77 Date: Sun, 27 Sep 2026 18:26:28 +0200 Subject: [PATCH 2/2] Hide parts by winning row, only when bundle includes them A shadowed copy could decide visibility, and a typo in @part-of hid a preset silently. README LiteLLM section points at the bundle. --- README.md | 8 +++++--- src/list.ts | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3e61a5b..bcf012b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/list.ts b/src/list.ts index ceb1ebc..cd67445 100644 --- a/src/list.ts +++ b/src/list.ts @@ -16,6 +16,7 @@ interface Row { error?: string; shadowed?: boolean; partOf: string; + includes: string[]; } export async function listConfs(dirs: string[], { long = false, all = false, repoRoot }: { long?: boolean; all?: boolean; repoRoot: string }): Promise { @@ -56,6 +57,7 @@ export async function listConfs(dirs: string[], { long = false, all = false, rep file: f, source: dir, partOf: meta.partOf, + includes: meta.includes, }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -70,6 +72,7 @@ export async function listConfs(dirs: string[], { long = false, all = false, rep file: f, source: dir, partOf: '', + includes: [], error: msg.replace(f + ':', '').trim(), }); } @@ -94,11 +97,18 @@ export async function listConfs(dirs: string[], { long = false, all = false, rep allRows.sort((a, b) => a.name.localeCompare(b.name) || (a.shadowed ? 1 : -1)); // 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. - const rows = all ? allRows : allRows.filter(r => !r.partOf); + // 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(); + 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 = allRows.filter(r => r.partOf && !r.shadowed).length; + 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)`));