Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,29 @@ skills }`) and optional `.mcp.json`. No published JSON Schema exists; the test
the marketplace entry. Codex shares no marketplace path with the other targets,
so it needs no separate output root.

## Update-check hook facts

The generated update-check hook (`updateCheck` config, claude/cursor targets)
relies on two upstream hook behaviors that have no referenceable schema. Both
were verified against product docs on 2026-07-22:

- **Claude Code** — a `SessionStart` hook may print JSON whose top-level
`systemMessage` field is shown directly to the user (separate from model
context), and plugin hooks are auto-discovered from `hooks/hooks.json` with
`${CLAUDE_PLUGIN_ROOT}` substituted in commands.
Source: <https://code.claude.com/docs/en/hooks>.
- **Cursor** — the `sessionStart` hook supports only `additional_context`
(injected into the agent's context); there is no user-visible message field,
so the cursor nudge asks the agent to relay it. Plugin hooks live in a
`hooks.json` (`{ "version": 1, "hooks": { "sessionStart": [...] } }`)
referenced from the plugin manifest, with commands relative to the plugin
root. Source: <https://cursor.com/docs/agent/hooks>.

The emitted `hooks/hooks.json` shapes are covered by the conformance suite (the
cursor manifest's `hooks` key validates against the vendored plugin schema, and
`claude plugin validate --strict` exercises the claude hooks file when the CLI
is present).

## Refreshing vendored schemas

The Cursor schemas are pinned copies. To update them, re-fetch from the source
Expand Down
46 changes: 36 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,30 @@ Each target wires that MCP config into its native shape:
| `copilot` | ships `.mcp.json`, referenced from the marketplace entry |
| `antigravity` | writes `mcp_config.json` beside `plugin.json` |

## Update Check (claude, cursor)

Hosts don't reliably tell users a plugin is outdated (Claude Code's auto-update is off by default for third-party marketplaces; Cursor has no nudge at all). Opt in per target with `updateCheck` and pluginpack generates a session-start hook into each emitted plugin:

```ts
targets: {
claude: { outDir: "...", updateCheck: {}, plugins: { ... } },
cursor: { outDir: "...", updateCheck: {}, plugins: { ... } },
}
```

Each emitted plugin gains `scripts/pluginpack-update-check.sh` plus a `hooks/hooks.json` registration (merged into a source-authored `hooks/hooks.json` when one exists). At session start the script compares the version stamped at build time against the latest stable semver git tag of the plugin repo and, when behind, nudges: on `claude` via a user-visible `systemMessage` (with the `/plugin update` command to run); on `cursor` via agent context (Cursor's `sessionStart` hook has no user-visible output).

The check follows update-notifier discipline:

- The repo URL comes from `updateCheck.repository`, defaulting to `metadata.repository` (an error if neither is set).
- At most one `git ls-remote` per repo per 24h, cached under `${XDG_CACHE_HOME:-~/.cache}/pluginpack/` and shared across plugins from the same repo.
- Fail-open: offline, missing `git`, odd tags, or any other problem exits silently.
- Skipped entirely when `CI` is set or `PLUGINPACK_NO_UPDATE_CHECK=1`.

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.

## 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:
Expand Down Expand Up @@ -371,16 +395,17 @@ To publish a repo-root file (for example a README authored once in the source re

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

| Field | Type | Required | Meaning |
| ------------------ | ---------------------- | -------- | ---------------------------------------------------------------------------------------- |
| `outDir` | string | yes | Output directory for this target, relative to the config root. |
| `plugins` | record | yes | Emitted plugins, keyed by emitted plugin name (see **`targets.<name>.plugins.<name>`**). |
| `marketplaceDir` | string (safe relative) | no | Override the marketplace dir (defaults: `.cursor-plugin` / `.claude-plugin`). |
| `pluginRoot` | string (safe relative) | no | Override the plugin root dir (`claude`; defaults to `plugins`). |
| `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. |
| Field | Type | Required | Meaning |
| ------------------ | ---------------------- | -------- | ------------------------------------------------------------------------------------------ |
| `outDir` | string | yes | Output directory for this target, relative to the config root. |
| `plugins` | record | yes | Emitted plugins, keyed by emitted plugin name (see **`targets.<name>.plugins.<name>`**). |
| `marketplaceDir` | string (safe relative) | no | Override the marketplace dir (defaults: `.cursor-plugin` / `.claude-plugin`). |
| `pluginRoot` | string (safe relative) | no | Override the plugin root dir (`claude`; defaults to `plugins`). |
| `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. |
| `updateCheck` | `{ repository? }` | no | Generate a session-start update-check hook (`claude`/`cursor` only; see **Update Check**). |

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

Expand All @@ -394,6 +419,7 @@ To publish a repo-root file (for example a README authored once in the source re
| `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. |
| `updateCheck` | `false` | no | Opt this plugin out of the target's update-check hook. |

