From 33c456afb64a1448c4e66d6926523889a7663711 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Mon, 10 Aug 2026 15:54:28 -0700 Subject: [PATCH 1/3] feat: add shared plugin sources and target overlays --- CLAUDE.md | 23 ++-- MIGRATING_TO_0.11.md | 197 ++++++++++++++++++++++++++++ README.md | 236 ++++++++++++++++++---------------- package.json | 1 + snippets/readme/snippet-02.ts | 18 +-- snippets/readme/snippet-03.ts | 14 +- src/cli.ts | 18 +-- src/managed.ts | 18 ++- src/schema.ts | 65 +++++++--- src/source.ts | 200 ++++++++++++++++++++++++++++ src/targets/engine.ts | 116 ++++++++++++++--- src/types.ts | 8 ++ tests/conformance.test.ts | 4 +- tests/core.test.ts | 162 +++++++++++++++++++++++ 14 files changed, 891 insertions(+), 189 deletions(-) create mode 100644 MIGRATING_TO_0.11.md diff --git a/CLAUDE.md b/CLAUDE.md index ebbca56..33791cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,11 +39,12 @@ diff, prune, and validate all derive from it. the public types are derived with `z.infer`. Edit schemas here, not `types.ts`. - `src/types.ts` — non-config types; re-exports the config types from `schema.ts`. - `src/components.ts` — `componentDirs` + `staticFiles` (shared by render/config). -- `src/config.ts` — `loadConfig` (jiti loads `pluginpack.config.ts`), source - plugin discovery (only dirs with a manifest or a component dir count, so - generated output is never misread as source), and the root-skills plugin. -- `src/render.ts` — `collectPluginFiles` (component dirs + static files, with - `targets//` override resolution) and `resolveMcpServers`. +- `src/config.ts` — `loadConfig` (jiti loads `pluginpack.config.ts`) and legacy + 0.10 source-plugin discovery during the migration window. +- `src/source.ts` — reads canonical direct `shared/` sources, applies + target overlays, packages `mcp/`, and retains the legacy filesystem source + provider. +- `src/render.ts` — legacy `collectPluginFiles`/`resolveMcpServers` composition. - `src/partials.ts` — `loadPartials`/`resolvePartials`: project-level `{{> name}}` text-reuse, wired into `collectPluginFiles` and `withRootFiles`. Substitution is real `mustache` rendering (view is always @@ -97,14 +98,14 @@ schemas at runtime — vendor a pinned copy with recorded provenance. ## Shapes and gotchas -- **Recommended shape:** top-level `skills/` (the portable surface) + generated - native outputs under `plugins//` in the same repo. +- **Recommended shape:** `shared//` canonical sources, + `overrides///` target overlays, and + `repositories//` generated-repository files. - **claude + copilot collide:** both write `.claude-plugin/marketplace.json`, so they need distinct `outDir`s. `build()` errors on overlapping output paths. -- **MCP:** a source plugin declares servers via a `.mcp.json` file (standard - `{ mcpServers: {...} }`) or an `mcpServers` key in `plugin.pluginpack.json` - (file wins). claude ships the file (auto-discovered); cursor/copilot reference - it; antigravity writes `mcp_config.json`. +- **MCP:** a canonical source keeps `mcp/config.json`, its implementation, tests, + and `mcp/pluginpack.json` shipping-file map together. Target adapters translate + the config into the native output. Legacy sources still read `.mcp.json`. ## Conventions diff --git a/MIGRATING_TO_0.11.md b/MIGRATING_TO_0.11.md new file mode 100644 index 0000000..97012dd --- /dev/null +++ b/MIGRATING_TO_0.11.md @@ -0,0 +1,197 @@ +# Migrating pluginpack 0.10 to 0.11 + +This guide is intentionally procedural. An agent can execute it without making +architectural choices. It migrates one repository at a time while preserving +the generated artifact. + +## Contract change + +| 0.10 | 0.11 | +| --------------------------------------- | ------------------------------------------------ | +| Discovered source plugins plus `from` | One direct `source` per emitted plugin | +| Root-level `skills/` special case | `shared//skills/` | +| `targets//` replacement files | `overrides///` post-source overlay | +| `components` | `include` or `exclude` | +| Root `.mcp.json` plus `additionalFiles` | `mcp/config.json` plus `mcp/pluginpack.json` | +| `rootFiles` map | `repositoryFiles` directory | + +Pluginpack 0.11 still reads the 0.10 fields for one migration window. Do not +mix `source` with `from`, or `components` with `include`/`exclude`, on the same +emitted plugin. + +## 1. Establish the artifact baseline + +From the consumer repository: + +```bash +npm ci +npm run build +``` + +Record every target's managed paths and hashes. If the repository has published +output checkouts, also run `pluginpack diff --target --against `. +Do not proceed while the existing build or validation fails. + +## 2. Create one shared directory per emitted plugin + +For every key below `targets..plugins`, choose a canonical plugin name. +Create `shared//` and move that plugin's portable files beneath it: + +```text +shared// + skills/ + agents/ + commands/ + rules/ + hooks/ + assets/ + README.md + CHANGELOG.md + LICENSE +``` + +If several 0.10 `from` entries fed one emitted plugin, merge their non-colliding +files into this one directory. A path collision must be resolved explicitly; +do not pick a winner based on old `from` order because 0.10 rejected collisions. + +## 3. Move target-specific content + +For each target, create `overrides///`. + +- Move every old `targets//` replacement to the same `` + below the overlay. +- Move every target-only source file into the overlay. +- Keep shared files in `shared//`. + +An overlay may replace a shared file or add a new one. + +## 4. Move MCP content + +For a plugin with an MCP server, create: + +```text +shared//mcp/ + config.json + pluginpack.json + # server source, tests, build files, and generated shipping files +``` + +Move the old `.mcp.json` to `mcp/config.json` without changing its JSON shape. +Move the local server implementation under `mcp/`. + +Convert `plugin.pluginpack.json.additionalFiles` into +`mcp/pluginpack.json.files`. Paths on the left remain emitted-plugin-relative; +paths on the right become relative to `mcp/`: + +```json +{ + "files": { + "mcp/start.mjs": "start.mjs", + "mcp/dist/index.js": "dist/index.js", + "mcp/package.json": "package.json" + } +} +``` + +Update commands in `mcp/config.json` to point at the new emitted paths. Put a +target-specific MCP config at +`overrides///mcp/config.json` when necessary. + +Use `exclude: ["mcp"]` for a target that must omit both the MCP configuration +and its declared shipping files. + +## 5. Move generated-repository files + +For each target using `rootFiles`, create `repositories//` and move each +mapped source file to its output-relative location below that directory. Replace +the entire `rootFiles` map with: + +```ts +repositoryFiles: "repositories/"; +``` + +## 6. Rewrite the config + +Before: + +```ts +plugins: { + acme: { + from: ["core", "cursor"], + components: ["skills", "agents", "rules"] + } +} +``` + +After: + +```ts +plugins: { + acme: { + source: "shared/acme", + include: ["skills", "agents", "rules", "static"], + overlay: "overrides/cursor/acme" + } +} +``` + +Omit `overlay` when that directory does not exist. Prefer `exclude` when a +target differs from pluginpack's defaults by only one or two content kinds. + +Delete `source.skills`, `source.rootPlugin`, and `source.plugins` after every +emitted plugin uses a direct `source`. Keep `source.partials` temporarily if the +repository uses partials. + +## 7. Update scripts and ignored paths + +Search the repository for every old path and config field: + +```bash +rg -n 'source\.skills|source\.plugins|rootPlugin|from:|components:|rootFiles|targets/' \ + -g '!node_modules/**' -g '!dist/**' +``` + +Update build scripts, TypeScript project roots, test roots, release metadata +sync, lint rules, documentation, and `.gitignore` entries. Ensure source code +under `mcp/src` and tests under `mcp/tests` are not accidentally emitted; only +files declared by `mcp/pluginpack.json` ship. + +## 8. Verify equivalence + +Install pluginpack 0.11, then run the repository's complete test gate. At +minimum: + +```bash +npm run build +npm run validate +npm test +``` + +Compare the new managed path and hash inventory with the baseline. Every change +must be one of: + +- an intentional emitted path change recorded in the migration; +- a target-only addition that was previously impossible to express as an + override; +- removal of an accidentally shipped file. + +Verify each MCP command resolves from the installed plugin directory, not only +from the source checkout. + +## 9. Commit and rollback + +Commit the consumer migration separately from behavioral content changes. To +roll back, revert that commit and restore the 0.10 dependency and lockfile; do +not hand-edit generated output. + +## Completion checklist + +- [ ] Every emitted plugin has exactly one `source`. +- [ ] Shared content lives below `shared//`. +- [ ] Target content lives below `overrides///`. +- [ ] MCP configuration and implementation live together below `mcp/`. +- [ ] Only `mcp/pluginpack.json` shipping files are emitted. +- [ ] Generated-repository files live below `repositories//`. +- [ ] No canonical config uses `from`, `components`, or `rootFiles`. +- [ ] All target builds and validators pass. +- [ ] Generated artifact differences are reviewed and explained. diff --git a/README.md b/README.md index 51dbd40..70d90a8 100644 --- a/README.md +++ b/README.md @@ -21,26 +21,24 @@ Start with portable plugin components, declare the native targets you want, then npm install -D @gleanwork/pluginpack ``` -Create repo-level component directories: +Create one shared source directory per plugin: ```tree -skills/ - release-notes/ - SKILL.md -agents/ - search-assistant.md -commands/ - summarize.md -rules/ - style.mdc -hooks/ - before-run.sh -assets/ - icon.png +shared/ + acme/ + skills/ + release-notes/ + SKILL.md + agents/ + search-assistant.md + assets/ + icon.png pluginpack.config.ts ``` -Add a config that maps that portable source into native plugin outputs. `source.skills` gives the repo a simple portable install surface; sibling component directories are included when the selected target supports them or when you opt into them with `components`. +Map that shared source directly into each native plugin output. Add an `overlay` +only when a target needs files that differ from or do not exist in the shared +source. ```ts snippet=readme/snippet-02.ts import { defineConfig } from "@gleanwork/pluginpack"; @@ -48,13 +46,6 @@ import { defineConfig } from "@gleanwork/pluginpack"; export default defineConfig({ name: "acme-plugins", version: "0.1.0", - source: { - skills: "skills", - rootPlugin: { - id: "core", - description: "Acme portable skills.", - }, - }, metadata: { description: "Acme agent plugins.", author: { name: "Acme" }, @@ -65,7 +56,8 @@ export default defineConfig({ outDir: ".", plugins: { acme: { - from: ["core"], + source: "shared/acme", + overlay: "overrides/cursor/acme", path: "plugins/cursor/acme", }, }, @@ -74,25 +66,25 @@ export default defineConfig({ outDir: ".", pluginRoot: "plugins/claude", plugins: { - acme: { from: ["core"] }, + acme: { source: "shared/acme" }, }, }, antigravity: { outDir: "plugins/antigravity", plugins: { - acme: { from: ["core"] }, + acme: { source: "shared/acme" }, }, }, copilot: { outDir: "plugins/copilot", plugins: { - acme: { from: ["core"] }, + acme: { source: "shared/acme" }, }, }, codex: { outDir: "plugins/codex", plugins: { - acme: { from: ["core"] }, + acme: { source: "shared/acme" }, }, }, }, @@ -106,7 +98,9 @@ npx pluginpack build npx pluginpack validate --target cursor ``` -Users who only want portable skills install from the `skills/` subpath, for example `npx skills add owner/repo/skills --skill '*'`. Claude, Cursor, Antigravity, Copilot, and Codex users install from the generated native layout that can include skills, agents, rules, hooks, assets, MCP config, and target-specific manifests. +Claude, Cursor, Antigravity, Copilot, and Codex users install from the generated +native layout. Repositories that also expose a `skills` CLI surface may point +that tool at `shared//skills`. ## Mental Model @@ -123,22 +117,25 @@ It does not try to make every app behave the same. Target adapters own target-sp ## Recommended Shape -The preferred path is one public plugin repository with top-level component directories. `skills/` remains the portable `skills` CLI install surface, while the other component directories feed native plugin outputs. +The preferred authored shape separates shared plugin content, target overlays, +and generated-repository files: ```tree -skills/ - release-notes/ - SKILL.md -agents/ - search-assistant.md -commands/ - summarize.md -rules/ - style.mdc -hooks/ - before-run.sh -assets/ - icon.png +shared/ + acme/ + skills/ + agents/ + assets/ + mcp/ + config.json + pluginpack.json +overrides/ + cursor/ + acme/ + rules/ +repositories/ + cursor/ + README.md pluginpack.config.ts .cursor-plugin/ @@ -187,7 +184,9 @@ plugins/ claude.json ``` -`source.skills` points at the repo-level skills directory and creates a root source plugin from the sibling component directories. `source.rootPlugin.id` creates the source plugin name used by each target's `from` array. The repo root is intentionally also home to generated native plugin outputs, so the `skills/` subpath keeps `skills` CLI discovery focused on the canonical portable skills. +Each emitted plugin names one `source`. An optional `overlay` is applied after +the source, so it can add or replace files for that target. `repositoryFiles` +copies a whole directory into the generated repository root. `pluginpack` writes a `.pluginpack/.json` managed-file manifest for each built target. That manifest lets builds and cleanup commands remove stale generated files without touching source files or unmanaged repo content. @@ -211,7 +210,7 @@ themes/ Target adapters translate those component directories into each app's native layout and manifest fields. Each target has a smart default component list. By default, `claude`, `cursor`, `antigravity`, and `copilot` emit skills and other native plugin support files but omit `commands`, since those ecosystems increasingly expose skills as slash commands. -Use `components` only when a plugin needs an exact target-specific component set: +Use `include` or `exclude` only when a target needs a different content set: ```ts snippet=readme/snippet-03.ts import { defineConfig } from "@gleanwork/pluginpack"; @@ -219,13 +218,6 @@ import { defineConfig } from "@gleanwork/pluginpack"; export default defineConfig({ name: "acme-plugins", version: "0.1.0", - source: { - skills: "skills", - rootPlugin: { - id: "core", - description: "Acme portable skills.", - }, - }, metadata: { description: "Acme agent plugins.", author: { name: "Acme" }, @@ -235,13 +227,16 @@ export default defineConfig({ antigravity: { outDir: "plugins/antigravity", plugins: { - acme: { from: ["core"], components: ["skills", "commands"] }, + acme: { + source: "shared/acme", + include: ["skills", "commands", "static"], + }, }, }, claude: { outDir: "plugins/claude", plugins: { - acme: { from: ["core"], components: ["skills"] }, + acme: { source: "shared/acme", exclude: ["commands"] }, }, }, }, @@ -264,42 +259,12 @@ Each target compiles the same source into one app's native plugin layout: New targets are added from official docs or real plugin examples — not guessed abstractions. -## Source Plugins +## Legacy 0.10 Source Composition -The quick-start shape treats repo-level component directories as one source plugin. For more complex source content, keep source plugins under `plugins/` and emit them into one or more target outputs: - -```tree -plugins/ - core/ - plugin.pluginpack.json - .mcp.json - skills/ - release-notes/ - SKILL.md - agents/ - commands/ - rules/ - hooks/ - assets/ -``` - -A target can emit a source plugin directly, rename it, or merge multiple source plugins into one emitted plugin. - -## MCP Servers - -A source plugin declares MCP servers with a standard `.mcp.json` file at its root (`{ "mcpServers": { "name": { ... } } }`), or with an `mcpServers` key in `plugin.pluginpack.json`. The file wins if both are present, and merging plugins with the same server name is an error. - -The `.mcp.json` file form supports per-target overrides: a `targets//.mcp.json` file next to the base wins for that host only. This lets one source ship different server definitions per app (for example, a `${CLAUDE_PLUGIN_ROOT}/start.mjs` invocation for Claude and a `cwd: "."` + `./start.mjs` invocation for Codex). The manifest (`mcpServers` in `plugin.pluginpack.json`) form has no per-file override — authors who need per-target MCP config should use the file form. - -Each target wires that MCP config into its native shape: - -| Target | How MCP is wired | -| ------------- | -------------------------------------------------------- | -| `claude` | ships `.mcp.json` at the plugin root (auto-discovered) | -| `cursor` | ships `.mcp.json`, referenced from `plugin.json` | -| `codex` | ships `.mcp.json`, referenced from `plugin.json` | -| `copilot` | ships `.mcp.json`, referenced from the marketplace entry | -| `antigravity` | writes `mcp_config.json` beside `plugin.json` | +Pluginpack 0.11 can still read `source.plugins`, `source.skills`, `rootPlugin`, +`from`, nested `targets/` replacements, and root `.mcp.json` files for one +migration window. New repositories should not use that interface. Follow +[`MIGRATING_TO_0.11.md`](./MIGRATING_TO_0.11.md) to convert an existing repo. ## Update Check (claude, cursor) @@ -323,7 +288,7 @@ The check follows update-notifier discipline: Disable for a single plugin with `updateCheck: false` on that plugin. Configuring `updateCheck` on `copilot`, `antigravity`, or `codex` is a config error — those hosts don't run plugin hooks. -Like MCP config, the generated hook is wired in regardless of a plugin's `components` selection: on `cursor`, the manifest's `hooks` field is set even if `components` doesn't include `"hooks"`, since the check itself is a separate opt-in from which source-authored component dirs get emitted. +Like MCP config, the generated hook is wired in regardless of a plugin's content selection: on `cursor`, the manifest's `hooks` field is set even if `include` does not name `"hooks"`, since the check itself is a separate opt-in. ## Install Snippet @@ -338,19 +303,61 @@ The repo comes from `targets..repository`, defaulting to `metadata.reposit ## Target Overrides -Skill files are not always perfectly portable. When one app needs different frontmatter or content, add a target override next to the base file: +When one app needs different or additional content, put it in that emitted +plugin's target overlay: + +```txt +shared/acme/skills/release-notes/SKILL.md +overrides/cursor/acme/skills/release-notes/SKILL.md +overrides/cursor/acme/rules/cursor-only.mdc +``` + +The overlay is applied after the shared source, so it can both replace the +shared skill and add the Cursor-only rule. Set its directory with the emitted +plugin's `overlay` field. + +## MCP Directory + +An authored plugin keeps its MCP configuration and local server together: ```txt -skills/release-notes/SKILL.md -skills/release-notes/targets/cursor/SKILL.md -skills/release-notes/targets/claude/SKILL.md +shared/acme/mcp/ + config.json + pluginpack.json + start.mjs + dist/index.js + src/ + tests/ +``` + +`config.json` uses the standard `{ "mcpServers": { ... } }` shape. +`pluginpack.json` declares only the server files that ship: + +```json +{ + "files": { + "mcp/start.mjs": "start.mjs", + "mcp/dist/index.js": "dist/index.js" + } +} ``` -Resolution order is target override first, then the base file. The same override mechanism applies to static files (README/CHANGELOG/LICENSE) and to MCP config (`.mcp.json`) and declared plugin-root `additionalFiles`. +Pluginpack translates the configuration into each target's native MCP layout. +The MCP source and tests remain in `mcp/`; only declared shipping files are +emitted. Add `"mcp"` to `exclude` to omit the complete capability for a target. + +| Target | How MCP is wired | +| ------------- | -------------------------------------------------------- | +| `claude` | ships `.mcp.json` at the plugin root (auto-discovered) | +| `cursor` | ships `.mcp.json`, referenced from `plugin.json` | +| `codex` | ships `.mcp.json`, referenced from `plugin.json` | +| `copilot` | ships `.mcp.json`, referenced from the marketplace entry | +| `antigravity` | writes `mcp_config.json` beside `plugin.json` | -## Additional Plugin-Root Files +## Legacy Additional Plugin-Root Files -A source plugin that needs files at its emitted root beyond the component and static files pluginpack supports by default — a bundled MCP server, a launcher script, or a `package.json` to set Node's module type — declares them under `additionalFiles` in `plugin.pluginpack.json`: +Legacy 0.10 sources may still declare `additionalFiles` in +`plugin.pluginpack.json`. New MCPs use `mcp/pluginpack.json` instead. ```json { @@ -370,8 +377,8 @@ Skills, agents, commands, and rules often need to repeat the same procedural pro ```txt partials/auth.md -skills/release-notes/SKILL.md -> contains {{> auth}} -skills/changelog/SKILL.md -> contains {{> auth}} +shared/acme/skills/release-notes/SKILL.md -> contains {{> auth}} +shared/acme/skills/changelog/SKILL.md -> contains {{> auth}} ``` Point `source.partials` at that directory in `pluginpack.config.ts`: @@ -385,7 +392,7 @@ export default defineConfig({ Partials are project-level (shared across every source plugin, not scoped to one), and may reference other partials — nested composition resolves in one pass, though a circular reference (A includes B includes A) is a build-time error. A tag alone on its own line — the common case — leaves no blank line behind. -Substitution runs on every `.md`/`.mdc`/`.markdown`/`.txt` file pluginpack emits — skills, agents, commands, rules, `additionalFiles`, and a target's `rootFiles` — via the real [`mustache`](https://github.com/janl/mustache.js) library. +Substitution runs on every `.md`/`.mdc`/`.markdown`/`.txt` file pluginpack emits — skills, agents, commands, rules, declared shipping files, and a target's `repositoryFiles` — via the real [`mustache`](https://github.com/janl/mustache.js) library. **A tag that cannot be resolved fails the build.** A `{{> name}}` reference naming a partial that does not exist is an error listing the available partials and the nearest match, rather than rendering as nothing — silently dropping a section out of a shipped skill file is worse than a red build. The same applies to a malformed reference (`{{> }}`), and to a tag inside a partial's own body. @@ -421,7 +428,9 @@ Use that in CI to fail clearly or to trigger an action that opens a PR against t When a generated target repo intentionally owns a path, add `ignoredDiffPaths` to that target config. Entries are target-output-relative paths; a directory entry ignores everything below it. -To publish a repo-root file (for example a README authored once in the source repo) into a target's output, add `rootFiles` to that target config — a map of output path to source path (relative to the config root). Emitted root files are managed like any other generated file, so an output repo's README stays synced from source instead of hand-maintained per repo. +To publish generated-repository files, point `repositoryFiles` at a directory. +Every file below it is copied to the target output root and managed like the +rest of the artifact. ## Configuration Reference @@ -429,15 +438,15 @@ To publish a repo-root file (for example a README authored once in the source re **Top level** -| Field | Type | Required | Meaning | -| ---------- | ------ | -------- | -------------------------------------------------------------------------- | -| `name` | string | yes | Marketplace/source name written into generated manifests. | -| `version` | string | yes | Default version stamped into manifests (per-target/plugin overridable). | -| `source` | object | no | Where source plugins come from (see **`source`**). | -| `metadata` | object | no | Shared metadata merged into manifests (see **`metadata`**). | -| `targets` | object | yes | Per-target output config, keyed by target name (see **`targets.`**). | +| Field | Type | Required | Meaning | +| ---------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `name` | string | yes | Marketplace/source name written into generated manifests. | +| `version` | string | yes | Default version stamped into manifests (per-target/plugin overridable). | +| `source` | object | legacy | 0.10 discovery/partials config; direct plugin sources now live on emitted plugins. | +| `metadata` | object | no | Shared metadata merged into manifests (see **`metadata`**). | +| `targets` | object | yes | Per-target output config, keyed by target name (see **`targets.`**). | -**`source`** +**Legacy `source` (0.10 migration compatibility)** | Field | Type | Required | Meaning | | ------------ | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -473,7 +482,8 @@ To publish a repo-root file (for example a README authored once in the source re | `version` | string | no | Override the version for this target (defaults to top-level `version`). | | `manifest` | object | no | Deep-merged into the generated marketplace manifest. | | `ignoredDiffPaths` | string[] | no | Output-relative paths `diff` ignores (a dir entry ignores everything below it). | -| `rootFiles` | record (safe relative) | no | Map of output path → source path emitted verbatim at the output root. | +| `repositoryFiles` | string (safe relative) | no | Directory copied recursively into the generated repository root. | +| `rootFiles` | record (safe relative) | no | Legacy 0.10 output path → source path map; migrate to `repositoryFiles`. | | `updateCheck` | `{ repository? }` | no | Generate a session-start update-check hook (`claude`/`cursor` only; see **Update Check**). | | `repository` | string | no | Repo this target's output lives in, for `install-info` (defaults to `metadata.repository`). | @@ -481,14 +491,18 @@ To publish a repo-root file (for example a README authored once in the source re | Field | Type | Required | Meaning | | ------------- | ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `from` | string[] (min 1) | yes | Source plugin ids to merge into this emitted plugin. | +| `source` | string (safe relative) | yes | Direct path to the plugin's shared authored source. | +| `overlay` | string (safe relative) | no | Target-specific directory applied after `source`; may add or replace files. | +| `include` | string[] | no | Exact content kinds to include (`skills`, `agents`, `rules`, `assets`, `static`, `mcp`, etc.). | +| `exclude` | string[] | no | Content kinds removed from the target defaults. | +| `from` | string[] (min 1) | legacy | 0.10 source-plugin composition; cannot be combined with `source`. | | `path` | string (safe relative) | no | Output path for the plugin, relative to `outDir`. Defaults to the plugin name (or `pluginRoot/` for `claude`). | | `version` | string | no | Per-plugin version override. | | `displayName` | string | no | Per-plugin display name. | | `description` | string | no | Per-plugin description override. | | `manifest` | object | no | Deep-merged into the generated plugin manifest. | | `entry` | object | no | Deep-merged into the generated marketplace entry (the object in the marketplace `plugins` array). Use for target-specific entry fields pluginpack can't derive — e.g. Codex `policy`/`category`. | -| `components` | string[] | no | Exact component set, overriding the target's smart default. | +| `components` | string[] | legacy | 0.10 include-only name; migrate to `include`. | | `updateCheck` | `false` | no | Opt this plugin out of the target's update-check hook. | ## Programmatic API diff --git a/package.json b/package.json index d0a17bb..7ff32b2 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "dist", "!dist/**/*.map", "CHANGELOG.md", + "MIGRATING_TO_0.11.md", "LICENSE", "README.md" ], diff --git a/snippets/readme/snippet-02.ts b/snippets/readme/snippet-02.ts index 9ca2cbc..3c1596f 100644 --- a/snippets/readme/snippet-02.ts +++ b/snippets/readme/snippet-02.ts @@ -3,13 +3,6 @@ import { defineConfig } from "@gleanwork/pluginpack"; export default defineConfig({ name: "acme-plugins", version: "0.1.0", - source: { - skills: "skills", - rootPlugin: { - id: "core", - description: "Acme portable skills.", - }, - }, metadata: { description: "Acme agent plugins.", author: { name: "Acme" }, @@ -20,7 +13,8 @@ export default defineConfig({ outDir: ".", plugins: { acme: { - from: ["core"], + source: "shared/acme", + overlay: "overrides/cursor/acme", path: "plugins/cursor/acme", }, }, @@ -29,25 +23,25 @@ export default defineConfig({ outDir: ".", pluginRoot: "plugins/claude", plugins: { - acme: { from: ["core"] }, + acme: { source: "shared/acme" }, }, }, antigravity: { outDir: "plugins/antigravity", plugins: { - acme: { from: ["core"] }, + acme: { source: "shared/acme" }, }, }, copilot: { outDir: "plugins/copilot", plugins: { - acme: { from: ["core"] }, + acme: { source: "shared/acme" }, }, }, codex: { outDir: "plugins/codex", plugins: { - acme: { from: ["core"] }, + acme: { source: "shared/acme" }, }, }, }, diff --git a/snippets/readme/snippet-03.ts b/snippets/readme/snippet-03.ts index a04a9e9..a3d86eb 100644 --- a/snippets/readme/snippet-03.ts +++ b/snippets/readme/snippet-03.ts @@ -3,13 +3,6 @@ import { defineConfig } from "@gleanwork/pluginpack"; export default defineConfig({ name: "acme-plugins", version: "0.1.0", - source: { - skills: "skills", - rootPlugin: { - id: "core", - description: "Acme portable skills.", - }, - }, metadata: { description: "Acme agent plugins.", author: { name: "Acme" }, @@ -19,13 +12,16 @@ export default defineConfig({ antigravity: { outDir: "plugins/antigravity", plugins: { - acme: { from: ["core"], components: ["skills", "commands"] }, + acme: { + source: "shared/acme", + include: ["skills", "commands", "static"], + }, }, }, claude: { outDir: "plugins/claude", plugins: { - acme: { from: ["core"], components: ["skills"] }, + acme: { source: "shared/acme", exclude: ["commands"] }, }, }, }, diff --git a/src/cli.ts b/src/cli.ts index fa78b5c..b18c24d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -311,18 +311,18 @@ function printCleanupResults( } } -/** Writes a starter `pluginpack.config.ts` and example source plugin. */ +/** Writes a starter `pluginpack.config.ts` and shared authored plugin. */ async function init(): Promise { const configPath = path.resolve("pluginpack.config.ts"); if (await exists(configPath)) { throw new Error("pluginpack.config.ts already exists."); } - await fs.mkdir(path.resolve("plugins", "example", "skills", "example"), { + await fs.mkdir(path.resolve("shared", "example", "skills", "example"), { recursive: true, }); await fs.writeFile(configPath, starterConfig()); await fs.writeFile( - path.resolve("plugins", "example", "plugin.pluginpack.json"), + path.resolve("shared", "example", "plugin.pluginpack.json"), `${JSON.stringify( { description: "Example plugin generated by pluginpack init", @@ -332,10 +332,10 @@ async function init(): Promise { )}\n`, ); await fs.writeFile( - path.resolve("plugins", "example", "skills", "example", "SKILL.md"), + path.resolve("shared", "example", "skills", "example", "SKILL.md"), starterSkill(), ); - console.log("Created pluginpack.config.ts and plugins/example."); + console.log("Created pluginpack.config.ts and shared/example."); } /** Commander option parser validating `--target` against the known target names. */ @@ -534,25 +534,25 @@ export default defineConfig({ cursor: { outDir: "dist/cursor", plugins: { - example: { from: ["example"] } + example: { source: "shared/example" } } }, claude: { outDir: "dist/claude", plugins: { - example: { from: ["example"] } + example: { source: "shared/example" } } }, antigravity: { outDir: "dist/antigravity", plugins: { - example: { from: ["example"] } + example: { source: "shared/example" } } }, copilot: { outDir: "dist/copilot", plugins: { - example: { from: ["example"] } + example: { source: "shared/example" } } } } diff --git a/src/managed.ts b/src/managed.ts index 6892530..377b348 100644 --- a/src/managed.ts +++ b/src/managed.ts @@ -174,6 +174,22 @@ export async function buildDeleteGuard( ...(await listSourcePluginDirs(path.resolve(rootDir, "plugins"))), ); } + for (const target of Object.values(config.targets)) { + if (!target) { + continue; + } + if (target.repositoryFiles) { + protectedRoots.push(path.resolve(rootDir, target.repositoryFiles)); + } + for (const plugin of Object.values(target.plugins)) { + if (plugin.source) { + protectedRoots.push(path.resolve(rootDir, plugin.source)); + } + if (plugin.overlay) { + protectedRoots.push(path.resolve(rootDir, plugin.overlay)); + } + } + } return { protectedRoots, configPath: path.resolve(project.configPath), @@ -201,7 +217,7 @@ function assertNoProtectedDeletions( throw new Error( `Refusing to ${command} ${blocked.length} path(s) that resolve inside your source tree or config:\n` + `${blocked.map(({ file, root }) => ` ${file} -> resolves inside ${root}`).join("\n")}\n` + - `This usually means a target outDir overlaps source.skills/source.plugins. ` + + `This usually means a target outDir overlaps source, overlay, repositoryFiles, or legacy source config. ` + `Fix the config, or re-run with --force to delete anyway.`, ); } diff --git a/src/schema.ts b/src/schema.ts index 14586c6..30ea0df 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -54,21 +54,45 @@ const updateCheckSchema = z.object({ repository: z.string().min(1).optional(), }); -/** A source plugin (or plugins) mapped to one emitted plugin for a target. */ -const emittedPluginSchema = z.object({ - from: z.array(z.string().min(1)).min(1), - path: safeRelativePath.optional(), - version: z.string().optional(), - description: z.string().optional(), - displayName: z.string().optional(), - manifest: z.record(z.string(), z.unknown()).optional(), - // Deep-merged into this plugin's generated marketplace entry (the object in - // the marketplace `plugins` array), letting a config supply target-specific - // entry fields a target can't derive — e.g. Codex `policy`/`category`. - entry: z.record(z.string(), z.unknown()).optional(), - components: z.array(z.string()).optional(), - updateCheck: z.literal(false).optional(), -}); +/** One authored plugin source mapped to an emitted plugin for a target. */ +const emittedPluginSchema = z + .object({ + // Canonical since 0.11: a direct path to one shared authored plugin. + source: safeRelativePath.optional(), + // Legacy 0.10 composition model. Kept readable for one migration window. + from: z.array(z.string().min(1)).min(1).optional(), + path: safeRelativePath.optional(), + version: z.string().optional(), + description: z.string().optional(), + displayName: z.string().optional(), + manifest: z.record(z.string(), z.unknown()).optional(), + // Deep-merged into this plugin's generated marketplace entry (the object in + // the marketplace `plugins` array), letting a config supply target-specific + // entry fields a target can't derive — e.g. Codex `policy`/`category`. + entry: z.record(z.string(), z.unknown()).optional(), + // Canonical selection names. `components` is the legacy include-only name. + include: z.array(z.string().min(1)).optional(), + exclude: z.array(z.string().min(1)).optional(), + components: z.array(z.string()).optional(), + // Applied after reading `source`, so it can add or replace target files. + overlay: safeRelativePath.optional(), + updateCheck: z.literal(false).optional(), + }) + .superRefine((plugin, ctx) => { + if (Boolean(plugin.source) === Boolean(plugin.from)) { + ctx.addIssue({ + code: "custom", + message: 'set exactly one of "source" or legacy "from"', + }); + } + if (plugin.components && (plugin.include || plugin.exclude)) { + ctx.addIssue({ + code: "custom", + message: + 'legacy "components" cannot be combined with "include" or "exclude"', + }); + } + }); /** One target's output configuration: where it's written, and which plugins it emits. */ const targetSchema = z.object({ @@ -89,6 +113,9 @@ const targetSchema = z.object({ // any other emitted file, so a repo-root README/LICENSE is authored once in // the source repo and synced to every target instead of hand-maintained. rootFiles: z.record(safeRelativePath, safeRelativePath).optional(), + // Canonical since 0.11: every file below this directory is emitted at the + // generated repository root. Replaces the per-file rootFiles map. + repositoryFiles: safeRelativePath.optional(), }); /** @@ -139,7 +166,12 @@ const sourcePluginManifestSchema = metadataSchema.extend({ additionalFiles: z.record(safeRelativePath, safeRelativePath).optional(), }); -export { configSchema, sourcePluginManifestSchema }; +/** Shipping files owned by an authored plugin's `mcp/` directory. */ +const mcpManifestSchema = z.object({ + files: z.record(safeRelativePath, safeRelativePath).optional(), +}); + +export { configSchema, mcpManifestSchema, sourcePluginManifestSchema }; export type Author = z.infer; export type Metadata = z.infer; @@ -149,3 +181,4 @@ export type UpdateCheckConfig = z.infer; export type TargetConfig = z.infer; export type PluginpackConfig = z.infer; export type SourcePluginManifest = z.infer; +export type McpManifest = z.infer; diff --git a/src/source.ts b/src/source.ts index 861298c..919b2c9 100644 --- a/src/source.ts +++ b/src/source.ts @@ -2,8 +2,11 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { componentDirs, staticFiles } from "./components.js"; import { exists, isSafeRelativePath, toPosix, walkFiles } from "./fs.js"; +import { mcpManifestSchema, sourcePluginManifestSchema } from "./schema.js"; import type { + AuthoredPlugin, FileValue, + McpManifest, SourcePlugin, SourceProvider, TargetName, @@ -26,6 +29,203 @@ export function createFilesystemSourceProvider( }; } +/** + * Reads the 0.11 authored-plugin shape: one direct shared source, followed by + * one target overlay that may add or replace selected content. + */ +export async function readAuthoredPlugin( + rootDir: string, + sourcePath: string, + overlayPath: string | undefined, + selected: Set, +): Promise { + const sourceDir = path.resolve(rootDir, sourcePath); + if (!(await exists(sourceDir))) { + throw new Error(`Authored plugin source is missing: ${sourcePath}`); + } + const overlayDir = overlayPath + ? path.resolve(rootDir, overlayPath) + : undefined; + if (overlayDir && !(await exists(overlayDir))) { + throw new Error(`Authored plugin overlay is missing: ${overlayPath}`); + } + + const manifest = await readOptionalJson( + path.join(sourceDir, "plugin.pluginpack.json"), + sourcePluginManifestSchema, + {}, + ); + const files = new Map(); + + for (const dirName of componentDirs) { + if (!selected.has(dirName)) { + continue; + } + await addTree(files, path.join(sourceDir, dirName), dirName, false); + if (overlayDir) { + await addTree(files, path.join(overlayDir, dirName), dirName, true); + } + } + + if (selected.has("static")) { + for (const fileName of staticFiles) { + const base = path.join(sourceDir, fileName); + if (await exists(base)) { + files.set(fileName, await fs.readFile(base)); + } + if (overlayDir) { + const overlay = path.join(overlayDir, fileName); + if (await exists(overlay)) { + files.set(fileName, await fs.readFile(overlay)); + } + } + } + } + + await addDeclaredFiles(files, sourceDir, manifest.additionalFiles); + + let mcpServers: Record | undefined; + if (selected.has("mcp")) { + const mcpDir = path.join(sourceDir, "mcp"); + const overlayMcpDir = overlayDir ? path.join(overlayDir, "mcp") : undefined; + const configPath = await lastExisting([ + path.join(mcpDir, "config.json"), + ...(overlayMcpDir ? [path.join(overlayMcpDir, "config.json")] : []), + ]); + if (configPath) { + const config = await readJsonObject(configPath); + if (isObject(config.mcpServers)) { + mcpServers = config.mcpServers; + } + } + + const mcpManifestPath = await lastExisting([ + path.join(mcpDir, "pluginpack.json"), + ...(overlayMcpDir ? [path.join(overlayMcpDir, "pluginpack.json")] : []), + ]); + if (mcpManifestPath) { + const mcpManifest = await readRequiredJson( + mcpManifestPath, + mcpManifestSchema, + ); + await addMcpFiles(files, mcpDir, overlayMcpDir, mcpManifest.files); + } + } + + return { files, manifest, mcpServers }; +} + +async function addTree( + files: Map, + dir: string, + prefix: string, + replace: boolean, +): Promise { + if (!(await exists(dir))) { + return; + } + for (const file of await walkFiles(dir)) { + const relative = toPosix(path.join(prefix, path.relative(dir, file))); + if (!replace && files.has(relative)) { + throw new Error(`Duplicate authored plugin file "${relative}".`); + } + files.set(relative, await fs.readFile(file)); + } +} + +async function addDeclaredFiles( + files: Map, + sourceDir: string, + declared: Record | undefined, +): Promise { + for (const [dest, source] of Object.entries(declared ?? {})) { + if (files.has(dest)) { + throw new Error( + `Authored plugin additionalFiles destination "${dest}" collides with another emitted file.`, + ); + } + const sourceFile = path.resolve(sourceDir, source); + if (!(await exists(sourceFile))) { + throw new Error( + `Authored plugin additionalFiles source "${source}" could not be read.`, + ); + } + files.set(toPosix(dest), await fs.readFile(sourceFile)); + } +} + +async function addMcpFiles( + files: Map, + mcpDir: string, + overlayMcpDir: string | undefined, + declared: McpManifest["files"], +): Promise { + for (const [dest, source] of Object.entries(declared ?? {})) { + const sourceFile = await lastExisting([ + path.resolve(mcpDir, source), + ...(overlayMcpDir ? [path.resolve(overlayMcpDir, source)] : []), + ]); + if (!sourceFile) { + throw new Error(`MCP shipping file "${source}" could not be read.`); + } + files.set(toPosix(dest), await fs.readFile(sourceFile)); + } +} + +async function lastExisting(candidates: string[]): Promise { + let result: string | undefined; + for (const candidate of candidates) { + if (await exists(candidate)) { + result = candidate; + } + } + return result; +} + +async function readJsonObject(file: string): Promise> { + let parsed: unknown; + try { + parsed = JSON.parse(await fs.readFile(file, "utf8")); + } catch (error) { + throw new Error(`Invalid JSON in ${file}: ${(error as Error).message}`, { + cause: error, + }); + } + if (!isObject(parsed)) { + throw new Error(`Invalid JSON object in ${file}.`); + } + return parsed; +} + +async function readRequiredJson( + file: string, + schema: { + safeParse(value: unknown): { + success: boolean; + data?: T; + error?: { issues: { path: PropertyKey[]; message: string }[] }; + }; + }, +): Promise { + const parsed = await readJsonObject(file); + const result = schema.safeParse(parsed); + if (!result.success) { + const details = result.error?.issues + .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) + .join("; "); + throw new Error(`Invalid pluginpack JSON in ${file}: ${details}`); + } + return result.data as T; +} + +async function readOptionalJson( + file: string, + schema: Parameters>[1], + fallback: T, +): Promise { + return (await exists(file)) ? readRequiredJson(file, schema) : fallback; +} + function pluginOrThrow( plugins: Map, pluginId: string, diff --git a/src/targets/engine.ts b/src/targets/engine.ts index d47caa0..11bee59 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -1,6 +1,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { collectPluginFiles, resolveMcpServers } from "../render.js"; +import { readAuthoredPlugin } from "../source.js"; import { isSafeRelativePath, json, toPosix } from "../fs.js"; import { resolvePartials } from "../partials.js"; import { validateNoSurvivingPartialTags } from "./validation-shared.js"; @@ -30,6 +31,24 @@ function resolveComponents( return new Set(pluginConfig.components ?? definition.defaultComponents); } +function resolveContentKinds( + definition: PluginTargetDefinition, + pluginConfig: EmittedPluginConfig, +): Set { + const selected = new Set( + pluginConfig.include ?? + pluginConfig.components ?? [ + ...definition.defaultComponents, + "static", + "mcp", + ], + ); + for (const excluded of pluginConfig.exclude ?? []) { + selected.delete(excluded); + } + return selected; +} + /** * Resolves a target's `updateCheck` config into the options `applyUpdateCheck` * needs, failing fast when no repository URL can be determined. Only @@ -96,12 +115,38 @@ export async function emitFromDefinition( pluginConfig, targetConfig, ); - const pluginFiles = await collectPluginFiles( - project, - target, - pluginConfig.from, - resolveComponents(definition, pluginConfig), - ); + let pluginFiles: Map; + let mcpServers: Record | undefined; + let sourceMetadata: Record | undefined; + if (pluginConfig.source) { + const authored = await readAuthoredPlugin( + project.rootDir, + pluginConfig.source, + pluginConfig.overlay, + resolveContentKinds(definition, pluginConfig), + ); + pluginFiles = new Map( + [...authored.files].map(([relativePath, value]) => [ + relativePath, + resolvePartials(relativePath, value, project.partials), + ]), + ); + mcpServers = authored.mcpServers; + sourceMetadata = authored.manifest; + } else { + const sourceIds = pluginConfig.from ?? []; + pluginFiles = await collectPluginFiles( + project, + target, + sourceIds, + resolveComponents(definition, pluginConfig), + ); + mcpServers = await resolveMcpServers(project, sourceIds, target); + sourceMetadata = + sourceIds.length === 1 + ? project.plugins.get(sourceIds[0])?.manifest + : undefined; + } // Applied before componentDirs is derived, so an injected hooks/ dir // registers as a present component (e.g. for a manifest pointer) even if // this plugin's own `components` override excludes hooks. @@ -133,17 +178,16 @@ export async function emitFromDefinition( files.set(toPosix(definition.hooksPath(pluginPath)), sourceHooksFile); } - const mcpServers = await resolveMcpServers( - project, - pluginConfig.from, - target, - ); const mcpConfigPath = definition.mcpConfigPath(pluginPath); if (mcpServers && mcpConfigPath) { files.set(toPosix(mcpConfigPath), json({ mcpServers })); } - const metadata = emittedPluginMetadata(project, pluginConfig); + const metadata = emittedPluginMetadata( + project, + pluginConfig, + sourceMetadata, + ); const manifest = definition.buildPluginManifest({ metadata, version, @@ -214,11 +258,8 @@ export async function validateFromDefinition( function emittedPluginMetadata( project: ResolvedProject, pluginConfig: EmittedPluginConfig, + sourceMetadata?: Record, ) { - const sourceMetadata = - pluginConfig.from.length === 1 - ? project.plugins.get(pluginConfig.from[0])?.manifest - : undefined; return stripUndefined({ ...project.config.metadata, ...sourceMetadata, @@ -251,11 +292,12 @@ export async function withRootFiles( result: Artifact, ): Promise { const rootFiles = targetConfig.rootFiles; - if (!rootFiles || Object.keys(rootFiles).length === 0) { + const repositoryFiles = targetConfig.repositoryFiles; + if ((!rootFiles || Object.keys(rootFiles).length === 0) && !repositoryFiles) { return result; } const files = new Map(result.files); - for (const [dest, source] of Object.entries(rootFiles)) { + for (const [dest, source] of Object.entries(rootFiles ?? {})) { const destPath = toPosix(dest); if (!isSafeRelativePath(destPath)) { throw new Error( @@ -277,5 +319,43 @@ export async function withRootFiles( } files.set(destPath, resolvePartials(destPath, contents, project.partials)); } + if (repositoryFiles) { + const repositoryDir = path.resolve(project.rootDir, repositoryFiles); + let repositoryEntries: string[]; + try { + repositoryEntries = await walkRepositoryFiles(repositoryDir); + } catch { + throw new Error( + `Target "${result.target}" repositoryFiles directory "${repositoryFiles}" could not be read.`, + ); + } + for (const source of repositoryEntries) { + const destPath = toPosix(path.relative(repositoryDir, source)); + if (files.has(destPath)) { + throw new Error( + `Target "${result.target}" repositoryFiles path "${destPath}" collides with a generated file.`, + ); + } + const contents = await fs.readFile(source); + files.set( + destPath, + resolvePartials(destPath, contents, project.partials), + ); + } + } return artifact(result.target, result.outDir, files); } + +async function walkRepositoryFiles(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await walkRepositoryFiles(absolute))); + } else if (entry.isFile()) { + files.push(absolute); + } + } + return files.sort(); +} diff --git a/src/types.ts b/src/types.ts index dcdf0af..c8e2777 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ import type { TargetConfig, PluginpackConfig, SourcePluginManifest, + McpManifest, UpdateCheckConfig, } from "./schema.js"; @@ -17,6 +18,7 @@ export type { TargetConfig, PluginpackConfig, SourcePluginManifest, + McpManifest, UpdateCheckConfig, }; @@ -52,6 +54,12 @@ export interface SourceProvider { ): Promise | undefined>; } +export type AuthoredPlugin = { + files: Map; + manifest: SourcePluginManifest; + mcpServers?: Record; +}; + /** A loaded, fully-resolved pluginpack project, ready to build or validate. */ export type ResolvedProject = { rootDir: string; diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index c822ea0..6045a0e 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -425,14 +425,14 @@ describe("emitted output conforms to external target schemas", () => { const result = await runBin("init"); expect(result.exitCode, String(result.stderr)).toBe(0); expect(result.stdout).toContain( - "Created pluginpack.config.ts and plugins/example.", + "Created pluginpack.config.ts and shared/example.", ); expect( fs.existsSync(path.join(project.baseDir, "pluginpack.config.ts")), ).toBe(true); expect( fs.existsSync( - path.join(project.baseDir, "plugins/example/skills/example/SKILL.md"), + path.join(project.baseDir, "shared/example/skills/example/SKILL.md"), ), ).toBe(true); }); diff --git a/tests/core.test.ts b/tests/core.test.ts index 3e40a2e..c013b9a 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -991,6 +991,168 @@ export default defineConfig({ expect(result.ok).toBe(true); }); + it("builds one direct shared plugin and applies a target overlay", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "shared-source-plugins", + version: "1.0.0", + metadata: { description: "Shared", author: { name: "S" }, license: "MIT" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { + demo: { + source: "shared/demo", + include: ["skills", "rules", "static", "mcp"], + overlay: "overrides/claude/demo" + } + } + } + } +}); +`, + shared: { + demo: { + "README.md": "# Shared README\n", + skills: { demo: { "SKILL.md": skill("demo", "Shared skill.") } }, + agents: { "helper.md": agent("helper", "Should be excluded.") }, + mcp: { + "config.json": `${JSON.stringify({ + mcpServers: { + shared: { command: "node", args: ["mcp/start.mjs"] }, + }, + })}\n`, + "pluginpack.json": `${JSON.stringify({ files: { "mcp/start.mjs": "start.mjs" } })}\n`, + "start.mjs": "console.log('shared');\n", + }, + }, + }, + overrides: { + claude: { + demo: { + "README.md": "# Claude README\n", + rules: { + "claude.md": + "---\nname: claude\ndescription: Claude rule.\n---\n", + }, + mcp: { + "config.json": `${JSON.stringify({ + mcpServers: { + claude: { command: "node", args: ["mcp/start.mjs"] }, + }, + })}\n`, + "start.mjs": "console.log('claude');\n", + }, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + const plugin = path.join(root, "dist/claude/plugins/demo"); + await expect( + readFile(path.join(plugin, "README.md"), "utf8"), + ).resolves.toBe("# Claude README\n"); + await expect( + readFile(path.join(plugin, "rules/claude.md"), "utf8"), + ).resolves.toContain("Claude rule."); + await expect( + readFile(path.join(plugin, "mcp/start.mjs"), "utf8"), + ).resolves.toContain("claude"); + await expect( + readFile(path.join(plugin, ".mcp.json"), "utf8"), + ).resolves.toContain('"claude"'); + await expect( + access(path.join(plugin, "agents/helper.md")), + ).rejects.toThrow(); + }); + + it("excludes an authored plugin's complete MCP capability", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "no-mcp-plugins", + version: "1.0.0", + metadata: { description: "No MCP", author: { name: "N" }, license: "MIT" }, + targets: { + cursor: { + outDir: "dist/cursor", + plugins: { demo: { source: "shared/demo", exclude: ["mcp"] } } + } + } +}); +`, + shared: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + mcp: { + "config.json": `${JSON.stringify({ + mcpServers: { demo: { command: "node" } }, + })}\n`, + "pluginpack.json": `${JSON.stringify({ files: { "mcp/start.mjs": "start.mjs" } })}\n`, + "start.mjs": "console.log('demo');\n", + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "cursor" }); + + await expect( + access(path.join(root, "dist/cursor/demo/.mcp.json")), + ).rejects.toThrow(); + await expect( + access(path.join(root, "dist/cursor/demo/mcp/start.mjs")), + ).rejects.toThrow(); + }); + + it("emits every repositoryFiles entry at the generated repository root", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "repository-files-plugins", + version: "1.0.0", + metadata: { description: "Repo", author: { name: "R" }, license: "MIT" }, + targets: { + claude: { + outDir: "dist/claude", + repositoryFiles: "repositories/claude", + plugins: { demo: { source: "shared/demo" } } + } + } +}); +`, + shared: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + repositories: { + claude: { + "README.md": "# Repository\n", + docs: { "INSTALL.md": "# Install\n" }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + await expect( + readFile(path.join(root, "dist/claude/README.md"), "utf8"), + ).resolves.toBe("# Repository\n"); + await expect( + readFile(path.join(root, "dist/claude/docs/INSTALL.md"), "utf8"), + ).resolves.toBe("# Install\n"); + }); + it("uses target-specific file overrides", async () => { const project = await fixture(); const root = project.baseDir; From 862a3dc3db66643fbe31a4f26b6ee05b6963b6a2 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Mon, 10 Aug 2026 15:56:43 -0700 Subject: [PATCH 2/3] fix(deps): update js-yaml to patched release --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7c109de..60f63b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6719,9 +6719,9 @@ } }, "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", From 0cd9c96f6c64da148906749f8c0c2553681f8a54 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Mon, 10 Aug 2026 16:18:16 -0700 Subject: [PATCH 3/3] fix: clarify target overrides and reject file collisions --- CLAUDE.md | 4 +-- MIGRATING_TO_0.11.md | 26 +++++++------- README.md | 18 +++++----- snippets/readme/snippet-02.ts | 2 +- src/managed.ts | 6 ++-- src/schema.ts | 2 +- src/source.ts | 68 ++++++++++++++++++++++------------- src/targets/engine.ts | 2 +- tests/core.test.ts | 49 +++++++++++++++++++++++-- 9 files changed, 121 insertions(+), 56 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 33791cf..c62009d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ diff, prune, and validate all derive from it. - `src/config.ts` — `loadConfig` (jiti loads `pluginpack.config.ts`) and legacy 0.10 source-plugin discovery during the migration window. - `src/source.ts` — reads canonical direct `shared/` sources, applies - target overlays, packages `mcp/`, and retains the legacy filesystem source + target overrides, packages `mcp/`, and retains the legacy filesystem source provider. - `src/render.ts` — legacy `collectPluginFiles`/`resolveMcpServers` composition. - `src/partials.ts` — `loadPartials`/`resolvePartials`: project-level @@ -99,7 +99,7 @@ schemas at runtime — vendor a pinned copy with recorded provenance. ## Shapes and gotchas - **Recommended shape:** `shared//` canonical sources, - `overrides///` target overlays, and + `overrides///` target overrides, and `repositories//` generated-repository files. - **claude + copilot collide:** both write `.claude-plugin/marketplace.json`, so they need distinct `outDir`s. `build()` errors on overlapping output paths. diff --git a/MIGRATING_TO_0.11.md b/MIGRATING_TO_0.11.md index 97012dd..86e9f9f 100644 --- a/MIGRATING_TO_0.11.md +++ b/MIGRATING_TO_0.11.md @@ -6,14 +6,14 @@ the generated artifact. ## Contract change -| 0.10 | 0.11 | -| --------------------------------------- | ------------------------------------------------ | -| Discovered source plugins plus `from` | One direct `source` per emitted plugin | -| Root-level `skills/` special case | `shared//skills/` | -| `targets//` replacement files | `overrides///` post-source overlay | -| `components` | `include` or `exclude` | -| Root `.mcp.json` plus `additionalFiles` | `mcp/config.json` plus `mcp/pluginpack.json` | -| `rootFiles` map | `repositoryFiles` directory | +| 0.10 | 0.11 | +| --------------------------------------- | --------------------------------------------- | +| Discovered source plugins plus `from` | One direct `source` per emitted plugin | +| Root-level `skills/` special case | `shared//skills/` | +| `targets//` replacement files | `overrides///` target overrides | +| `components` | `include` or `exclude` | +| Root `.mcp.json` plus `additionalFiles` | `mcp/config.json` plus `mcp/pluginpack.json` | +| `rootFiles` map | `repositoryFiles` directory | Pluginpack 0.11 still reads the 0.10 fields for one migration window. Do not mix `source` with `from`, or `components` with `include`/`exclude`, on the same @@ -59,11 +59,11 @@ do not pick a winner based on old `from` order because 0.10 rejected collisions. For each target, create `overrides///`. - Move every old `targets//` replacement to the same `` - below the overlay. -- Move every target-only source file into the overlay. + below the overrides directory. +- Move every target-only source file into the overrides directory. - Keep shared files in `shared//`. -An overlay may replace a shared file or add a new one. +Overrides may replace a shared file or add a new one. ## 4. Move MCP content @@ -130,12 +130,12 @@ plugins: { acme: { source: "shared/acme", include: ["skills", "agents", "rules", "static"], - overlay: "overrides/cursor/acme" + overrides: "overrides/cursor/acme" } } ``` -Omit `overlay` when that directory does not exist. Prefer `exclude` when a +Omit `overrides` when that directory does not exist. Prefer `exclude` when a target differs from pluginpack's defaults by only one or two content kinds. Delete `source.skills`, `source.rootPlugin`, and `source.plugins` after every diff --git a/README.md b/README.md index 70d90a8..351d049 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ shared/ pluginpack.config.ts ``` -Map that shared source directly into each native plugin output. Add an `overlay` +Map that shared source directly into each native plugin output. Add `overrides` only when a target needs files that differ from or do not exist in the shared source. @@ -57,7 +57,7 @@ export default defineConfig({ plugins: { acme: { source: "shared/acme", - overlay: "overrides/cursor/acme", + overrides: "overrides/cursor/acme", path: "plugins/cursor/acme", }, }, @@ -117,7 +117,7 @@ It does not try to make every app behave the same. Target adapters own target-sp ## Recommended Shape -The preferred authored shape separates shared plugin content, target overlays, +The preferred authored shape separates shared plugin content, target overrides, and generated-repository files: ```tree @@ -184,7 +184,7 @@ plugins/ claude.json ``` -Each emitted plugin names one `source`. An optional `overlay` is applied after +Each emitted plugin names one `source`. Optional `overrides` are applied after the source, so it can add or replace files for that target. `repositoryFiles` copies a whole directory into the generated repository root. @@ -304,7 +304,7 @@ The repo comes from `targets..repository`, defaulting to `metadata.reposit ## Target Overrides When one app needs different or additional content, put it in that emitted -plugin's target overlay: +plugin's target overrides: ```txt shared/acme/skills/release-notes/SKILL.md @@ -312,9 +312,9 @@ overrides/cursor/acme/skills/release-notes/SKILL.md overrides/cursor/acme/rules/cursor-only.mdc ``` -The overlay is applied after the shared source, so it can both replace the -shared skill and add the Cursor-only rule. Set its directory with the emitted -plugin's `overlay` field. +The overrides are applied after the shared source, so they can both replace the +shared skill and add the Cursor-only rule. Set their directory with the emitted +plugin's `overrides` field. ## MCP Directory @@ -492,7 +492,7 @@ rest of the artifact. | Field | Type | Required | Meaning | | ------------- | ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `source` | string (safe relative) | yes | Direct path to the plugin's shared authored source. | -| `overlay` | string (safe relative) | no | Target-specific directory applied after `source`; may add or replace files. | +| `overrides` | string (safe relative) | no | Target-specific directory applied after `source`; may add or replace files. | | `include` | string[] | no | Exact content kinds to include (`skills`, `agents`, `rules`, `assets`, `static`, `mcp`, etc.). | | `exclude` | string[] | no | Content kinds removed from the target defaults. | | `from` | string[] (min 1) | legacy | 0.10 source-plugin composition; cannot be combined with `source`. | diff --git a/snippets/readme/snippet-02.ts b/snippets/readme/snippet-02.ts index 3c1596f..d9d44ee 100644 --- a/snippets/readme/snippet-02.ts +++ b/snippets/readme/snippet-02.ts @@ -14,7 +14,7 @@ export default defineConfig({ plugins: { acme: { source: "shared/acme", - overlay: "overrides/cursor/acme", + overrides: "overrides/cursor/acme", path: "plugins/cursor/acme", }, }, diff --git a/src/managed.ts b/src/managed.ts index 377b348..86f86c4 100644 --- a/src/managed.ts +++ b/src/managed.ts @@ -185,8 +185,8 @@ export async function buildDeleteGuard( if (plugin.source) { protectedRoots.push(path.resolve(rootDir, plugin.source)); } - if (plugin.overlay) { - protectedRoots.push(path.resolve(rootDir, plugin.overlay)); + if (plugin.overrides) { + protectedRoots.push(path.resolve(rootDir, plugin.overrides)); } } } @@ -217,7 +217,7 @@ function assertNoProtectedDeletions( throw new Error( `Refusing to ${command} ${blocked.length} path(s) that resolve inside your source tree or config:\n` + `${blocked.map(({ file, root }) => ` ${file} -> resolves inside ${root}`).join("\n")}\n` + - `This usually means a target outDir overlaps source, overlay, repositoryFiles, or legacy source config. ` + + `This usually means a target outDir overlaps source, overrides, repositoryFiles, or legacy source config. ` + `Fix the config, or re-run with --force to delete anyway.`, ); } diff --git a/src/schema.ts b/src/schema.ts index 30ea0df..f1fe1ac 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -75,7 +75,7 @@ const emittedPluginSchema = z exclude: z.array(z.string().min(1)).optional(), components: z.array(z.string()).optional(), // Applied after reading `source`, so it can add or replace target files. - overlay: safeRelativePath.optional(), + overrides: safeRelativePath.optional(), updateCheck: z.literal(false).optional(), }) .superRefine((plugin, ctx) => { diff --git a/src/source.ts b/src/source.ts index 919b2c9..cea3da4 100644 --- a/src/source.ts +++ b/src/source.ts @@ -31,23 +31,23 @@ export function createFilesystemSourceProvider( /** * Reads the 0.11 authored-plugin shape: one direct shared source, followed by - * one target overlay that may add or replace selected content. + * one target-specific overrides directory that may add or replace content. */ export async function readAuthoredPlugin( rootDir: string, sourcePath: string, - overlayPath: string | undefined, + overridesPath: string | undefined, selected: Set, ): Promise { const sourceDir = path.resolve(rootDir, sourcePath); if (!(await exists(sourceDir))) { throw new Error(`Authored plugin source is missing: ${sourcePath}`); } - const overlayDir = overlayPath - ? path.resolve(rootDir, overlayPath) + const overridesDir = overridesPath + ? path.resolve(rootDir, overridesPath) : undefined; - if (overlayDir && !(await exists(overlayDir))) { - throw new Error(`Authored plugin overlay is missing: ${overlayPath}`); + if (overridesDir && !(await exists(overridesDir))) { + throw new Error(`Authored plugin overrides are missing: ${overridesPath}`); } const manifest = await readOptionalJson( @@ -62,8 +62,8 @@ export async function readAuthoredPlugin( continue; } await addTree(files, path.join(sourceDir, dirName), dirName, false); - if (overlayDir) { - await addTree(files, path.join(overlayDir, dirName), dirName, true); + if (overridesDir) { + await addTree(files, path.join(overridesDir, dirName), dirName, true); } } @@ -73,24 +73,31 @@ export async function readAuthoredPlugin( if (await exists(base)) { files.set(fileName, await fs.readFile(base)); } - if (overlayDir) { - const overlay = path.join(overlayDir, fileName); - if (await exists(overlay)) { - files.set(fileName, await fs.readFile(overlay)); + if (overridesDir) { + const override = path.join(overridesDir, fileName); + if (await exists(override)) { + files.set(fileName, await fs.readFile(override)); } } } } - await addDeclaredFiles(files, sourceDir, manifest.additionalFiles); + await addDeclaredFiles( + files, + sourceDir, + overridesDir, + manifest.additionalFiles, + ); let mcpServers: Record | undefined; if (selected.has("mcp")) { const mcpDir = path.join(sourceDir, "mcp"); - const overlayMcpDir = overlayDir ? path.join(overlayDir, "mcp") : undefined; + const overridesMcpDir = overridesDir + ? path.join(overridesDir, "mcp") + : undefined; const configPath = await lastExisting([ path.join(mcpDir, "config.json"), - ...(overlayMcpDir ? [path.join(overlayMcpDir, "config.json")] : []), + ...(overridesMcpDir ? [path.join(overridesMcpDir, "config.json")] : []), ]); if (configPath) { const config = await readJsonObject(configPath); @@ -101,14 +108,16 @@ export async function readAuthoredPlugin( const mcpManifestPath = await lastExisting([ path.join(mcpDir, "pluginpack.json"), - ...(overlayMcpDir ? [path.join(overlayMcpDir, "pluginpack.json")] : []), + ...(overridesMcpDir + ? [path.join(overridesMcpDir, "pluginpack.json")] + : []), ]); if (mcpManifestPath) { const mcpManifest = await readRequiredJson( mcpManifestPath, mcpManifestSchema, ); - await addMcpFiles(files, mcpDir, overlayMcpDir, mcpManifest.files); + await addMcpFiles(files, mcpDir, overridesMcpDir, mcpManifest.files); } } @@ -136,39 +145,50 @@ async function addTree( async function addDeclaredFiles( files: Map, sourceDir: string, + overridesDir: string | undefined, declared: Record | undefined, ): Promise { for (const [dest, source] of Object.entries(declared ?? {})) { - if (files.has(dest)) { + const destPath = toPosix(dest); + if (files.has(destPath)) { throw new Error( `Authored plugin additionalFiles destination "${dest}" collides with another emitted file.`, ); } - const sourceFile = path.resolve(sourceDir, source); - if (!(await exists(sourceFile))) { + const sourceFile = await lastExisting([ + path.resolve(sourceDir, source), + ...(overridesDir ? [path.resolve(overridesDir, source)] : []), + ]); + if (!sourceFile) { throw new Error( `Authored plugin additionalFiles source "${source}" could not be read.`, ); } - files.set(toPosix(dest), await fs.readFile(sourceFile)); + files.set(destPath, await fs.readFile(sourceFile)); } } async function addMcpFiles( files: Map, mcpDir: string, - overlayMcpDir: string | undefined, + overridesMcpDir: string | undefined, declared: McpManifest["files"], ): Promise { for (const [dest, source] of Object.entries(declared ?? {})) { + const destPath = toPosix(dest); + if (files.has(destPath)) { + throw new Error( + `MCP shipping destination "${dest}" collides with another emitted file.`, + ); + } const sourceFile = await lastExisting([ path.resolve(mcpDir, source), - ...(overlayMcpDir ? [path.resolve(overlayMcpDir, source)] : []), + ...(overridesMcpDir ? [path.resolve(overridesMcpDir, source)] : []), ]); if (!sourceFile) { throw new Error(`MCP shipping file "${source}" could not be read.`); } - files.set(toPosix(dest), await fs.readFile(sourceFile)); + files.set(destPath, await fs.readFile(sourceFile)); } } diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 11bee59..729b8a1 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -122,7 +122,7 @@ export async function emitFromDefinition( const authored = await readAuthoredPlugin( project.rootDir, pluginConfig.source, - pluginConfig.overlay, + pluginConfig.overrides, resolveContentKinds(definition, pluginConfig), ); pluginFiles = new Map( diff --git a/tests/core.test.ts b/tests/core.test.ts index c013b9a..76ada4e 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -991,7 +991,7 @@ export default defineConfig({ expect(result.ok).toBe(true); }); - it("builds one direct shared plugin and applies a target overlay", async () => { + it("builds one direct shared plugin and applies target overrides", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; @@ -1006,7 +1006,7 @@ export default defineConfig({ demo: { source: "shared/demo", include: ["skills", "rules", "static", "mcp"], - overlay: "overrides/claude/demo" + overrides: "overrides/claude/demo" } } } @@ -1016,6 +1016,10 @@ export default defineConfig({ shared: { demo: { "README.md": "# Shared README\n", + "plugin.pluginpack.json": `${JSON.stringify({ + additionalFiles: { "scripts/start.mjs": "extra/start.mjs" }, + })}\n`, + extra: { "start.mjs": "console.log('shared extra');\n" }, skills: { demo: { "SKILL.md": skill("demo", "Shared skill.") } }, agents: { "helper.md": agent("helper", "Should be excluded.") }, mcp: { @@ -1033,6 +1037,7 @@ export default defineConfig({ claude: { demo: { "README.md": "# Claude README\n", + extra: { "start.mjs": "console.log('claude extra');\n" }, rules: { "claude.md": "---\nname: claude\ndescription: Claude rule.\n---\n", @@ -1063,6 +1068,9 @@ export default defineConfig({ await expect( readFile(path.join(plugin, "mcp/start.mjs"), "utf8"), ).resolves.toContain("claude"); + await expect( + readFile(path.join(plugin, "scripts/start.mjs"), "utf8"), + ).resolves.toContain("claude extra"); await expect( readFile(path.join(plugin, ".mcp.json"), "utf8"), ).resolves.toContain('"claude"'); @@ -1071,6 +1079,43 @@ export default defineConfig({ ).rejects.toThrow(); }); + it("rejects MCP shipping destinations that collide with plugin content", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "mcp-collision-plugins", + version: "1.0.0", + metadata: { description: "MCP collision", author: { name: "M" }, license: "MIT" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { source: "shared/demo" } } + } + } +}); +`, + shared: { + demo: { + "README.md": "# Shared README\n", + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + mcp: { + "pluginpack.json": `${JSON.stringify({ + files: { "README.md": "start.mjs" }, + })}\n`, + "start.mjs": "console.log('server');\n", + }, + }, + }, + }); + + await expect( + build({ cwd: project.baseDir, target: "claude" }), + ).rejects.toThrow( + /MCP shipping destination "README\.md" collides with another emitted file/, + ); + }); + it("excludes an authored plugin's complete MCP capability", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}";