diff --git a/README.md b/README.md index de34f87..8859cd7 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,8 @@ A target can emit a source plugin directly, rename it, or merge multiple source 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 | @@ -338,7 +340,23 @@ skills/release-notes/targets/cursor/SKILL.md skills/release-notes/targets/claude/SKILL.md ``` -Resolution order is target override first, then the base file. +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`. + +## 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`: + +```json +{ + "additionalFiles": { + "dist/index.js": "dist/index.js", + "start.mjs": "start.mjs", + "package.json": "package.json" + } +} +``` + +The map is destination (emitted plugin root relative) -> source (source plugin relative). Files are emitted verbatim at the plugin root, are tracked as managed output, and support target overrides: a `targets//` file wins for that host. A destination that collides with a component or static file is an error, and `pluginpack` does not build bundles — produce build output before running `pluginpack build` so the declared source paths exist. ## Other Shapes diff --git a/src/render.ts b/src/render.ts index c8a8909..4d756c9 100644 --- a/src/render.ts +++ b/src/render.ts @@ -35,6 +35,7 @@ export async function collectPluginFiles( export async function resolveMcpServers( project: ResolvedProject, sourceIds: string[], + target: TargetName, ): Promise | undefined> { const merged: Record = {}; let found = false; @@ -42,7 +43,7 @@ export async function resolveMcpServers( if (!project.plugins.has(sourceId)) { continue; } - const servers = await project.source.readMcpServers(sourceId); + const servers = await project.source.readMcpServers(sourceId, target); if (!servers) { continue; } diff --git a/src/schema.ts b/src/schema.ts index 93e29a3..63116ba 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -124,6 +124,7 @@ const sourcePluginManifestSchema = metadataSchema.extend({ name: z.string().optional(), description: z.string().optional(), mcpServers: z.record(z.string(), z.unknown()).optional(), + additionalFiles: z.record(safeRelativePath, safeRelativePath).optional(), }); export { configSchema, sourcePluginManifestSchema }; diff --git a/src/source.ts b/src/source.ts index 7a20eb8..fbbedf7 100644 --- a/src/source.ts +++ b/src/source.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { componentDirs, staticFiles } from "./components.js"; -import { exists, toPosix, walkFiles } from "./fs.js"; +import { exists, isSafeRelativePath, toPosix, walkFiles } from "./fs.js"; import type { FileValue, SourcePlugin, @@ -21,8 +21,8 @@ export function createFilesystemSourceProvider( return { readPluginFiles: (pluginId, target) => readPluginFiles(pluginOrThrow(plugins, pluginId), target), - readMcpServers: (pluginId) => - readMcpServers(pluginOrThrow(plugins, pluginId)), + readMcpServers: (pluginId, target) => + readMcpServers(pluginOrThrow(plugins, pluginId), target), }; } @@ -72,6 +72,37 @@ async function readPluginFiles( files.set(fileName, await fs.readFile(resolved)); } } + + // Arbitrary files the source plugin declares in plugin.pluginpack.json + // (e.g. a bundled server, launcher, or a package.json). Emitted verbatim at + // the plugin root, with target overrides on the source path. + const additionalFiles = plugin.manifest.additionalFiles; + if (additionalFiles) { + for (const [dest, source] of Object.entries(additionalFiles)) { + const destPath = toPosix(dest); + if (!isSafeRelativePath(destPath)) { + throw new Error( + `Source plugin "${plugin.id}" additionalFiles destination "${dest}" must be a safe relative path.`, + ); + } + if (files.has(destPath)) { + throw new Error( + `Source plugin "${plugin.id}" additionalFiles destination "${dest}" collides with another emitted file.`, + ); + } + const resolved = await resolveTargetOverride( + plugin.dir, + path.resolve(plugin.dir, source), + target, + ); + if (!(await exists(resolved))) { + throw new Error( + `Source plugin "${plugin.id}" additionalFiles source "${source}" could not be read.`, + ); + } + files.set(destPath, await fs.readFile(resolved)); + } + } return files; } @@ -103,18 +134,26 @@ async function resolveTargetOverride( // A source plugin declares MCP servers via a .mcp.json file (standard // { mcpServers: {...} } shape) or an mcpServers key in plugin.pluginpack.json. -// The file takes precedence when both are present. +// The file takes precedence when both are present. The file form supports +// per-target overrides: targets//.mcp.json wins for that host. The +// manifest form has no per-file override; authors who need per-target MCP +// config should use the .mcp.json file form. async function readMcpServers( plugin: SourcePlugin, + target: TargetName, ): Promise | undefined> { - const filePath = path.join(plugin.dir, ".mcp.json"); - if (await exists(filePath)) { + const resolved = await resolveTargetOverride( + plugin.dir, + path.join(plugin.dir, ".mcp.json"), + target, + ); + if (await exists(resolved)) { let parsed: unknown; try { - parsed = JSON.parse(await fs.readFile(filePath, "utf8")); + parsed = JSON.parse(await fs.readFile(resolved, "utf8")); } catch (error) { throw new Error( - `Invalid JSON in ${filePath}: ${(error as Error).message}`, + `Invalid JSON in ${resolved}: ${(error as Error).message}`, { cause: error }, ); } diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 0ea1514..962d63d 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -131,7 +131,11 @@ export async function emitFromDefinition( files.set(toPosix(definition.hooksPath(pluginPath)), sourceHooksFile); } - const mcpServers = await resolveMcpServers(project, pluginConfig.from); + const mcpServers = await resolveMcpServers( + project, + pluginConfig.from, + target, + ); const mcpConfigPath = definition.mcpConfigPath(pluginPath); if (mcpServers && mcpConfigPath) { files.set(toPosix(mcpConfigPath), json({ mcpServers })); diff --git a/src/types.ts b/src/types.ts index 9290a00..0ba42aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,7 @@ export interface SourceProvider { ): Promise>; readMcpServers( pluginId: string, + target: TargetName, ): Promise | undefined>; } diff --git a/tests/core.test.ts b/tests/core.test.ts index c892e5a..9c9675a 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1338,6 +1338,199 @@ export default defineConfig({ ); }); + it("overrides MCP config per target from targets//.mcp.json", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "mcp-override-plugins", + version: "1.0.0", + metadata: { description: "MCP override", author: { name: "X" }, license: "MIT" }, + targets: { + cursor: { outDir: "dist/cursor", plugins: { filed: { from: ["filed"], components: ["skills"] } } }, + claude: { outDir: "dist/claude", plugins: { filed: { from: ["filed"] } } } + } +}); +`, + plugins: { + filed: { + ".mcp.json": `${JSON.stringify( + { mcpServers: { glean: { command: "node", args: ["start.mjs"] } } }, + null, + 2, + )}\n`, + targets: { + cursor: { + ".mcp.json": `${JSON.stringify( + { + mcpServers: { + "glean-local": { + command: "node", + args: ["./start.mjs"], + cwd: ".", + }, + }, + }, + null, + 2, + )}\n`, + }, + }, + skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root }); + + // cursor picks up the target override (different server name + args) + const cursorMcp = JSON.parse( + await readFile(path.join(root, "dist/cursor/filed/.mcp.json"), "utf8"), + ) as { mcpServers: Record }; + expect(cursorMcp.mcpServers).toMatchObject({ + "glean-local": { command: "node", args: ["./start.mjs"], cwd: "." }, + }); + expect(cursorMcp.mcpServers).not.toHaveProperty("glean"); + + // claude keeps the base .mcp.json (no override present for claude) + const claudeMcp = JSON.parse( + await readFile( + path.join(root, "dist/claude/plugins/filed/.mcp.json"), + "utf8", + ), + ) as { mcpServers: Record }; + expect(claudeMcp.mcpServers).toMatchObject({ + glean: { command: "node", args: ["start.mjs"] }, + }); + expect(claudeMcp.mcpServers).not.toHaveProperty("glean-local"); + }); + + it("emits arbitrary plugin-root files declared in plugin.pluginpack.json", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "files-plugins", + version: "1.0.0", + metadata: { description: "Files", author: { name: "X" }, license: "MIT" }, + targets: { + cursor: { outDir: "dist/cursor", plugins: { srv: { from: ["srv"], components: ["skills"] } } }, + claude: { outDir: "dist/claude", plugins: { srv: { from: ["srv"] } } } + } +}); +`, + plugins: { + srv: { + "plugin.pluginpack.json": `${JSON.stringify({ + additionalFiles: { + "dist/index.js": "dist/index.js", + "start.mjs": "start.mjs", + "package.json": "package.json", + }, + })}\n`, + "start.mjs": "import './dist/index.js';\n", + "package.json": `{ "name": "srv", "type": "module" }\n`, + dist: { "index.js": "console.log('bundle');\n" }, + targets: { + cursor: { "start.mjs": "import './dist/index.js'; // cursor\n" }, + }, + skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root }); + + // cursor plugin root carries the declared files verbatim + await expect( + readFile(path.join(root, "dist/cursor/srv/dist/index.js"), "utf8"), + ).resolves.toContain("bundle"); + await expect( + readFile(path.join(root, "dist/cursor/srv/package.json"), "utf8"), + ).resolves.toContain('"type": "module"'); + // target override applies to a declared file's source + await expect( + readFile(path.join(root, "dist/cursor/srv/start.mjs"), "utf8"), + ).resolves.toContain("// cursor"); + + // claude keeps the base start.mjs (no override) + await expect( + readFile(path.join(root, "dist/claude/plugins/srv/start.mjs"), "utf8"), + ).resolves.not.toContain("// cursor"); + + // declared files are managed (tracked in the managed manifest) + const manifest = JSON.parse( + await readFile( + path.join(root, "dist/cursor/.pluginpack/cursor.json"), + "utf8", + ), + ) as { files: string[] }; + expect(manifest.files).toContain("srv/dist/index.js"); + expect(manifest.files).toContain("srv/package.json"); + expect(manifest.files).toContain("srv/start.mjs"); + }); + + it("rejects a plugin-root files destination that collides with a component file", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "files-collide-plugins", + version: "1.0.0", + metadata: { description: "Collide", author: { name: "X" }, license: "MIT" }, + targets: { + claude: { outDir: "dist/claude", plugins: { srv: { from: ["srv"] } } } + } +}); +`, + plugins: { + srv: { + "plugin.pluginpack.json": `${JSON.stringify({ + additionalFiles: { "skills/s1/SKILL.md": "extra.md" }, + })}\n`, + "extra.md": "# extra\n", + skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, + }, + }, + }); + const root = project.baseDir; + + await expect(build({ cwd: root, target: "claude" })).rejects.toThrow( + /additionalFiles destination "skills\/s1\/SKILL.md" collides with another emitted file/, + ); + }); + + it("rejects a plugin-root files source that cannot be read", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "files-missing-plugins", + version: "1.0.0", + metadata: { description: "Missing", author: { name: "X" }, license: "MIT" }, + targets: { + claude: { outDir: "dist/claude", plugins: { srv: { from: ["srv"] } } } + } +}); +`, + plugins: { + srv: { + "plugin.pluginpack.json": `${JSON.stringify({ + additionalFiles: { "start.mjs": "start.mjs" }, + })}\n`, + skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, + }, + }, + }); + const root = project.baseDir; + + await expect(build({ cwd: root, target: "claude" })).rejects.toThrow( + /additionalFiles source "start.mjs" could not be read/, + ); + }); + it("generates the update-check hook for claude and cursor", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}";