## Programmatic API

Expand Down
50 changes: 37 additions & 13 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ const sourceSchema = z.object({
rootPlugin: rootPluginSchema.optional(),
});

// Opt-in generated session-start hook that nudges the user when the installed
// plugin is older than the latest git tag of `repository` (defaults to
// `metadata.repository`). Only claude and cursor support hooks.
const updateCheckSchema = z.object({
repository: z.string().min(1).optional(),
});

const emittedPluginSchema = z.object({
from: z.array(z.string().min(1)).min(1),
path: safeRelativePath.optional(),
Expand All @@ -54,6 +61,7 @@ const emittedPluginSchema = z.object({
// 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(),
});

const targetSchema = z.object({
Expand All @@ -64,26 +72,41 @@ const targetSchema = z.object({
plugins: z.record(z.string(), emittedPluginSchema),
manifest: z.record(z.string(), z.unknown()).optional(),
ignoredDiffPaths: z.array(z.string()).optional(),
updateCheck: updateCheckSchema.optional(),
// Files emitted verbatim at the output repo root (relative to outDir), keyed
// by output path → source path (relative to the config root). Managed like
// 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(),
});

const configSchema = z.object({
name: z.string().min(1),
version: z.string().min(1),
source: sourceSchema.optional(),
metadata: metadataSchema.optional(),
targets: z.object({
claude: targetSchema.optional(),
copilot: targetSchema.optional(),
cursor: targetSchema.optional(),
antigravity: targetSchema.optional(),
codex: targetSchema.optional(),
}),
});
const configSchema = z
.object({
name: z.string().min(1),
version: z.string().min(1),
source: sourceSchema.optional(),
metadata: metadataSchema.optional(),
targets: z.object({
claude: targetSchema.optional(),
copilot: targetSchema.optional(),
cursor: targetSchema.optional(),
antigravity: targetSchema.optional(),
codex: targetSchema.optional(),
}),
})
.superRefine((config, ctx) => {
// updateCheck emits a session-start hook; only claude and cursor run hooks.
for (const target of ["copilot", "antigravity", "codex"] as const) {
if (config.targets[target]?.updateCheck) {
ctx.addIssue({
code: "custom",
path: ["targets", target, "updateCheck"],
message:
"updateCheck is only supported for the claude and cursor targets",
});
}
}
});

const sourcePluginManifestSchema = metadataSchema.extend({
name: z.string().optional(),
Expand All @@ -97,6 +120,7 @@ export type Author = z.infer<typeof authorSchema>;
export type Metadata = z.infer<typeof metadataSchema>;
export type SourceConfig = z.infer<typeof sourceSchema>;
export type EmittedPluginConfig = z.infer<typeof emittedPluginSchema>;
export type UpdateCheckConfig = z.infer<typeof updateCheckSchema>;
export type TargetConfig = z.infer<typeof targetSchema>;
export type PluginpackConfig = z.infer<typeof configSchema>;
export type SourcePluginManifest = z.infer<typeof sourcePluginManifestSchema>;
80 changes: 77 additions & 3 deletions src/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import path from "node:path";
import { collectPluginFiles, resolveMcpServers } from "./render.js";
import { isSafeRelativePath, json, toPosix } from "./fs.js";
import { resolveTargetComponents } from "./components.js";
import { applyUpdateCheck, pluginAllowsUpdateCheck } from "./update-check.js";
import type { UpdateCheckFormat } from "./update-check.js";
import type {
Artifact,
EmittedPluginConfig,
Expand Down Expand Up @@ -81,6 +83,13 @@ type EmitPluginsOptions = {
// Build this plugin's marketplace entry; omit when the target has no marketplace.
buildEntry?: (ctx: EmitPluginContext) => Record<string, unknown> | undefined;
mcp?: "file" | "antigravity";
// Inject the generated update-check hook (claude/cursor only); resolved from
// targetConfig.updateCheck by the emitter so repository errors surface there.
updateCheck?: {
format: UpdateCheckFormat;
repository: string;
version: (pluginConfig: EmittedPluginConfig) => string;
};
};

async function emitPlugins(
Expand All @@ -101,6 +110,16 @@ async function emitPlugins(
pluginConfig.from,
resolveTargetComponents(target, pluginConfig),
);
// Inject before componentDirs is derived so hooks/ and scripts/ register
// as components (e.g. the cursor manifest's `hooks` pointer).
if (options.updateCheck && pluginAllowsUpdateCheck(pluginConfig)) {
applyUpdateCheck(pluginFiles, target, {
format: options.updateCheck.format,
pluginName,
version: options.updateCheck.version(pluginConfig),
repository: options.updateCheck.repository,
});
}
const componentDirs = new Set(
[...pluginFiles.keys()].map((file) => file.split("/")[0]),
);
Expand Down Expand Up @@ -156,6 +175,33 @@ async function emitPlugins(
return entries;
}

// Resolve a target's updateCheck config into the emitPlugins option, failing
// fast when no repository URL can be determined.
function updateCheckOption(
project: ResolvedProject,
target: TargetName,
targetConfig: TargetConfig,
format: UpdateCheckFormat,
version: string,
): EmitPluginsOptions["updateCheck"] {
if (!targetConfig.updateCheck) {
return undefined;
}
const repository =
targetConfig.updateCheck.repository ?? project.config.metadata?.repository;
if (!repository) {
throw new Error(
`Target "${target}" updateCheck requires a repository ` +
`(set targets.${target}.updateCheck.repository or metadata.repository).`,
);
}
return {
format,
repository,
version: (pluginConfig) => pluginConfig.version ?? version,
};
}

export async function emitCursor(
project: ResolvedProject,
target: TargetName,
Expand All @@ -172,15 +218,29 @@ export async function emitCursor(
pluginManifest: {
path: (pluginPath) =>
path.join(pluginPath, marketplaceDir, "plugin.json"),
build: (metadata, pluginName, pluginConfig, componentDirs, mcpServers) =>
cursorPluginManifest(
build: (
metadata,
pluginName,
pluginConfig,
componentDirs,
mcpServers,
) => {
const manifest = cursorPluginManifest(
metadata,
pluginConfig.version ?? version,
pluginName,
pluginConfig,
componentDirs,
mcpServers,
),
);
// updateCheck injects hooks/ regardless of the components filter (see
// README "Update Check"), and Cursor only runs hooks the manifest
// points at, so force the pointer even if `components` excludes hooks.
if (targetConfig.updateCheck && pluginAllowsUpdateCheck(pluginConfig)) {
manifest.hooks ??= "./hooks/";
}
return manifest;
},
},
buildEntry: ({ pluginName, pluginPath, pluginConfig, manifest }) => ({
name: pluginName,
Expand All @@ -190,6 +250,13 @@ export async function emitCursor(
(manifest?.description as string | undefined),
}),
mcp: "file",
updateCheck: updateCheckOption(
project,
target,
targetConfig,
"cursor",
version,
),
});

const marketplace = stripUndefined(
Expand Down Expand Up @@ -249,6 +316,13 @@ export async function emitClaude(
(manifest?.description as string | undefined),
}),
mcp: "file",
updateCheck: updateCheckOption(
project,
target,
targetConfig,
"claude",
version,
),
});

const marketplace = stripUndefined(
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
TargetConfig,
PluginpackConfig,
SourcePluginManifest,
UpdateCheckConfig,
} from "./schema.js";

export type {
Expand All @@ -16,6 +17,7 @@ export type {
TargetConfig,
PluginpackConfig,
SourcePluginManifest,
UpdateCheckConfig,
};

export type TargetName =
Expand Down
Loading