Skip to content

Commit 0e5938f

Browse files
feat: per-plugin/target versions and deep-merge manifest overrides
- version is settable per target (default for its marketplace + plugins) and per emitted plugin, resolving plugin.version ?? target.version ?? config.version. - the manifest: override (target + plugin) now deep-merges onto the generated manifest: nested objects merge (siblings preserved), arrays/scalars replace. Structured metadata and version stay first-class; manifest: is the general escape hatch for anything pluginpack doesn't model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cefb5fe commit 0e5938f

3 files changed

Lines changed: 202 additions & 48 deletions

File tree

src/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const sourceSchema = z.object({
3535
const emittedPluginSchema = z.object({
3636
from: z.array(z.string().min(1)).min(1),
3737
path: z.string().optional(),
38+
version: z.string().optional(),
3839
description: z.string().optional(),
3940
displayName: z.string().optional(),
4041
manifest: z.record(z.string(), z.unknown()).optional(),
@@ -45,6 +46,7 @@ const targetSchema = z.object({
4546
outDir: z.string().min(1),
4647
marketplaceDir: z.string().optional(),
4748
pluginRoot: z.string().optional(),
49+
version: z.string().optional(),
4850
plugins: z.record(z.string(), emittedPluginSchema),
4951
manifest: z.record(z.string(), z.unknown()).optional(),
5052
ignoredDiffPaths: z.array(z.string()).optional(),

src/targets.ts

Lines changed: 98 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ async function emitCursor(
126126
outDir: string,
127127
): Promise<Artifact> {
128128
const marketplaceDir = targetConfig.marketplaceDir ?? ".cursor-plugin";
129-
const version = project.config.version;
129+
const version = targetConfig.version ?? project.config.version;
130130
const files = new Map<string, string | Buffer>();
131131

132132
const plugins = await emitPlugins(project, target, targetConfig, files, {
@@ -143,7 +143,7 @@ async function emitCursor(
143143
) =>
144144
cursorPluginManifest(
145145
metadata,
146-
version,
146+
pluginConfig.version ?? version,
147147
pluginName,
148148
pluginConfig,
149149
componentDirs,
@@ -153,20 +153,25 @@ async function emitCursor(
153153
mcp: "file",
154154
});
155155

156-
const marketplace = {
157-
name: project.config.name,
158-
owner: project.config.metadata?.owner ?? project.config.metadata?.author,
159-
metadata: {
160-
description: project.config.metadata?.description,
161-
keywords: project.config.metadata?.keywords,
162-
},
163-
plugins,
164-
version,
165-
...targetConfig.manifest,
166-
};
156+
const marketplace = stripUndefined(
157+
deepMerge(
158+
{
159+
name: project.config.name,
160+
owner:
161+
project.config.metadata?.owner ?? project.config.metadata?.author,
162+
metadata: {
163+
description: project.config.metadata?.description,
164+
keywords: project.config.metadata?.keywords,
165+
},
166+
plugins,
167+
version,
168+
},
169+
targetConfig.manifest ?? {},
170+
),
171+
);
167172
files.set(
168173
toPosix(path.join(marketplaceDir, "marketplace.json")),
169-
json(stripUndefined(marketplace)),
174+
json(marketplace),
170175
);
171176

172177
return artifact(target, outDir, files);
@@ -180,7 +185,7 @@ async function emitClaude(
180185
): Promise<Artifact> {
181186
const marketplaceDir = targetConfig.marketplaceDir ?? ".claude-plugin";
182187
const pluginRoot = targetConfig.pluginRoot ?? "plugins";
183-
const version = project.config.version;
188+
const version = targetConfig.version ?? project.config.version;
184189
const files = new Map<string, string | Buffer>();
185190

186191
const plugins = await emitPlugins(project, target, targetConfig, files, {
@@ -189,23 +194,33 @@ async function emitClaude(
189194
pluginManifestPath: (pluginPath) =>
190195
path.join(pluginPath, marketplaceDir, "plugin.json"),
191196
buildManifest: (metadata, pluginName, pluginConfig) =>
192-
claudePluginManifest(metadata, version, pluginName, pluginConfig),
197+
claudePluginManifest(
198+
metadata,
199+
pluginConfig.version ?? version,
200+
pluginName,
201+
pluginConfig,
202+
),
193203
entrySource: (pluginPath) => `./${pluginPath}`,
194204
mcp: "file",
195205
});
196206

197-
const marketplace = {
198-
$schema: "https://anthropic.com/claude-code/marketplace.schema.json",
199-
name: project.config.name,
200-
version,
201-
description: project.config.metadata?.description,
202-
owner: project.config.metadata?.owner ?? project.config.metadata?.author,
203-
plugins,
204-
...targetConfig.manifest,
205-
};
207+
const marketplace = stripUndefined(
208+
deepMerge(
209+
{
210+
$schema: "https://anthropic.com/claude-code/marketplace.schema.json",
211+
name: project.config.name,
212+
version,
213+
description: project.config.metadata?.description,
214+
owner:
215+
project.config.metadata?.owner ?? project.config.metadata?.author,
216+
plugins,
217+
},
218+
targetConfig.manifest ?? {},
219+
),
220+
);
206221
files.set(
207222
toPosix(path.join(marketplaceDir, "marketplace.json")),
208-
json(stripUndefined(marketplace)),
223+
json(marketplace),
209224
);
210225

211226
return artifact(target, outDir, files);
@@ -217,7 +232,7 @@ async function emitGemini(
217232
targetConfig: TargetConfig,
218233
outDir: string,
219234
): Promise<Artifact> {
220-
const version = project.config.version;
235+
const version = targetConfig.version ?? project.config.version;
221236
const files = new Map<string, string | Buffer>();
222237

223238
await emitPlugins(project, target, targetConfig, files, {
@@ -226,7 +241,13 @@ async function emitGemini(
226241
pluginManifestPath: (pluginPath) =>
227242
path.join(pluginPath, "gemini-extension.json"),
228243
buildManifest: (metadata, pluginName, pluginConfig, _componentDirs, mcp) =>
229-
geminiExtensionManifest(metadata, version, pluginName, pluginConfig, mcp),
244+
geminiExtensionManifest(
245+
metadata,
246+
pluginConfig.version ?? version,
247+
pluginName,
248+
pluginConfig,
249+
mcp,
250+
),
230251
mcp: "inline",
231252
});
232253

@@ -239,7 +260,7 @@ async function emitCopilot(
239260
targetConfig: TargetConfig,
240261
outDir: string,
241262
): Promise<Artifact> {
242-
const version = project.config.version;
263+
const version = targetConfig.version ?? project.config.version;
243264
const pluginRoot = targetConfig.pluginRoot ?? "plugins";
244265
const files = new Map<string, string | Buffer>();
245266
const plugins: Record<string, unknown>[] = [];
@@ -279,24 +300,29 @@ async function emitCopilot(
279300
name: pluginName,
280301
source: `./${pluginPath}`,
281302
description: pluginConfig.description ?? metadata?.description,
282-
version,
303+
version: pluginConfig.version ?? version,
283304
skills,
284305
mcpServers: mcpServers ? ".mcp.json" : undefined,
285306
}),
286307
);
287308
}
288309

289-
const marketplace = stripUndefined({
290-
name: project.config.name,
291-
metadata: stripUndefined({
292-
description: project.config.metadata?.description,
293-
version,
294-
keywords: project.config.metadata?.keywords,
295-
}),
296-
owner: project.config.metadata?.owner ?? project.config.metadata?.author,
297-
plugins,
298-
...targetConfig.manifest,
299-
});
310+
const marketplace = stripUndefined(
311+
deepMerge(
312+
{
313+
name: project.config.name,
314+
metadata: stripUndefined({
315+
description: project.config.metadata?.description,
316+
version,
317+
keywords: project.config.metadata?.keywords,
318+
}),
319+
owner:
320+
project.config.metadata?.owner ?? project.config.metadata?.author,
321+
plugins,
322+
},
323+
targetConfig.manifest ?? {},
324+
),
325+
);
300326
// Copilot reuses the Claude marketplace schema and reads it from both the
301327
// repo-root .claude-plugin/ and .github/plugin/ (see github/copilot-plugins).
302328
const marketplaceJson = json(marketplace);
@@ -366,7 +392,7 @@ function cursorPluginManifest(
366392
if (mcpServers) {
367393
manifest.mcpServers = "./.mcp.json";
368394
}
369-
return stripUndefined({ ...manifest, ...pluginConfig.manifest });
395+
return stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {}));
370396
}
371397

372398
function claudePluginManifest(
@@ -375,7 +401,7 @@ function claudePluginManifest(
375401
pluginName: string,
376402
pluginConfig: EmittedPluginConfig,
377403
): Record<string, unknown> {
378-
return stripUndefined({
404+
const manifest: Record<string, unknown> = {
379405
name: pluginName,
380406
version,
381407
description: pluginConfig.description ?? metadata?.description,
@@ -384,8 +410,8 @@ function claudePluginManifest(
384410
repository: metadata?.repository,
385411
license: metadata?.license,
386412
keywords: metadata?.keywords,
387-
...pluginConfig.manifest,
388-
});
413+
};
414+
return stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {}));
389415
}
390416

391417
function geminiExtensionManifest(
@@ -395,13 +421,13 @@ function geminiExtensionManifest(
395421
pluginConfig: EmittedPluginConfig,
396422
mcpServers: Record<string, unknown> | undefined,
397423
): Record<string, unknown> {
398-
return stripUndefined({
424+
const manifest: Record<string, unknown> = {
399425
name: pluginName,
400426
version,
401427
description: pluginConfig.description ?? metadata?.description,
402428
mcpServers,
403-
...pluginConfig.manifest,
404-
});
429+
};
430+
return stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {}));
405431
}
406432

407433
function artifact(
@@ -427,6 +453,30 @@ function stripUndefined<T extends Record<string, unknown>>(value: T): T {
427453
return value;
428454
}
429455

456+
function isPlainObject(value: unknown): value is Record<string, unknown> {
457+
return typeof value === "object" && value !== null && !Array.isArray(value);
458+
}
459+
460+
// Deep-merge an override onto a generated manifest. Nested objects merge so a
461+
// sibling key isn't lost; arrays and scalars from the override replace (not
462+
// concatenate, so keywords/tags don't double up). This is the general escape
463+
// hatch — any field, at any depth, can be overridden via a target/plugin
464+
// `manifest`.
465+
function deepMerge(
466+
base: Record<string, unknown>,
467+
override: Record<string, unknown>,
468+
): Record<string, unknown> {
469+
const result: Record<string, unknown> = { ...base };
470+
for (const [key, value] of Object.entries(override)) {
471+
const existing = result[key];
472+
result[key] =
473+
isPlainObject(existing) && isPlainObject(value)
474+
? deepMerge(existing, value)
475+
: value;
476+
}
477+
return result;
478+
}
479+
430480
function titleCase(value: string): string {
431481
return value
432482
.split(/[-_.]/)

tests/core.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,108 @@ export default defineConfig({
540540
/overlapping output paths/,
541541
);
542542
});
543+
544+
it("applies per-target and per-plugin version overrides", async () => {
545+
const root = await mkdtemp(path.join(tmpdir(), "pluginpack-version-"));
546+
roots.push(root);
547+
await mkdir(path.join(root, "plugins/a/skills/sa"), { recursive: true });
548+
await mkdir(path.join(root, "plugins/b/skills/sb"), { recursive: true });
549+
await writeFile(
550+
path.join(root, "plugins/a/skills/sa/SKILL.md"),
551+
skill("sa", "SA."),
552+
);
553+
await writeFile(
554+
path.join(root, "plugins/b/skills/sb/SKILL.md"),
555+
skill("sb", "SB."),
556+
);
557+
await writeFile(
558+
path.join(root, "pluginpack.config.ts"),
559+
`import { defineConfig } from "${path.resolve("src/index.ts")}";
560+
561+
export default defineConfig({
562+
name: "ver-plugins",
563+
version: "1.0.0",
564+
metadata: { description: "V", author: { name: "V" }, license: "MIT" },
565+
targets: {
566+
claude: {
567+
outDir: "dist/claude",
568+
version: "2.0.0",
569+
plugins: {
570+
a: { from: ["a"] },
571+
b: { from: ["b"], version: "3.0.0" }
572+
}
573+
}
574+
}
575+
});
576+
`,
577+
);
578+
579+
await build({ cwd: root, target: "claude" });
580+
581+
const read = async (p: string) =>
582+
JSON.parse(await readFile(path.join(root, p), "utf8")) as Record<
583+
string,
584+
unknown
585+
>;
586+
// Marketplace + un-overridden plugin take the target version (not config 1.0.0).
587+
expect(
588+
(await read("dist/claude/.claude-plugin/marketplace.json")).version,
589+
).toBe("2.0.0");
590+
expect(
591+
(await read("dist/claude/plugins/a/.claude-plugin/plugin.json")).version,
592+
).toBe("2.0.0");
593+
// Per-plugin override wins.
594+
expect(
595+
(await read("dist/claude/plugins/b/.claude-plugin/plugin.json")).version,
596+
).toBe("3.0.0");
597+
});
598+
599+
it("deep-merges manifest overrides without dropping sibling keys", async () => {
600+
const root = await mkdtemp(path.join(tmpdir(), "pluginpack-merge-"));
601+
roots.push(root);
602+
await mkdir(path.join(root, "skills/demo"), { recursive: true });
603+
await writeFile(
604+
path.join(root, "skills/demo/SKILL.md"),
605+
skill("demo", "Demo skill."),
606+
);
607+
await writeFile(
608+
path.join(root, "pluginpack.config.ts"),
609+
`import { defineConfig } from "${path.resolve("src/index.ts")}";
610+
611+
export default defineConfig({
612+
name: "merge-plugins",
613+
version: "1.0.0",
614+
source: { skills: "skills", rootPlugin: { id: "core" } },
615+
metadata: {
616+
description: "Base desc",
617+
keywords: ["a", "b"],
618+
author: { name: "X" },
619+
license: "MIT"
620+
},
621+
targets: {
622+
cursor: {
623+
outDir: "dist/cursor",
624+
manifest: { metadata: { description: "Overridden desc" } },
625+
plugins: { demo: { from: ["core"], components: ["skills"] } }
626+
}
627+
}
628+
});
629+
`,
630+
);
631+
632+
await build({ cwd: root, target: "cursor" });
633+
634+
const marketplace = JSON.parse(
635+
await readFile(
636+
path.join(root, "dist/cursor/.cursor-plugin/marketplace.json"),
637+
"utf8",
638+
),
639+
) as { metadata: { description: string; keywords: string[] } };
640+
// Override replaced description...
641+
expect(marketplace.metadata.description).toBe("Overridden desc");
642+
// ...but the sibling keywords survived (shallow spread would have dropped them).
643+
expect(marketplace.metadata.keywords).toEqual(["a", "b"]);
644+
});
543645
});
544646

545647
async function fixture(): Promise<string> {

0 commit comments

Comments
 (0)