From a2ac67ab0e5c219e90304cab0ce21b88608f1f5d Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 25 Jul 2026 14:58:13 -0700 Subject: [PATCH 1/4] feat: add opt-in update-check hook for claude and cursor targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosts don't reliably surface plugin updates: Claude Code's auto-update is off by default for third-party marketplaces, and Cursor has no nudge at all. When a target sets `updateCheck`, pluginpack now generates a session-start hook per emitted plugin that compares the build-stamped version against the latest git tag of the plugin repo and nudges the user when outdated — via Claude's user-visible `systemMessage`, or via agent context on Cursor (whose sessionStart hook has no user-facing output). The generated script follows update-notifier discipline: throttled to one `git ls-remote` per repo per 24h via a shared cache file, fail-open on any error/offline/missing git, and skippable via CI or PLUGINPACK_NO_UPDATE_CHECK=1. The hook registration merges into a source-authored hooks/hooks.json rather than clobbering it. Co-Authored-By: Claude Fable 5 --- src/schema.ts | 50 ++++++++--- src/targets.ts | 79 ++++++++++++++++- src/types.ts | 2 + src/update-check.ts | 211 ++++++++++++++++++++++++++++++++++++++++++++ src/validate.ts | 56 +++++++++++- 5 files changed, 381 insertions(+), 17 deletions(-) create mode 100644 src/update-check.ts diff --git a/src/schema.ts b/src/schema.ts index 88ce225..eb73aec 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -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(), @@ -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({ @@ -64,6 +72,7 @@ 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 @@ -71,19 +80,33 @@ const targetSchema = z.object({ 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(), @@ -97,6 +120,7 @@ export type Author = z.infer; export type Metadata = z.infer; export type SourceConfig = z.infer; export type EmittedPluginConfig = z.infer; +export type UpdateCheckConfig = z.infer; export type TargetConfig = z.infer; export type PluginpackConfig = z.infer; export type SourcePluginManifest = z.infer; diff --git a/src/targets.ts b/src/targets.ts index 82e06e6..14fb999 100644 --- a/src/targets.ts +++ b/src/targets.ts @@ -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 } from "./update-check.js"; +import type { UpdateCheckFormat } from "./update-check.js"; import type { Artifact, EmittedPluginConfig, @@ -81,6 +83,13 @@ type EmitPluginsOptions = { // Build this plugin's marketplace entry; omit when the target has no marketplace. buildEntry?: (ctx: EmitPluginContext) => Record | 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( @@ -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 && pluginConfig.updateCheck !== false) { + 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]), ); @@ -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, @@ -172,15 +218,28 @@ 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, and + // Cursor only runs hooks the manifest points at. + if (targetConfig.updateCheck && pluginConfig.updateCheck !== false) { + manifest.hooks ??= "./hooks/"; + } + return manifest; + }, }, buildEntry: ({ pluginName, pluginPath, pluginConfig, manifest }) => ({ name: pluginName, @@ -190,6 +249,13 @@ export async function emitCursor( (manifest?.description as string | undefined), }), mcp: "file", + updateCheck: updateCheckOption( + project, + target, + targetConfig, + "cursor", + version, + ), }); const marketplace = stripUndefined( @@ -249,6 +315,13 @@ export async function emitClaude( (manifest?.description as string | undefined), }), mcp: "file", + updateCheck: updateCheckOption( + project, + target, + targetConfig, + "claude", + version, + ), }); const marketplace = stripUndefined( diff --git a/src/types.ts b/src/types.ts index 066eeb8..37217f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ import type { TargetConfig, PluginpackConfig, SourcePluginManifest, + UpdateCheckConfig, } from "./schema.js"; export type { @@ -16,6 +17,7 @@ export type { TargetConfig, PluginpackConfig, SourcePluginManifest, + UpdateCheckConfig, }; export type TargetName = diff --git a/src/update-check.ts b/src/update-check.ts new file mode 100644 index 0000000..9aa6528 --- /dev/null +++ b/src/update-check.ts @@ -0,0 +1,211 @@ +import { json } from "./fs.js"; +import type { FileValue } from "./types.js"; + +export const UPDATE_CHECK_SCRIPT_PATH = "scripts/pluginpack-update-check.sh"; +export const HOOKS_FILE_PATH = "hooks/hooks.json"; + +// The marker every generated command contains; merge is idempotent on it, so a +// re-run (or a source plugin that already wired the script) never appends twice. +const SCRIPT_MARKER = "pluginpack-update-check"; + +export type UpdateCheckFormat = "claude" | "cursor"; + +export type UpdateCheckOptions = { + format: UpdateCheckFormat; + pluginName: string; + version: string; + repository: string; +}; + +// POSIX single-quote: close, escaped quote, reopen. +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +// Escape a value for interpolation into a printf format string ("%" is the +// only metacharacter) that is then JSON-string-escaped as a whole. +function printfEscape(value: string): string { + return value.replaceAll("%", "%%"); +} + +// The nudge is a printf format string: JSON payload with two %s slots, plus a +// trailing newline. printf interprets backslash escapes in its format string, +// so JSON's own escapes (e.g. \") must be doubled to survive, while the +// appended \n is left single so printf renders the newline. +function nudgeFormat(format: UpdateCheckFormat, pluginName: string): string { + const name = printfEscape(pluginName); + const payload = + format === "claude" + ? // systemMessage is shown directly to the user by Claude Code. + // Args: latest, installed. + { + systemMessage: `Plugin ${name} v%s is available (installed v%s). Run /plugin update ${name} to upgrade.`, + } + : // Cursor sessionStart has no user-visible output; ask the agent to relay. + // Args: installed, latest. + { + additional_context: `The installed Cursor plugin "${name}" is outdated (installed v%s, latest v%s). Briefly let the user know they can update it from Cursor's plugin settings.`, + }; + const literal = JSON.stringify(payload).replaceAll("\\", "\\\\"); + return shellQuote(`${literal}\\n`); +} + +export function renderUpdateCheckScript(options: UpdateCheckOptions): string { + const { format, pluginName, version, repository } = options; + const nudgeArgs = + format === "claude" ? '"$latest" "$INSTALLED"' : '"$INSTALLED" "$latest"'; + return `#!/bin/sh +# Generated by pluginpack — session-start update check for ${JSON.stringify(pluginName)}. +# Compares the installed version against the latest git tag of the plugin repo, +# throttled to one network check per 24h (cache shared across plugins from the +# same repo). Fail-open by design: any problem exits 0 with no output. +[ "\${PLUGINPACK_NO_UPDATE_CHECK:-}" = "1" ] && exit 0 +[ -n "\${CI:-}" ] && exit 0 +command -v git >/dev/null 2>&1 || exit 0 + +INSTALLED=${shellQuote(version)} +REPO_URL=${shellQuote(repository)} + +# Only plain dotted-numeric versions compare meaningfully with sort below. +case "$INSTALLED" in + "" | *[!0-9.]* | .* | *.) exit 0 ;; +esac + +CACHE_DIR="\${XDG_CACHE_HOME:-$HOME/.cache}/pluginpack" +KEY=$(printf '%s' "$REPO_URL" | cksum | cut -d ' ' -f 1) || exit 0 +CACHE_FILE="$CACHE_DIR/latest-$KEY" +now=$(date +%s) || exit 0 +latest="" +cached_at=0 +if [ -f "$CACHE_FILE" ]; then + read -r cached_at latest <"$CACHE_FILE" || true +fi +case "$cached_at" in + "" | *[!0-9]*) cached_at=0 ;; +esac +if [ $((now - cached_at)) -ge 86400 ]; then + mkdir -p "$CACHE_DIR" 2>/dev/null || exit 0 + # Timestamp sentinel before fetching, so sibling plugins don't race-fetch. + printf '%s %s\\n' "$now" "" >"$CACHE_FILE" 2>/dev/null || true + tags=$(GIT_TERMINAL_PROMPT=0 git -c http.connectTimeout=5 \\ + -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=10 \\ + ls-remote --tags --refs "$REPO_URL" 2>/dev/null) || exit 0 + # Highest stable semver tag; "v" prefix tolerated, prereleases ignored. + latest=$(printf '%s\\n' "$tags" | + sed -n 's#.*refs/tags/v\\{0,1\\}\\([0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*\\)$#\\1#p' | + sort -t . -k 1,1n -k 2,2n -k 3,3n | tail -n 1) + printf '%s %s\\n' "$now" "$latest" >"$CACHE_FILE" 2>/dev/null || true +fi +[ -n "$latest" ] || exit 0 +[ "$latest" = "$INSTALLED" ] && exit 0 +newest=$(printf '%s\\n%s\\n' "$INSTALLED" "$latest" | + sort -t . -k 1,1n -k 2,2n -k 3,3n | tail -n 1) +[ "$newest" = "$latest" ] || exit 0 +printf ${nudgeFormat(format, pluginName)} ${nudgeArgs} +exit 0 +`; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Parse an existing hooks.json and hand back the object plus the event array to +// append to, enforcing the shapes both hosts expect. Throws bare reasons; the +// caller wraps them with target/plugin context. +function parseHooksFile( + existing: string, + eventName: string, +): { root: Record; events: unknown[] } { + let root: unknown; + try { + root = JSON.parse(existing); + } catch { + throw new Error("not valid JSON"); + } + if (!isPlainObject(root)) { + throw new Error("must be a JSON object"); + } + const hooks = root.hooks ?? {}; + if (!isPlainObject(hooks)) { + throw new Error('"hooks" must be an object'); + } + const events = hooks[eventName] ?? []; + if (!Array.isArray(events)) { + throw new Error(`"hooks.${eventName}" must be an array`); + } + root.hooks = hooks; + hooks[eventName] = events; + return { root, events }; +} + +export function mergeClaudeHooks(existing: string | undefined): string { + if (existing?.includes(SCRIPT_MARKER)) { + return existing; + } + const { root, events } = parseHooksFile(existing ?? "{}", "SessionStart"); + events.push({ + matcher: "startup|resume", + hooks: [ + { + type: "command", + command: `sh "\${CLAUDE_PLUGIN_ROOT}/${UPDATE_CHECK_SCRIPT_PATH}"`, + timeout: 15, + }, + ], + }); + return json(root); +} + +export function mergeCursorHooks(existing: string | undefined): string { + if (existing?.includes(SCRIPT_MARKER)) { + return existing; + } + const { root, events } = parseHooksFile( + existing ?? '{ "version": 1 }', + "sessionStart", + ); + root.version ??= 1; + events.push({ + command: `sh ./${UPDATE_CHECK_SCRIPT_PATH}`, + timeout: 15, + }); + return json(root); +} + +// Inject the generated script and hook registration into a plugin's collected +// files (paths relative to the plugin root), merging with a source-authored +// hooks/hooks.json when present. +export function applyUpdateCheck( + pluginFiles: Map, + target: string, + options: UpdateCheckOptions, +): void { + if (pluginFiles.has(UPDATE_CHECK_SCRIPT_PATH)) { + throw new Error( + `Target "${target}" plugin "${options.pluginName}" already contains ` + + `"${UPDATE_CHECK_SCRIPT_PATH}", which collides with the generated update-check script.`, + ); + } + const existingValue = pluginFiles.get(HOOKS_FILE_PATH); + const existing = + existingValue === undefined + ? undefined + : typeof existingValue === "string" + ? existingValue + : existingValue.toString("utf8"); + const merge = + options.format === "claude" ? mergeClaudeHooks : mergeCursorHooks; + let merged: string; + try { + merged = merge(existing); + } catch (error) { + throw new Error( + `Target "${target}" plugin "${options.pluginName}" has an invalid ` + + `"${HOOKS_FILE_PATH}" (${error instanceof Error ? error.message : String(error)}); ` + + `cannot merge the generated update-check hook.`, + ); + } + pluginFiles.set(HOOKS_FILE_PATH, merged); + pluginFiles.set(UPDATE_CHECK_SCRIPT_PATH, renderUpdateCheckScript(options)); +} diff --git a/src/validate.ts b/src/validate.ts index 3915346..94e51d3 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -158,6 +158,7 @@ export async function validateCursor( issues, ); await validateFrontmatter(pluginDir, pluginName, "cursor", issues); + await validateHooks(pluginDir, pluginName, issues); } } @@ -537,12 +538,65 @@ async function validateHooks( `${pluginName} hooks/hooks.json`, issues, ); - if (hooks && (!hooks.hooks || typeof hooks.hooks !== "object")) { + if (!hooks) { + return; + } + if ( + !hooks.hooks || + typeof hooks.hooks !== "object" || + Array.isArray(hooks.hooks) + ) { error( issues, `${pluginName}: hooks/hooks.json must have a "hooks" object.`, ); + return; + } + for (const [event, entries] of Object.entries( + hooks.hooks as Record, + )) { + if (!Array.isArray(entries)) { + error( + issues, + `${pluginName}: hooks/hooks.json "hooks.${event}" must be an array.`, + ); + } } + for (const command of collectCommandStrings(hooks.hooks)) { + if (!command) { + error( + issues, + `${pluginName}: hooks/hooks.json has an empty "command" string.`, + ); + continue; + } + // A command referencing the generated update-check script must ship it. + if ( + command.includes("pluginpack-update-check.sh") && + !(await exists( + path.join(pluginDir, "scripts", "pluginpack-update-check.sh"), + )) + ) { + error( + issues, + `${pluginName}: hooks/hooks.json references scripts/pluginpack-update-check.sh but the script is missing.`, + ); + } + } +} + +// All string values under a "command" key, at any depth (Claude nests command +// entries under matcher groups; Cursor keeps them at the event level). +function collectCommandStrings(value: unknown): string[] { + if (Array.isArray(value)) { + return value.flatMap(collectCommandStrings); + } + if (value && typeof value === "object") { + const object = value as Record; + const own = typeof object.command === "string" ? [object.command] : []; + return [...own, ...Object.values(object).flatMap(collectCommandStrings)]; + } + return []; } async function readJson( From e5bc7bf04c0b69ad2e39b558072d838e31badc23 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 25 Jul 2026 14:58:27 -0700 Subject: [PATCH 2/4] test: cover update-check generation, merging, and script behavior - core.test.ts: build-level coverage for hook injection, per-plugin version overrides, merging into a source-authored hooks.json, and the reject paths (unsupported target, missing repository, script path collision, invalid hooks.json). - update-check.test.ts: unit tests for the hooks.json merge helpers, and end-to-end execution of the generated script under /bin/sh with a git shim, covering the throttle/cache, opt-out env vars, offline fallback, and semver tag filtering. - conformance.test.ts: enables updateCheck in the fixture config so the emitted cursor `hooks` manifest key and hooks.json validate against the existing oracles. Co-Authored-By: Claude Fable 5 --- tests/conformance.test.ts | 28 ++++ tests/core.test.ts | 282 +++++++++++++++++++++++++++++++++++++ tests/update-check.test.ts | 262 ++++++++++++++++++++++++++++++++++ 3 files changed, 572 insertions(+) create mode 100644 tests/update-check.test.ts diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index d3bb798..9b8be7d 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -91,15 +91,18 @@ const CONFIG = `export default { owner: { name: "Glean" }, author: { name: "Glean" }, license: "MIT", + repository: "https://github.com/gleanwork/plugins", keywords: ["glean", "enterprise-search"] }, targets: { cursor: { outDir: "out-cursor", + updateCheck: {}, plugins: { glean: { from: ["glean"], path: "glean", components: ["skills"] } } }, claude: { outDir: "out-claude", + updateCheck: {}, plugins: { glean: { from: ["glean"] } } }, copilot: { @@ -184,6 +187,17 @@ describe("emitted output conforms to external target schemas", () => { expect(schemaErrors(cursorPluginSchema, plugin)).toEqual([]); expect(plugin.mcpServers).toBe("./.mcp.json"); + // updateCheck: the schema-validated manifest points at the generated hooks + // dir, and the emitted hooks.json registers the sessionStart command. + expect(plugin.hooks).toBe("./hooks/"); + const hooks = readJson( + project.baseDir, + "out-cursor/glean/hooks/hooks.json", + ); + expect( + (hooks.hooks as { sessionStart: { command: string }[] }).sessionStart[0] + .command, + ).toContain("pluginpack-update-check.sh"); // Cursor tolerates a top-level marketplace `version` at runtime: the published // schema is stricter than reality (gleanwork/cursor-plugins ships `version` and // is live in Cursor's marketplace). Any OTHER unexpected key still fails here. @@ -220,6 +234,20 @@ describe("emitted output conforms to external target schemas", () => { expect(plugin).toMatchObject({ name: "glean", version: "2.1.1" }); expect(typeof plugin.description).toBe("string"); expect(plugin.author).toMatchObject({ name: "Glean" }); + + // updateCheck: Claude discovers hooks/hooks.json by convention. + const hooks = readJson( + project.baseDir, + "out-claude/plugins/glean/hooks/hooks.json", + ); + const sessionStart = ( + hooks.hooks as { + SessionStart: { hooks: { command: string }[] }[]; + } + ).SessionStart; + expect(sessionStart[0].hooks[0].command).toContain( + "${CLAUDE_PLUGIN_ROOT}/scripts/pluginpack-update-check.sh", + ); }); it.skipIf(!hasClaude)( diff --git a/tests/core.test.ts b/tests/core.test.ts index 041fc76..e934c68 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -721,6 +721,288 @@ export default defineConfig({ ); }); + 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")}"; + +export default defineConfig({ + name: "uc-plugins", + version: "1.0.0", + metadata: { + description: "UC", + author: { name: "X" }, + license: "MIT", + repository: "https://github.com/example/uc-plugins" + }, + targets: { + cursor: { + outDir: "dist/cursor", + updateCheck: {}, + plugins: { + demo: { from: ["demo"] }, + optout: { from: ["demo"], updateCheck: false } + } + }, + claude: { + outDir: "dist/claude", + updateCheck: { repository: "https://github.com/example/claude-repo" }, + plugins: { demo: { from: ["demo"], version: "3.0.0" } } + } + } +}); +`, + plugins: { + demo: { + skills: { + demo: { + "SKILL.md": skill("demo", "Demo skill."), + }, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root }); + + // cursor: script + hooks.json + manifest pointer, repo from metadata. + const cursorScript = await readFile( + path.join(root, "dist/cursor/demo/scripts/pluginpack-update-check.sh"), + "utf8", + ); + expect(cursorScript).toContain("INSTALLED='1.0.0'"); + expect(cursorScript).toContain( + "REPO_URL='https://github.com/example/uc-plugins'", + ); + expect(cursorScript).toContain("additional_context"); + const cursorHooks = JSON.parse( + await readFile( + path.join(root, "dist/cursor/demo/hooks/hooks.json"), + "utf8", + ), + ) as { + version: number; + hooks: { sessionStart: { command: string; timeout: number }[] }; + }; + expect(cursorHooks.version).toBe(1); + expect(cursorHooks.hooks.sessionStart).toEqual([ + { command: "sh ./scripts/pluginpack-update-check.sh", timeout: 15 }, + ]); + const cursorManifest = JSON.parse( + await readFile( + path.join(root, "dist/cursor/demo/.cursor-plugin/plugin.json"), + "utf8", + ), + ) as Record; + expect(cursorManifest.hooks).toBe("./hooks/"); + + // per-plugin opt-out gets no injected files. + await expectMissing( + path.join(root, "dist/cursor/optout/scripts/pluginpack-update-check.sh"), + ); + await expectMissing(path.join(root, "dist/cursor/optout/hooks/hooks.json")); + + // claude: per-plugin version and per-target repository win; user-visible + // systemMessage nudge; command routes through CLAUDE_PLUGIN_ROOT. + const claudeScript = await readFile( + path.join( + root, + "dist/claude/plugins/demo/scripts/pluginpack-update-check.sh", + ), + "utf8", + ); + expect(claudeScript).toContain("INSTALLED='3.0.0'"); + expect(claudeScript).toContain( + "REPO_URL='https://github.com/example/claude-repo'", + ); + expect(claudeScript).toContain("systemMessage"); + const claudeHooks = JSON.parse( + await readFile( + path.join(root, "dist/claude/plugins/demo/hooks/hooks.json"), + "utf8", + ), + ) as { + hooks: { + SessionStart: { + matcher: string; + hooks: { type: string; command: string; timeout: number }[]; + }[]; + }; + }; + expect(claudeHooks.hooks.SessionStart).toEqual([ + { + matcher: "startup|resume", + hooks: [ + { + type: "command", + command: + 'sh "${CLAUDE_PLUGIN_ROOT}/scripts/pluginpack-update-check.sh"', + timeout: 15, + }, + ], + }, + ]); + + await expect( + validateOutput("cursor", path.join(root, "dist/cursor")), + ).resolves.toMatchObject({ ok: true }); + await expect( + validateOutput("claude", path.join(root, "dist/claude")), + ).resolves.toMatchObject({ ok: true }); + }); + + it("merges the update-check hook into a source-authored hooks.json", async () => { + const existingHooks = { + description: "Source hooks", + hooks: { + PostToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "echo post" }], + }, + ], + SessionStart: [{ hooks: [{ type: "command", command: "echo start" }] }], + }, + }; + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "uc-merge", + version: "1.0.0", + metadata: { + description: "UC", + author: { name: "X" }, + license: "MIT", + repository: "https://github.com/example/uc-merge" + }, + targets: { + claude: { + outDir: "dist/claude", + updateCheck: {}, + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + plugins: { + demo: { + hooks: { + "hooks.json": `${JSON.stringify(existingHooks, null, 2)}\n`, + }, + skills: { + demo: { + "SKILL.md": skill("demo", "Demo skill."), + }, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + const merged = JSON.parse( + await readFile( + path.join(root, "dist/claude/plugins/demo/hooks/hooks.json"), + "utf8", + ), + ) as { + description: string; + hooks: { + PostToolUse: unknown[]; + SessionStart: { hooks: { command: string }[] }[]; + }; + }; + expect(merged.description).toBe("Source hooks"); + expect(merged.hooks.PostToolUse).toEqual(existingHooks.hooks.PostToolUse); + expect(merged.hooks.SessionStart).toHaveLength(2); + expect(merged.hooks.SessionStart[0]).toEqual( + existingHooks.hooks.SessionStart[0], + ); + expect(merged.hooks.SessionStart[1].hooks[0].command).toContain( + "pluginpack-update-check.sh", + ); + }); + + it("rejects update-check conflicts and unsupported configurations", async () => { + const configFor = ( + targets: string, + ) => `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "uc-bad", + version: "1.0.0", + metadata: { description: "UC", author: { name: "X" }, license: "MIT" }, + targets: { ${targets} } +}); +`; + const demoPlugin = { + skills: { + demo: { + "SKILL.md": skill("demo", "Demo skill."), + }, + }, + }; + + // updateCheck on a target without hook support fails at config load. + let project = await fixtureProject({ + "pluginpack.config.ts": configFor( + `copilot: { outDir: "dist/copilot", updateCheck: {}, plugins: { demo: { from: ["demo"] } } }`, + ), + plugins: { demo: demoPlugin }, + }); + await expect(build({ cwd: project.baseDir })).rejects.toThrow( + /only supported for the claude and cursor targets/, + ); + + // No repository anywhere → build fails fast. + await project.dispose(); + project = await fixtureProject({ + "pluginpack.config.ts": configFor( + `claude: { outDir: "dist/claude", updateCheck: {}, plugins: { demo: { from: ["demo"] } } }`, + ), + plugins: { demo: demoPlugin }, + }); + await expect(build({ cwd: project.baseDir })).rejects.toThrow( + /updateCheck requires a repository/, + ); + + // A source plugin shipping the reserved script path collides. + await project.dispose(); + project = await fixtureProject({ + "pluginpack.config.ts": configFor( + `claude: { outDir: "dist/claude", updateCheck: { repository: "https://github.com/example/r" }, plugins: { demo: { from: ["demo"] } } }`, + ), + plugins: { + demo: { + ...demoPlugin, + scripts: { "pluginpack-update-check.sh": "#!/bin/sh\n" }, + }, + }, + }); + await expect(build({ cwd: project.baseDir })).rejects.toThrow( + /collides with the generated update-check script/, + ); + + // Invalid source hooks.json fails loud at build time. + await project.dispose(); + project = await fixtureProject({ + "pluginpack.config.ts": configFor( + `claude: { outDir: "dist/claude", updateCheck: { repository: "https://github.com/example/r" }, plugins: { demo: { from: ["demo"] } } }`, + ), + plugins: { + demo: { + ...demoPlugin, + hooks: { "hooks.json": "{ not json" }, + }, + }, + }); + await expect(build({ cwd: project.baseDir })).rejects.toThrow( + /invalid "hooks\/hooks\.json" \(not valid JSON\)/, + ); + }); + it("detects cross-target output path collisions", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts new file mode 100644 index 0000000..487bd7b --- /dev/null +++ b/tests/update-check.test.ts @@ -0,0 +1,262 @@ +import { execFile } from "node:child_process"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + mergeClaudeHooks, + mergeCursorHooks, + renderUpdateCheckScript, +} from "../src/update-check.js"; + +const run = promisify(execFile); + +describe("update-check hooks.json merging", () => { + it("creates a claude hooks.json from scratch", () => { + const merged = JSON.parse(mergeClaudeHooks(undefined)) as { + hooks: { + SessionStart: { + matcher: string; + hooks: { type: string; command: string; timeout: number }[]; + }[]; + }; + }; + expect(merged.hooks.SessionStart).toHaveLength(1); + expect(merged.hooks.SessionStart[0].matcher).toBe("startup|resume"); + expect(merged.hooks.SessionStart[0].hooks[0]).toMatchObject({ + type: "command", + command: 'sh "${CLAUDE_PLUGIN_ROOT}/scripts/pluginpack-update-check.sh"', + timeout: 15, + }); + }); + + it("appends to an existing claude hooks.json, preserving other content", () => { + const existing = JSON.stringify({ + description: "mine", + hooks: { + PostToolUse: [{ hooks: [{ type: "command", command: "echo x" }] }], + SessionStart: [{ hooks: [{ type: "command", command: "echo y" }] }], + }, + }); + const merged = JSON.parse(mergeClaudeHooks(existing)) as { + description: string; + hooks: { PostToolUse: unknown[]; SessionStart: unknown[] }; + }; + expect(merged.description).toBe("mine"); + expect(merged.hooks.PostToolUse).toHaveLength(1); + expect(merged.hooks.SessionStart).toHaveLength(2); + }); + + it("is idempotent once the update-check command is present", () => { + const once = mergeClaudeHooks(undefined); + expect(mergeClaudeHooks(once)).toBe(once); + const cursorOnce = mergeCursorHooks(undefined); + expect(mergeCursorHooks(cursorOnce)).toBe(cursorOnce); + }); + + it("creates a cursor hooks.json with version and sessionStart", () => { + const merged = JSON.parse(mergeCursorHooks(undefined)) as { + version: number; + hooks: { sessionStart: { command: string; timeout: number }[] }; + }; + expect(merged.version).toBe(1); + expect(merged.hooks.sessionStart).toEqual([ + { command: "sh ./scripts/pluginpack-update-check.sh", timeout: 15 }, + ]); + }); + + it("preserves an existing cursor version and events", () => { + const existing = JSON.stringify({ + version: 2, + hooks: { beforeShellExecution: [{ command: "./guard.sh" }] }, + }); + const merged = JSON.parse(mergeCursorHooks(existing)) as { + version: number; + hooks: { beforeShellExecution: unknown[]; sessionStart: unknown[] }; + }; + expect(merged.version).toBe(2); + expect(merged.hooks.beforeShellExecution).toHaveLength(1); + expect(merged.hooks.sessionStart).toHaveLength(1); + }); + + it("rejects malformed hooks files", () => { + expect(() => mergeClaudeHooks("{ nope")).toThrow(/not valid JSON/); + expect(() => mergeClaudeHooks("[]")).toThrow(/must be a JSON object/); + expect(() => mergeClaudeHooks('{ "hooks": [] }')).toThrow( + /"hooks" must be an object/, + ); + expect(() => + mergeCursorHooks('{ "hooks": { "sessionStart": {} } }'), + ).toThrow(/"hooks.sessionStart" must be an array/); + }); +}); + +describe("generated update-check script", () => { + let tmp: string; + let scriptDir: string; + let shimDir: string; + let baseEnv: Record; + + const TAGS = [ + "aaa\trefs/tags/v0.9.0", + "bbb\trefs/tags/v1.2.0", + "ccc\trefs/tags/v1.2.0-rc.1", + "ddd\trefs/tags/nonsense", + "", + ].join("\n"); + + beforeEach(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), "pluginpack-uc-")); + scriptDir = path.join(tmp, "plugin"); + shimDir = path.join(tmp, "bin"); + await mkdir(scriptDir, { recursive: true }); + await mkdir(shimDir, { recursive: true }); + await mkdir(path.join(tmp, "home"), { recursive: true }); + // git shim: log the call, replay canned ls-remote output (fails without it). + const shim = path.join(shimDir, "git"); + await writeFile( + shim, + `#!/bin/sh\nprintf 'called\\n' >> "$GIT_SHIM_LOG"\nexec cat "$GIT_SHIM_TAGS"\n`, + ); + await chmod(shim, 0o755); + await writeFile(path.join(tmp, "tags"), TAGS); + baseEnv = { + PATH: `${shimDir}:/usr/bin:/bin`, + HOME: path.join(tmp, "home"), + XDG_CACHE_HOME: path.join(tmp, "cache"), + GIT_SHIM_LOG: path.join(tmp, "git.log"), + GIT_SHIM_TAGS: path.join(tmp, "tags"), + }; + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + async function writeScript( + format: "claude" | "cursor", + version = "1.0.0", + ): Promise { + const scriptPath = path.join(scriptDir, "pluginpack-update-check.sh"); + await writeFile( + scriptPath, + renderUpdateCheckScript({ + format, + pluginName: "demo", + version, + repository: "https://example.com/repo.git", + }), + ); + return scriptPath; + } + + async function runScript( + scriptPath: string, + env: Record = {}, + ): Promise { + const { stdout } = await run("/bin/sh", [scriptPath], { + env: { ...baseEnv, ...env }, + }); + return stdout; + } + + async function gitCalls(): Promise { + try { + const log = await readFile(baseEnv.GIT_SHIM_LOG, "utf8"); + return log.split("\n").filter(Boolean).length; + } catch { + return 0; + } + } + + it("nudges with a user-visible systemMessage for claude", async () => { + const script = await writeScript("claude"); + const stdout = await runScript(script); + expect(stdout).toBe( + `${JSON.stringify({ + systemMessage: + "Plugin demo v1.2.0 is available (installed v1.0.0). Run /plugin update demo to upgrade.", + })}\n`, + ); + }); + + it("nudges via agent context for cursor", async () => { + const script = await writeScript("cursor"); + const stdout = await runScript(script); + expect(stdout).toBe( + `${JSON.stringify({ + additional_context: + 'The installed Cursor plugin "demo" is outdated (installed v1.0.0, latest v1.2.0). Briefly let the user know they can update it from Cursor\'s plugin settings.', + })}\n`, + ); + }); + + it("stays silent when up to date or ahead of the latest tag", async () => { + let script = await writeScript("claude", "1.2.0"); + expect(await runScript(script)).toBe(""); + script = await writeScript("claude", "2.0.0"); + expect(await runScript(script)).toBe(""); + }); + + it("honors the opt-out env var and CI, without touching the network", async () => { + const script = await writeScript("claude"); + expect(await runScript(script, { PLUGINPACK_NO_UPDATE_CHECK: "1" })).toBe( + "", + ); + expect(await runScript(script, { CI: "true" })).toBe(""); + expect(await gitCalls()).toBe(0); + }); + + it("stays silent when git is unavailable", async () => { + const script = await writeScript("claude"); + expect(await runScript(script, { PATH: path.join(tmp, "empty") })).toBe(""); + }); + + it("stays silent when the fetch fails (offline)", async () => { + const script = await writeScript("claude"); + expect( + await runScript(script, { GIT_SHIM_TAGS: path.join(tmp, "missing") }), + ).toBe(""); + }); + + it("ignores prerelease and non-semver tags", async () => { + await writeFile( + path.join(tmp, "tags"), + "aaa\trefs/tags/v2.0.0-rc.1\nbbb\trefs/tags/release\n", + ); + const script = await writeScript("claude"); + expect(await runScript(script)).toBe(""); + }); + + it("bails on a non-semver installed version without fetching", async () => { + const script = await writeScript("claude", "main"); + expect(await runScript(script)).toBe(""); + expect(await gitCalls()).toBe(0); + }); + + it("throttles to one fetch per day but keeps nudging from cache", async () => { + const script = await writeScript("claude"); + expect(await runScript(script)).toContain("systemMessage"); + expect(await runScript(script)).toContain("systemMessage"); + expect(await gitCalls()).toBe(1); + }); + + it("recovers from a corrupt cache file", async () => { + const script = await writeScript("claude"); + await runScript(script); + const cacheDir = path.join(baseEnv.XDG_CACHE_HOME, "pluginpack"); + const [cacheFile] = await readdir(cacheDir); + await writeFile(path.join(cacheDir, cacheFile), "garbage nonsense\n"); + expect(await runScript(script)).toContain("systemMessage"); + expect(await gitCalls()).toBe(2); + }); +}); From a1dc97c6c0ea9716e606dcb76201c9dcdbc35514 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 25 Jul 2026 14:58:34 -0700 Subject: [PATCH 3/4] docs: document the update-check feature and its upstream hook facts README: new "Update Check" section and config-reference rows for `updateCheck` (target-level) and the per-plugin opt-out. CONFORMANCE.md: record the two upstream hook behaviors the feature relies on (Claude SessionStart systemMessage, Cursor sessionStart additional_context) with sources and verification date. Co-Authored-By: Claude Fable 5 --- CONFORMANCE.md | 23 +++++++++++++++++++++++ README.md | 44 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/CONFORMANCE.md b/CONFORMANCE.md index a9ff02f..e94b9dd 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -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: . +- **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: . + +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 diff --git a/README.md b/README.md index 6c069bd..9c74188 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,28 @@ 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. + ## 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: @@ -371,16 +393,17 @@ To publish a repo-root file (for example a README authored once in the source re **`targets.`** — `` 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..plugins.`**). | -| `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..plugins.`**). | +| `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..plugins.`** @@ -394,6 +417,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 From 5b3a6307c900a50a6638547137d06e3a05828125 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 25 Jul 2026 15:06:48 -0700 Subject: [PATCH 4/4] refactor: address code review findings on the update-check hook - validate.ts: import UPDATE_CHECK_SCRIPT_PATH instead of re-hardcoding the path, so validation can't silently desync from generation. - update-check.ts: collapse the three separate `format === "claude"` branches into one per-format config table, and extract the duplicated per-plugin opt-out check into `pluginAllowsUpdateCheck`. - README: document that the generated hook is wired in independently of a plugin's `components` selection (mirrors the MCP precedent), since Cursor's manifest needs the `hooks` pointer set even when `components` excludes "hooks". - update-check.test.ts: add coverage for the throttle cache being shared across sibling plugins pointing at the same repository. Co-Authored-By: Claude Fable 5 --- README.md | 2 ++ src/targets.ts | 11 ++++--- src/update-check.ts | 63 ++++++++++++++++++++++++++------------ src/validate.ts | 7 ++--- tests/update-check.test.ts | 43 +++++++++++++++++++++----- 5 files changed, 91 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 9c74188..b0fe174 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,8 @@ 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. + ## 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: diff --git a/src/targets.ts b/src/targets.ts index 14fb999..a1fe01d 100644 --- a/src/targets.ts +++ b/src/targets.ts @@ -3,7 +3,7 @@ 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 } from "./update-check.js"; +import { applyUpdateCheck, pluginAllowsUpdateCheck } from "./update-check.js"; import type { UpdateCheckFormat } from "./update-check.js"; import type { Artifact, @@ -112,7 +112,7 @@ async function emitPlugins( ); // Inject before componentDirs is derived so hooks/ and scripts/ register // as components (e.g. the cursor manifest's `hooks` pointer). - if (options.updateCheck && pluginConfig.updateCheck !== false) { + if (options.updateCheck && pluginAllowsUpdateCheck(pluginConfig)) { applyUpdateCheck(pluginFiles, target, { format: options.updateCheck.format, pluginName, @@ -233,9 +233,10 @@ export async function emitCursor( componentDirs, mcpServers, ); - // updateCheck injects hooks/ regardless of the components filter, and - // Cursor only runs hooks the manifest points at. - if (targetConfig.updateCheck && pluginConfig.updateCheck !== false) { + // 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; diff --git a/src/update-check.ts b/src/update-check.ts index 9aa6528..06ef29c 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -1,5 +1,5 @@ import { json } from "./fs.js"; -import type { FileValue } from "./types.js"; +import type { EmittedPluginConfig, FileValue } from "./types.js"; export const UPDATE_CHECK_SCRIPT_PATH = "scripts/pluginpack-update-check.sh"; export const HOOKS_FILE_PATH = "hooks/hooks.json"; @@ -17,6 +17,16 @@ export type UpdateCheckOptions = { repository: string; }; +// The per-plugin opt-out gate, shared by both call sites that decide whether +// this plugin gets the generated hook (file injection in emitPlugins, and the +// manifest's `hooks` pointer in emitCursor) — callers still check their own +// target-level `updateCheck` presence first (for type-narrowing convenience). +export function pluginAllowsUpdateCheck( + pluginConfig: Pick, +): boolean { + return pluginConfig.updateCheck !== false; +} + // POSIX single-quote: close, escaped quote, reopen. function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; @@ -28,32 +38,49 @@ function printfEscape(value: string): string { return value.replaceAll("%", "%%"); } +// Everything that varies by target format, in one place — adding a third +// format means adding one entry here, not hunting down every `=== "claude"`. +type FormatConfig = { + mergeHooks: (existing: string | undefined) => string; + // Shell expression supplying printf's args, in the order this format's + // payload() below expects them (claude: latest, installed; cursor: the reverse). + nudgeArgs: string; + // The nudge payload with printf's two %s slots left as literal placeholders. + payload: (name: string) => Record; +}; + +const FORMATS: Record = { + claude: { + mergeHooks: mergeClaudeHooks, + nudgeArgs: '"$latest" "$INSTALLED"', + // systemMessage is shown directly to the user by Claude Code. + payload: (name) => ({ + systemMessage: `Plugin ${name} v%s is available (installed v%s). Run /plugin update ${name} to upgrade.`, + }), + }, + cursor: { + mergeHooks: mergeCursorHooks, + nudgeArgs: '"$INSTALLED" "$latest"', + // Cursor's sessionStart hook has no user-visible output; ask the agent to relay it. + payload: (name) => ({ + additional_context: `The installed Cursor plugin "${name}" is outdated (installed v%s, latest v%s). Briefly let the user know they can update it from Cursor's plugin settings.`, + }), + }, +}; + // The nudge is a printf format string: JSON payload with two %s slots, plus a // trailing newline. printf interprets backslash escapes in its format string, // so JSON's own escapes (e.g. \") must be doubled to survive, while the // appended \n is left single so printf renders the newline. function nudgeFormat(format: UpdateCheckFormat, pluginName: string): string { - const name = printfEscape(pluginName); - const payload = - format === "claude" - ? // systemMessage is shown directly to the user by Claude Code. - // Args: latest, installed. - { - systemMessage: `Plugin ${name} v%s is available (installed v%s). Run /plugin update ${name} to upgrade.`, - } - : // Cursor sessionStart has no user-visible output; ask the agent to relay. - // Args: installed, latest. - { - additional_context: `The installed Cursor plugin "${name}" is outdated (installed v%s, latest v%s). Briefly let the user know they can update it from Cursor's plugin settings.`, - }; + const payload = FORMATS[format].payload(printfEscape(pluginName)); const literal = JSON.stringify(payload).replaceAll("\\", "\\\\"); return shellQuote(`${literal}\\n`); } export function renderUpdateCheckScript(options: UpdateCheckOptions): string { const { format, pluginName, version, repository } = options; - const nudgeArgs = - format === "claude" ? '"$latest" "$INSTALLED"' : '"$INSTALLED" "$latest"'; + const nudgeArgs = FORMATS[format].nudgeArgs; return `#!/bin/sh # Generated by pluginpack — session-start update check for ${JSON.stringify(pluginName)}. # Compares the installed version against the latest git tag of the plugin repo, @@ -194,11 +221,9 @@ export function applyUpdateCheck( : typeof existingValue === "string" ? existingValue : existingValue.toString("utf8"); - const merge = - options.format === "claude" ? mergeClaudeHooks : mergeCursorHooks; let merged: string; try { - merged = merge(existing); + merged = FORMATS[options.format].mergeHooks(existing); } catch (error) { throw new Error( `Target "${target}" plugin "${options.pluginName}" has an invalid ` + diff --git a/src/validate.ts b/src/validate.ts index 94e51d3..2af3784 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -8,6 +8,7 @@ import { toPosix, walkFiles, } from "./fs.js"; +import { UPDATE_CHECK_SCRIPT_PATH } from "./update-check.js"; import type { TargetName, ValidationIssue } from "./types.js"; const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; @@ -572,10 +573,8 @@ async function validateHooks( } // A command referencing the generated update-check script must ship it. if ( - command.includes("pluginpack-update-check.sh") && - !(await exists( - path.join(pluginDir, "scripts", "pluginpack-update-check.sh"), - )) + command.includes(UPDATE_CHECK_SCRIPT_PATH) && + !(await exists(path.join(pluginDir, UPDATE_CHECK_SCRIPT_PATH))) ) { error( issues, diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts index 487bd7b..912f536 100644 --- a/tests/update-check.test.ts +++ b/tests/update-check.test.ts @@ -146,15 +146,21 @@ describe("generated update-check script", () => { format: "claude" | "cursor", version = "1.0.0", ): Promise { - const scriptPath = path.join(scriptDir, "pluginpack-update-check.sh"); + return writePluginScript("demo", scriptDir, format, version); + } + + async function writePluginScript( + pluginName: string, + dir: string, + format: "claude" | "cursor", + version = "1.0.0", + repository = "https://example.com/repo.git", + ): Promise { + await mkdir(dir, { recursive: true }); + const scriptPath = path.join(dir, "pluginpack-update-check.sh"); await writeFile( scriptPath, - renderUpdateCheckScript({ - format, - pluginName: "demo", - version, - repository: "https://example.com/repo.git", - }), + renderUpdateCheckScript({ format, pluginName, version, repository }), ); return scriptPath; } @@ -250,6 +256,29 @@ describe("generated update-check script", () => { expect(await gitCalls()).toBe(1); }); + it("shares the throttle cache across sibling plugins from the same repo", async () => { + // Two different plugins, same repository — the cache key is derived from + // the repo URL, so the second plugin's first run should ride the first + // plugin's cache instead of fetching again. + const scriptA = await writePluginScript( + "demo-a", + path.join(tmp, "plugin-a"), + "claude", + ); + const scriptB = await writePluginScript( + "demo-b", + path.join(tmp, "plugin-b"), + "claude", + ); + const stdoutA = await runScript(scriptA); + expect(stdoutA).toContain("Plugin demo-a"); + expect(await gitCalls()).toBe(1); + + const stdoutB = await runScript(scriptB); + expect(stdoutB).toContain("Plugin demo-b"); + expect(await gitCalls()).toBe(1); + }); + it("recovers from a corrupt cache file", async () => { const script = await writeScript("claude"); await runScript(script);