From 20e719b91b7b5da4fbc76bb4d36b8c8066f79779 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 09:54:11 -0700 Subject: [PATCH 1/5] refactor: scaffold the polymorphic per-target registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces PluginTargetDefinition — one interface a target implements once (default components, manifest/entry/marketplace builders, hooks and MCP config paths, validation, install snippets, citations) instead of the same target's facts being scattered across components.ts, targets.ts, and validate.ts independently. adapters.ts now bridges old and new: a target resolves through the registry if migrated, falling back to the legacy per-target functions otherwise, so targets migrate one at a time without breaking the build in between. The registry starts empty; this commit is a no-op bridge (verified via the full existing test suite passing unchanged). Co-Authored-By: Claude Fable 5 --- src/adapters.ts | 40 +++- src/targets/engine.ts | 145 ++++++++++++++ src/targets/registry.ts | 9 + src/targets/shared.ts | 42 +++++ src/targets/types.ts | 135 ++++++++++++++ src/targets/validation-shared.ts | 311 +++++++++++++++++++++++++++++++ 6 files changed, 677 insertions(+), 5 deletions(-) create mode 100644 src/targets/engine.ts create mode 100644 src/targets/registry.ts create mode 100644 src/targets/shared.ts create mode 100644 src/targets/types.ts create mode 100644 src/targets/validation-shared.ts diff --git a/src/adapters.ts b/src/adapters.ts index 0b7618a..c805766 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -14,6 +14,8 @@ import { validateCopilot, validateCursor, } from "./validate.js"; +import { emitFromDefinition, validateFromDefinition } from "./targets/engine.js"; +import { targets as registry } from "./targets/registry.js"; import type { Artifact, ResolvedProject, @@ -40,11 +42,10 @@ export type TargetAdapter = { validate: TargetValidator; }; -// 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. -export const adapters: Record = { +// 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 }, @@ -52,6 +53,35 @@ export const adapters: Record = { 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; + export const targetNames = Object.keys(adapters) as TargetName[]; export async function emitTarget( diff --git a/src/targets/engine.ts b/src/targets/engine.ts new file mode 100644 index 0000000..2118c0b --- /dev/null +++ b/src/targets/engine.ts @@ -0,0 +1,145 @@ +import path from "node:path"; +import { collectPluginFiles, resolveMcpServers } from "../render.js"; +import { json, toPosix } from "../fs.js"; +import { deepMerge, stripUndefined } from "./shared.js"; +import type { + Artifact, + EmittedPluginConfig, + FileValue, + ResolvedProject, + TargetConfig, + TargetName, + ValidationIssue, +} from "../types.js"; +import type { PluginTargetDefinition } from "./types.js"; + +// The one place a plugin's file set gets filtered to its resolved component +// set — decoupled from the legacy global `targetDefaultComponents` table +// (components.ts) so a migrated target's default component list is owned +// entirely by its own PluginTargetDefinition. +function resolveComponents( + definition: PluginTargetDefinition, + pluginConfig: { components?: string[] }, +): Set { + return new Set(pluginConfig.components ?? definition.defaultComponents); +} + +export async function emitFromDefinition( + project: ResolvedProject, + target: TargetName, + targetConfig: TargetConfig, + outDir: string, + definition: PluginTargetDefinition, +): Promise { + const version = targetConfig.version ?? project.config.version; + const files = new Map(); + const entries: Record[] = []; + + for (const [pluginName, pluginConfig] of Object.entries( + targetConfig.plugins, + )) { + const pluginPath = definition.resolvePluginPath(pluginName, pluginConfig); + const pluginFiles = await collectPluginFiles( + project, + target, + pluginConfig.from, + resolveComponents(definition, pluginConfig), + ); + 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); + const mcpConfigPath = definition.mcpConfigPath(pluginPath); + if (mcpServers && mcpConfigPath) { + files.set(toPosix(mcpConfigPath), json({ mcpServers })); + } + + const metadata = emittedPluginMetadata(project, pluginConfig); + const manifest = definition.buildPluginManifest({ + metadata, + version, + pluginName, + pluginConfig, + componentDirs, + mcpServers, + }); + files.set( + toPosix(definition.manifestPath(pluginPath)), + json(stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {}))), + ); + + const entry = definition.buildMarketplaceEntry({ + 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 ?? {}))); + } + } + + const marketplace = stripUndefined( + deepMerge( + definition.buildMarketplaceManifest({ + project, + targetConfig, + version, + plugins: entries, + }), + targetConfig.manifest ?? {}, + ), + ); + const marketplaceContent = json(marketplace); + for (const marketplacePath of definition.marketplacePaths()) { + files.set(toPosix(marketplacePath), marketplaceContent); + } + + return artifact(target, outDir, files); +} + +export async function validateFromDefinition( + root: string, + issues: ValidationIssue[], + definition: PluginTargetDefinition, +): Promise { + await definition.validateOutput(root, issues); +} + +function emittedPluginMetadata( + project: ResolvedProject, + pluginConfig: EmittedPluginConfig, +) { + const sourceMetadata = + pluginConfig.from.length === 1 + ? project.plugins.get(pluginConfig.from[0])?.manifest + : undefined; + return stripUndefined({ + ...project.config.metadata, + ...sourceMetadata, + }); +} + +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, + }; +} diff --git a/src/targets/registry.ts b/src/targets/registry.ts new file mode 100644 index 0000000..371d86a --- /dev/null +++ b/src/targets/registry.ts @@ -0,0 +1,9 @@ +import type { TargetName } from "../types.js"; +import type { PluginTargetDefinition } from "./types.js"; + +// Filled in one target at a time as each migrates off the legacy +// emitXxx/validateXxx functions in ../targets.ts and ../validate.ts (see +// CLAUDE.md's target-registry note and the migration plan in this repo's +// history). Once every TargetName has an entry, this becomes +// `Record` and `adapters.ts` is deleted. +export const targets: Partial> = {}; diff --git a/src/targets/shared.ts b/src/targets/shared.ts new file mode 100644 index 0000000..839d57d --- /dev/null +++ b/src/targets/shared.ts @@ -0,0 +1,42 @@ +export 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`. +export 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; +} + +export 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/types.ts b/src/targets/types.ts new file mode 100644 index 0000000..d80e690 --- /dev/null +++ b/src/targets/types.ts @@ -0,0 +1,135 @@ +import type { + EmittedPluginConfig, + FileValue, + Metadata, + ResolvedProject, + TargetConfig, + TargetName, + ValidationIssue, +} from "../types.js"; + +export type ManifestBuildContext = { + metadata: Metadata | undefined; + version: string; + pluginName: string; + pluginConfig: EmittedPluginConfig; + componentDirs: Set; + mcpServers: Record | undefined; +}; + +export type MarketplaceEntryContext = { + pluginName: string; + pluginPath: string; + pluginConfig: EmittedPluginConfig; + metadata: Metadata | undefined; + componentDirs: Set; + mcpServers: Record | undefined; + pluginFiles: Map; + manifest: Record | undefined; +}; + +export type MarketplaceManifestContext = { + project: ResolvedProject; + targetConfig: TargetConfig; + version: string; + plugins: Record[]; +}; + +export type InstallSnippetKind = "command" | "url"; + +export type InstallParams = { + repository: string; + marketplaceName: string; + pluginName: string; + pluginPath: string; +}; + +export type InstallSnippet = + | { + userConfigurable: true; + kind: InstallSnippetKind; + snippet: string; + note?: string; + } + | { userConfigurable: false; reason: string }; + +// A fact about a target traces to one of these — the discipline this whole +// registry exists to enforce (see CONFORMANCE.md). `claim` is a short +// human-readable description of what the citation backs, not the fact itself. +export type Citation = { + claim: string; + documentationUrl: string; + verifiedAt: string; +}; + +export type InstallSnippetDefinition = { + userConfigurable: boolean; + build?: (params: InstallParams) => { + kind: InstallSnippetKind; + snippet: string; + note?: string; + }; + unsupportedReason?: string; + citation: Citation; +}; + +// Everything that varies by target, in one place. A new target means one new +// file implementing this interface and one new entry in the registry — not a +// new branch scattered across components.ts/targets.ts/validate.ts, which is +// exactly how the bugs this registry fixes went unnoticed (see CONFORMANCE.md +// "Per-target spec verification"). +export type PluginTargetDefinition = { + name: TargetName; + + // The component dirs copied into an emitted plugin when a plugin config + // doesn't supply its own `components` override. + defaultComponents: readonly string[]; + + resolvePluginPath: ( + pluginName: string, + pluginConfig: EmittedPluginConfig, + ) => string; + + // Every target writes one now (Copilot didn't; that was the biggest Plan 1 fix). + buildPluginManifest: (ctx: ManifestBuildContext) => Record; + manifestPath: (pluginPath: string) => string; + + // Omit (return undefined) for a target with no marketplace-entry concept — + // none currently omit it, but the shape allows for one that might. + buildMarketplaceEntry: ( + ctx: MarketplaceEntryContext, + ) => Record | undefined; + buildMarketplaceManifest: ( + ctx: MarketplaceManifestContext, + ) => Record; + // One or more output-relative paths the marketplace manifest is written to + // (Copilot mirrors it at two paths). + marketplacePaths: () => string[]; + + // undefined for a target with no bundled-MCP-config file convention. + mcpConfigPath: (pluginPath: string) => string | undefined; + hooksPath: (pluginPath: string) => string; + + validateManifest: ( + manifest: Record, + pluginName: string, + issues: ValidationIssue[], + ) => void; + // Returns the entry's plugin name if it's well-formed enough to keep + // validating, or null (having already pushed an issue) if not — mirrors the + // existing validatePluginEntry contract so per-target output validation can + // keep sharing its calling convention. + validateMarketplaceEntry: ( + entry: Record, + index: number, + root: string, + issues: ValidationIssue[], + ) => string | null; + validateOutput: (root: string, issues: ValidationIssue[]) => Promise; + + installSnippet: InstallSnippetDefinition; + + // Facts about this target not already carried by a more specific citation + // above (e.g. the overall component model, naming pattern). + citations: Citation[]; +}; diff --git a/src/targets/validation-shared.ts b/src/targets/validation-shared.ts new file mode 100644 index 0000000..1d4726c --- /dev/null +++ b/src/targets/validation-shared.ts @@ -0,0 +1,311 @@ +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 type { TargetName, ValidationIssue } from "../types.js"; + +export const marketplaceNamePattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + +// The shared shape most targets' marketplace entries share (a bare-string +// `source`). Codex's `source` is a structured object instead (see Plan 1 / +// CONFORMANCE.md), so it implements its own validateMarketplaceEntry rather +// than calling this — this is exactly the shape assumption that used to be +// baked into one function for every target regardless of fit. +export function validateBareStringSourceEntry( + entry: Record, + index: number, + root: string, + issues: ValidationIssue[], + namePattern: RegExp, +): string | null { + if (!entry || typeof entry !== "object") { + error(issues, `plugins[${index}] must be an object.`); + return null; + } + if (typeof entry.name !== "string" || !namePattern.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; +} + +export const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +export function error(issues: ValidationIssue[], message: string): void { + issues.push({ level: "error", message }); +} + +export 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; + } +} + +export function pathExistsSync(filePath: string): boolean { + try { + statSync(filePath); + return true; + } catch (err) { + if (isNotFoundError(err)) { + return false; + } + throw err; + } +} + +export function validateMarketplaceBasics( + marketplace: Record, + issues: ValidationIssue[], + options: { requireOwner: boolean } = { requireOwner: false }, +): 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 (options.requireOwner && !owner) { + error(issues, 'Marketplace is missing required field "owner".'); + } + if (owner && (typeof owner.name !== "string" || !owner.name)) { + error( + issues, + 'Marketplace "owner.name" must be a non-empty string when owner is present.', + ); + } +} + +export function validateReferencedManifestPaths( + pluginDir: string, + pluginName: string, + manifest: Record, + fields: string[], + issues: ValidationIssue[], +): Promise { + return (async () => { + 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 []; +} + +export 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.`, + ); + } + } +} From 528da85eb269260e31a98f33d04c7657322c4017 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 09:59:08 -0700 Subject: [PATCH 2/5] fix: add the required per-plugin plugin.json manifest for copilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's emitted output previously carried no per-plugin plugin.json at all — a comment in the old emitCopilot said "Copilot has no per-plugin manifest," which was true of an earlier, thinner version of the format. The current spec is explicit: "All plugins consist of a plugin directory containing, at minimum, a manifest file named plugin.json" (docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference). Without it, a plugin likely isn't recognized at all once Copilot fetches it from the marketplace's `source`. Migrates the copilot target onto the new PluginTargetDefinition registry (see the scaffolding commit) as part of landing this fix, and adds a validation check that agent files are named NAME.agent.md per docs.github.com/.../plugins-creating, since Copilot derives agent identity from the filename. Co-Authored-By: Claude Fable 5 --- src/adapters.ts | 5 +- src/targets/copilot.ts | 256 +++++++++++++++++++++++++++++++ src/targets/engine.ts | 6 +- src/targets/registry.ts | 5 +- src/targets/shared.ts | 4 +- src/targets/types.ts | 1 + src/targets/validation-shared.ts | 5 +- tests/conformance.test.ts | 14 ++ tests/core.test.ts | 128 ++++++++++++++++ 9 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 src/targets/copilot.ts diff --git a/src/adapters.ts b/src/adapters.ts index c805766..e5a93cc 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -14,7 +14,10 @@ import { validateCopilot, validateCursor, } from "./validate.js"; -import { emitFromDefinition, validateFromDefinition } from "./targets/engine.js"; +import { + emitFromDefinition, + validateFromDefinition, +} from "./targets/engine.js"; import { targets as registry } from "./targets/registry.js"; import type { Artifact, diff --git a/src/targets/copilot.ts b/src/targets/copilot.ts new file mode 100644 index 0000000..6f767d2 --- /dev/null +++ b/src/targets/copilot.ts @@ -0,0 +1,256 @@ +import path from "node:path"; +import { exists, toPosix, walkFiles } from "../fs.js"; +import { stripUndefined } from "./shared.js"; +import { + error, + readJson, + validateBareStringSourceEntry, + validateFrontmatter, + validateMarketplaceBasics, + validateReferencedManifestPaths, +} from "./validation-shared.js"; +import type { ValidationIssue } from "../types.js"; +import type { PluginTargetDefinition } from "./types.js"; + +const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +// docs.github.com's plugin.json field reference calls this out explicitly: +// "Kebab-case plugin name (letters, numbers, hyphens only). Max 64 chars." +const copilotNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export const copilot: PluginTargetDefinition = { + name: "copilot", + + defaultComponents: ["skills", "agents", "hooks", "scripts", "assets"], + + resolvePluginPath: (pluginName, pluginConfig, targetConfig) => + pluginConfig.path ?? + toPosix(path.join(targetConfig.pluginRoot ?? "plugins", pluginName)), + + // Plan 1's biggest fix: Copilot requires a per-plugin plugin.json — pluginpack + // previously wrote none at all ("Copilot has no per-plugin manifest" was true + // of an earlier, thinner version of the format; it isn't true of the current + // one). See citations below. + buildPluginManifest: ({ + metadata, + version, + pluginName, + pluginConfig, + componentDirs, + mcpServers, + }) => { + const manifest: Record = { + name: pluginName, + version: pluginConfig.version ?? version, + description: pluginConfig.description ?? metadata?.description, + author: metadata?.author, + homepage: metadata?.homepage, + repository: metadata?.repository, + license: metadata?.license, + keywords: metadata?.keywords, + category: metadata?.category, + tags: metadata?.tags, + }; + if (componentDirs.has("agents")) { + manifest.agents = "agents/"; + } + if (componentDirs.has("skills")) { + manifest.skills = "skills/"; + } + if (componentDirs.has("hooks")) { + manifest.hooks = "hooks/hooks.json"; + } + if (mcpServers) { + manifest.mcpServers = ".mcp.json"; + } + return stripUndefined(manifest); + }, + manifestPath: (pluginPath) => path.join(pluginPath, "plugin.json"), + + buildMarketplaceEntry: ({ + pluginName, + pluginPath, + pluginConfig, + metadata, + mcpServers, + pluginFiles, + manifest, + }) => + stripUndefined({ + name: pluginName, + source: `./${pluginPath}`, + description: + pluginConfig.description ?? + (manifest?.description as string | undefined) ?? + metadata?.description, + version: + pluginConfig.version ?? (manifest?.version as string | undefined), + skills: [ + ...new Set( + [...pluginFiles.keys()] + .filter((file) => file.startsWith("skills/")) + .map((file) => `./skills/${file.split("/")[1]}`), + ), + ].sort(), + mcpServers: mcpServers ? ".mcp.json" : undefined, + }), + + buildMarketplaceManifest: ({ project, version, plugins }) => + stripUndefined({ + 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, + }), + // Copilot reads the marketplace from both the repo-root .claude-plugin/ and + // .github/plugin/ (see github/copilot-plugins) — mirror it at both. + marketplacePaths: () => [ + path.join(".claude-plugin", "marketplace.json"), + path.join(".github", "plugin", "marketplace.json"), + ], + + mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"), + hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), + + validateManifest: (manifest, pluginName, issues) => { + if ( + typeof manifest.name !== "string" || + !copilotNamePattern.test(manifest.name) + ) { + error( + issues, + `${pluginName}: plugin.json must have a kebab-case "name".`, + ); + } + }, + validateMarketplaceEntry: (entry, index, root, issues) => + validateBareStringSourceEntry( + entry, + index, + root, + issues, + pluginNamePattern, + ), + + validateOutput: async (root, issues) => { + 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 = copilot.validateMarketplaceEntry( + entry, + index, + root, + issues, + ); + if (!pluginName) { + continue; + } + const pluginDir = path.join(root, entry.source); + const manifest = await readJson( + path.join(pluginDir, "plugin.json"), + `${pluginName} plugin manifest`, + issues, + ); + if (manifest) { + copilot.validateManifest(manifest, pluginName, issues); + await validateReferencedManifestPaths( + pluginDir, + pluginName, + manifest, + ["agents", "skills", "hooks", "mcpServers"], + issues, + ); + } + await validateAgentFileNames(pluginDir, pluginName, issues); + await validateFrontmatter(pluginDir, pluginName, "copilot", issues); + } + }, + + installSnippet: { + userConfigurable: true, + build: ({ repository, pluginName, marketplaceName }) => ({ + kind: "command", + snippet: `copilot plugin marketplace add ${repository}\ncopilot plugin install ${pluginName}@${marketplaceName}`, + note: "VS Code automatically discovers plugins installed this way (from ~/.copilot/installed-plugins/).", + }), + citation: { + claim: "copilot plugin marketplace add / copilot plugin install syntax", + documentationUrl: + "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-finding-installing", + verifiedAt: "2026-07-25", + }, + }, + + citations: [ + { + claim: "plugin.json is a required manifest file for every plugin", + documentationUrl: + "https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference", + verifiedAt: "2026-07-25", + }, + { + claim: "agent files must be named NAME.agent.md", + documentationUrl: + "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-creating", + verifiedAt: "2026-07-25", + }, + { + claim: + "manifest field paths (agents/skills/hooks/mcpServers) and marketplace.json shape", + documentationUrl: + "https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference", + verifiedAt: "2026-07-25", + }, + ], +}; + +async function validateAgentFileNames( + pluginDir: string, + pluginName: string, + issues: ValidationIssue[], +): Promise { + const agentsDir = path.join(pluginDir, "agents"); + if (!(await exists(agentsDir))) { + return; + } + const files = await walkFiles(agentsDir); + for (const file of files) { + if (!file.endsWith(".agent.md")) { + error( + issues, + `${pluginName}: agent file "${toPosix(path.relative(pluginDir, file))}" must be named NAME.agent.md for Copilot to discover it.`, + ); + } + } +} diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 2118c0b..8cd606e 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -38,7 +38,11 @@ export async function emitFromDefinition( for (const [pluginName, pluginConfig] of Object.entries( targetConfig.plugins, )) { - const pluginPath = definition.resolvePluginPath(pluginName, pluginConfig); + const pluginPath = definition.resolvePluginPath( + pluginName, + pluginConfig, + targetConfig, + ); const pluginFiles = await collectPluginFiles( project, target, diff --git a/src/targets/registry.ts b/src/targets/registry.ts index 371d86a..b7b26d8 100644 --- a/src/targets/registry.ts +++ b/src/targets/registry.ts @@ -1,3 +1,4 @@ +import { copilot } from "./copilot.js"; import type { TargetName } from "../types.js"; import type { PluginTargetDefinition } from "./types.js"; @@ -6,4 +7,6 @@ import type { PluginTargetDefinition } from "./types.js"; // CLAUDE.md's target-registry note and the migration plan in this repo's // history). Once every TargetName has an entry, this becomes // `Record` and `adapters.ts` is deleted. -export const targets: Partial> = {}; +export const targets: Partial> = { + copilot, +}; diff --git a/src/targets/shared.ts b/src/targets/shared.ts index 839d57d..296b9fe 100644 --- a/src/targets/shared.ts +++ b/src/targets/shared.ts @@ -1,6 +1,4 @@ -export function stripUndefined>( - value: T, -): T { +export function stripUndefined>(value: T): T { for (const key of Object.keys(value)) { if (value[key] === undefined) { delete value[key]; diff --git a/src/targets/types.ts b/src/targets/types.ts index d80e690..433312a 100644 --- a/src/targets/types.ts +++ b/src/targets/types.ts @@ -88,6 +88,7 @@ export type PluginTargetDefinition = { resolvePluginPath: ( pluginName: string, pluginConfig: EmittedPluginConfig, + targetConfig: TargetConfig, ) => string; // Every target writes one now (Copilot didn't; that was the biggest Plan 1 fix). diff --git a/src/targets/validation-shared.ts b/src/targets/validation-shared.ts index 1d4726c..bde9ce8 100644 --- a/src/targets/validation-shared.ts +++ b/src/targets/validation-shared.ts @@ -35,10 +35,7 @@ export function validateBareStringSourceEntry( ); return null; } - if ( - typeof entry.source !== "string" || - !isSafeRelativePath(entry.source) - ) { + if (typeof entry.source !== "string" || !isSafeRelativePath(entry.source)) { error(issues, `plugins[${index}].source must be a safe relative path.`); return null; } diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 9b8be7d..347f7f6 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -301,6 +301,20 @@ describe("emitted output conforms to external target schemas", () => { skills: ["./skills/example"], mcpServers: ".mcp.json", }); + + // Per docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference: + // "All plugins consist of a plugin directory containing, at minimum, a + // manifest file named plugin.json." + const pluginManifest = readJson( + project.baseDir, + "out-copilot/plugins/glean/plugin.json", + ); + expect(pluginManifest).toMatchObject({ + name: "glean", + version: "2.1.1", + skills: "skills/", + mcpServers: ".mcp.json", + }); }); it("codex emits the documented Codex plugin marketplace layout", async () => { diff --git a/tests/core.test.ts b/tests/core.test.ts index e934c68..c0ad2fa 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -65,6 +65,124 @@ describe("pluginpack core", () => { ).resolves.toMatchObject({ ok: true }); }); + it('writes a required per-plugin plugin.json for copilot (docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference: "All plugins consist of a plugin directory containing, at minimum, a manifest file named plugin.json")', async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "copilot-manifest-plugins", + version: "2.0.0", + metadata: { description: "Copilot", author: { name: "X" }, license: "MIT" }, + targets: { + copilot: { + outDir: "dist/copilot", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + agents: { "helper.agent.md": agent("helper", "Helps with tasks.") }, + hooks: { + "hooks.json": `${JSON.stringify( + { + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "echo hi" }] }, + ], + }, + }, + null, + 2, + )}\n`, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "copilot" }); + + const manifestPath = path.join( + root, + "dist/copilot/plugins/demo/plugin.json", + ); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Record< + string, + unknown + >; + expect(manifest).toMatchObject({ + name: "demo", + version: "2.0.0", + agents: "agents/", + skills: "skills/", + hooks: "hooks/hooks.json", + }); + + await expect( + validateOutput("copilot", path.join(root, "dist/copilot")), + ).resolves.toMatchObject({ ok: true }); + }); + + it('flags copilot agent files not named NAME.agent.md (docs.github.com/.../plugins-creating: "Add an agent by creating a NAME.agent.md file")', async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "copilot-agent-naming", + version: "1.0.0", + metadata: { description: "Copilot", author: { name: "X" }, license: "MIT" }, + targets: { + copilot: { + outDir: "dist/copilot", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + // Wrong extension: not NAME.agent.md. + agents: { "helper.md": agent("helper", "Helps with tasks.") }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "copilot" }); + + const result = await validateOutput( + "copilot", + path.join(root, "dist/copilot"), + ); + expect(result.ok).toBe(false); + expect( + result.issues.some((issue) => + issue.message.includes("must be named NAME.agent.md"), + ), + ).toBe(true); + }); + + it("catches a missing copilot plugin.json at validate time", async () => { + const project = await fixture(); + const root = project.baseDir; + await build({ cwd: root, target: "copilot" }); + + await rm(path.join(root, "dist/copilot/plugins/demo/plugin.json")); + + const result = await validateOutput( + "copilot", + path.join(root, "dist/copilot"), + ); + expect(result.ok).toBe(false); + expect( + result.issues.some((issue) => issue.message.includes("plugin manifest")), + ).toBe(true); + }); + it("uses target-specific file overrides", async () => { const project = await fixture(); const root = project.baseDir; @@ -1315,3 +1433,13 @@ description: ${description} # ${name} `; } + +function agent(name: string, description: string): string { + return `--- +name: ${name} +description: ${description} +--- + +# ${name} +`; +} From 842c5968af92c53df48727dc62e0e5d3c3e982a4 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 10:02:35 -0700 Subject: [PATCH 3/5] fix: correct antigravity's hooks location and manifest schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two confirmed, verified-twice bugs in the antigravity target: - Hooks were emitted at hooks/hooks.json (a directory), but Antigravity's actual convention (confirmed on both docs.google/docs/ide/plugins and .../docs/cli/plugins) is a single root-level hooks.json file. Hooks authored for this target were silently inert — the host never looked where pluginpack put them. - The plugin.json manifest included a "version" field, but Antigravity's actual JSON Schema is additionalProperties: false with only "name" (required) and "description" (optional) — a strict validator would reject the whole manifest for the extra key, not just ignore it. The emit engine (src/targets/engine.ts) now relocates a source plugin's hooks/hooks.json to wherever the target's own hooksPath() says hooks belong, rather than copying it verbatim — this is the actual fix, and it generalizes: any future target with a different hooks convention gets it right by construction instead of by remembering to special-case it. validateAntigravity also now rejects any manifest field outside {name, description} as a regression guard, and no longer requires description (the schema makes it optional). Also reversed, with a citation: an earlier pass claimed Antigravity doesn't support an agents/ component at all. It does (per antigravity.google/docs/cli/plugins) — that was wrong and is corrected here; no change to defaultComponents was needed. Co-Authored-By: Claude Fable 5 --- src/targets/antigravity.ts | 158 +++++++++++++++++++++++++++++++ src/targets/engine.ts | 14 +++ src/targets/registry.ts | 2 + src/targets/validation-shared.ts | 23 +++++ tests/core.test.ts | 155 ++++++++++++++++++++++++++++++ 5 files changed, 352 insertions(+) create mode 100644 src/targets/antigravity.ts diff --git a/src/targets/antigravity.ts b/src/targets/antigravity.ts new file mode 100644 index 0000000..5f80d00 --- /dev/null +++ b/src/targets/antigravity.ts @@ -0,0 +1,158 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { exists } from "../fs.js"; +import { stripUndefined } from "./shared.js"; +import { + error, + readJson, + validateBareStringSourceEntry, + validateFrontmatter, + validateHooksShape, +} from "./validation-shared.js"; +import type { PluginTargetDefinition } from "./types.js"; + +// Confirmed against the actual JSON Schema on antigravity.google/docs/cli/plugins: +// { "properties": { "name": {...}, "description": {...} }, "required": ["name"], +// "additionalProperties": false } — no other field, including "version", is +// permitted at all. A strict validator rejects the whole manifest for an extra +// key, it doesn't just ignore it. +const antigravityNamePattern = /^[a-zA-Z0-9-_]+$/; +const ALLOWED_MANIFEST_KEYS = new Set(["name", "description"]); + +export const antigravity: PluginTargetDefinition = { + name: "antigravity", + + defaultComponents: [ + "skills", + "agents", + "rules", + "hooks", + "scripts", + "assets", + ], + + resolvePluginPath: (pluginName, pluginConfig) => + pluginConfig.path ?? pluginName, + + // Only name (required) and description (optional) — no version. Antigravity + // has no marketplace to track a version in either; this is a real + // capability gap for this target, not something to work around by + // relocating the field elsewhere. + buildPluginManifest: ({ metadata, pluginName, pluginConfig }) => + stripUndefined({ + name: pluginName, + description: pluginConfig.description ?? metadata?.description, + }), + manifestPath: (pluginPath) => path.join(pluginPath, "plugin.json"), + + // No marketplace/registry concept exists for Antigravity. + buildMarketplaceEntry: () => undefined, + buildMarketplaceManifest: () => ({}), + marketplacePaths: () => [], + + mcpConfigPath: (pluginPath) => path.join(pluginPath, "mcp_config.json"), + // Root-level hooks.json, not a hooks/ directory — the Plan 1 fix. Confirmed + // on both antigravity.google/docs/ide/plugins and .../docs/cli/plugins: + // "Hooks — Configured via hooks.json at the plugin root." + hooksPath: (pluginPath) => path.join(pluginPath, "hooks.json"), + + validateManifest: (manifest, pluginName, issues) => { + if ( + typeof manifest.name !== "string" || + !antigravityNamePattern.test(manifest.name) + ) { + error( + issues, + `${pluginName}: plugin.json "name" must match ^[a-zA-Z0-9-_]+$.`, + ); + } + for (const key of Object.keys(manifest)) { + if (!ALLOWED_MANIFEST_KEYS.has(key)) { + error( + issues, + `${pluginName}: plugin.json has field "${key}", which Antigravity's schema (additionalProperties: false) does not allow. Only "name" and "description" are permitted.`, + ); + } + } + }, + // No marketplace entries exist for this target; implemented defensively in + // case that ever changes, using the same shared shape most other targets use. + validateMarketplaceEntry: (entry, index, root, issues) => + validateBareStringSourceEntry( + entry, + index, + root, + issues, + antigravityNamePattern, + ), + + validateOutput: async (root, issues) => { + 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); + antigravity.validateManifest(manifest, pluginName, issues); + if (manifest.name && manifest.name !== pluginName) { + error( + issues, + `${pluginName}: manifest name must match plugin directory name.`, + ); + } + 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 validateHooksShape(pluginDir, pluginName, "hooks.json", issues); + } + }, + + installSnippet: { + userConfigurable: true, + build: ({ repository, pluginPath }) => ({ + kind: "command", + snippet: `git clone ${repository} plugin-source && cd plugin-source && agy plugin install ${pluginPath}`, + }), + citation: { + claim: "agy plugin install syntax (no git-URL support)", + documentationUrl: "https://antigravity.google/docs/cli/plugins", + verifiedAt: "2026-07-25", + }, + }, + + citations: [ + { + claim: + "plugin.json schema (name required, description optional, additionalProperties: false)", + documentationUrl: "https://antigravity.google/docs/cli/plugins", + verifiedAt: "2026-07-25", + }, + { + claim: "hooks.json lives at the plugin root, not in a hooks/ directory", + documentationUrl: "https://antigravity.google/docs/ide/plugins", + verifiedAt: "2026-07-25", + }, + { + claim: "agents/ is a supported component (subagent definition templates)", + documentationUrl: "https://antigravity.google/docs/cli/plugins", + verifiedAt: "2026-07-25", + }, + ], +}; diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 8cd606e..62cc2de 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -52,9 +52,23 @@ export async function emitFromDefinition( const componentDirs = new Set( [...pluginFiles.keys()].map((file) => file.split("/")[0]), ); + + // Hooks are relocated to wherever this target's hooksPath() says they + // belong, rather than copied verbatim from the source's conventional + // hooks/hooks.json — this is what actually fixes a target whose real + // convention differs (Antigravity: a root-level hooks.json, not a + // directory). For a target whose hooksPath already is "hooks/hooks.json" + // this is a same-path no-op. + const sourceHooksFile = pluginFiles.get("hooks/hooks.json"); + if (sourceHooksFile !== undefined) { + pluginFiles.delete("hooks/hooks.json"); + } for (const [relativePath, value] of pluginFiles) { files.set(toPosix(path.join(pluginPath, relativePath)), value); } + if (sourceHooksFile !== undefined) { + files.set(toPosix(definition.hooksPath(pluginPath)), sourceHooksFile); + } const mcpServers = await resolveMcpServers(project, pluginConfig.from); const mcpConfigPath = definition.mcpConfigPath(pluginPath); diff --git a/src/targets/registry.ts b/src/targets/registry.ts index b7b26d8..2556cf9 100644 --- a/src/targets/registry.ts +++ b/src/targets/registry.ts @@ -1,3 +1,4 @@ +import { antigravity } from "./antigravity.js"; import { copilot } from "./copilot.js"; import type { TargetName } from "../types.js"; import type { PluginTargetDefinition } from "./types.js"; @@ -9,4 +10,5 @@ import type { PluginTargetDefinition } from "./types.js"; // `Record` and `adapters.ts` is deleted. export const targets: Partial> = { copilot, + antigravity, }; diff --git a/src/targets/validation-shared.ts b/src/targets/validation-shared.ts index bde9ce8..3df6262 100644 --- a/src/targets/validation-shared.ts +++ b/src/targets/validation-shared.ts @@ -113,6 +113,29 @@ export function validateMarketplaceBasics( } } +// Parameterized by the target's own hooksPath() rather than a hardcoded +// "hooks/hooks.json" — a target that relocates hooks (Antigravity, to a +// root-level hooks.json) validates its own actual location, not everyone +// else's convention. +export async function validateHooksShape( + pluginDir: string, + pluginName: string, + hooksRelativePath: string, + issues: ValidationIssue[], +): Promise { + const hooksFile = path.join(pluginDir, hooksRelativePath); + if (!(await exists(hooksFile))) { + return; + } + const hooks = await readJson(hooksFile, `${pluginName} hooks`, issues); + if (hooks && (!hooks.hooks || typeof hooks.hooks !== "object")) { + error( + issues, + `${pluginName}: ${hooksRelativePath} must have a "hooks" object.`, + ); + } +} + export function validateReferencedManifestPaths( pluginDir: string, pluginName: string, diff --git a/tests/core.test.ts b/tests/core.test.ts index c0ad2fa..2eb3783 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -183,6 +183,161 @@ export default defineConfig({ ).toBe(true); }); + it('never writes a "version" field into antigravity plugin.json (antigravity.google/docs/cli/plugins schema: additionalProperties: false, only name+description)', async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "antigravity-version-plugins", + version: "3.0.0", + metadata: { description: "AG", author: { name: "X" }, license: "MIT" }, + targets: { + antigravity: { + outDir: "dist/antigravity", + plugins: { demo: { from: ["demo"], version: "9.9.9" } } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "antigravity" }); + + const manifest = JSON.parse( + await readFile( + path.join(root, "dist/antigravity/demo/plugin.json"), + "utf8", + ), + ) as Record; + expect(Object.keys(manifest).sort()).toEqual(["description", "name"]); + expect(manifest).not.toHaveProperty("version"); + + await expect( + validateOutput("antigravity", path.join(root, "dist/antigravity")), + ).resolves.toMatchObject({ ok: true }); + }); + + it("rejects an antigravity plugin.json with a disallowed field at validate time", async () => { + const project = await fixture(); + const root = project.baseDir; + await build({ cwd: root, target: "antigravity" }); + + const manifestPath = path.join(root, "dist/antigravity/demo/plugin.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Record< + string, + unknown + >; + manifest.version = "1.0.0"; + await writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + + const result = await validateOutput( + "antigravity", + path.join(root, "dist/antigravity"), + ); + expect(result.ok).toBe(false); + expect( + result.issues.some((issue) => issue.message.includes("does not allow")), + ).toBe(true); + }); + + it('relocates antigravity hooks to a root-level hooks.json, not hooks/hooks.json (antigravity.google/docs/ide/plugins: "Hooks — Configured via hooks.json at the plugin root")', async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "antigravity-hooks-plugins", + version: "1.0.0", + metadata: { description: "AG", author: { name: "X" }, license: "MIT" }, + targets: { + antigravity: { + outDir: "dist/antigravity", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + hooks: { + "hooks.json": `${JSON.stringify( + { + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "echo hi" }] }, + ], + }, + }, + null, + 2, + )}\n`, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "antigravity" }); + + // Root-level file exists, with the source content preserved. + const rootHooks = JSON.parse( + await readFile( + path.join(root, "dist/antigravity/demo/hooks.json"), + "utf8", + ), + ) as Record; + expect(rootHooks).toMatchObject({ + hooks: { + SessionStart: [{ hooks: [{ type: "command", command: "echo hi" }] }], + }, + }); + // No nested hooks/ directory was also written. + await expectMissing( + path.join(root, "dist/antigravity/demo/hooks/hooks.json"), + ); + + await expect( + validateOutput("antigravity", path.join(root, "dist/antigravity")), + ).resolves.toMatchObject({ ok: true }); + }); + + it("does not require description on an antigravity plugin (schema only requires name)", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "antigravity-no-description", + version: "1.0.0", + metadata: { author: { name: "X" }, license: "MIT" }, + targets: { + antigravity: { + outDir: "dist/antigravity", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "antigravity" }); + + await expect( + validateOutput("antigravity", path.join(root, "dist/antigravity")), + ).resolves.toMatchObject({ ok: true }); + }); + it("uses target-specific file overrides", async () => { const project = await fixture(); const root = project.baseDir; From b3b848e487def3b8531cb90b7daa8247e1ddd836 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 11:27:41 -0700 Subject: [PATCH 4/5] fix: emit copilot plugin.json at .github/plugin/, not just plugin root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every docs.github.com example tree shows plugin.json at the plugin root, which is what the previous commit shipped. Before treating that fix as done, checked it against real published examples rather than trusting the docs' illustrative trees alone — two real, working plugins (github/copilot-advanced-security-plugin, microsoft/skills-for-fabric) both put plugin.json at .github/plugin/plugin.json instead. Root-only would have repeated the same class of bug (a plugin the real tooling doesn't find where pluginpack put it), just relocated. Mirrors it at both locations now, matching how the marketplace manifest already dual-writes to .claude-plugin/ and .github/plugin/ for the same reason. Generalized manifestPath -> manifestPaths on PluginTargetDefinition to support this (cheap now, before more targets implement the single-path version). Co-Authored-By: Claude Fable 5 --- src/targets/antigravity.ts | 2 +- src/targets/copilot.ts | 32 ++++++++++++++++++++++++-- src/targets/engine.ts | 8 ++++--- src/targets/types.ts | 5 ++++- tests/conformance.test.ts | 9 ++++++++ tests/core.test.ts | 46 ++++++++++++++++++++++++++++++-------- 6 files changed, 86 insertions(+), 16 deletions(-) diff --git a/src/targets/antigravity.ts b/src/targets/antigravity.ts index 5f80d00..3c5c895 100644 --- a/src/targets/antigravity.ts +++ b/src/targets/antigravity.ts @@ -43,7 +43,7 @@ export const antigravity: PluginTargetDefinition = { name: pluginName, description: pluginConfig.description ?? metadata?.description, }), - manifestPath: (pluginPath) => path.join(pluginPath, "plugin.json"), + manifestPaths: (pluginPath) => [path.join(pluginPath, "plugin.json")], // No marketplace/registry concept exists for Antigravity. buildMarketplaceEntry: () => undefined, diff --git a/src/targets/copilot.ts b/src/targets/copilot.ts index 6f767d2..bab72ab 100644 --- a/src/targets/copilot.ts +++ b/src/targets/copilot.ts @@ -65,7 +65,15 @@ export const copilot: PluginTargetDefinition = { } return stripUndefined(manifest); }, - manifestPath: (pluginPath) => path.join(pluginPath, "plugin.json"), + // The docs' illustrative directory trees all show plugin.json at the plugin + // root, but real published plugins (github/copilot-advanced-security-plugin, + // microsoft/skills-for-fabric) put it at .github/plugin/plugin.json instead — + // matching how the marketplace manifest is also dual-written below. Ship + // both rather than bet on one location. + manifestPaths: (pluginPath) => [ + path.join(pluginPath, "plugin.json"), + path.join(pluginPath, ".github", "plugin", "plugin.json"), + ], buildMarketplaceEntry: ({ pluginName, @@ -177,11 +185,21 @@ export const copilot: PluginTargetDefinition = { continue; } const pluginDir = path.join(root, entry.source); + // .github/plugin/plugin.json is the location real published plugins + // use; validate that copy as authoritative and only flag the root + // mirror if it's missing or diverges. const manifest = await readJson( - path.join(pluginDir, "plugin.json"), + path.join(pluginDir, ".github", "plugin", "plugin.json"), `${pluginName} plugin manifest`, issues, ); + const rootManifestPath = path.join(pluginDir, "plugin.json"); + if (!(await exists(rootManifestPath))) { + error( + issues, + `${pluginName}: plugin.json must also be mirrored at the plugin root.`, + ); + } if (manifest) { copilot.validateManifest(manifest, pluginName, issues); await validateReferencedManifestPaths( @@ -219,6 +237,16 @@ export const copilot: PluginTargetDefinition = { "https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference", verifiedAt: "2026-07-25", }, + { + // The docs' example trees all show plugin.json at the plugin root; real + // published plugins put it at .github/plugin/plugin.json instead. Found + // by diffing against two real repos rather than trusting the docs alone. + claim: + "real published plugins locate plugin.json at .github/plugin/plugin.json, not the plugin root", + documentationUrl: + "https://github.com/github/copilot-advanced-security-plugin/blob/main/.github/plugin/plugin.json", + verifiedAt: "2026-07-26", + }, { claim: "agent files must be named NAME.agent.md", documentationUrl: diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 62cc2de..0f08cd9 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -85,10 +85,12 @@ export async function emitFromDefinition( componentDirs, mcpServers, }); - files.set( - toPosix(definition.manifestPath(pluginPath)), - json(stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {}))), + const manifestContent = json( + stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {})), ); + for (const manifestPath of definition.manifestPaths(pluginPath)) { + files.set(toPosix(manifestPath), manifestContent); + } const entry = definition.buildMarketplaceEntry({ pluginName, diff --git a/src/targets/types.ts b/src/targets/types.ts index 433312a..cd7b8a0 100644 --- a/src/targets/types.ts +++ b/src/targets/types.ts @@ -93,7 +93,10 @@ export type PluginTargetDefinition = { // Every target writes one now (Copilot didn't; that was the biggest Plan 1 fix). buildPluginManifest: (ctx: ManifestBuildContext) => Record; - manifestPath: (pluginPath: string) => string; + // One or more output-relative paths (Copilot mirrors it at both the plugin + // root and .github/plugin/, matching real published plugins rather than + // the docs' single-location illustrative tree — see CONFORMANCE.md). + manifestPaths: (pluginPath: string) => string[]; // Omit (return undefined) for a target with no marketplace-entry concept — // none currently omit it, but the shape allows for one that might. diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 347f7f6..1a768c7 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -315,6 +315,15 @@ describe("emitted output conforms to external target schemas", () => { skills: "skills/", mcpServers: ".mcp.json", }); + + // Real published plugins (github/copilot-advanced-security-plugin, + // microsoft/skills-for-fabric) put plugin.json here, not at the plugin + // root the docs' example trees show — mirror both. + const githubPluginManifest = readJson( + project.baseDir, + "out-copilot/plugins/glean/.github/plugin/plugin.json", + ); + expect(githubPluginManifest).toEqual(pluginManifest); }); it("codex emits the documented Codex plugin marketplace layout", async () => { diff --git a/tests/core.test.ts b/tests/core.test.ts index 2eb3783..c42ee4c 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -105,14 +105,19 @@ export default defineConfig({ await build({ cwd: root, target: "copilot" }); - const manifestPath = path.join( - root, - "dist/copilot/plugins/demo/plugin.json", + // Mirrored at both the plugin root (the docs' illustrative location) and + // .github/plugin/ (where real published plugins actually put it — + // github/copilot-advanced-security-plugin, microsoft/skills-for-fabric). + const rootManifest = await readFile( + path.join(root, "dist/copilot/plugins/demo/plugin.json"), + "utf8", ); - const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Record< - string, - unknown - >; + const githubManifest = await readFile( + path.join(root, "dist/copilot/plugins/demo/.github/plugin/plugin.json"), + "utf8", + ); + expect(rootManifest).toBe(githubManifest); + const manifest = JSON.parse(rootManifest) as Record; expect(manifest).toMatchObject({ name: "demo", version: "2.0.0", @@ -166,12 +171,16 @@ export default defineConfig({ ).toBe(true); }); - it("catches a missing copilot plugin.json at validate time", async () => { + it("catches a missing copilot plugin.json (.github/plugin copy) at validate time", async () => { const project = await fixture(); const root = project.baseDir; await build({ cwd: root, target: "copilot" }); - await rm(path.join(root, "dist/copilot/plugins/demo/plugin.json")); + // .github/plugin/plugin.json is the authoritative copy (real published + // plugins use it); removing it should be treated as the manifest missing. + await rm( + path.join(root, "dist/copilot/plugins/demo/.github/plugin/plugin.json"), + ); const result = await validateOutput( "copilot", @@ -183,6 +192,25 @@ export default defineConfig({ ).toBe(true); }); + it("catches a missing root-level copilot plugin.json mirror at validate time", async () => { + const project = await fixture(); + const root = project.baseDir; + await build({ cwd: root, target: "copilot" }); + + await rm(path.join(root, "dist/copilot/plugins/demo/plugin.json")); + + const result = await validateOutput( + "copilot", + path.join(root, "dist/copilot"), + ); + expect(result.ok).toBe(false); + expect( + result.issues.some((issue) => + issue.message.includes("must also be mirrored at the plugin root"), + ), + ).toBe(true); + }); + it('never writes a "version" field into antigravity plugin.json (antigravity.google/docs/cli/plugins schema: additionalProperties: false, only name+description)', async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; From c0d2399318deb7c0c823aecadbc9149a7664025c Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 12:25:07 -0700 Subject: [PATCH 5/5] docs: convert comments to JSDoc, strip development narrative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes across every source file: - Every exported function, type, and const now has a JSDoc block (/** ... */) rather than a bare // line comment, matching normal TypeScript convention for documenting public API surface. Applied codebase-wide, not just to this session's new files, since a partial conversion would leave the codebase in a more inconsistent state than before. - Removed comments that narrated the investigation itself rather than documenting a durable fact about the code — citation trails ("confirmed via direct fetch of..."), references to internal planning artifacts ("Plan 1's fix"), and before/after narration ("an earlier pass claimed X — reversed here"). That content belongs in commit messages and PR descriptions, which is where it already was — duplicating it into source comments only means it goes stale the next time someone re-verifies the underlying fact and doesn't think to hunt down every comment that mentions it. No behavior changes; every edit is comment-only. Full test suite (56/56) passes unchanged. Co-Authored-By: Claude Fable 5 --- src/adapters.ts | 24 ++++++++++----- src/build.ts | 1 + src/cleanup.ts | 2 ++ src/cli.ts | 18 ++++++++++++ src/components.ts | 5 ++++ src/config.ts | 3 ++ src/diff.ts | 1 + src/fs.ts | 14 +++++++-- src/index.ts | 1 + src/managed.ts | 7 +++++ src/render.ts | 12 ++++---- src/schema.ts | 18 ++++++++---- src/source.ts | 10 ++++--- src/targets.ts | 29 ++++++++++++++---- src/targets/antigravity.ts | 21 ++++++-------- src/targets/copilot.ts | 30 ++++++------------- src/targets/engine.ts | 31 ++++++++++++-------- src/targets/registry.ts | 12 ++++---- src/targets/shared.ts | 14 +++++---- src/targets/types.ts | 50 ++++++++++++++++---------------- src/targets/validation-shared.ts | 25 ++++++++++------ src/types.ts | 14 +++++++-- src/update-check.ts | 24 ++++++++++----- src/validate.ts | 15 ++++++++++ 24 files changed, 252 insertions(+), 129 deletions(-) diff --git a/src/adapters.ts b/src/adapters.ts index e5a93cc..2015a9d 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -40,6 +40,7 @@ type TargetValidator = ( issues: ValidationIssue[], ) => Promise; +/** The emit and validate functions for one target. */ export type TargetAdapter = { emit: TargetEmitter; validate: TargetValidator; @@ -56,14 +57,18 @@ const legacyAdapters: Record = { 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. +/** + * 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]; @@ -85,8 +90,10 @@ export const adapters: Record = Object.fromEntries( }), ) 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[]; +/** Emits one target's output and applies its `rootFiles`, resolving `outDir` from config if omitted. */ export async function emitTarget( project: ResolvedProject, target: TargetName, @@ -109,6 +116,7 @@ export async function emitTarget( return withRootFiles(project, targetConfig, result); } +/** Validates an already-built target's output directory. */ export async function validateOutput( target: TargetName, dir: string, diff --git a/src/build.ts b/src/build.ts index b4e19c9..5228b15 100644 --- a/src/build.ts +++ b/src/build.ts @@ -9,6 +9,7 @@ import { import { emitTarget, targetNames } from "./adapters.js"; import type { Artifact, BuildOptions, TargetName } from "./types.js"; +/** Builds every configured (or explicitly selected) target and writes its output, unless `dryRun` is set. */ export async function build(options: BuildOptions = {}): Promise { const project = await loadConfig(options.cwd, options.configPath); const targets = options.target diff --git a/src/cleanup.ts b/src/cleanup.ts index bfaeb3a..8882d19 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -8,6 +8,7 @@ import { } from "./managed.js"; import type { CleanupResult, TargetName } from "./types.js"; +/** Deletes managed files no longer produced by the current build, without rewriting current output. */ export async function prune( options: { cwd?: string; @@ -39,6 +40,7 @@ export async function prune( return results; } +/** Deletes every managed file for the given target(s), tearing the generated output down entirely. */ export async function clean( options: { cwd?: string; diff --git a/src/cli.ts b/src/cli.ts index ef481d8..abd554d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,11 +8,13 @@ import { diffTarget } from "./diff.js"; import { targetNames, validateOutput } from "./adapters.js"; import type { TargetName } from "./types.js"; +/** CLI entry point. */ async function main(): Promise { const program = createProgram(); await program.parseAsync(process.argv); } +/** Builds the `pluginpack` commander program: init, build, validate, diff, prune, clean, docs. */ function createProgram(): Command { const program = new Command(); const pkg = readPackageJson(); @@ -227,6 +229,7 @@ function createProgram(): Command { return program; } +/** Prints `prune`/`clean` results, one line per affected file. */ function printCleanupResults( results: Awaited>, verb: string, @@ -241,6 +244,7 @@ function printCleanupResults( } } +/** Writes a starter `pluginpack.config.ts` and example source plugin. */ async function init(): Promise { const configPath = path.resolve("pluginpack.config.ts"); if (await exists(configPath)) { @@ -267,6 +271,7 @@ async function init(): Promise { console.log("Created pluginpack.config.ts and plugins/example."); } +/** Commander option parser validating `--target` against the known target names. */ function parseTarget(value: string): TargetName { if (!(targetNames as string[]).includes(value)) { throw new Error(`Expected one of: ${targetNames.join(", ")}.`); @@ -274,6 +279,7 @@ function parseTarget(value: string): TargetName { return value as TargetName; } +/** Splices generated content between README.md's `pluginpack-cli` marker comments. */ function replaceGeneratedSection(readme: string, content: string): string { const start = ""; const end = ""; @@ -285,6 +291,7 @@ function replaceGeneratedSection(readme: string, content: string): string { return `${readme.slice(0, startIndex + start.length)}\n\n${content}\n${readme.slice(endIndex)}`; } +/** Reads the package's own version and description, for `--version`/`--help`. */ function readPackageJson(): { version: string; description: string } { const packageJson = JSON.parse( readFileSync(new URL("../package.json", import.meta.url), "utf8"), @@ -298,6 +305,7 @@ function readPackageJson(): { version: string; description: string } { return { version: packageJson.version, description: packageJson.description }; } +/** Renders the README's "CLI Reference" section from the live commander program. */ function renderCliReference(program: Command): string { const lines = ["## CLI Reference", ""]; for (const command of program.commands) { @@ -344,6 +352,7 @@ function renderCliReference(program: Command): string { return lines.join("\n").trimEnd(); } +/** A command's usage line, e.g. `pluginpack build [--target ]`. */ function commandUsage(command: Command): string { const usage = command.usage(); return usage @@ -351,6 +360,11 @@ function commandUsage(command: Command): string { : `pluginpack ${command.name()}`; } +/** + * Example invocations shown in the generated CLI reference, keyed by + * command name. Hand-maintained, not derived from the command definitions — + * a new command needs a case added here or its docs section omits examples. + */ function commandExamples(commandName: string): string[] { switch (commandName) { case "init": @@ -376,6 +390,7 @@ function commandExamples(commandName: string): string[] { } } +/** Exit-code documentation shown in the generated CLI reference, keyed by command name (hand-maintained, see `commandExamples`). */ function commandExitCodes(commandName: string): string[] { switch (commandName) { case "init": @@ -415,6 +430,7 @@ function commandExitCodes(commandName: string): string[] { } } +/** Whether the file at `filePath` exists. */ async function exists(filePath: string): Promise { try { await fs.access(filePath); @@ -424,6 +440,7 @@ async function exists(filePath: string): Promise { } } +/** Content of the `pluginpack.config.ts` written by `init`. */ function starterConfig(): string { return `import { defineConfig } from "@gleanwork/pluginpack"; @@ -465,6 +482,7 @@ export default defineConfig({ `; } +/** Content of the example `SKILL.md` written by `init`. */ function starterSkill(): string { return `--- name: example diff --git a/src/components.ts b/src/components.ts index fe3af20..45a37ea 100644 --- a/src/components.ts +++ b/src/components.ts @@ -1,5 +1,6 @@ import type { TargetName } from "./types.js"; +/** Every recognized component directory, across all targets. */ export const componentDirs = [ "skills", "agents", @@ -12,8 +13,10 @@ export const componentDirs = [ "themes", ]; +/** 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"], @@ -22,6 +25,7 @@ export const targetDefaultComponents: Record = { 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[] }, @@ -29,6 +33,7 @@ export function resolveTargetComponents( 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/config.ts b/src/config.ts index a41ff31..72be5e6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,10 +15,12 @@ import type { SourcePluginManifest, } from "./types.js"; +/** Identity helper giving config authors type-checking and editor completion. */ export function defineConfig(config: PluginpackConfig): PluginpackConfig { return config; } +/** Loads the project config and discovers its source plugins, ready for `build()`. */ export async function loadConfig( cwd = process.cwd(), configPath?: string, @@ -36,6 +38,7 @@ export async function loadConfig( }; } +/** Loads and validates the config file itself, without discovering source plugins. */ export async function loadProjectConfig( cwd = process.cwd(), configPath?: string, diff --git a/src/diff.ts b/src/diff.ts index 2a25c82..eb3cbc8 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -7,6 +7,7 @@ import { exists } from "./fs.js"; import { normalizeManagedPath, readManagedManifest } from "./managed.js"; import type { DiffEntry, DiffResult, TargetName } from "./types.js"; +/** Builds a target into a temp dir and diffs its managed files against an existing output repo. */ export async function diffTarget(options: { cwd?: string; configPath?: string; diff --git a/src/fs.ts b/src/fs.ts index 8f9d58a..1667756 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -3,10 +3,12 @@ import path from "node:path"; import fastGlob from "fast-glob"; import type { FileValue } from "./types.js"; +/** Converts OS-specific path separators to forward slashes. */ export function toPosix(value: string): string { return value.split(path.sep).join("/"); } +/** Lists every file under `dir`, recursively, as absolute paths, sorted. */ export async function walkFiles(dir: string): Promise { const entries = await fastGlob("**/*", { cwd: dir, @@ -17,6 +19,7 @@ export async function walkFiles(dir: string): Promise { return entries.sort(); } +/** Writes every file in `files` under `outDir`, refusing to write outside it. */ export async function writeArtifact( outDir: string, files: Map, @@ -37,10 +40,12 @@ export async function writeArtifact( } } +/** Serializes `value` as pretty-printed JSON with a trailing newline. */ export function json(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } +/** Whether `value` is a URL, or a relative path that can't escape its base directory. */ export function isSafeRelativePath(value: string): boolean { if (!value) { return false; @@ -55,6 +60,7 @@ export function isSafeRelativePath(value: string): boolean { return normalized !== ".." && !normalized.startsWith("../"); } +/** Whether the file at `filePath` exists. */ export async function exists(filePath: string): Promise { try { await fs.access(filePath); @@ -67,9 +73,11 @@ export async function exists(filePath: string): Promise { } } -// ENOENT/ENOTDIR mean the path simply isn't there; any other errno (EACCES, -// ELOOP, …) is a real IO/permission failure that should surface rather than be -// silently read as "does not exist". +/** + * Whether `error` is an ENOENT/ENOTDIR ("path doesn't exist") rather than a + * real IO/permission failure (EACCES, ELOOP, …) that should surface instead + * of being read as "does not exist". + */ export function isNotFoundError(error: unknown): boolean { if (!(error instanceof Error) || !("code" in error)) { return false; diff --git a/src/index.ts b/src/index.ts index 6b79223..a272e7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +/** Public API — everything the CLI itself uses is exported here for programmatic use. */ export { defineConfig, loadConfig } from "./config.js"; export { build } from "./build.js"; export { clean, prune } from "./cleanup.js"; diff --git a/src/managed.ts b/src/managed.ts index bf6cd53..3d847d5 100644 --- a/src/managed.ts +++ b/src/managed.ts @@ -16,10 +16,12 @@ type ManagedManifest = { files: string[]; }; +/** Output-relative path of a target's managed-file manifest. */ export function managedManifestPath(target: TargetName): string { return toPosix(path.join(".pluginpack", `${target}.json`)); } +/** Writes the list of files a build produced, for later prune/clean/diff to compare against. */ export async function writeManagedManifest(artifact: Artifact): Promise { const manifest: ManagedManifest = { version: 1, @@ -34,6 +36,7 @@ export async function writeManagedManifest(artifact: Artifact): Promise { await fs.writeFile(destination, json(manifest)); } +/** Reads a target's managed-file manifest, or `null` if none exists yet. */ export async function readManagedManifest( outDir: string, target: TargetName, @@ -65,6 +68,7 @@ export async function readManagedManifest( return parsed as ManagedManifest; } +/** Deletes files the previous build managed but the current one no longer produces. */ export async function pruneManagedFiles( artifact: Artifact, options: { dryRun?: boolean; guard?: DeleteGuard } = {}, @@ -95,6 +99,7 @@ export async function pruneManagedFiles( }; } +/** Deletes every file the previous build managed for a target, including its manifest. */ export async function cleanManagedFiles( outDir: string, target: TargetName, @@ -123,6 +128,7 @@ export async function cleanManagedFiles( return { target, outDir, entries }; } +/** Builds the guard that stops prune/clean from deleting paths inside the config's source tree. */ export function buildDeleteGuard( rootDir: string, config: PluginpackConfig, @@ -176,6 +182,7 @@ function isProtectedDeletion( ); } +/** Normalizes a managed path to a safe, relative, forward-slash form, throwing if it escapes the output dir. */ export function normalizeManagedPath(value: string): string { const normalized = path.posix.normalize(value.replace(/\\/g, "/")); if ( diff --git a/src/render.ts b/src/render.ts index 27d992f..c8a8909 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,9 +1,12 @@ import { isComponentPath } from "./components.js"; import type { FileValue, ResolvedProject, TargetName } from "./types.js"; -// Merge the files of one or more source plugins into a single emitted-plugin -// file map. File acquisition is delegated to project.source; the merge + -// duplicate-path guard is pluginpack logic that holds regardless of source. +/** + * Merges the files of one or more source plugins into a single + * emitted-plugin file map. File acquisition is delegated to + * `project.source`; the merge and duplicate-path guard are pluginpack logic + * that hold regardless of source. + */ export async function collectPluginFiles( project: ResolvedProject, target: TargetName, @@ -28,8 +31,7 @@ export async function collectPluginFiles( return files; } -// Merge the MCP servers of one or more source plugins; a server name present in -// two merged plugins is an error. +/** Merges the MCP servers of one or more source plugins; a name collision across plugins is an error. */ export async function resolveMcpServers( project: ResolvedProject, sourceIds: string[], diff --git a/src/schema.ts b/src/schema.ts index eb73aec..c2463d7 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,8 +1,10 @@ import { z } from "zod"; import { isSafeRelativePath } from "./fs.js"; -// Paths that get written to / read from disk relative to a root — reject -// absolute paths and ".." escapes so a config can't write or read outside. +/** + * A path written to or read from disk relative to a root — rejects absolute + * paths and `..` escapes so a config can't write or read outside it. + */ const safeRelativePath = z .string() .refine( @@ -42,13 +44,16 @@ 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. +/** + * 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(), }); +/** A source plugin (or plugins) mapped to one emitted plugin for a target. */ const emittedPluginSchema = z.object({ from: z.array(z.string().min(1)).min(1), path: safeRelativePath.optional(), @@ -64,6 +69,7 @@ const emittedPluginSchema = z.object({ updateCheck: z.literal(false).optional(), }); +/** One target's output configuration: where it's written, and which plugins it emits. */ const targetSchema = z.object({ outDir: z.string().min(1), marketplaceDir: safeRelativePath.optional(), @@ -80,6 +86,7 @@ const targetSchema = z.object({ rootFiles: z.record(safeRelativePath, safeRelativePath).optional(), }); +/** The root `pluginpack.config.ts` schema. */ const configSchema = z .object({ name: z.string().min(1), @@ -108,6 +115,7 @@ const configSchema = z } }); +/** A source plugin's own `plugin.pluginpack.json`, if it has one. */ const sourcePluginManifestSchema = metadataSchema.extend({ name: z.string().optional(), description: z.string().optional(), diff --git a/src/source.ts b/src/source.ts index 04ea5b8..e96ef8d 100644 --- a/src/source.ts +++ b/src/source.ts @@ -9,10 +9,12 @@ import type { TargetName, } from "./types.js"; -// Filesystem-backed source: reads a discovered plugin's component and static -// files (with target overrides) and its MCP servers from disk. The discovered -// plugins map is passed in; an API-backed provider would implement the same -// SourceProvider interface against the Skills API instead. +/** + * Filesystem-backed `SourceProvider`: reads a discovered plugin's component + * and static files (with target overrides) and its MCP servers from disk. + * An API-backed provider would implement the same interface against a + * remote source instead. + */ export function createFilesystemSourceProvider( plugins: Map, ): SourceProvider { diff --git a/src/targets.ts b/src/targets.ts index a1fe01d..e0e727b 100644 --- a/src/targets.ts +++ b/src/targets.ts @@ -15,9 +15,12 @@ import type { TargetName, } from "./types.js"; -// Emit 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. +/** + * 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, @@ -92,6 +95,7 @@ type EmitPluginsOptions = { }; }; +/** Shared per-plugin emit loop every legacy `emitXxx` function delegates to. */ async function emitPlugins( project: ResolvedProject, target: TargetName, @@ -175,8 +179,10 @@ async function emitPlugins( return entries; } -// Resolve a target's updateCheck config into the emitPlugins option, failing -// fast when no repository URL can be determined. +/** + * 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, @@ -202,6 +208,7 @@ function updateCheckOption( }; } +/** Emits the Cursor target's plugins and marketplace manifest. */ export async function emitCursor( project: ResolvedProject, target: TargetName, @@ -283,6 +290,7 @@ export async function emitCursor( return artifact(target, outDir, files); } +/** Emits the Claude target's plugins and marketplace manifest. */ export async function emitClaude( project: ResolvedProject, target: TargetName, @@ -347,6 +355,11 @@ export async function emitClaude( 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, @@ -375,6 +388,11 @@ export async function emitAntigravity( 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, @@ -447,6 +465,7 @@ export async function emitCopilot( return artifact(target, outDir, files); } +/** Emits the Codex target's plugins and marketplace manifest. */ export async function emitCodex( project: ResolvedProject, target: TargetName, diff --git a/src/targets/antigravity.ts b/src/targets/antigravity.ts index 3c5c895..8d7e2ee 100644 --- a/src/targets/antigravity.ts +++ b/src/targets/antigravity.ts @@ -11,14 +11,14 @@ import { } from "./validation-shared.js"; import type { PluginTargetDefinition } from "./types.js"; -// Confirmed against the actual JSON Schema on antigravity.google/docs/cli/plugins: -// { "properties": { "name": {...}, "description": {...} }, "required": ["name"], -// "additionalProperties": false } — no other field, including "version", is -// permitted at all. A strict validator rejects the whole manifest for an extra -// key, it doesn't just ignore it. const antigravityNamePattern = /^[a-zA-Z0-9-_]+$/; + +// plugin.json's schema is { name (required), description (optional) }, +// additionalProperties: false — a strict validator rejects the whole +// manifest for an extra key, not just the key itself. See citations below. const ALLOWED_MANIFEST_KEYS = new Set(["name", "description"]); +/** Antigravity plugin target — see `citations` for source facts. */ export const antigravity: PluginTargetDefinition = { name: "antigravity", @@ -34,10 +34,9 @@ export const antigravity: PluginTargetDefinition = { resolvePluginPath: (pluginName, pluginConfig) => pluginConfig.path ?? pluginName, - // Only name (required) and description (optional) — no version. Antigravity - // has no marketplace to track a version in either; this is a real - // capability gap for this target, not something to work around by - // relocating the field elsewhere. + // No version field: Antigravity's schema doesn't allow one, and there's no + // marketplace to track a version in either — a real capability gap for + // this target, not something to work around by relocating the field. buildPluginManifest: ({ metadata, pluginName, pluginConfig }) => stripUndefined({ name: pluginName, @@ -51,9 +50,7 @@ export const antigravity: PluginTargetDefinition = { marketplacePaths: () => [], mcpConfigPath: (pluginPath) => path.join(pluginPath, "mcp_config.json"), - // Root-level hooks.json, not a hooks/ directory — the Plan 1 fix. Confirmed - // on both antigravity.google/docs/ide/plugins and .../docs/cli/plugins: - // "Hooks — Configured via hooks.json at the plugin root." + // Root-level hooks.json, not a hooks/ directory (see citations). hooksPath: (pluginPath) => path.join(pluginPath, "hooks.json"), validateManifest: (manifest, pluginName, issues) => { diff --git a/src/targets/copilot.ts b/src/targets/copilot.ts index bab72ab..b91bf1a 100644 --- a/src/targets/copilot.ts +++ b/src/targets/copilot.ts @@ -14,10 +14,10 @@ import type { PluginTargetDefinition } from "./types.js"; const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; -// docs.github.com's plugin.json field reference calls this out explicitly: -// "Kebab-case plugin name (letters, numbers, hyphens only). Max 64 chars." +/** Kebab-case, letters/numbers/hyphens only, per the Copilot plugin.json field reference. */ const copilotNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** GitHub Copilot agent-plugin target — see `citations` for source facts. */ export const copilot: PluginTargetDefinition = { name: "copilot", @@ -27,10 +27,6 @@ export const copilot: PluginTargetDefinition = { pluginConfig.path ?? toPosix(path.join(targetConfig.pluginRoot ?? "plugins", pluginName)), - // Plan 1's biggest fix: Copilot requires a per-plugin plugin.json — pluginpack - // previously wrote none at all ("Copilot has no per-plugin manifest" was true - // of an earlier, thinner version of the format; it isn't true of the current - // one). See citations below. buildPluginManifest: ({ metadata, version, @@ -65,11 +61,9 @@ export const copilot: PluginTargetDefinition = { } return stripUndefined(manifest); }, - // The docs' illustrative directory trees all show plugin.json at the plugin - // root, but real published plugins (github/copilot-advanced-security-plugin, - // microsoft/skills-for-fabric) put it at .github/plugin/plugin.json instead — - // matching how the marketplace manifest is also dual-written below. Ship - // both rather than bet on one location. + // Mirrored at both the plugin root and .github/plugin/, since published + // plugins use the latter (see citations) — shipping only one risks the + // manifest not being found at all. manifestPaths: (pluginPath) => [ path.join(pluginPath, "plugin.json"), path.join(pluginPath, ".github", "plugin", "plugin.json"), @@ -114,8 +108,7 @@ export const copilot: PluginTargetDefinition = { owner: project.config.metadata?.owner ?? project.config.metadata?.author, plugins, }), - // Copilot reads the marketplace from both the repo-root .claude-plugin/ and - // .github/plugin/ (see github/copilot-plugins) — mirror it at both. + // Copilot reads the marketplace from both locations; mirror it at both. marketplacePaths: () => [ path.join(".claude-plugin", "marketplace.json"), path.join(".github", "plugin", "marketplace.json"), @@ -185,16 +178,14 @@ export const copilot: PluginTargetDefinition = { continue; } const pluginDir = path.join(root, entry.source); - // .github/plugin/plugin.json is the location real published plugins - // use; validate that copy as authoritative and only flag the root - // mirror if it's missing or diverges. + // .github/plugin/ is the authoritative copy; the root copy is only + // checked for presence, not re-parsed. const manifest = await readJson( path.join(pluginDir, ".github", "plugin", "plugin.json"), `${pluginName} plugin manifest`, issues, ); - const rootManifestPath = path.join(pluginDir, "plugin.json"); - if (!(await exists(rootManifestPath))) { + if (!(await exists(path.join(pluginDir, "plugin.json")))) { error( issues, `${pluginName}: plugin.json must also be mirrored at the plugin root.`, @@ -238,9 +229,6 @@ export const copilot: PluginTargetDefinition = { verifiedAt: "2026-07-25", }, { - // The docs' example trees all show plugin.json at the plugin root; real - // published plugins put it at .github/plugin/plugin.json instead. Found - // by diffing against two real repos rather than trusting the docs alone. claim: "real published plugins locate plugin.json at .github/plugin/plugin.json, not the plugin root", documentationUrl: diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 0f08cd9..c2c8f22 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -13,10 +13,12 @@ import type { } from "../types.js"; import type { PluginTargetDefinition } from "./types.js"; -// The one place a plugin's file set gets filtered to its resolved component -// set — decoupled from the legacy global `targetDefaultComponents` table -// (components.ts) so a migrated target's default component list is owned -// entirely by its own PluginTargetDefinition. +/** + * 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`. + */ function resolveComponents( definition: PluginTargetDefinition, pluginConfig: { components?: string[] }, @@ -24,6 +26,11 @@ function resolveComponents( return new Set(pluginConfig.components ?? definition.defaultComponents); } +/** + * Emits one target's output using its `PluginTargetDefinition` — the shared + * engine every migrated target runs through, in place of a bespoke + * `emitXxx` function per target. + */ export async function emitFromDefinition( project: ResolvedProject, target: TargetName, @@ -53,12 +60,11 @@ export async function emitFromDefinition( [...pluginFiles.keys()].map((file) => file.split("/")[0]), ); - // Hooks are relocated to wherever this target's hooksPath() says they - // belong, rather than copied verbatim from the source's conventional - // hooks/hooks.json — this is what actually fixes a target whose real - // convention differs (Antigravity: a root-level hooks.json, not a - // directory). For a target whose hooksPath already is "hooks/hooks.json" - // this is a same-path no-op. + // Hooks are relocated to definition.hooksPath() rather than copied + // verbatim from the source's conventional hooks/hooks.json, so a target + // whose real hooks convention differs (e.g. a root-level file, not a + // directory) gets it right by construction. A same-path target is a + // same-path no-op. const sourceHooksFile = pluginFiles.get("hooks/hooks.json"); if (sourceHooksFile !== undefined) { pluginFiles.delete("hooks/hooks.json"); @@ -103,8 +109,8 @@ export async function emitFromDefinition( 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). + // Deep-merge the config's per-plugin entry passthrough, so a target + // can carry author-supplied fields it can't derive on its own. entries.push(stripUndefined(deepMerge(entry, pluginConfig.entry ?? {}))); } } @@ -128,6 +134,7 @@ export async function emitFromDefinition( return artifact(target, outDir, files); } +/** Validates one target's output using its `PluginTargetDefinition`. */ export async function validateFromDefinition( root: string, issues: ValidationIssue[], diff --git a/src/targets/registry.ts b/src/targets/registry.ts index 2556cf9..fdde0c2 100644 --- a/src/targets/registry.ts +++ b/src/targets/registry.ts @@ -3,11 +3,13 @@ import { copilot } from "./copilot.js"; import type { TargetName } from "../types.js"; import type { PluginTargetDefinition } from "./types.js"; -// Filled in one target at a time as each migrates off the legacy -// emitXxx/validateXxx functions in ../targets.ts and ../validate.ts (see -// CLAUDE.md's target-registry note and the migration plan in this repo's -// history). Once every TargetName has an entry, this becomes -// `Record` and `adapters.ts` is deleted. +/** + * 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. + */ export const targets: Partial> = { copilot, antigravity, diff --git a/src/targets/shared.ts b/src/targets/shared.ts index 296b9fe..b0eca5f 100644 --- a/src/targets/shared.ts +++ b/src/targets/shared.ts @@ -1,3 +1,4 @@ +/** Deletes keys whose value is `undefined`, mutating and returning `value`. */ export function stripUndefined>(value: T): T { for (const key of Object.keys(value)) { if (value[key] === undefined) { @@ -11,11 +12,13 @@ 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`. +/** + * Deep-merges 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 that lets any field, at any depth, be overridden via a + * target/plugin `manifest`. + */ export function deepMerge( base: Record, override: Record, @@ -31,6 +34,7 @@ export function deepMerge( return result; } +/** Converts a kebab/snake/dot-cased string to Title Case. */ export function titleCase(value: string): string { return value .split(/[-_.]/) diff --git a/src/targets/types.ts b/src/targets/types.ts index cd7b8a0..d838561 100644 --- a/src/targets/types.ts +++ b/src/targets/types.ts @@ -8,6 +8,7 @@ import type { ValidationIssue, } from "../types.js"; +/** Inputs available when building a single plugin's own manifest file. */ export type ManifestBuildContext = { metadata: Metadata | undefined; version: string; @@ -17,6 +18,7 @@ export type ManifestBuildContext = { mcpServers: Record | undefined; }; +/** Inputs available when building a plugin's marketplace entry. */ export type MarketplaceEntryContext = { pluginName: string; pluginPath: string; @@ -28,6 +30,7 @@ export type MarketplaceEntryContext = { manifest: Record | undefined; }; +/** Inputs available when building a target's top-level marketplace manifest. */ export type MarketplaceManifestContext = { project: ResolvedProject; targetConfig: TargetConfig; @@ -53,9 +56,11 @@ export type InstallSnippet = } | { userConfigurable: false; reason: string }; -// A fact about a target traces to one of these — the discipline this whole -// registry exists to enforce (see CONFORMANCE.md). `claim` is a short -// human-readable description of what the citation backs, not the fact itself. +/** + * A dated pointer to the documentation (or other source) a fact about a + * target is based on. `claim` describes what the citation backs, not the + * fact itself. + */ export type Citation = { claim: string; documentationUrl: string; @@ -73,16 +78,16 @@ export type InstallSnippetDefinition = { citation: Citation; }; -// Everything that varies by target, in one place. A new target means one new -// file implementing this interface and one new entry in the registry — not a -// new branch scattered across components.ts/targets.ts/validate.ts, which is -// exactly how the bugs this registry fixes went unnoticed (see CONFORMANCE.md -// "Per-target spec verification"). +/** + * Everything that varies by target, in one place. A new target means one new + * file implementing this interface and one new registry entry, rather than a + * new branch added independently to component defaults, manifest building, + * and validation. + */ export type PluginTargetDefinition = { name: TargetName; - // The component dirs copied into an emitted plugin when a plugin config - // doesn't supply its own `components` override. + /** Component directories copied into a plugin when it has no `components` override. */ defaultComponents: readonly string[]; resolvePluginPath: ( @@ -91,26 +96,21 @@ export type PluginTargetDefinition = { targetConfig: TargetConfig, ) => string; - // Every target writes one now (Copilot didn't; that was the biggest Plan 1 fix). buildPluginManifest: (ctx: ManifestBuildContext) => Record; - // One or more output-relative paths (Copilot mirrors it at both the plugin - // root and .github/plugin/, matching real published plugins rather than - // the docs' single-location illustrative tree — see CONFORMANCE.md). + /** Output-relative paths the plugin manifest is written to (may be more than one). */ manifestPaths: (pluginPath: string) => string[]; - // Omit (return undefined) for a target with no marketplace-entry concept — - // none currently omit it, but the shape allows for one that might. + /** Return `undefined` for a target with no marketplace-entry concept. */ buildMarketplaceEntry: ( ctx: MarketplaceEntryContext, ) => Record | undefined; buildMarketplaceManifest: ( ctx: MarketplaceManifestContext, ) => Record; - // One or more output-relative paths the marketplace manifest is written to - // (Copilot mirrors it at two paths). + /** Output-relative paths the marketplace manifest is written to (may be more than one). */ marketplacePaths: () => string[]; - // undefined for a target with no bundled-MCP-config file convention. + /** Return `undefined` for a target with no bundled-MCP-config file convention. */ mcpConfigPath: (pluginPath: string) => string | undefined; hooksPath: (pluginPath: string) => string; @@ -119,10 +119,11 @@ export type PluginTargetDefinition = { pluginName: string, issues: ValidationIssue[], ) => void; - // Returns the entry's plugin name if it's well-formed enough to keep - // validating, or null (having already pushed an issue) if not — mirrors the - // existing validatePluginEntry contract so per-target output validation can - // keep sharing its calling convention. + /** + * Validates one marketplace entry. Returns the entry's plugin name if it's + * well-formed enough to keep validating, or `null` (having already pushed + * an issue) otherwise. + */ validateMarketplaceEntry: ( entry: Record, index: number, @@ -133,7 +134,6 @@ export type PluginTargetDefinition = { installSnippet: InstallSnippetDefinition; - // Facts about this target not already carried by a more specific citation - // above (e.g. the overall component model, naming pattern). + /** Facts about this target not already carried by a more specific citation above. */ citations: Citation[]; }; diff --git a/src/targets/validation-shared.ts b/src/targets/validation-shared.ts index 3df6262..a07f1ed 100644 --- a/src/targets/validation-shared.ts +++ b/src/targets/validation-shared.ts @@ -12,11 +12,12 @@ import type { TargetName, ValidationIssue } from "../types.js"; export const marketplaceNamePattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; -// The shared shape most targets' marketplace entries share (a bare-string -// `source`). Codex's `source` is a structured object instead (see Plan 1 / -// CONFORMANCE.md), so it implements its own validateMarketplaceEntry rather -// than calling this — this is exactly the shape assumption that used to be -// baked into one function for every target regardless of fit. +/** + * Validates a marketplace entry whose `source` is a bare string — the shape + * most targets share. A target whose entry shape differs (e.g. a structured + * `source` object) implements its own `validateMarketplaceEntry` instead of + * calling this. + */ export function validateBareStringSourceEntry( entry: Record, index: number, @@ -52,10 +53,12 @@ export function validateBareStringSourceEntry( export const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; +/** Pushes an error-level issue onto `issues`. */ export function error(issues: ValidationIssue[], message: string): void { issues.push({ level: "error", message }); } +/** Reads and parses a JSON file, pushing an issue and returning `null` on failure. */ export async function readJson( filePath: string, context: string, @@ -87,6 +90,7 @@ export function pathExistsSync(filePath: string): boolean { } } +/** Validates the fields common to every target's marketplace manifest. */ export function validateMarketplaceBasics( marketplace: Record, issues: ValidationIssue[], @@ -113,10 +117,11 @@ export function validateMarketplaceBasics( } } -// Parameterized by the target's own hooksPath() rather than a hardcoded -// "hooks/hooks.json" — a target that relocates hooks (Antigravity, to a -// root-level hooks.json) validates its own actual location, not everyone -// else's convention. +/** + * 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). + */ export async function validateHooksShape( pluginDir: string, pluginName: string, @@ -136,6 +141,7 @@ export async function validateHooksShape( } } +/** Validates that manifest fields referencing paths point at files that exist. */ export function validateReferencedManifestPaths( pluginDir: string, pluginName: string, @@ -183,6 +189,7 @@ function extractPathValues(value: unknown): string[] { return []; } +/** Validates skill/agent/command/rule frontmatter conventions for a plugin's files. */ export async function validateFrontmatter( pluginDir: string, pluginName: string, diff --git a/src/types.ts b/src/types.ts index 37217f6..9180fbd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,7 @@ export type TargetName = | "antigravity" | "codex"; +/** A discovered source plugin, before it's emitted into any target. */ export type SourcePlugin = { id: string; dir: string; @@ -35,9 +36,12 @@ export type SourcePlugin = { includeStaticFiles?: boolean; }; -// The one surface pluginpack uses to acquire source. A filesystem provider -// backs it today; an API-backed provider (Glean Skills API) can implement the -// same two methods without touching the emit/validate/diff pipeline. +/** + * The one surface pluginpack uses to acquire source. A filesystem provider + * backs it today; an API-backed provider (e.g. a remote skills API) can + * implement the same two methods without touching the emit/validate/diff + * pipeline. + */ export interface SourceProvider { readPluginFiles( pluginId: string, @@ -48,6 +52,7 @@ export interface SourceProvider { ): Promise | undefined>; } +/** A loaded, fully-resolved pluginpack project, ready to build or validate. */ export type ResolvedProject = { rootDir: string; configPath: string; @@ -57,6 +62,7 @@ export type ResolvedProject = { source: SourceProvider; }; +/** A loaded pluginpack config, before source plugin discovery. */ export type ResolvedProjectConfig = { rootDir: string; configPath: string; @@ -65,6 +71,7 @@ export type ResolvedProjectConfig = { export type FileValue = string | Buffer; +/** One target's build output: its files, and which of them are managed by pluginpack. */ export type Artifact = { target: TargetName; outDir: string; @@ -112,6 +119,7 @@ export type CleanupResult = { entries: CleanupEntry[]; }; +/** Paths prune/clean will refuse to delete unless `force` is set. */ export type DeleteGuard = { protectedRoots: string[]; configPath?: string; diff --git a/src/update-check.ts b/src/update-check.ts index 06ef29c..fbe309d 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -1,7 +1,9 @@ import { json } from "./fs.js"; import type { EmittedPluginConfig, FileValue } from "./types.js"; +/** Output-relative path of the generated update-check script within a plugin. */ export const UPDATE_CHECK_SCRIPT_PATH = "scripts/pluginpack-update-check.sh"; +/** Output-relative path of a plugin's hooks registration file. */ export const HOOKS_FILE_PATH = "hooks/hooks.json"; // The marker every generated command contains; merge is idempotent on it, so a @@ -17,10 +19,13 @@ 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). +/** + * 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 { @@ -78,6 +83,7 @@ function nudgeFormat(format: UpdateCheckFormat, pluginName: string): string { return shellQuote(`${literal}\\n`); } +/** Renders the POSIX-sh update-check script embedded into a plugin, for the given format. */ export function renderUpdateCheckScript(options: UpdateCheckOptions): string { const { format, pluginName, version, repository } = options; const nudgeArgs = FORMATS[format].nudgeArgs; @@ -166,6 +172,7 @@ function parseHooksFile( return { root, events }; } +/** Merges the update-check hook into an existing (or new) Claude `hooks/hooks.json`. */ export function mergeClaudeHooks(existing: string | undefined): string { if (existing?.includes(SCRIPT_MARKER)) { return existing; @@ -184,6 +191,7 @@ export function mergeClaudeHooks(existing: string | undefined): string { return json(root); } +/** Merges the update-check hook into an existing (or new) Cursor `hooks/hooks.json`. */ export function mergeCursorHooks(existing: string | undefined): string { if (existing?.includes(SCRIPT_MARKER)) { return existing; @@ -200,9 +208,11 @@ export function mergeCursorHooks(existing: string | undefined): string { 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. +/** + * Injects 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, diff --git a/src/validate.ts b/src/validate.ts index 2af3784..85498f3 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -14,6 +14,12 @@ 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[], @@ -71,6 +77,12 @@ export async function validateAntigravity( } } +/** + * @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[], @@ -112,6 +124,7 @@ export async function validateCopilot( } } +/** Validates a built Cursor target's output directory. */ export async function validateCursor( root: string, issues: ValidationIssue[], @@ -163,6 +176,7 @@ export async function validateCursor( } } +/** Validates a built Claude target's output directory. */ export async function validateClaude( root: string, issues: ValidationIssue[], @@ -222,6 +236,7 @@ export async function validateClaude( } } +/** Validates a built Codex target's output directory. */ export async function validateCodex( root: string, issues: ValidationIssue[],