diff --git a/src/adapters.ts b/src/adapters.ts index 0b7618a..2015a9d 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -14,6 +14,11 @@ 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, @@ -35,16 +40,16 @@ type TargetValidator = ( issues: ValidationIssue[], ) => Promise; +/** The emit and validate functions for one target. */ export type TargetAdapter = { emit: TargetEmitter; 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,8 +57,43 @@ 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; + +/** 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, @@ -76,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 new file mode 100644 index 0000000..8d7e2ee --- /dev/null +++ b/src/targets/antigravity.ts @@ -0,0 +1,155 @@ +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"; + +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", + + defaultComponents: [ + "skills", + "agents", + "rules", + "hooks", + "scripts", + "assets", + ], + + resolvePluginPath: (pluginName, pluginConfig) => + pluginConfig.path ?? pluginName, + + // 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, + description: pluginConfig.description ?? metadata?.description, + }), + manifestPaths: (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 (see citations). + 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/copilot.ts b/src/targets/copilot.ts new file mode 100644 index 0000000..b91bf1a --- /dev/null +++ b/src/targets/copilot.ts @@ -0,0 +1,272 @@ +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])?$/; + +/** 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", + + defaultComponents: ["skills", "agents", "hooks", "scripts", "assets"], + + resolvePluginPath: (pluginName, pluginConfig, targetConfig) => + pluginConfig.path ?? + toPosix(path.join(targetConfig.pluginRoot ?? "plugins", pluginName)), + + 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); + }, + // 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"), + ], + + 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 locations; 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); + // .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, + ); + if (!(await exists(path.join(pluginDir, "plugin.json")))) { + error( + issues, + `${pluginName}: plugin.json must also be mirrored at the plugin root.`, + ); + } + 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: + "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: + "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 new file mode 100644 index 0000000..c2c8f22 --- /dev/null +++ b/src/targets/engine.ts @@ -0,0 +1,172 @@ +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"; + +/** + * 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[] }, +): Set { + 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, + 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, + targetConfig, + ); + const pluginFiles = await collectPluginFiles( + project, + target, + pluginConfig.from, + resolveComponents(definition, pluginConfig), + ); + const componentDirs = new Set( + [...pluginFiles.keys()].map((file) => file.split("/")[0]), + ); + + // 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"); + } + 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); + if (mcpServers && mcpConfigPath) { + files.set(toPosix(mcpConfigPath), json({ mcpServers })); + } + + const metadata = emittedPluginMetadata(project, pluginConfig); + const manifest = definition.buildPluginManifest({ + metadata, + version, + pluginName, + pluginConfig, + componentDirs, + mcpServers, + }); + 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, + 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 on its own. + 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); +} + +/** Validates one target's output using its `PluginTargetDefinition`. */ +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..fdde0c2 --- /dev/null +++ b/src/targets/registry.ts @@ -0,0 +1,16 @@ +import { antigravity } from "./antigravity.js"; +import { copilot } from "./copilot.js"; +import type { TargetName } from "../types.js"; +import type { PluginTargetDefinition } from "./types.js"; + +/** + * Migrated targets, filled in one at a time as each moves off the legacy + * per-target functions in `../targets.ts` and `../validate.ts` — see + * `../adapters.ts`, which falls back to those for any target not yet + * present here. Becomes `Record` once + * every target has an entry, at which point `adapters.ts` can be deleted. + */ +export const targets: Partial> = { + copilot, + antigravity, +}; diff --git a/src/targets/shared.ts b/src/targets/shared.ts new file mode 100644 index 0000000..b0eca5f --- /dev/null +++ b/src/targets/shared.ts @@ -0,0 +1,44 @@ +/** 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) { + delete value[key]; + } + } + return value; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * 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, +): 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; +} + +/** Converts a kebab/snake/dot-cased string to Title Case. */ +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..d838561 --- /dev/null +++ b/src/targets/types.ts @@ -0,0 +1,139 @@ +import type { + EmittedPluginConfig, + FileValue, + Metadata, + ResolvedProject, + TargetConfig, + TargetName, + ValidationIssue, +} from "../types.js"; + +/** Inputs available when building a single plugin's own manifest file. */ +export type ManifestBuildContext = { + metadata: Metadata | undefined; + version: string; + pluginName: string; + pluginConfig: EmittedPluginConfig; + componentDirs: Set; + mcpServers: Record | undefined; +}; + +/** Inputs available when building a plugin's marketplace entry. */ +export type MarketplaceEntryContext = { + pluginName: string; + pluginPath: string; + pluginConfig: EmittedPluginConfig; + metadata: Metadata | undefined; + componentDirs: Set; + mcpServers: Record | undefined; + pluginFiles: Map; + manifest: Record | undefined; +}; + +/** Inputs available when building a target's top-level marketplace manifest. */ +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 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; + 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 registry entry, rather than a + * new branch added independently to component defaults, manifest building, + * and validation. + */ +export type PluginTargetDefinition = { + name: TargetName; + + /** Component directories copied into a plugin when it has no `components` override. */ + defaultComponents: readonly string[]; + + resolvePluginPath: ( + pluginName: string, + pluginConfig: EmittedPluginConfig, + targetConfig: TargetConfig, + ) => string; + + buildPluginManifest: (ctx: ManifestBuildContext) => Record; + /** Output-relative paths the plugin manifest is written to (may be more than one). */ + manifestPaths: (pluginPath: string) => string[]; + + /** Return `undefined` for a target with no marketplace-entry concept. */ + buildMarketplaceEntry: ( + ctx: MarketplaceEntryContext, + ) => Record | undefined; + buildMarketplaceManifest: ( + ctx: MarketplaceManifestContext, + ) => Record; + /** Output-relative paths the marketplace manifest is written to (may be more than one). */ + marketplacePaths: () => string[]; + + /** Return `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; + /** + * 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, + 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. */ + citations: Citation[]; +}; diff --git a/src/targets/validation-shared.ts b/src/targets/validation-shared.ts new file mode 100644 index 0000000..a07f1ed --- /dev/null +++ b/src/targets/validation-shared.ts @@ -0,0 +1,338 @@ +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])?$/; + +/** + * 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, + 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])?$/; + +/** 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, + 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; + } +} + +/** Validates the fields common to every target's marketplace manifest. */ +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.', + ); + } +} + +/** + * 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, + 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.`, + ); + } +} + +/** Validates that manifest fields referencing paths point at files that exist. */ +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 []; +} + +/** Validates skill/agent/command/rule frontmatter conventions for a plugin's files. */ +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.`, + ); + } + } +} 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[], diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 9b8be7d..1a768c7 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -301,6 +301,29 @@ 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", + }); + + // 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 e934c68..c42ee4c 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -65,6 +65,307 @@ 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" }); + + // 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 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", + 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 (.github/plugin copy) at validate time", async () => { + const project = await fixture(); + const root = project.baseDir; + await build({ cwd: root, target: "copilot" }); + + // .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", + path.join(root, "dist/copilot"), + ); + expect(result.ok).toBe(false); + expect( + result.issues.some((issue) => issue.message.includes("plugin manifest")), + ).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")}"; + +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; @@ -1315,3 +1616,13 @@ description: ${description} # ${name} `; } + +function agent(name: string, description: string): string { + return `--- +name: ${name} +description: ${description} +--- + +# ${name} +`; +}