diff --git a/CLAUDE.md b/CLAUDE.md index 2d23daa..e8e7879 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,25 +37,35 @@ diff, prune, and validate all derive from it. generated output is never misread as source), and the root-skills plugin. - `src/render.ts` — `collectPluginFiles` (component dirs + static files, with `targets//` override resolution) and `resolveMcpServers`. -- `src/targets.ts` — `emitTarget` + per-target emitters. `cursor`/`claude`/ - `antigravity` share the `emitPlugins` engine via callbacks; `emitCopilot` is - bespoke (no per-plugin manifest, dual marketplace). Manifest builders live here - too. +- `src/targets/registry.ts` — `targets: Record`, + one file per target (`src/targets/.ts`). Everything that varies by + target — default components, manifest/marketplace builders, output paths, + validation, install snippet — lives on that target's own + `PluginTargetDefinition` (`src/targets/types.ts`). +- `src/targets/engine.ts` — `emitFromDefinition`/`validateFromDefinition`: the + one emit/validate engine every target runs through, driven by its + `PluginTargetDefinition`. Also `withRootFiles` (injects per-target + repo-root files into the artifact). +- `src/targets/validation-shared.ts` — validators shared across targets whose + shape actually matches (bare-string marketplace `source`, hooks.json shape, + frontmatter conventions); a target with a genuinely different shape (e.g. + Codex's structured `source`) writes its own instead of forcing a fit. +- `src/adapters.ts` — `emitTarget`/`validateOutput`/`targetNames`, thin + wrappers around the registry + engine. - `src/build.ts` — `build()`: emit all targets → `assertNoCrossTargetCollisions` → write/prune/manifest. Holds the delete guard. - `src/managed.ts` — the managed-file manifest (`.pluginpack/.json`), `prune`/`clean`, the delete guard, and path-safety checks. - `src/diff.ts` — `diffTarget`: build to a temp dir and compare against an existing target repo (the CI staleness gate). -- `src/validate.ts` — per-target output validation. ## Targets -`cursor`, `claude`, `antigravity`, `copilot`. Adding a target currently touches ~5 -places: the `TargetName` union (`types.ts`), the `targets` array + `parseTarget` -(`cli.ts`), `allTargets` (`build.ts`), the `emitters` map + a new `emitFoo` -(`targets.ts`), and a branch + `validateFoo` (`validate.ts`). If you are adding a -target, consider introducing a single target registry first to localize this. +`copilot`, `antigravity`, `cursor`, `claude`, `codex`. Adding a target means one +new file implementing `PluginTargetDefinition` (`src/targets/.ts`) plus one +new entry in `src/targets/registry.ts` — `TargetName` (`types.ts`) is still a +separate union to extend, but everything else (CLI `--target` choices, `build()`'s +target set, emit/validate dispatch) derives from the registry automatically. ## Conformance @@ -80,6 +90,7 @@ schemas at runtime — vendor a pinned copy with recorded provenance. ## Conventions -- Strict TypeScript, no `any` (the one exception is `readJson` in `validate.ts`). +- Strict TypeScript, no `any` (the one exception is `readJson` in + `src/targets/validation-shared.ts`). - Prettier + eslint enforced by the gate. - Conventional commits. Keep the README CLI reference regenerated. diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 80f1a66..fb0a764 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -90,6 +90,12 @@ 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). +Every target's `validateOutput` shares one `validateHooksShape` +(`src/targets/validation-shared.ts`) for the parts of a hooks file that are +target-agnostic: each event's entries must be an array, no `command` string +may be empty, and any command referencing the generated update-check script +(`scripts/pluginpack-update-check.sh`) must ship that script. + ## 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 b0fe174..576e6dd 100644 --- a/README.md +++ b/README.md @@ -481,7 +481,7 @@ Exit codes: Compile configured source plugins into target-native plugin payloads. ```bash -pluginpack build [--target cursor|claude|antigravity|copilot|codex] [--out-dir ] [--dry-run] +pluginpack build [--target copilot|antigravity|cursor|claude|codex] [--out-dir ] [--dry-run] ``` Options: @@ -506,7 +506,7 @@ Exit codes: Validate an existing target output directory for native manifest, path, and frontmatter requirements. ```bash -pluginpack validate --target cursor|claude|antigravity|copilot|codex [--dir ] +pluginpack validate --target copilot|antigravity|cursor|claude|codex [--dir ] ``` Options: @@ -528,7 +528,7 @@ Exit codes: Build into a temporary directory and compare generated managed files with an existing target repo. ```bash -pluginpack diff --target cursor|claude|antigravity|copilot|codex --against +pluginpack diff --target copilot|antigravity|cursor|claude|codex --against ``` Options: @@ -550,7 +550,7 @@ Exit codes: Remove stale managed files that are no longer emitted by the current config. ```bash -pluginpack prune [--target cursor|claude|antigravity|copilot|codex] [--dry-run] +pluginpack prune [--target copilot|antigravity|cursor|claude|codex] [--dry-run] ``` Options: @@ -574,7 +574,7 @@ Exit codes: Remove all managed files for configured target outputs. ```bash -pluginpack clean [--target cursor|claude|antigravity|copilot|codex] [--dry-run] +pluginpack clean [--target copilot|antigravity|cursor|claude|codex] [--dry-run] ``` Options: diff --git a/src/adapters.ts b/src/adapters.ts index 2015a9d..52c3a7c 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -1,97 +1,20 @@ import path from "node:path"; -import { - emitAntigravity, - emitClaude, - emitCodex, - emitCopilot, - emitCursor, - withRootFiles, -} from "./targets.js"; -import { - validateAntigravity, - validateClaude, - validateCodex, - validateCopilot, - validateCursor, -} from "./validate.js"; import { emitFromDefinition, validateFromDefinition, + withRootFiles, } from "./targets/engine.js"; import { targets as registry } from "./targets/registry.js"; import type { Artifact, ResolvedProject, - TargetConfig, TargetName, ValidationIssue, ValidationResult, } from "./types.js"; -type TargetEmitter = ( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - outDir: string, -) => Promise; - -type TargetValidator = ( - root: string, - issues: ValidationIssue[], -) => Promise; - -/** The emit and validate functions for one target. */ -export type TargetAdapter = { - emit: TargetEmitter; - validate: TargetValidator; -}; - -// Legacy per-target functions, used only for targets not yet migrated to -// src/targets/registry.ts. Delete this map (and ../targets.ts/../validate.ts's -// per-target functions) once every TargetName has a registry entry. -const legacyAdapters: Record = { - cursor: { emit: emitCursor, validate: validateCursor }, - claude: { emit: emitClaude, validate: validateClaude }, - antigravity: { emit: emitAntigravity, validate: validateAntigravity }, - copilot: { emit: emitCopilot, validate: validateCopilot }, - codex: { emit: emitCodex, validate: validateCodex }, -}; - -/** - * The one place a target is wired. `Record` is exhaustive at - * compile time — a new `TargetName` won't build until it has an entry here — - * so emit dispatch, validate dispatch, the CLI `--target` choices, and the - * set `build()` iterates all derive from this single source instead of - * parallel maps. - * - * During migration, a target resolves to the new registry - * (`src/targets/*.ts`) if it has an entry there, otherwise falls back to the - * legacy function — this map's shape stays the same either way, so callers - * never notice. - */ -export const adapters: Record = Object.fromEntries( - (Object.keys(legacyAdapters) as TargetName[]).map((target) => { - const definition = registry[target]; - const adapter: TargetAdapter = definition - ? { - emit: (project, targetName, targetConfig, outDir) => - emitFromDefinition( - project, - targetName, - targetConfig, - outDir, - definition, - ), - validate: (root, issues) => - validateFromDefinition(root, issues, definition), - } - : legacyAdapters[target]; - return [target, adapter]; - }), -) as Record; - -/** Every target name with an adapter — the exhaustive list `build()` and the CLI derive from. */ -export const targetNames = Object.keys(adapters) as TargetName[]; +/** Every target name with a registry entry — the exhaustive list `build()` and the CLI derive from. */ +export const targetNames = Object.keys(registry) as TargetName[]; /** Emits one target's output and applies its `rootFiles`, resolving `outDir` from config if omitted. */ export async function emitTarget( @@ -107,11 +30,12 @@ export async function emitTarget( project.rootDir, outDir ?? targetConfig.outDir, ); - const result = await adapters[target].emit( + const result = await emitFromDefinition( project, target, targetConfig, resolvedOutDir, + registry[target], ); return withRootFiles(project, targetConfig, result); } @@ -123,7 +47,7 @@ export async function validateOutput( ): Promise { const root = path.resolve(dir); const issues: ValidationIssue[] = []; - await adapters[target].validate(root, issues); + await validateFromDefinition(root, issues, registry[target]); return { ok: issues.every((issue) => issue.level !== "error"), issues, diff --git a/src/components.ts b/src/components.ts index 45a37ea..f4cb226 100644 --- a/src/components.ts +++ b/src/components.ts @@ -1,5 +1,3 @@ -import type { TargetName } from "./types.js"; - /** Every recognized component directory, across all targets. */ export const componentDirs = [ "skills", @@ -16,23 +14,6 @@ export const componentDirs = [ /** Files copied verbatim to a target's output root rather than treated as components. */ export const staticFiles = ["README.md", "CHANGELOG.md", "LICENSE"]; -/** Component directories emitted for a target when a plugin has no `components` override. */ -export const targetDefaultComponents: Record = { - claude: ["skills", "agents", "hooks", "scripts", "assets"], - copilot: ["skills", "agents", "hooks", "scripts", "assets"], - cursor: ["skills", "agents", "rules", "hooks", "scripts", "assets"], - antigravity: ["skills", "agents", "rules", "hooks", "scripts", "assets"], - codex: ["skills", "hooks", "scripts", "assets"], -}; - -/** Resolves a plugin's component set from its own override, or the target's default. */ -export function resolveTargetComponents( - target: TargetName, - pluginConfig: { components?: string[] }, -): Set { - return new Set(pluginConfig.components ?? targetDefaultComponents[target]); -} - /** Whether a relative path falls under a recognized component directory. */ export function isComponentPath(relativePath: string): boolean { return componentDirs.includes(relativePath.split("/")[0]); diff --git a/src/targets.ts b/src/targets.ts deleted file mode 100644 index 9b490cf..0000000 --- a/src/targets.ts +++ /dev/null @@ -1,707 +0,0 @@ -import { promises as fs } from "node:fs"; -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, - FileValue, - Metadata, - ResolvedProject, - TargetConfig, - TargetName, -} from "./types.js"; - -/** - * Emits per-target repo-root files (e.g. a README authored once in the - * source repo) into the artifact so they are managed, pruned, and synced - * like every other generated file — rather than hand-maintained in each - * output repo. - */ -export async function withRootFiles( - project: ResolvedProject, - targetConfig: TargetConfig, - result: Artifact, -): Promise { - const rootFiles = targetConfig.rootFiles; - if (!rootFiles || Object.keys(rootFiles).length === 0) { - return result; - } - const files = new Map(result.files); - for (const [dest, source] of Object.entries(rootFiles)) { - const destPath = toPosix(dest); - if (!isSafeRelativePath(destPath)) { - throw new Error( - `Target "${result.target}" rootFiles destination "${dest}" must be a safe relative path.`, - ); - } - if (files.has(destPath)) { - throw new Error( - `Target "${result.target}" rootFiles destination "${dest}" collides with a generated file.`, - ); - } - let contents: Buffer; - try { - contents = await fs.readFile(path.resolve(project.rootDir, source)); - } catch { - throw new Error( - `Target "${result.target}" rootFiles source "${source}" could not be read.`, - ); - } - files.set(destPath, contents); - } - return artifact(result.target, result.outDir, files); -} - -type EmitPluginContext = { - pluginName: string; - pluginPath: string; - pluginConfig: EmittedPluginConfig; - metadata: Metadata | undefined; - componentDirs: Set; - mcpServers: Record | undefined; - pluginFiles: Map; - manifest: Record | undefined; -}; - -type EmitPluginsOptions = { - resolvePluginPath: ( - pluginName: string, - pluginConfig: EmittedPluginConfig, - ) => string; - // Some targets (Copilot) carry no per-plugin manifest; omit to skip writing one. - pluginManifest?: { - path: (pluginPath: string) => string; - build: ( - metadata: Metadata | undefined, - pluginName: string, - pluginConfig: EmittedPluginConfig, - componentDirs: Set, - mcpServers: Record | undefined, - ) => Record; - }; - // 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; - }; -}; - -/** Shared per-plugin emit loop every legacy `emitXxx` function delegates to. */ -async function emitPlugins( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - files: Map, - options: EmitPluginsOptions, -): Promise[]> { - const entries: Record[] = []; - for (const [pluginName, pluginConfig] of Object.entries( - targetConfig.plugins, - )) { - const pluginPath = options.resolvePluginPath(pluginName, pluginConfig); - const pluginFiles = await collectPluginFiles( - project, - target, - 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]), - ); - for (const [relativePath, value] of pluginFiles) { - files.set(toPosix(path.join(pluginPath, relativePath)), value); - } - - const mcpServers = await resolveMcpServers(project, pluginConfig.from); - if (mcpServers && options.mcp === "file") { - files.set( - toPosix(path.join(pluginPath, ".mcp.json")), - json({ mcpServers }), - ); - } else if (mcpServers && options.mcp === "antigravity") { - files.set( - toPosix(path.join(pluginPath, "mcp_config.json")), - json({ mcpServers }), - ); - } - - const metadata = emittedPluginMetadata(project, pluginConfig); - let manifest: Record | undefined; - if (options.pluginManifest) { - manifest = options.pluginManifest.build( - metadata, - pluginName, - pluginConfig, - componentDirs, - mcpServers, - ); - files.set( - toPosix(options.pluginManifest.path(pluginPath)), - json(manifest), - ); - } - - const entry = options.buildEntry?.({ - pluginName, - pluginPath, - pluginConfig, - metadata, - componentDirs, - mcpServers, - pluginFiles, - manifest, - }); - if (entry) { - // Deep-merge the config's per-plugin entry passthrough so a target can - // carry author-supplied fields it can't derive (e.g. Codex policy/category). - entries.push(stripUndefined(deepMerge(entry, pluginConfig.entry ?? {}))); - } - } - return entries; -} - -/** - * Resolves 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, - }; -} - -/** Emits the Cursor target's plugins and marketplace manifest. */ -export async function emitCursor( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - outDir: string, -): Promise { - const marketplaceDir = targetConfig.marketplaceDir ?? ".cursor-plugin"; - const version = targetConfig.version ?? project.config.version; - const files = new Map(); - - const plugins = await emitPlugins(project, target, targetConfig, files, { - resolvePluginPath: (pluginName, pluginConfig) => - pluginConfig.path ?? pluginName, - pluginManifest: { - path: (pluginPath) => - path.join(pluginPath, marketplaceDir, "plugin.json"), - 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, - source: pluginPath, - description: - pluginConfig.description ?? - (manifest?.description as string | undefined), - }), - mcp: "file", - updateCheck: updateCheckOption( - project, - target, - targetConfig, - "cursor", - version, - ), - }); - - const marketplace = stripUndefined( - deepMerge( - { - name: project.config.name, - owner: - project.config.metadata?.owner ?? project.config.metadata?.author, - metadata: { - description: project.config.metadata?.description, - keywords: project.config.metadata?.keywords, - }, - plugins, - version, - }, - targetConfig.manifest ?? {}, - ), - ); - files.set( - toPosix(path.join(marketplaceDir, "marketplace.json")), - json(marketplace), - ); - - return artifact(target, outDir, files); -} - -/** - * @deprecated Legacy emitter, superseded by `src/targets/claude.ts` via the - * registry in `src/targets/registry.ts`. Kept only until every target has - * migrated (see `src/adapters.ts`). - */ -export async function emitClaude( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - outDir: string, -): Promise { - const marketplaceDir = targetConfig.marketplaceDir ?? ".claude-plugin"; - const pluginRoot = targetConfig.pluginRoot ?? "plugins"; - const version = targetConfig.version ?? project.config.version; - const files = new Map(); - - const plugins = await emitPlugins(project, target, targetConfig, files, { - resolvePluginPath: (pluginName, pluginConfig) => - pluginConfig.path ?? toPosix(path.join(pluginRoot, pluginName)), - pluginManifest: { - path: (pluginPath) => - path.join(pluginPath, marketplaceDir, "plugin.json"), - build: (metadata, pluginName, pluginConfig) => - claudePluginManifest( - metadata, - pluginConfig.version ?? version, - pluginName, - pluginConfig, - ), - }, - buildEntry: ({ pluginName, pluginPath, pluginConfig, manifest }) => ({ - name: pluginName, - source: `./${pluginPath}`, - description: - pluginConfig.description ?? - (manifest?.description as string | undefined), - }), - mcp: "file", - updateCheck: updateCheckOption( - project, - target, - targetConfig, - "claude", - version, - ), - }); - - const marketplace = stripUndefined( - deepMerge( - { - $schema: "https://anthropic.com/claude-code/marketplace.schema.json", - name: project.config.name, - version, - description: project.config.metadata?.description, - owner: - project.config.metadata?.owner ?? project.config.metadata?.author, - plugins, - }, - targetConfig.manifest ?? {}, - ), - ); - files.set( - toPosix(path.join(marketplaceDir, "marketplace.json")), - json(marketplace), - ); - - return artifact(target, outDir, files); -} - -/** - * @deprecated Legacy emitter, superseded by `src/targets/antigravity.ts` via - * the registry in `src/targets/registry.ts`. Kept only until every target - * has migrated (see `src/adapters.ts`). - */ -export async function emitAntigravity( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - outDir: string, -): Promise { - const version = targetConfig.version ?? project.config.version; - const files = new Map(); - - await emitPlugins(project, target, targetConfig, files, { - resolvePluginPath: (pluginName, pluginConfig) => - pluginConfig.path ?? pluginName, - pluginManifest: { - path: (pluginPath) => path.join(pluginPath, "plugin.json"), - build: (metadata, pluginName, pluginConfig) => - antigravityPluginManifest( - metadata, - pluginConfig.version ?? version, - pluginName, - pluginConfig, - ), - }, - mcp: "antigravity", - }); - - return artifact(target, outDir, files); -} - -/** - * @deprecated Legacy emitter, superseded by `src/targets/copilot.ts` via the - * registry in `src/targets/registry.ts`. Kept only until every target has - * migrated (see `src/adapters.ts`). - */ -export async function emitCopilot( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - outDir: string, -): Promise { - const version = targetConfig.version ?? project.config.version; - const pluginRoot = targetConfig.pluginRoot ?? "plugins"; - const files = new Map(); - - // Copilot has no per-plugin manifest; each plugin's data lives in its - // marketplace entry (with a derived skills list), so this omits pluginManifest - // and builds the entry directly. - const plugins = await emitPlugins(project, target, targetConfig, files, { - resolvePluginPath: (pluginName, pluginConfig) => - pluginConfig.path ?? toPosix(path.join(pluginRoot, pluginName)), - buildEntry: ({ - pluginName, - pluginPath, - pluginConfig, - metadata, - mcpServers, - pluginFiles, - }) => - stripUndefined({ - name: pluginName, - source: `./${pluginPath}`, - description: pluginConfig.description ?? metadata?.description, - version: pluginConfig.version ?? version, - skills: [ - ...new Set( - [...pluginFiles.keys()] - .filter((file) => file.startsWith("skills/")) - .map((file) => `./skills/${file.split("/")[1]}`), - ), - ].sort(), - mcpServers: mcpServers ? ".mcp.json" : undefined, - }), - mcp: "file", - }); - - const marketplace = stripUndefined( - deepMerge( - { - name: project.config.name, - metadata: stripUndefined({ - description: project.config.metadata?.description, - version, - keywords: project.config.metadata?.keywords, - }), - owner: - project.config.metadata?.owner ?? project.config.metadata?.author, - plugins, - }, - targetConfig.manifest ?? {}, - ), - ); - // Copilot reuses the Claude marketplace schema and reads it from both the - // repo-root .claude-plugin/ and .github/plugin/ (see github/copilot-plugins). - const marketplaceJson = json(marketplace); - files.set( - toPosix(path.join(".claude-plugin", "marketplace.json")), - marketplaceJson, - ); - files.set( - toPosix(path.join(".github", "plugin", "marketplace.json")), - marketplaceJson, - ); - - return artifact(target, outDir, files); -} - -/** - * @deprecated Legacy emitter, superseded by `src/targets/codex.ts` via the - * registry in `src/targets/registry.ts`. Kept only until every target has - * migrated (see `src/adapters.ts`). - */ -export async function emitCodex( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - outDir: string, -): Promise { - const pluginRoot = targetConfig.pluginRoot ?? "plugins"; - const version = targetConfig.version ?? project.config.version; - const files = new Map(); - - const plugins = await emitPlugins(project, target, targetConfig, files, { - resolvePluginPath: (pluginName, pluginConfig) => - pluginConfig.path ?? toPosix(path.join(pluginRoot, pluginName)), - pluginManifest: { - path: (pluginPath) => - path.join(pluginPath, ".codex-plugin", "plugin.json"), - build: (metadata, pluginName, pluginConfig, componentDirs, mcpServers) => - codexPluginManifest( - metadata, - pluginConfig.version ?? version, - pluginName, - pluginConfig, - componentDirs, - mcpServers, - ), - }, - // Base entry stays guess-free; authors supply policy/category via `entry`. - buildEntry: ({ pluginName, pluginPath, pluginConfig, manifest }) => ({ - name: pluginName, - source: `./${pluginPath}`, - description: - pluginConfig.description ?? - (manifest?.description as string | undefined), - version: pluginConfig.version ?? version, - }), - mcp: "file", - }); - - const marketplace = stripUndefined( - deepMerge( - { - name: project.config.name, - interface: { - displayName: - project.config.metadata?.displayName ?? project.config.name, - }, - plugins, - }, - targetConfig.manifest ?? {}, - ), - ); - files.set( - toPosix(path.join(".agents", "plugins", "marketplace.json")), - json(marketplace), - ); - - return artifact(target, outDir, files); -} - -function emittedPluginMetadata( - project: ResolvedProject, - pluginConfig: EmittedPluginConfig, -): Metadata | undefined { - const sourceMetadata = - pluginConfig.from.length === 1 - ? project.plugins.get(pluginConfig.from[0])?.manifest - : undefined; - return stripUndefined({ - ...project.config.metadata, - ...sourceMetadata, - }); -} - -function cursorPluginManifest( - metadata: Metadata | undefined, - version: string, - pluginName: string, - pluginConfig: EmittedPluginConfig, - componentDirs: Set, - mcpServers: Record | undefined, -): Record { - const manifest: Record = { - name: pluginName, - displayName: - pluginConfig.displayName ?? - metadata?.displayName ?? - titleCase(pluginName), - version, - description: pluginConfig.description ?? metadata?.description, - author: metadata?.author, - homepage: metadata?.homepage, - repository: metadata?.repository, - license: metadata?.license, - logo: metadata?.logo, - keywords: metadata?.keywords, - category: metadata?.category, - tags: metadata?.tags, - }; - const components = pluginConfig.components ?? [ - "skills", - "agents", - "commands", - "rules", - "hooks", - ]; - for (const component of components) { - if (componentDirs.has(component)) { - manifest[component] = `./${component}/`; - } - } - if (mcpServers) { - manifest.mcpServers = "./.mcp.json"; - } - return stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {})); -} - -function claudePluginManifest( - metadata: Metadata | undefined, - version: string, - pluginName: string, - pluginConfig: EmittedPluginConfig, -): Record { - const manifest: Record = { - name: pluginName, - version, - description: pluginConfig.description ?? metadata?.description, - author: metadata?.author, - homepage: metadata?.homepage, - repository: metadata?.repository, - license: metadata?.license, - keywords: metadata?.keywords, - }; - return stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {})); -} - -function codexPluginManifest( - metadata: Metadata | undefined, - version: string, - pluginName: string, - pluginConfig: EmittedPluginConfig, - componentDirs: Set, - mcpServers: Record | undefined, -): Record { - const manifest: Record = { - name: pluginName, - version, - description: pluginConfig.description ?? metadata?.description, - author: metadata?.author, - homepage: metadata?.homepage, - repository: metadata?.repository, - license: metadata?.license, - keywords: metadata?.keywords, - }; - // Codex manifests point `skills` at the bundled folder (not a per-skill list). - if (componentDirs.has("skills")) { - manifest.skills = "./skills/"; - } - if (mcpServers) { - manifest.mcpServers = "./.mcp.json"; - } - return stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {})); -} - -function antigravityPluginManifest( - metadata: Metadata | undefined, - version: string, - pluginName: string, - pluginConfig: EmittedPluginConfig, -): Record { - const manifest: Record = { - name: pluginName, - version, - description: pluginConfig.description ?? metadata?.description, - }; - return stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {})); -} - -function artifact( - target: TargetName, - outDir: string, - files: Map, -): Artifact { - const managedPaths = [...files.keys()].sort(); - return { - target, - outDir, - files: new Map([...files.entries()].sort(([a], [b]) => a.localeCompare(b))), - managedPaths, - }; -} - -function stripUndefined>(value: T): T { - for (const key of Object.keys(value)) { - if (value[key] === undefined) { - delete value[key]; - } - } - return value; -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Deep-merge an override onto a generated manifest. Nested objects merge so a -// sibling key isn't lost; arrays and scalars from the override replace (not -// concatenate, so keywords/tags don't double up). This is the general escape -// hatch — any field, at any depth, can be overridden via a target/plugin -// `manifest`. -function deepMerge( - base: Record, - override: Record, -): Record { - const result: Record = { ...base }; - for (const [key, value] of Object.entries(override)) { - const existing = result[key]; - result[key] = - isPlainObject(existing) && isPlainObject(value) - ? deepMerge(existing, value) - : value; - } - return result; -} - -function titleCase(value: string): string { - return value - .split(/[-_.]/) - .filter(Boolean) - .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) - .join(" "); -} diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 76cf3cd..0ea1514 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -1,6 +1,7 @@ +import { promises as fs } from "node:fs"; import path from "node:path"; import { collectPluginFiles, resolveMcpServers } from "../render.js"; -import { json, toPosix } from "../fs.js"; +import { isSafeRelativePath, json, toPosix } from "../fs.js"; import { deepMerge, stripUndefined } from "./shared.js"; import { applyUpdateCheck, pluginAllowsUpdateCheck } from "../update-check.js"; import type { UpdateCheckFormat } from "../update-check.js"; @@ -17,9 +18,8 @@ import type { PluginTargetDefinition } from "./types.js"; /** * Resolves a plugin's component set from its own `components` override, or - * the target definition's default — decoupled from the legacy global - * `targetDefaultComponents` table (`../components.ts`) so a migrated - * target's defaults are owned entirely by its own `PluginTargetDefinition`. + * the target definition's default — each target owns its own defaults via + * `PluginTargetDefinition.defaultComponents` rather than a shared table. */ function resolveComponents( definition: PluginTargetDefinition, @@ -228,3 +228,44 @@ function artifact( managedPaths, }; } + +/** + * Emits per-target repo-root files (e.g. a README authored once in the + * source repo) into the artifact so they are managed, pruned, and synced + * like every other generated file — rather than hand-maintained in each + * output repo. + */ +export async function withRootFiles( + project: ResolvedProject, + targetConfig: TargetConfig, + result: Artifact, +): Promise { + const rootFiles = targetConfig.rootFiles; + if (!rootFiles || Object.keys(rootFiles).length === 0) { + return result; + } + const files = new Map(result.files); + for (const [dest, source] of Object.entries(rootFiles)) { + const destPath = toPosix(dest); + if (!isSafeRelativePath(destPath)) { + throw new Error( + `Target "${result.target}" rootFiles destination "${dest}" must be a safe relative path.`, + ); + } + if (files.has(destPath)) { + throw new Error( + `Target "${result.target}" rootFiles destination "${dest}" collides with a generated file.`, + ); + } + let contents: Buffer; + try { + contents = await fs.readFile(path.resolve(project.rootDir, source)); + } catch { + throw new Error( + `Target "${result.target}" rootFiles source "${source}" could not be read.`, + ); + } + files.set(destPath, contents); + } + return artifact(result.target, result.outDir, files); +} diff --git a/src/targets/registry.ts b/src/targets/registry.ts index 3d884fe..aa8dab6 100644 --- a/src/targets/registry.ts +++ b/src/targets/registry.ts @@ -7,13 +7,12 @@ import type { TargetName } from "../types.js"; import type { PluginTargetDefinition } from "./types.js"; /** - * Migrated targets, filled in one at a time as each moves off the legacy - * per-target functions in `../targets.ts` and `../validate.ts` — see - * `../adapters.ts`, which falls back to those for any target not yet - * present here. Becomes `Record` once - * every target has an entry, at which point `adapters.ts` can be deleted. + * Every target, in one place — `Record` is exhaustive at + * compile time, so a new `TargetName` won't build until it has an entry + * here. `../adapters.ts` builds its emit/validate dispatch, the CLI + * `--target` choices, and the set `build()` iterates all from this. */ -export const targets: Partial> = { +export const targets: Record = { copilot, antigravity, cursor, diff --git a/src/targets/validation-shared.ts b/src/targets/validation-shared.ts index a07f1ed..75b6754 100644 --- a/src/targets/validation-shared.ts +++ b/src/targets/validation-shared.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"; export const marketplaceNamePattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; @@ -120,7 +121,10 @@ export function validateMarketplaceBasics( /** * Validates a hooks file's shape at `hooksRelativePath` — parameterized by * path rather than a hardcoded location, since a target may relocate hooks - * to somewhere other than `hooks/hooks.json` (e.g. a root-level file). + * to somewhere other than `hooks/hooks.json` (e.g. a root-level file). Also + * checks that any command referencing the generated update-check script + * (`UPDATE_CHECK_SCRIPT_PATH`) actually ships it, since that hook is + * injected separately from the plugin's own authored hooks. */ export async function validateHooksShape( pluginDir: string, @@ -133,14 +137,67 @@ export async function validateHooksShape( return; } const hooks = await readJson(hooksFile, `${pluginName} hooks`, 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}: ${hooksRelativePath} must have a "hooks" object.`, ); + return; + } + for (const [event, entries] of Object.entries( + hooks.hooks as Record, + )) { + if (!Array.isArray(entries)) { + error( + issues, + `${pluginName}: ${hooksRelativePath} "hooks.${event}" must be an array.`, + ); + } + } + for (const command of extractCommandStrings(hooks.hooks)) { + if (!command) { + error( + issues, + `${pluginName}: ${hooksRelativePath} has an empty "command" string.`, + ); + continue; + } + if ( + command.includes(UPDATE_CHECK_SCRIPT_PATH) && + !(await exists(path.join(pluginDir, UPDATE_CHECK_SCRIPT_PATH))) + ) { + error( + issues, + `${pluginName}: ${hooksRelativePath} references ${UPDATE_CHECK_SCRIPT_PATH} 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 extractCommandStrings(value: unknown): string[] { + if (Array.isArray(value)) { + return value.flatMap(extractCommandStrings); + } + if (value && typeof value === "object") { + const object = value as Record; + const own = typeof object.command === "string" ? [object.command] : []; + return [...own, ...Object.values(object).flatMap(extractCommandStrings)]; + } + return []; +} + /** Validates that manifest fields referencing paths point at files that exist. */ export function validateReferencedManifestPaths( pluginDir: string, diff --git a/src/update-check.ts b/src/update-check.ts index fbe309d..f5b7b7c 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -21,10 +21,11 @@ export type UpdateCheckOptions = { /** * 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. + * this plugin gets the generated hook (file injection in + * `src/targets/engine.ts`'s `emitFromDefinition`, and each target's own + * `buildPluginManifest` for the manifest's `hooks` pointer) — callers still + * check their own target-level `updateCheck` presence first, for + * type-narrowing convenience. */ export function pluginAllowsUpdateCheck( pluginConfig: Pick, diff --git a/src/validate.ts b/src/validate.ts deleted file mode 100644 index 64875af..0000000 --- a/src/validate.ts +++ /dev/null @@ -1,659 +0,0 @@ -import { promises as fs, statSync } from "node:fs"; -import path from "node:path"; -import matter from "gray-matter"; -import { - exists, - isNotFoundError, - isSafeRelativePath, - 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])?$/; -const marketplaceNamePattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; - -/** - * @deprecated Legacy validator, superseded by `src/targets/antigravity.ts`'s - * `PluginTargetDefinition.validateOutput` via the registry in - * `src/targets/registry.ts`. Kept only until every target has migrated (see - * `src/adapters.ts`). - */ -export async function validateAntigravity( - root: string, - issues: ValidationIssue[], -): Promise { - const entries = await fs.readdir(root, { withFileTypes: true }); - const pluginDirs = entries - .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) - .map((entry) => path.join(root, entry.name)); - if (pluginDirs.length === 0) { - error( - issues, - "Antigravity output must contain at least one plugin directory.", - ); - return; - } - for (const pluginDir of pluginDirs) { - const manifest = await readJson( - path.join(pluginDir, "plugin.json"), - "Antigravity plugin manifest", - issues, - ); - if (!manifest) { - continue; - } - const pluginName = path.basename(pluginDir); - if ( - typeof manifest.name !== "string" || - !pluginNamePattern.test(manifest.name) - ) { - error( - issues, - `${pluginName}: plugin.json must have a lowercase kebab-case "name".`, - ); - } - if (manifest.name && manifest.name !== pluginName) { - error( - issues, - `${pluginName}: manifest name must match plugin directory name.`, - ); - } - for (const field of ["version", "description"]) { - if (typeof manifest[field] !== "string" || !manifest[field]) { - error( - issues, - `${pluginName}: plugin.json is missing required field "${field}".`, - ); - } - } - const mcpConfigPath = path.join(pluginDir, "mcp_config.json"); - if (await exists(mcpConfigPath)) { - await readJson(mcpConfigPath, `${pluginName} MCP config`, issues); - } - await validateFrontmatter(pluginDir, pluginName, "antigravity", issues); - await validateHooks(pluginDir, pluginName, issues); - } -} - -/** - * @deprecated Legacy validator, superseded by `src/targets/copilot.ts`'s - * `PluginTargetDefinition.validateOutput` via the registry in - * `src/targets/registry.ts`. Kept only until every target has migrated (see - * `src/adapters.ts`). - */ -export async function validateCopilot( - root: string, - issues: ValidationIssue[], -): Promise { - const marketplacePath = path.join(root, ".claude-plugin", "marketplace.json"); - const marketplace = await readJson( - marketplacePath, - "Marketplace manifest", - issues, - ); - if (!marketplace) { - return; - } - validateMarketplaceBasics(marketplace, issues); - if ( - !(await exists(path.join(root, ".github", "plugin", "marketplace.json"))) - ) { - error( - issues, - "Copilot output must mirror the marketplace at .github/plugin/marketplace.json.", - ); - } - const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : []; - if (plugins.length === 0) { - error(issues, 'Marketplace "plugins" must be a non-empty array.'); - return; - } - for (const [index, entry] of plugins.entries()) { - const pluginName = validatePluginEntry(entry, index, root, issues); - if (!pluginName) { - continue; - } - await validateFrontmatter( - path.join(root, entry.source), - pluginName, - "copilot", - issues, - ); - } -} - -/** Validates a built Cursor target's output directory. */ -export async function validateCursor( - root: string, - issues: ValidationIssue[], -): Promise { - const marketplacePath = path.join(root, ".cursor-plugin", "marketplace.json"); - const marketplace = await readJson( - marketplacePath, - "Marketplace manifest", - issues, - ); - if (!marketplace) { - return; - } - validateMarketplaceBasics(marketplace, issues); - const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : []; - if (plugins.length === 0) { - error(issues, 'Marketplace "plugins" must be a non-empty array.'); - return; - } - for (const [index, entry] of plugins.entries()) { - const pluginName = validatePluginEntry(entry, index, root, issues); - if (!pluginName) { - continue; - } - const pluginDir = path.join(root, entry.source); - const manifest = await readJson( - path.join(pluginDir, ".cursor-plugin", "plugin.json"), - `${pluginName} plugin manifest`, - issues, - ); - if (!manifest) { - continue; - } - if (manifest.name !== pluginName) { - error( - issues, - `${pluginName}: marketplace entry name does not match plugin.json name ("${manifest.name}").`, - ); - } - await validateReferencedManifestPaths( - pluginDir, - pluginName, - manifest, - ["logo", "commands", "agents", "skills", "rules", "hooks", "mcpServers"], - issues, - ); - await validateFrontmatter(pluginDir, pluginName, "cursor", issues); - await validateHooks(pluginDir, pluginName, issues); - } -} - -/** - * @deprecated Legacy validator, superseded by `src/targets/claude.ts`'s - * `PluginTargetDefinition.validateOutput` via the registry in - * `src/targets/registry.ts`. Kept only until every target has migrated (see - * `src/adapters.ts`). - */ -export async function validateClaude( - root: string, - issues: ValidationIssue[], -): Promise { - const marketplacePath = path.join(root, ".claude-plugin", "marketplace.json"); - const marketplace = await readJson( - marketplacePath, - "Marketplace manifest", - issues, - ); - if (!marketplace) { - return; - } - validateMarketplaceBasics(marketplace, issues); - const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : []; - if (plugins.length === 0) { - error(issues, 'Marketplace "plugins" must be a non-empty array.'); - return; - } - for (const [index, entry] of plugins.entries()) { - const pluginName = validatePluginEntry(entry, index, root, issues); - if (!pluginName) { - continue; - } - const pluginDir = path.join(root, entry.source); - const manifest = await readJson( - path.join(pluginDir, ".claude-plugin", "plugin.json"), - `${pluginName} plugin manifest`, - issues, - ); - if (!manifest) { - continue; - } - if (manifest.name !== pluginName) { - error( - issues, - `${pluginName}: marketplace entry name does not match plugin.json name ("${manifest.name}").`, - ); - } - for (const field of ["name", "version", "description"]) { - if (typeof manifest[field] !== "string" || !manifest[field]) { - error( - issues, - `${pluginName}: plugin.json is missing required field "${field}".`, - ); - } - } - if ( - !manifest.author || - typeof manifest.author.name !== "string" || - !manifest.author.name - ) { - error(issues, `${pluginName}: plugin.json is missing "author.name".`); - } - await validateFrontmatter(pluginDir, pluginName, "claude", issues); - await validateHooks(pluginDir, pluginName, issues); - } -} - -/** - * @deprecated Legacy validator, superseded by `src/targets/codex.ts`'s - * `PluginTargetDefinition.validateOutput` via the registry in - * `src/targets/registry.ts`. Kept only until every target has migrated (see - * `src/adapters.ts`). - */ -export async function validateCodex( - root: string, - issues: ValidationIssue[], -): Promise { - const marketplacePath = path.join( - root, - ".agents", - "plugins", - "marketplace.json", - ); - const marketplace = await readJson( - marketplacePath, - "Marketplace manifest", - issues, - ); - if (!marketplace) { - return; - } - validateMarketplaceBasics(marketplace, issues); - const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : []; - if (plugins.length === 0) { - error(issues, 'Marketplace "plugins" must be a non-empty array.'); - return; - } - for (const [index, entry] of plugins.entries()) { - const pluginName = validatePluginEntry(entry, index, root, issues); - if (!pluginName) { - continue; - } - const pluginDir = path.join(root, entry.source); - const manifest = await readJson( - path.join(pluginDir, ".codex-plugin", "plugin.json"), - `${pluginName} plugin manifest`, - issues, - ); - if (!manifest) { - continue; - } - if (manifest.name !== pluginName) { - error( - issues, - `${pluginName}: marketplace entry name does not match plugin.json name ("${manifest.name}").`, - ); - } - for (const field of ["name", "version", "description"]) { - if (typeof manifest[field] !== "string" || !manifest[field]) { - error( - issues, - `${pluginName}: plugin.json is missing required field "${field}".`, - ); - } - } - await validateFrontmatter(pluginDir, pluginName, "codex", issues); - await validateHooks(pluginDir, pluginName, issues); - } -} - -function validateMarketplaceBasics( - marketplace: Record, - issues: ValidationIssue[], -): void { - if ( - typeof marketplace.name !== "string" || - !marketplaceNamePattern.test(marketplace.name) - ) { - error( - issues, - 'Marketplace "name" must be lowercase kebab-case and start/end with an alphanumeric character.', - ); - } - const owner = marketplace.owner as Record | undefined; - if (owner && (typeof owner.name !== "string" || !owner.name)) { - error( - issues, - 'Marketplace "owner.name" must be a non-empty string when owner is present.', - ); - } -} - -function validatePluginEntry( - entry: Record, - index: number, - root: string, - issues: ValidationIssue[], -): string | null { - if (!entry || typeof entry !== "object") { - error(issues, `plugins[${index}] must be an object.`); - return null; - } - if (typeof entry.name !== "string" || !pluginNamePattern.test(entry.name)) { - error( - issues, - `plugins[${index}].name must be lowercase and use only alphanumerics, hyphens, and periods.`, - ); - return null; - } - if (typeof entry.source !== "string" || !isSafeRelativePath(entry.source)) { - error(issues, `plugins[${index}].source must be a safe relative path.`); - return null; - } - const pluginDir = path.join(root, entry.source); - if (!entry.source.startsWith("http") && !pathExistsSync(pluginDir)) { - error( - issues, - `plugins[${index}].source directory is missing: ${entry.source}`, - ); - return null; - } - return entry.name; -} - -async function validateReferencedManifestPaths( - pluginDir: string, - pluginName: string, - manifest: Record, - fields: string[], - issues: ValidationIssue[], -): Promise { - for (const field of fields) { - for (const value of extractPathValues(manifest[field])) { - if (value.startsWith("http://") || value.startsWith("https://")) { - continue; - } - if (!isSafeRelativePath(value)) { - error( - issues, - `${pluginName}: field "${field}" has unsafe path "${value}".`, - ); - continue; - } - if (!(await exists(path.join(pluginDir, value)))) { - error( - issues, - `${pluginName}: field "${field}" references missing path "${value}".`, - ); - } - } - } -} - -function extractPathValues(value: unknown): string[] { - if (typeof value === "string") { - return [value]; - } - if (Array.isArray(value)) { - return value.flatMap(extractPathValues); - } - if (value && typeof value === "object") { - const object = value as Record; - return [object.path, object.file].filter( - (entry): entry is string => typeof entry === "string", - ); - } - return []; -} - -async function validateFrontmatter( - pluginDir: string, - pluginName: string, - target: TargetName, - issues: ValidationIssue[], -): Promise { - const files = await walkFiles(pluginDir); - for (const file of files) { - const kind = detectFrontmatterKind(file); - if (!kind) { - continue; - } - const relative = toPosix(path.relative(pluginDir, file)); - const parsed = parseFrontmatter(await fs.readFile(file, "utf8")); - if (!parsed.ok) { - error( - issues, - `${pluginName}: ${kind} frontmatter error in ${relative}: ${parsed.error}`, - ); - continue; - } - if (kind === "agent") { - requireFrontmatter( - pluginName, - kind, - relative, - parsed.value, - ["name", "description"], - issues, - ); - } else if (kind === "command") { - requireFrontmatter( - pluginName, - kind, - relative, - parsed.value, - ["description"], - issues, - ); - if ( - target === "cursor" || - target === "antigravity" || - target === "copilot" - ) { - requireFrontmatter( - pluginName, - kind, - relative, - parsed.value, - ["name"], - issues, - ); - } - } else if (kind === "skill") { - if (target === "cursor") { - requireFrontmatter( - pluginName, - kind, - relative, - parsed.value, - ["name", "description"], - issues, - ); - } else if (!parsed.value.description && !parsed.value.when_to_use) { - error( - issues, - `${pluginName}: ${kind} frontmatter error in ${relative}: Missing required "description" field.`, - ); - } - } else if (kind === "rule") { - requireFrontmatter( - pluginName, - kind, - relative, - parsed.value, - ["description"], - issues, - ); - } - } -} - -function detectFrontmatterKind( - filePath: string, -): "agent" | "skill" | "command" | "rule" | null { - const normalized = toPosix(filePath); - const inSkillContent = - /\/skills\/[^/]+\//.test(normalized) && !normalized.endsWith("/SKILL.md"); - if ( - normalized.includes("/agents/") && - /\.(md|mdc|markdown)$/.test(normalized) && - !inSkillContent - ) { - return "agent"; - } - if (normalized.includes("/skills/") && normalized.endsWith("/SKILL.md")) { - return "skill"; - } - if ( - normalized.includes("/commands/") && - /\.(md|mdc|markdown|txt)$/.test(normalized) && - !inSkillContent - ) { - return "command"; - } - if ( - normalized.includes("/rules/") && - /\.(md|mdc|markdown)$/.test(normalized) && - !inSkillContent - ) { - return "rule"; - } - return null; -} - -function parseFrontmatter( - content: string, -): { ok: true; value: Record } | { ok: false; error: string } { - try { - const parsed = matter(content); - if (Object.keys(parsed.data).length === 0) { - return { ok: false, error: "No frontmatter found" }; - } - return { ok: true, value: parsed.data }; - } catch (err) { - return { ok: false, error: `YAML parse failed: ${(err as Error).message}` }; - } -} - -function requireFrontmatter( - pluginName: string, - kind: string, - relative: string, - frontmatter: Record, - fields: string[], - issues: ValidationIssue[], -): void { - for (const field of fields) { - if (typeof frontmatter[field] !== "string" || !frontmatter[field]) { - error( - issues, - `${pluginName}: ${kind} frontmatter error in ${relative}: Missing required "${field}" field.`, - ); - } - } -} - -async function validateHooks( - pluginDir: string, - pluginName: string, - issues: ValidationIssue[], -): Promise { - const hooksPath = path.join(pluginDir, "hooks", "hooks.json"); - if (!(await exists(hooksPath))) { - return; - } - const hooks = await readJson( - hooksPath, - `${pluginName} hooks/hooks.json`, - issues, - ); - 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(UPDATE_CHECK_SCRIPT_PATH) && - !(await exists(path.join(pluginDir, UPDATE_CHECK_SCRIPT_PATH))) - ) { - 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( - filePath: string, - context: string, - issues: ValidationIssue[], -): Promise | null> { - try { - return JSON.parse(await fs.readFile(filePath, "utf8")) as Record< - string, - any - >; - } catch (err) { - error( - issues, - `${context} is missing or invalid (${filePath}): ${(err as Error).message}`, - ); - return null; - } -} - -function pathExistsSync(filePath: string): boolean { - try { - statSync(filePath); - return true; - } catch (error) { - if (isNotFoundError(error)) { - return false; - } - throw error; - } -} - -function error(issues: ValidationIssue[], message: string): void { - issues.push({ level: "error", message }); -} diff --git a/tests/core.test.ts b/tests/core.test.ts index 0e93962..c892e5a 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1468,6 +1468,59 @@ export default defineConfig({ ).resolves.toMatchObject({ ok: true }); }); + it("catches a hooks.json command that references the generated update-check script once that script is removed", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "uc-missing-script", + version: "1.0.0", + metadata: { + description: "UC", + author: { name: "UC" }, + license: "MIT", + repository: "https://github.com/example/uc-plugins" + }, + targets: { + cursor: { + outDir: "dist/cursor", + updateCheck: {}, + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "cursor" }); + await expect( + validateOutput("cursor", path.join(root, "dist/cursor")), + ).resolves.toMatchObject({ ok: true }); + + await rm( + path.join(root, "dist/cursor/demo/scripts/pluginpack-update-check.sh"), + ); + + const result = await validateOutput( + "cursor", + path.join(root, "dist/cursor"), + ); + expect(result.ok).toBe(false); + expect( + result.issues.some((issue) => + issue.message.includes( + "references scripts/pluginpack-update-check.sh but the script is missing", + ), + ), + ).toBe(true); + }); + it("merges the update-check hook into a source-authored hooks.json", async () => { const existingHooks = { description: "Source hooks",