Skip to content

Commit 9657f6e

Browse files
feat: add install-info command and buildInstallSnippet API (#18)
* refactor: migrate cursor to the target registry (no behavior change) Re-checking the originally planned Cursor "fixes" against the vendored plugin.schema.json / marketplace.schema.json (this repo's own conformance oracle, already passing against the current shape) showed they were wrong: - displayName/category/tags ARE valid plugin.json fields per the schema (additionalProperties: false, and they're explicitly listed) — not marketplace-entry-only fields as previously assumed. - Marketplace `owner` is genuinely optional (required: ["name", "plugins"] does not include it) — not required as previously assumed. Moving category/tags to the marketplace entry would have been actively wrong: entries only allow name/source/description (additionalProperties: false). None of that is changed here. What this commit actually does: - Migrates cursor onto PluginTargetDefinition, preserving every existing field and behavior (verified by the vendored-schema conformance test staying green). - Fixes one genuine, low-risk issue: the manifest builder's own hardcoded default-components list could diverge from this target's actual defaultComponents (components.ts). Replaced both with one list of schema-valid pointer fields, checked directly against the plugin's real resolved componentDirs — eliminates the divergence risk with no observable behavior change (confirmed via a new test exercising the one case that could have differed: an explicit `components: [...]` override). - Ports update-check's hook-injection into the new shared engine (src/targets/engine.ts), which previously only existed in the legacy emitCursor/emitClaude path — migrating cursor without this would have silently dropped update-check support. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: apply the per-plugin version override in cursor's manifest buildPluginManifest used the target-level `version` param directly instead of `pluginConfig.version ?? version`, silently dropping a per-plugin version override — a real regression from the pre-migration behavior, caught by porting the equivalent test from the claude migration (no test previously covered this for cursor specifically). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: migrate claude to the target registry (no behavior change) Ports emitClaude/validateClaude into src/targets/claude.ts as a PluginTargetDefinition, verified directly against `claude plugin validate --strict`: a minimal manifest with only `name` fails that check, confirming the existing version/description/author.name requirements already match the real CLI rather than over-constraining it. Applies the per-plugin version override in buildPluginManifest (pluginConfig.version ?? version) up front, matching the identical fix just made to cursor's copy of this pattern. * fix: correct Codex plugin output for the target registry Ports emitCodex/validateCodex into src/targets/codex.ts as a PluginTargetDefinition, correcting the plugin format's real shape — re-verified directly against developers.openai.com/codex/plugins/build (fetched twice, independently, for consistency) since Codex has no CLI validator or vendored schema to check against: - plugin.json requires only "name"; version/description/author etc. are optional. The previous validator wrongly required version and description. - Every marketplace entry needs policy.installation, policy.authentication, and category — previously unvalidated, so an incomplete entry shipped silently. pluginpack can't infer these, so the base entry stays guess-free and validateOutput now errors clearly when an author never supplies them via the per-plugin `entry` passthrough (already how the existing conformance fixture supplies them). - A marketplace entry's source is a bare string only for local plugins (the only shape pluginpack itself ever emits); url/git-subdir/npm sources are structured objects with an inner "source" discriminator. validateMarketplaceEntry now accepts either shape instead of the previously shared, string-only validator. - plugin.json now declares a `hooks` pointer when hooks/ is present, matching skills/mcpServers (previously only skills/mcpServers were declared, so hooks were emitted but never referenced). Updates CONFORMANCE.md's Codex section, which had pinned a stale, bare-string-only shape from an earlier doc retrieval. * fix: restore deeper hooks.json validation dropped during target migration Every migrated target's validateOutput calls validateHooksShape (added in the registry scaffold), which only checked that hooks.json has a "hooks" object — narrower than the legacy per-target validateHooks it replaced, which also required each event's entries to be an array, rejected empty "command" strings, and errored if a command referenced the generated update-check script without that script actually being present. None of that depth had a regression test, so the narrowing was silent. Ports the full check into validateHooksShape once, so every target that already calls it (all 5, post-migration) regains it for free instead of needing the fix repeated per target file. * refactor: delete legacy per-target emitters/validators now that all targets are migrated All 5 targets (copilot, antigravity, cursor, claude, codex) now have a PluginTargetDefinition in src/targets/registry.ts, so the legacy fallback path adapters.ts existed for is dead: - Deletes src/targets.ts and src/validate.ts entirely (their only consumer was adapters.ts's legacyAdapters map). - Moves withRootFiles into src/targets/engine.ts, next to the artifact helper it depends on. - Tightens the registry's type from Partial<Record<TargetName, ...>> to Record<TargetName, ...> now that every target has an entry — a new TargetName won't build until it has a registry entry, the same exhaustiveness guarantee the deleted legacyAdapters map used to provide. - Simplifies adapters.ts to a thin emitTarget/validateOutput wrapper around the registry + engine, dropping the now-pointless TargetAdapter/adapters indirection that existed only to switch between legacy and registry per target. - Removes targetDefaultComponents/resolveTargetComponents from components.ts (superseded by each target's own defaultComponents). - Updates CLAUDE.md's Architecture/Targets sections and a couple of stale doc-comment references to match. * docs: note the shared hooks validation depth in CONFORMANCE.md Cross-references the validateHooksShape fix from the prior commit — what it checks and where it's shared from, for the conformance doc's own "what's actually verified and how" mandate. * feat: add install-info command and buildInstallSnippet API Adds the install-snippet feature designed alongside the target registry migration: every PluginTargetDefinition already carries an installSnippet (populated as each target migrated), so this wires it up to a public API and CLI command rather than introducing new per-target data. - src/install-snippet.ts: buildInstallSnippet, getInstallSnippetCitation, getSupportedInstallTargets, getUnsupportedInstallTargets — thin wrappers around each target's registry entry. - New `pluginpack install-info [--target <t>] [--json]` CLI command, defaulting to every configured target (matching `build`'s pattern). Resolves each plugin's path via that target's own resolvePluginPath for accuracy (antigravity's snippet is the one that consumes it). - targets.<name>.repository config field (falls back to metadata.repository, mirroring updateCheck.repository's existing fallback) — "which repo does this target's output live in." - Exported from the package entry for programmatic use. - README: new "Install Snippet" section + Configuration Reference row + Programmatic API table rows. CONFORMANCE.md: "Install-snippet facts" table with per-target doc citations. Tests: tests/install-snippet.test.ts locks in the exact snippet text per target; tests/conformance.test.ts adds CLI end-to-end coverage (default/--target/--json/missing-repository error) via the real built binary. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6a96abb commit 9657f6e

8 files changed

Lines changed: 388 additions & 20 deletions

File tree

CONFORMANCE.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,33 @@ target-agnostic: each event's entries must be an array, no `command` string
9696
may be empty, and any command referencing the generated update-check script
9797
(`scripts/pluginpack-update-check.sh`) must ship that script.
9898

99+
## Install-snippet facts
100+
101+
`pluginpack install-info` (and the `buildInstallSnippet` library function)
102+
prints the real command or URL a user needs to add a pluginpack-built
103+
marketplace. Each target's snippet and citation live on its own
104+
`PluginTargetDefinition.installSnippet` in `src/targets/<name>.ts`, verified
105+
directly against product docs:
106+
107+
| Target | Snippet | Source | Verified |
108+
| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ---------- |
109+
| `claude` | `/plugin marketplace add <repo>` then `/plugin install <name>@<marketplace>` (slash-only) | <https://code.claude.com/docs/en/plugins-reference#cli-commands-reference> | 2026-07-25 |
110+
| `codex` | `codex plugin marketplace add <repo>` | <https://learn.chatgpt.com/codex/developer-commands> | 2026-07-25 |
111+
| `copilot` | `copilot plugin marketplace add <repo>` then `copilot plugin install <name>@<marketplace>` | <https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-finding-installing> | 2026-07-25 |
112+
| `antigravity` | `git clone <repo> plugin-source && cd plugin-source && agy plugin install <pluginPath>` | <https://antigravity.google/docs/cli/plugins> | 2026-07-25 |
113+
| `cursor` | No CLI equivalent exists. Prints the repo URL, pasted into Dashboard → Plugins → Team Marketplaces → "Import from Repo." | <https://cursor.com/docs/plugins> | 2026-07-25 |
114+
115+
`claude`'s two-step sequence is not symmetric with `codex`/`copilot`'s shell
116+
commands: `/plugin marketplace add` is slash-only inside an active Claude Code
117+
session, with no shell equivalent — but once a marketplace is already added,
118+
`claude plugin install <name>@<marketplace>` does work as a standalone shell
119+
command, surfaced as a secondary `note`.
120+
121+
Every target resolves to `userConfigurable: true` today;
122+
`getUnsupportedInstallTargets()` returns `[]`. The `false` branch of the
123+
`InstallSnippet` union exists for forward-compatibility, not because any
124+
target needs it now.
125+
99126
## Refreshing vendored schemas
100127

101128
The Cursor schemas are pinned copies. To update them, re-fetch from the source

README.md

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,17 @@ Disable for a single plugin with `updateCheck: false` on that plugin. Configurin
317317

318318
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.
319319

320+
## Install Snippet
321+
322+
Once a target's output is pushed to a repo, `pluginpack install-info` prints the real, doc-verified command or URL a user needs to add that marketplace — one per configured target:
323+
324+
```bash
325+
pluginpack install-info
326+
pluginpack install-info --target claude
327+
```
328+
329+
The repo comes from `targets.<name>.repository`, defaulting to `metadata.repository` (an error if neither is set) — the same fallback `updateCheck.repository` uses. Every target today resolves to a real command except `cursor`, which has no CLI equivalent: it prints the repo URL and a note to paste it into Cursor's Dashboard under Team Marketplaces. See `CONFORMANCE.md`'s "Install-snippet facts" section for the doc citation behind each target's snippet.
330+
320331
## Target Overrides
321332

322333
Skill files are not always perfectly portable. When one app needs different frontmatter or content, add a target override next to the base file:
@@ -395,17 +406,18 @@ To publish a repo-root file (for example a README authored once in the source re
395406

396407
**`targets.<name>`**`<name>` is one of `cursor`, `claude`, `antigravity`, `copilot`, `codex`.
397408

398-
| Field | Type | Required | Meaning |
399-
| ------------------ | ---------------------- | -------- | ------------------------------------------------------------------------------------------ |
400-
| `outDir` | string | yes | Output directory for this target, relative to the config root. |
401-
| `plugins` | record | yes | Emitted plugins, keyed by emitted plugin name (see **`targets.<name>.plugins.<name>`**). |
402-
| `marketplaceDir` | string (safe relative) | no | Override the marketplace dir (defaults: `.cursor-plugin` / `.claude-plugin`). |
403-
| `pluginRoot` | string (safe relative) | no | Override the plugin root dir (`claude`; defaults to `plugins`). |
404-
| `version` | string | no | Override the version for this target (defaults to top-level `version`). |
405-
| `manifest` | object | no | Deep-merged into the generated marketplace manifest. |
406-
| `ignoredDiffPaths` | string[] | no | Output-relative paths `diff` ignores (a dir entry ignores everything below it). |
407-
| `rootFiles` | record (safe relative) | no | Map of output path → source path emitted verbatim at the output root. |
408-
| `updateCheck` | `{ repository? }` | no | Generate a session-start update-check hook (`claude`/`cursor` only; see **Update Check**). |
409+
| Field | Type | Required | Meaning |
410+
| ------------------ | ---------------------- | -------- | ------------------------------------------------------------------------------------------- |
411+
| `outDir` | string | yes | Output directory for this target, relative to the config root. |
412+
| `plugins` | record | yes | Emitted plugins, keyed by emitted plugin name (see **`targets.<name>.plugins.<name>`**). |
413+
| `marketplaceDir` | string (safe relative) | no | Override the marketplace dir (defaults: `.cursor-plugin` / `.claude-plugin`). |
414+
| `pluginRoot` | string (safe relative) | no | Override the plugin root dir (`claude`; defaults to `plugins`). |
415+
| `version` | string | no | Override the version for this target (defaults to top-level `version`). |
416+
| `manifest` | object | no | Deep-merged into the generated marketplace manifest. |
417+
| `ignoredDiffPaths` | string[] | no | Output-relative paths `diff` ignores (a dir entry ignores everything below it). |
418+
| `rootFiles` | record (safe relative) | no | Map of output path → source path emitted verbatim at the output root. |
419+
| `updateCheck` | `{ repository? }` | no | Generate a session-start update-check hook (`claude`/`cursor` only; see **Update Check**). |
420+
| `repository` | string | no | Repo this target's output lives in, for `install-info` (defaults to `metadata.repository`). |
409421

410422
**`targets.<name>.plugins.<name>`**
411423

@@ -434,18 +446,24 @@ import {
434446
validateOutput,
435447
prune,
436448
clean,
449+
buildInstallSnippet,
450+
getSupportedInstallTargets,
451+
getUnsupportedInstallTargets,
437452
} from "@gleanwork/pluginpack";
438453
```
439454

440-
| Function | Returns | Purpose |
441-
| ------------------------------- | --------------------------- | ---------------------------------------------------------------- |
442-
| `defineConfig(config)` | `PluginpackConfig` | Identity helper that types `pluginpack.config.ts`. |
443-
| `loadConfig(cwd?, configPath?)` | `Promise<ResolvedProject>` | Resolve config and discover source plugins. |
444-
| `build(options?)` | `Promise<Artifact[]>` | Emit configured targets; writes to disk unless `options.dryRun`. |
445-
| `diffTarget(options)` | `Promise<DiffResult>` | Build into a temp dir and compare against an existing repo. |
446-
| `validateOutput(target, dir)` | `Promise<ValidationResult>` | Validate an existing target output directory. |
447-
| `prune(options?)` | `Promise<CleanupResult[]>` | Remove stale managed files no longer emitted by the config. |
448-
| `clean(options?)` | `Promise<CleanupResult[]>` | Remove all managed files for configured targets. |
455+
| Function | Returns | Purpose |
456+
| ------------------------------------- | --------------------------- | ----------------------------------------------------------------- |
457+
| `defineConfig(config)` | `PluginpackConfig` | Identity helper that types `pluginpack.config.ts`. |
458+
| `loadConfig(cwd?, configPath?)` | `Promise<ResolvedProject>` | Resolve config and discover source plugins. |
459+
| `build(options?)` | `Promise<Artifact[]>` | Emit configured targets; writes to disk unless `options.dryRun`. |
460+
| `diffTarget(options)` | `Promise<DiffResult>` | Build into a temp dir and compare against an existing repo. |
461+
| `validateOutput(target, dir)` | `Promise<ValidationResult>` | Validate an existing target output directory. |
462+
| `prune(options?)` | `Promise<CleanupResult[]>` | Remove stale managed files no longer emitted by the config. |
463+
| `clean(options?)` | `Promise<CleanupResult[]>` | Remove all managed files for configured targets. |
464+
| `buildInstallSnippet(target, params)` | `InstallSnippet` | The install command/URL for one target (see **Install Snippet**). |
465+
| `getSupportedInstallTargets()` | `TargetName[]` | Targets with a real install snippet today. |
466+
| `getUnsupportedInstallTargets()` | `TargetName[]` | Targets with none (empty today, kept for forward-compatibility). |
449467

450468
Option objects:
451469

@@ -593,6 +611,30 @@ Exit codes:
593611
- 0 when managed files are removed or listed
594612
- 1 when config, manifest loading, or cleanup fails
595613

614+
### `install-info`
615+
616+
Print the real install command or URL for a target's built marketplace.
617+
618+
```bash
619+
pluginpack install-info [--target copilot|antigravity|cursor|claude|codex] [--json]
620+
```
621+
622+
Options:
623+
624+
- `--target <target>`: Print only one configured target's install info.
625+
- `--json`: Print machine-readable JSON instead of text.
626+
627+
Examples:
628+
629+
- `pluginpack install-info`
630+
- `pluginpack install-info --target claude`
631+
- `pluginpack install-info --json`
632+
633+
Exit codes:
634+
635+
- 0 when install info is printed
636+
- 1 when config loading fails or a target has no repository configured
637+
596638
### `docs`
597639

598640
Generate the README CLI reference section from command metadata.

src/cli.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { build } from "./build.js";
55
import { clean, prune } from "./cleanup.js";
66
import { loadConfig } from "./config.js";
77
import { diffTarget } from "./diff.js";
8+
import { buildInstallSnippet } from "./install-snippet.js";
89
import { targetNames, validateOutput } from "./adapters.js";
10+
import { targets as targetRegistry } from "./targets/registry.js";
911
import type { TargetName } from "./types.js";
1012

1113
/** CLI entry point. */
@@ -198,6 +200,71 @@ function createProgram(): Command {
198200
},
199201
);
200202

203+
program
204+
.command("install-info")
205+
.description(
206+
"Print the real install command or URL for a target's built marketplace.",
207+
)
208+
.usage(`[--target ${targetList}] [--json]`)
209+
.addOption(
210+
new Option(
211+
"--target <target>",
212+
"Print only one configured target's install info.",
213+
).choices([...targetNames]),
214+
)
215+
.option("--json", "Print machine-readable JSON instead of text.")
216+
.action(async (options: { target?: TargetName; json?: boolean }) => {
217+
const project = await loadConfig();
218+
const targets = options.target
219+
? [options.target]
220+
: targetNames.filter((target) => project.config.targets[target]);
221+
const entries = targets.flatMap((target) => {
222+
const targetConfig = project.config.targets[target];
223+
if (!targetConfig) {
224+
throw new Error(`Target "${target}" is not configured.`);
225+
}
226+
const repository =
227+
targetConfig.repository ?? project.config.metadata?.repository;
228+
if (!repository) {
229+
throw new Error(
230+
`Target "${target}" has no repository configured ` +
231+
`(set targets.${target}.repository or metadata.repository).`,
232+
);
233+
}
234+
return Object.entries(targetConfig.plugins).map(
235+
([pluginName, pluginConfig]) => ({
236+
target,
237+
plugin: pluginName,
238+
snippet: buildInstallSnippet(target, {
239+
repository,
240+
marketplaceName: project.config.name,
241+
pluginName,
242+
pluginPath: targetRegistry[target].resolvePluginPath(
243+
pluginName,
244+
pluginConfig,
245+
targetConfig,
246+
),
247+
}),
248+
}),
249+
);
250+
});
251+
if (options.json) {
252+
console.log(JSON.stringify(entries, null, 2));
253+
return;
254+
}
255+
for (const entry of entries) {
256+
console.log(`${entry.target} / ${entry.plugin}`);
257+
if (entry.snippet.userConfigurable) {
258+
console.log(` ${entry.snippet.snippet.replaceAll("\n", "\n ")}`);
259+
if (entry.snippet.note) {
260+
console.log(` note: ${entry.snippet.note}`);
261+
}
262+
} else {
263+
console.log(` unsupported: ${entry.snippet.reason}`);
264+
}
265+
}
266+
});
267+
201268
program
202269
.command("docs")
203270
.description(
@@ -383,6 +450,12 @@ function commandExamples(commandName: string): string[] {
383450
return ["pluginpack prune", "pluginpack prune --target claude --dry-run"];
384451
case "clean":
385452
return ["pluginpack clean", "pluginpack clean --target cursor --dry-run"];
453+
case "install-info":
454+
return [
455+
"pluginpack install-info",
456+
"pluginpack install-info --target claude",
457+
"pluginpack install-info --json",
458+
];
386459
case "docs":
387460
return ["pluginpack docs", "pluginpack docs --check"];
388461
default:
@@ -420,6 +493,11 @@ function commandExitCodes(commandName: string): string[] {
420493
"0 when managed files are removed or listed",
421494
"1 when config, manifest loading, or cleanup fails",
422495
];
496+
case "install-info":
497+
return [
498+
"0 when install info is printed",
499+
"1 when config loading fails or a target has no repository configured",
500+
];
423501
case "docs":
424502
return [
425503
"0 when docs are current or updated",

src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ export { build } from "./build.js";
44
export { clean, prune } from "./cleanup.js";
55
export { diffTarget } from "./diff.js";
66
export { validateOutput } from "./adapters.js";
7+
export {
8+
buildInstallSnippet,
9+
getInstallSnippetCitation,
10+
getSupportedInstallTargets,
11+
getUnsupportedInstallTargets,
12+
} from "./install-snippet.js";
13+
export type {
14+
Citation,
15+
InstallParams,
16+
InstallSnippet,
17+
} from "./install-snippet.js";
718
export type {
819
Artifact,
920
Author,

src/install-snippet.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { targets as registry } from "./targets/registry.js";
2+
import type {
3+
Citation,
4+
InstallParams,
5+
InstallSnippet,
6+
} from "./targets/types.js";
7+
import type { TargetName } from "./types.js";
8+
9+
export type {
10+
Citation,
11+
InstallParams,
12+
InstallSnippet,
13+
} from "./targets/types.js";
14+
15+
/**
16+
* Builds the real, doc-verified command or URL a user needs to add a
17+
* pluginpack-built marketplace for `target`. See each target's
18+
* `installSnippet.citation` (and `getInstallSnippetCitation`) for the source
19+
* this is based on.
20+
*/
21+
export function buildInstallSnippet(
22+
target: TargetName,
23+
params: InstallParams,
24+
): InstallSnippet {
25+
const definition = registry[target].installSnippet;
26+
if (!definition.userConfigurable || !definition.build) {
27+
return {
28+
userConfigurable: false,
29+
reason:
30+
definition.unsupportedReason ??
31+
`${target} has no install command or URL.`,
32+
};
33+
}
34+
return { userConfigurable: true, ...definition.build(params) };
35+
}
36+
37+
/** The citation backing `target`'s install snippet. */
38+
export function getInstallSnippetCitation(target: TargetName): Citation {
39+
return registry[target].installSnippet.citation;
40+
}
41+
42+
/** Targets with a real, user-configurable install snippet today. */
43+
export function getSupportedInstallTargets(): TargetName[] {
44+
return (Object.keys(registry) as TargetName[]).filter(
45+
(target) => registry[target].installSnippet.userConfigurable,
46+
);
47+
}
48+
49+
/** Targets with no install snippet — empty today, kept for forward-compatibility. */
50+
export function getUnsupportedInstallTargets(): TargetName[] {
51+
return (Object.keys(registry) as TargetName[]).filter(
52+
(target) => !registry[target].installSnippet.userConfigurable,
53+
);
54+
}

src/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ const targetSchema = z.object({
7575
marketplaceDir: safeRelativePath.optional(),
7676
pluginRoot: safeRelativePath.optional(),
7777
version: z.string().optional(),
78+
// The repo this target's output lives in, for install-snippet generation
79+
// (falls back to metadata.repository) — the same "which repo" question
80+
// updateCheck.repository answers, asked by a different feature.
81+
repository: z.string().min(1).optional(),
7882
plugins: z.record(z.string(), emittedPluginSchema),
7983
manifest: z.record(z.string(), z.unknown()).optional(),
8084
ignoredDiffPaths: z.array(z.string()).optional(),

0 commit comments

Comments
 (0)