diff --git a/.prettierignore b/.prettierignore index 94e6de5..6cc72db 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,5 @@ package-lock.json tests/fixtures/cursor/*.schema.json tests/fixtures/agent-plugins/*.schema.json CHANGELOG.md +# Golden output is pinned byte for byte as pluginpack emits it. +tests/golden/ diff --git a/package.json b/package.json index 3316b40..2e6cd45 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "test": "vitest run", "test:all": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build && npm run docs", "test:coverage": "vitest run --coverage", + "test:golden:update": "env UPDATE_GOLDEN=1 vitest run tests/golden.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/scripts/verify-packed-install.mjs b/scripts/verify-packed-install.mjs index a08837b..672decd 100755 --- a/scripts/verify-packed-install.mjs +++ b/scripts/verify-packed-install.mjs @@ -2,16 +2,65 @@ // CI check: every existing test runs against the in-repo `dist/cli.js` or // source, never a truly installed package. This packs the real tarball, // installs it into a scratch project the way a real consumer would, and -// exercises the installed binary — catching a broken `bin` shebang or a -// `files`/tsup misconfig that `npm pack --dry-run` alone would miss. +// walks the documented lifecycle with the installed binary: init, add a +// skill, build, validate, delete the skill, build, validate, clean. That +// catches a broken `bin` shebang or a `files`/tsup misconfig that +// `npm pack --dry-run` alone would miss, and a lifecycle step that breaks +// for a real install (such as a rebuild that cannot remove a deleted skill). import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; const repoRoot = process.cwd(); const scratch = mkdtempSync(path.join(tmpdir(), "pluginpack-pack-smoke-")); +/** + * The targets `pluginpack init` configures, each writing to `dist/`. + * Read from the first build's output rather than listed here, so the script + * keeps checking every scaffolded target when init gains one. + */ +let initTargets; + +/** Every file under `dir`, as posix paths relative to it, sorted. */ +function listFiles(dir) { + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => + path + .relative(dir, path.join(entry.parentPath, entry.name)) + .split(path.sep) + .join("/"), + ) + .sort(); +} + +/** Every file under `dir` with its contents, skipping `skip` (relative to dir). */ +function snapshot(dir, skip) { + return Object.fromEntries( + listFiles(dir) + .filter((file) => !skip(file)) + .map((file) => [file, readFileSync(path.join(dir, file), "utf8")]), + ); +} + +function check(condition, message) { + if (!condition) { + throw new Error(message); + } +} + try { const packRaw = execFileSync( "npm", @@ -25,7 +74,11 @@ try { mkdirSync(installDir); writeFileSync( path.join(installDir, "package.json"), - JSON.stringify({ name: "pack-smoke-test", version: "0.0.0", private: true }), + JSON.stringify({ + name: "pack-smoke-test", + version: "0.0.0", + private: true, + }), ); execFileSync("npm", ["install", tarballPath], { cwd: installDir, @@ -34,20 +87,92 @@ try { const bin = path.join(installDir, "node_modules", ".bin", "pluginpack"); const version = execFileSync(bin, ["--version"], { encoding: "utf8" }).trim(); - if (!version) { - throw new Error("Installed CLI printed no version."); - } + check(version, "Installed CLI printed no version."); console.log(`Installed CLI reports version: ${version}`); - const initDir = path.join(installDir, "project"); - mkdirSync(initDir); - execFileSync(bin, ["init"], { cwd: initDir, stdio: "inherit" }); - if (!existsSync(path.join(initDir, "pluginpack.config.ts"))) { - throw new Error( - "pluginpack init did not create pluginpack.config.ts from the installed package.", + const project = path.join(installDir, "project"); + mkdirSync(project); + const pluginpack = (...args) => { + console.log(`$ pluginpack ${args.join(" ")}`); + execFileSync(bin, args, { cwd: project, stdio: "inherit" }); + }; + const isOutput = (file) => file.startsWith("dist/"); + const outputs = () => + listFiles(path.join(project, "dist")).map((file) => `dist/${file}`); + const skillOutputs = (skill) => + outputs().filter((file) => file.includes(`/skills/${skill}/`)); + + // --- init -------------------------------------------------------------- + pluginpack("init"); + check( + existsSync(path.join(project, "pluginpack.config.ts")), + "pluginpack init did not create pluginpack.config.ts from the installed package.", + ); + + // --- add a skill, build, validate --------------------------------------- + const extraSkill = path.join(project, "shared/example/skills/extra"); + mkdirSync(extraSkill, { recursive: true }); + writeFileSync( + path.join(extraSkill, "SKILL.md"), + "---\nname: extra\ndescription: A second skill.\n---\n\nExtra.\n", + ); + const sources = snapshot(project, isOutput); + pluginpack("build"); + initTargets = readdirSync(path.join(project, "dist")).sort(); + check( + initTargets.length >= 4, + `Expected init to scaffold at least four targets, got: ${initTargets.join(", ")}`, + ); + for (const target of initTargets) { + pluginpack("validate", "--target", target); + check( + existsSync( + path.join(project, "dist", target, ".pluginpack", `${target}.json`), + ), + `build wrote no managed manifest for ${target}.`, ); } - console.log("Installed CLI's `init` command works."); + check( + skillOutputs("extra").length === initTargets.length, + `Expected the extra skill in every target, got: ${skillOutputs("extra").join(", ")}`, + ); + // A hand-written file in the output, which no command may delete. + const notes = path.join(project, "dist/cursor/NOTES.md"); + writeFileSync(notes, "Hand-written.\n"); + + // --- delete the skill, build, validate ---------------------------------- + rmSync(extraSkill, { recursive: true }); + delete sources["shared/example/skills/extra/SKILL.md"]; + pluginpack("build"); + for (const target of initTargets) { + pluginpack("validate", "--target", target); + } + check( + skillOutputs("extra").length === 0, + `Rebuild left the deleted skill behind: ${skillOutputs("extra").join(", ")}`, + ); + check( + skillOutputs("example").length === initTargets.length, + `Rebuild lost the remaining skill: ${skillOutputs("example").join(", ")}`, + ); + + // --- clean -------------------------------------------------------------- + pluginpack("clean"); + check( + JSON.stringify(outputs()) === JSON.stringify(["dist/cursor/NOTES.md"]), + `clean should leave only the hand-written file, left: ${outputs().join(", ")}`, + ); + check( + readFileSync(notes, "utf8") === "Hand-written.\n", + "clean changed a hand-written file.", + ); + check( + JSON.stringify(snapshot(project, isOutput)) === JSON.stringify(sources), + "The lifecycle changed an authored source file.", + ); + console.log( + "Installed CLI walks init -> build -> validate -> delete a skill -> build -> clean.", + ); } finally { rmSync(scratch, { recursive: true, force: true }); } diff --git a/tests/golden.test.ts b/tests/golden.test.ts new file mode 100644 index 0000000..c52b706 --- /dev/null +++ b/tests/golden.test.ts @@ -0,0 +1,352 @@ +/** + * Golden output: builds fixed configs and compares every emitted file — its + * path and its exact bytes — against the tree checked in under + * `tests/golden///`, including the serialized managed manifest + * `.pluginpack/.json`. Emitted output is a compatibility surface: + * renaming a path, reordering a manifest field or changing a byte of a + * generated file changes what hosts and downstream repos see. This test makes + * such a change fail CI until someone regenerates the goldens on purpose: + * + * npm run test:golden:update + * + * then reviews `git diff tests/golden` and says in the PR why output changed. + */ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Project, type ProjectArgs } from "fixturify-project"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { build } from "../src/build.js"; +import { validateOutput } from "../src/adapters.js"; +import { exists, toPosix, walkFiles } from "../src/fs.js"; +import { managedManifestPath } from "../src/managed.js"; +import type { TargetName } from "../src/types.js"; + +type DirJSON = NonNullable; + +const goldenRoot = fileURLToPath(new URL("./golden/", import.meta.url)); +const update = process.env.UPDATE_GOLDEN === "1"; +const updateCommand = "npm run test:golden:update"; + +if (update && process.env.CI) { + throw new Error( + "UPDATE_GOLDEN is set in CI. Golden output is regenerated locally and reviewed, never rewritten by CI.", + ); +} + +const indexPath = path.resolve("src/index.ts"); + +let project: Project | undefined; + +afterEach(async () => { + vi.restoreAllMocks(); + await project?.dispose(); + project = undefined; +}); + +const skillMd = `--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +{{> greeting}} + +Group the changes by feature and fix. +`; + +// A minimal valid PNG header and IHDR chunk, so binary passthrough is pinned too. +const iconPng = Buffer.from( + "89504e470d0a1a0a0000000d4948445200000001000000010806000000", + "hex", +); + +/** The authored text files every golden case builds, plus `iconPng`. Change them and the goldens change. */ +function sourceFiles(): DirJSON { + return { + partials: { "greeting.md": "Start by reading the changelog.\n" }, + repositories: { + all: { + "README.md": "# Golden plugins\n\n{{> greeting}}\n", + "CONTRIBUTING.md": "Edit shared/, not the generated output.\n", + }, + }, + shared: { + acme: { + "plugin.pluginpack.json": `${JSON.stringify({ description: "Acme from the source manifest." }, null, 2)}\n`, + "README.md": "# Acme\n\n{{> greeting}}\n", + LICENSE: "MIT\n", + skills: { + "release-notes": { + "SKILL.md": skillMd, + references: { "template.txt": "## Features\n\n## Fixes\n" }, + }, + }, + agents: { + "helper.agent.md": + "---\nname: helper\ndescription: Find documents that answer a question.\n---\n\nSearch and cite sources.\n", + }, + commands: { + "summarize.md": + "---\ndescription: Summarize the open file.\n---\n\nSummarize it.\n", + }, + rules: { + "style.mdc": + "---\ndescription: House style.\nalwaysApply: false\n---\n\nUse short sentences.\n", + }, + hooks: { + "hooks.json": `${JSON.stringify( + { + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "echo ready" }] }, + ], + }, + }, + null, + 2, + )}\n`, + }, + scripts: { "run.sh": "#!/bin/sh\necho run\n" }, + mcp: { + "config.json": `${JSON.stringify( + { + mcpServers: { + acme: { + type: "stdio", + command: "./bin/server", + args: ["--stdio"], + env: { ACME_MODE: "plugin" }, + }, + docs: { + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + }, + }, + null, + 2, + )}\n`, + "pluginpack.json": `${JSON.stringify({ files: { "bin/server": "bin/server" } }, null, 2)}\n`, + bin: { server: "#!/bin/sh\necho server\n" }, + src: { "server.ts": "export {};\n" }, + }, + }, + }, + overrides: { + cursor: { + acme: { + rules: { + "cursor-only.mdc": + "---\ndescription: Cursor only.\nalwaysApply: true\n---\n\nOnly in Cursor.\n", + }, + }, + }, + codex: { + acme: { + skills: { + "release-notes": { + "SKILL.md": skillMd.replace( + "Group the changes", + "In Codex, group the changes", + ), + }, + }, + }, + }, + }, + }; +} + +const codexEntry = `{ policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity" }`; + +/** Each case is one fixed config; its targets each write to `out/`. */ +const cases: Record = { + default: ` + cursor: { + outDir: "out/cursor", + repositoryFiles: "repositories/all", + updateCheck: {}, + plugins: { + acme: { + source: "shared/acme", + overrides: "overrides/cursor/acme", + path: "plugins/acme", + displayName: "Acme", + }, + }, + }, + claude: { + outDir: "out/claude", + pluginRoot: "plugins", + repositoryFiles: "repositories/all", + updateCheck: {}, + plugins: { acme: { source: "shared/acme", version: "1.2.4" } }, + }, + antigravity: { + outDir: "out/antigravity", + repositoryFiles: "repositories/all", + plugins: { acme: { source: "shared/acme" } }, + }, + copilot: { + outDir: "out/copilot", + repositoryFiles: "repositories/all", + plugins: { acme: { source: "shared/acme", description: "Acme for Copilot." } }, + }, + codex: { + outDir: "out/codex", + repositoryFiles: "repositories/all", + manifest: { interface: { displayName: "Golden plugins" } }, + plugins: { + acme: { + source: "shared/acme", + overrides: "overrides/codex/acme", + entry: ${codexEntry}, + manifest: { interface: { displayName: "Acme", category: "Productivity" } }, + }, + }, + }, + "agent-plugins": { + outDir: "out/agent-plugins", + repositoryFiles: "repositories/all", + clients: { + codex: { manifest: { interface: { displayName: "Golden plugins" } } }, + copilot: {}, + }, + plugins: { + acme: { + source: "shared/acme", + manifest: { extensions: { "com.openai": { interface: { displayName: "Acme" } } } }, + clients: { codex: { entry: ${codexEntry} } }, + }, + }, + },`, + variants: ` + codex: { + outDir: "out/codex", + format: "legacy", + plugins: { acme: { source: "shared/acme", entry: ${codexEntry} } }, + }, + "agent-plugins": { + outDir: "out/agent-plugins", + plugins: { acme: { source: "shared/acme" } }, + },`, +}; + +function configSource(targets: string): string { + return `import { defineConfig } from "${indexPath}"; + +export default defineConfig({ + name: "golden-plugins", + version: "1.2.3", + source: { partials: "partials" }, + metadata: { + displayName: "Golden Plugins", + description: "Plugins whose output is pinned byte for byte.", + author: { name: "Golden", email: "golden@example.com" }, + owner: { name: "Golden" }, + homepage: "https://example.com/golden", + repository: "https://github.com/example/golden-plugins", + license: "MIT", + keywords: ["golden", "test"], + category: "Productivity", + }, + targets: {${targets} + }, +}); +`; +} + +/** Every file under `dir`, keyed by posix path relative to it, sorted. */ +async function readTree(dir: string): Promise> { + const tree = new Map(); + if (!(await exists(dir))) { + return tree; + } + for (const file of await walkFiles(dir)) { + tree.set(toPosix(path.relative(dir, file)), await readFile(file)); + } + return new Map([...tree].sort(([a], [b]) => (a < b ? -1 : 1))); +} + +async function writeTree( + dir: string, + tree: Map, +): Promise { + await rm(dir, { recursive: true, force: true }); + for (const [relative, contents] of tree) { + await mkdir(path.dirname(path.join(dir, relative)), { recursive: true }); + await writeFile(path.join(dir, relative), contents); + } +} + +describe.each(Object.entries(cases))( + "golden output: %s config", + (name, targets) => { + it("matches tests/golden byte for byte", async () => { + project = new Project("pluginpack-golden", "1.0.0", { + files: { + "pluginpack.config.ts": configSource(targets), + ...sourceFiles(), + }, + }); + await project.write(); + const root = project.baseDir; + // fixturify writes only text, so the binary asset is written here. + await mkdir(path.join(root, "shared/acme/assets"), { recursive: true }); + await writeFile(path.join(root, "shared/acme/assets/icon.png"), iconPng); + // Build warnings (such as the legacy-format deprecation) are not output. + vi.spyOn(console, "warn").mockImplementation(() => {}); + const artifacts = await build({ cwd: root }); + + const caseDir = path.join(goldenRoot, name); + const builtTargets = artifacts.map((artifact) => artifact.target).sort(); + if (update) { + await rm(caseDir, { recursive: true, force: true }); + } + for (const artifact of artifacts) { + const actual = await readTree(artifact.outDir); + const goldenDir = path.join(caseDir, artifact.target); + if (update) { + await writeTree(goldenDir, actual); + continue; + } + const expected = await readTree(goldenDir); + // The managed manifest is pinned like any other output file. + expect(actual.has(managedManifestPath(artifact.target))).toBe(true); + expect( + [...actual.keys()], + `${name}/${artifact.target}: emitted paths changed. If that is intended, run \`${updateCommand}\` and review git diff tests/golden.`, + ).toEqual([...expected.keys()]); + for (const [relative, contents] of actual) { + expect( + contents.toString("utf8"), + `${name}/${artifact.target}/${relative}: bytes changed. If that is intended, run \`${updateCommand}\` and review git diff tests/golden.`, + ).toBe(expected.get(relative)?.toString("utf8")); + expect(contents.equals(expected.get(relative)!)).toBe(true); + } + } + // A golden directory for a target the case no longer builds is stale. + const goldenTargets = (await exists(caseDir)) + ? [ + ...new Set( + [...(await readTree(caseDir)).keys()].map( + (file) => file.split("/")[0], + ), + ), + ].sort() + : []; + expect(goldenTargets).toEqual(builtTargets as TargetName[]); + + // The pinned output is output a user could ship: every target validates. + for (const artifact of artifacts) { + const result = await validateOutput(artifact.target, artifact.outDir); + expect( + result.issues.filter((issue) => issue.level === "error"), + `${name}/${artifact.target} validate`, + ).toEqual([]); + } + }); + }, +); diff --git a/tests/golden/default/agent-plugins/.agents/plugins/marketplace.json b/tests/golden/default/agent-plugins/.agents/plugins/marketplace.json new file mode 100644 index 0000000..4eb2380 --- /dev/null +++ b/tests/golden/default/agent-plugins/.agents/plugins/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "golden-plugins", + "interface": { + "displayName": "Golden plugins" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme from the source manifest.", + "version": "1.2.3", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/tests/golden/default/agent-plugins/.github/plugin/marketplace.json b/tests/golden/default/agent-plugins/.github/plugin/marketplace.json new file mode 100644 index 0000000..c39e8d0 --- /dev/null +++ b/tests/golden/default/agent-plugins/.github/plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "name": "golden-plugins", + "owner": { + "name": "Golden" + }, + "metadata": { + "description": "Plugins whose output is pinned byte for byte.", + "version": "1.2.3" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme from the source manifest.", + "version": "1.2.3" + } + ] +} diff --git a/tests/golden/default/agent-plugins/.pluginpack/agent-plugins.json b/tests/golden/default/agent-plugins/.pluginpack/agent-plugins.json new file mode 100644 index 0000000..9468b53 --- /dev/null +++ b/tests/golden/default/agent-plugins/.pluginpack/agent-plugins.json @@ -0,0 +1,36 @@ +{ + "version": 2, + "target": "agent-plugins", + "files": [ + ".agents/plugins/marketplace.json", + ".github/plugin/marketplace.json", + "CONTRIBUTING.md", + "README.md", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/com.github.copilot/agents/helper.agent.md", + "plugins/acme/mcp.json", + "plugins/acme/plugin.json", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".agents/plugins/marketplace.json": "c929e0fb6a0376199e20c75b96b7ad544cf1026351445ecd11d6b7e16a559c6d", + ".github/plugin/marketplace.json": "e115239188e2e21a9a31c727963030ce09c0adb321f22545f2fd3d203c12676f", + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/com.github.copilot/agents/helper.agent.md": "f98ea67be542b0a45b4b493c086c36fef4bb7b18c3a9ed957370083b1b0962f4", + "plugins/acme/mcp.json": "f6d7a3e17bd9f611b74858306a38f4904a0dba89da8e069a9e09ddadc6cd7b73", + "plugins/acme/plugin.json": "537dfa228c4c2898ea1eefca98986a8f1aa149786992402b7787f2eb63a7e8a7", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/agent-plugins/CONTRIBUTING.md b/tests/golden/default/agent-plugins/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/agent-plugins/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/agent-plugins/README.md b/tests/golden/default/agent-plugins/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/agent-plugins/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/agent-plugins/plugins/acme/LICENSE b/tests/golden/default/agent-plugins/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/agent-plugins/plugins/acme/README.md b/tests/golden/default/agent-plugins/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/agent-plugins/plugins/acme/assets/icon.png b/tests/golden/default/agent-plugins/plugins/acme/assets/icon.png new file mode 100644 index 0000000..d1ed0c8 Binary files /dev/null and b/tests/golden/default/agent-plugins/plugins/acme/assets/icon.png differ diff --git a/tests/golden/default/agent-plugins/plugins/acme/bin/server b/tests/golden/default/agent-plugins/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/agent-plugins/plugins/acme/com.github.copilot/agents/helper.agent.md b/tests/golden/default/agent-plugins/plugins/acme/com.github.copilot/agents/helper.agent.md new file mode 100644 index 0000000..8d32edd --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/com.github.copilot/agents/helper.agent.md @@ -0,0 +1,6 @@ +--- +name: helper +description: Find documents that answer a question. +--- + +Search and cite sources. diff --git a/tests/golden/default/agent-plugins/plugins/acme/mcp.json b/tests/golden/default/agent-plugins/plugins/acme/mcp.json new file mode 100644 index 0000000..4417b24 --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/mcp.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "acme": { + "type": "stdio", + "command": "./bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "streamable-http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/agent-plugins/plugins/acme/plugin.json b/tests/golden/default/agent-plugins/plugins/acme/plugin.json new file mode 100644 index 0000000..fde5c7e --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/plugin.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "acme", + "version": "1.2.3", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "extensions": { + "com.openai": { + "interface": { + "displayName": "Acme" + } + } + } +} diff --git a/tests/golden/default/agent-plugins/plugins/acme/scripts/run.sh b/tests/golden/default/agent-plugins/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/agent-plugins/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/default/antigravity/.pluginpack/antigravity.json b/tests/golden/default/antigravity/.pluginpack/antigravity.json new file mode 100644 index 0000000..3c1185a --- /dev/null +++ b/tests/golden/default/antigravity/.pluginpack/antigravity.json @@ -0,0 +1,36 @@ +{ + "version": 2, + "target": "antigravity", + "files": [ + "CONTRIBUTING.md", + "README.md", + "acme/LICENSE", + "acme/README.md", + "acme/agents/helper.agent.md", + "acme/assets/icon.png", + "acme/bin/server", + "acme/hooks.json", + "acme/mcp_config.json", + "acme/plugin.json", + "acme/rules/style.mdc", + "acme/scripts/run.sh", + "acme/skills/release-notes/SKILL.md", + "acme/skills/release-notes/references/template.txt" + ], + "sha256": { + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "acme/agents/helper.agent.md": "f98ea67be542b0a45b4b493c086c36fef4bb7b18c3a9ed957370083b1b0962f4", + "acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "acme/hooks.json": "ffa7f7bd137c04c26f889c25e3ea9cad84596c619f55a2f6324369c9a413c94d", + "acme/mcp_config.json": "f3c91992c02ed1cb0767accf66bf8e7bca72408b401771c6c9ab5c5c55fc6aa1", + "acme/plugin.json": "af2e215200fc6427ae5630ff25de0d9db4d66d3a068fb07274f0e7ccd11fc5d1", + "acme/rules/style.mdc": "8d5532f408bbf1d24877ce3afb4cdf15114628ab98235787d1957bfe425546ee", + "acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/antigravity/CONTRIBUTING.md b/tests/golden/default/antigravity/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/antigravity/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/antigravity/README.md b/tests/golden/default/antigravity/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/antigravity/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/antigravity/acme/LICENSE b/tests/golden/default/antigravity/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/antigravity/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/antigravity/acme/README.md b/tests/golden/default/antigravity/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/antigravity/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/antigravity/acme/agents/helper.agent.md b/tests/golden/default/antigravity/acme/agents/helper.agent.md new file mode 100644 index 0000000..8d32edd --- /dev/null +++ b/tests/golden/default/antigravity/acme/agents/helper.agent.md @@ -0,0 +1,6 @@ +--- +name: helper +description: Find documents that answer a question. +--- + +Search and cite sources. diff --git a/tests/golden/default/antigravity/acme/assets/icon.png b/tests/golden/default/antigravity/acme/assets/icon.png new file mode 100644 index 0000000..d1ed0c8 Binary files /dev/null and b/tests/golden/default/antigravity/acme/assets/icon.png differ diff --git a/tests/golden/default/antigravity/acme/bin/server b/tests/golden/default/antigravity/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/antigravity/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/antigravity/acme/hooks.json b/tests/golden/default/antigravity/acme/hooks.json new file mode 100644 index 0000000..fd02484 --- /dev/null +++ b/tests/golden/default/antigravity/acme/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + } + ] + } +} diff --git a/tests/golden/default/antigravity/acme/mcp_config.json b/tests/golden/default/antigravity/acme/mcp_config.json new file mode 100644 index 0000000..4ac170d --- /dev/null +++ b/tests/golden/default/antigravity/acme/mcp_config.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "acme": { + "type": "stdio", + "command": "./bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "streamable-http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/antigravity/acme/plugin.json b/tests/golden/default/antigravity/acme/plugin.json new file mode 100644 index 0000000..a493fbf --- /dev/null +++ b/tests/golden/default/antigravity/acme/plugin.json @@ -0,0 +1,4 @@ +{ + "name": "acme", + "description": "Acme from the source manifest." +} diff --git a/tests/golden/default/antigravity/acme/rules/style.mdc b/tests/golden/default/antigravity/acme/rules/style.mdc new file mode 100644 index 0000000..47b1a62 --- /dev/null +++ b/tests/golden/default/antigravity/acme/rules/style.mdc @@ -0,0 +1,6 @@ +--- +description: House style. +alwaysApply: false +--- + +Use short sentences. diff --git a/tests/golden/default/antigravity/acme/scripts/run.sh b/tests/golden/default/antigravity/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/antigravity/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/antigravity/acme/skills/release-notes/SKILL.md b/tests/golden/default/antigravity/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/default/antigravity/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/default/antigravity/acme/skills/release-notes/references/template.txt b/tests/golden/default/antigravity/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/antigravity/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/default/claude/.claude-plugin/marketplace.json b/tests/golden/default/claude/.claude-plugin/marketplace.json new file mode 100644 index 0000000..e0a38bb --- /dev/null +++ b/tests/golden/default/claude/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "golden-plugins", + "version": "1.2.3", + "description": "Plugins whose output is pinned byte for byte.", + "owner": { + "name": "Golden" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme from the source manifest." + } + ] +} diff --git a/tests/golden/default/claude/.pluginpack/claude.json b/tests/golden/default/claude/.pluginpack/claude.json new file mode 100644 index 0000000..762249b --- /dev/null +++ b/tests/golden/default/claude/.pluginpack/claude.json @@ -0,0 +1,38 @@ +{ + "version": 2, + "target": "claude", + "files": [ + ".claude-plugin/marketplace.json", + "CONTRIBUTING.md", + "README.md", + "plugins/acme/.claude-plugin/plugin.json", + "plugins/acme/.mcp.json", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/agents/helper.agent.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/hooks/hooks.json", + "plugins/acme/scripts/pluginpack-update-check.sh", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".claude-plugin/marketplace.json": "8bfbcee80b5ba0957a482e8f0e28c7c9917a69054cbfed51554047a8d474c71c", + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "plugins/acme/.claude-plugin/plugin.json": "9135121c5a0f3e97ad930f2c692472b1cc506abd3ec596da5fdb8103178bd16b", + "plugins/acme/.mcp.json": "856185b2681805ec57e9b3ef1edbad20dcc1d4a410a61f11ef9ba6b1fe337ca0", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/agents/helper.agent.md": "f98ea67be542b0a45b4b493c086c36fef4bb7b18c3a9ed957370083b1b0962f4", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/hooks/hooks.json": "9bbe97ed7682f4e1e837309c1c3ba75a7d199931a1a21a0f58c18793b88e73ef", + "plugins/acme/scripts/pluginpack-update-check.sh": "4edae52013092d8ee9212299aefe80e4e91c9bf6fe923bc57daf3efcc4da507e", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/claude/CONTRIBUTING.md b/tests/golden/default/claude/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/claude/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/claude/README.md b/tests/golden/default/claude/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/claude/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/claude/plugins/acme/.claude-plugin/plugin.json b/tests/golden/default/claude/plugins/acme/.claude-plugin/plugin.json new file mode 100644 index 0000000..d156fd2 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/.claude-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "acme", + "version": "1.2.4", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ] +} diff --git a/tests/golden/default/claude/plugins/acme/.mcp.json b/tests/golden/default/claude/plugins/acme/.mcp.json new file mode 100644 index 0000000..d467c00 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "acme": { + "type": "stdio", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/claude/plugins/acme/LICENSE b/tests/golden/default/claude/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/claude/plugins/acme/README.md b/tests/golden/default/claude/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/claude/plugins/acme/agents/helper.agent.md b/tests/golden/default/claude/plugins/acme/agents/helper.agent.md new file mode 100644 index 0000000..8d32edd --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/agents/helper.agent.md @@ -0,0 +1,6 @@ +--- +name: helper +description: Find documents that answer a question. +--- + +Search and cite sources. diff --git a/tests/golden/default/claude/plugins/acme/assets/icon.png b/tests/golden/default/claude/plugins/acme/assets/icon.png new file mode 100644 index 0000000..d1ed0c8 Binary files /dev/null and b/tests/golden/default/claude/plugins/acme/assets/icon.png differ diff --git a/tests/golden/default/claude/plugins/acme/bin/server b/tests/golden/default/claude/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/claude/plugins/acme/hooks/hooks.json b/tests/golden/default/claude/plugins/acme/hooks/hooks.json new file mode 100644 index 0000000..89a22d9 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/hooks/hooks.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + }, + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/pluginpack-update-check.sh\"", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/tests/golden/default/claude/plugins/acme/scripts/pluginpack-update-check.sh b/tests/golden/default/claude/plugins/acme/scripts/pluginpack-update-check.sh new file mode 100644 index 0000000..969a117 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/scripts/pluginpack-update-check.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Generated by pluginpack — session-start update check for "acme". +# Compares the installed version against the latest git tag of the plugin repo, +# throttled to one network check per 24h (cache shared across plugins from the +# same repo). Fail-open by design: any problem exits 0 with no output. +[ "${PLUGINPACK_NO_UPDATE_CHECK:-}" = "1" ] && exit 0 +[ -n "${CI:-}" ] && exit 0 +command -v git >/dev/null 2>&1 || exit 0 + +INSTALLED='1.2.4' +REPO_URL='https://github.com/example/golden-plugins' + +# Only plain dotted-numeric versions compare meaningfully with sort below. +case "$INSTALLED" in + "" | *[!0-9.]* | .* | *.) exit 0 ;; +esac + +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/pluginpack" +KEY=$(printf '%s' "$REPO_URL" | cksum | cut -d ' ' -f 1) || exit 0 +CACHE_FILE="$CACHE_DIR/latest-$KEY" +now=$(date +%s) || exit 0 +latest="" +cached_at=0 +if [ -f "$CACHE_FILE" ]; then + read -r cached_at latest <"$CACHE_FILE" || true +fi +case "$cached_at" in + "" | *[!0-9]*) cached_at=0 ;; +esac +if [ $((now - cached_at)) -ge 86400 ]; then + mkdir -p "$CACHE_DIR" 2>/dev/null || exit 0 + # Timestamp sentinel before fetching, so sibling plugins don't race-fetch. + printf '%s %s\n' "$now" "" >"$CACHE_FILE" 2>/dev/null || true + tags=$(GIT_TERMINAL_PROMPT=0 git -c http.connectTimeout=5 \ + -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=10 \ + ls-remote --tags --refs "$REPO_URL" 2>/dev/null) || exit 0 + # Highest stable semver tag; "v" prefix tolerated, prereleases ignored. + latest=$(printf '%s\n' "$tags" | + sed -n 's#.*refs/tags/v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$#\1#p' | + sort -t . -k 1,1n -k 2,2n -k 3,3n | tail -n 1) + printf '%s %s\n' "$now" "$latest" >"$CACHE_FILE" 2>/dev/null || true +fi +[ -n "$latest" ] || exit 0 +[ "$latest" = "$INSTALLED" ] && exit 0 +newest=$(printf '%s\n%s\n' "$INSTALLED" "$latest" | + sort -t . -k 1,1n -k 2,2n -k 3,3n | tail -n 1) +[ "$newest" = "$latest" ] || exit 0 +printf '{"systemMessage":"Plugin acme v%s is available (installed v%s). Run /plugin update acme to upgrade."}\n' "$latest" "$INSTALLED" +exit 0 diff --git a/tests/golden/default/claude/plugins/acme/scripts/run.sh b/tests/golden/default/claude/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/claude/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/default/claude/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/default/claude/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/default/claude/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/claude/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/default/codex/.agents/plugins/marketplace.json b/tests/golden/default/codex/.agents/plugins/marketplace.json new file mode 100644 index 0000000..4eb2380 --- /dev/null +++ b/tests/golden/default/codex/.agents/plugins/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "golden-plugins", + "interface": { + "displayName": "Golden plugins" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme from the source manifest.", + "version": "1.2.3", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/tests/golden/default/codex/.pluginpack/codex.json b/tests/golden/default/codex/.pluginpack/codex.json new file mode 100644 index 0000000..6d122fb --- /dev/null +++ b/tests/golden/default/codex/.pluginpack/codex.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "target": "codex", + "files": [ + ".agents/plugins/marketplace.json", + "CONTRIBUTING.md", + "README.md", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/hooks/hooks.json", + "plugins/acme/mcp.json", + "plugins/acme/plugin.json", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".agents/plugins/marketplace.json": "c929e0fb6a0376199e20c75b96b7ad544cf1026351445ecd11d6b7e16a559c6d", + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/hooks/hooks.json": "ffa7f7bd137c04c26f889c25e3ea9cad84596c619f55a2f6324369c9a413c94d", + "plugins/acme/mcp.json": "f6d7a3e17bd9f611b74858306a38f4904a0dba89da8e069a9e09ddadc6cd7b73", + "plugins/acme/plugin.json": "6399ba0f48ba6f10251c99950d900933d33a03a7cb158afaec0182f8499fd753", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "91a5dad01d643b10c4f1e413bbab93e6f97d0aa213fa83ada8a26b0063670ddd", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/codex/CONTRIBUTING.md b/tests/golden/default/codex/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/codex/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/codex/README.md b/tests/golden/default/codex/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/codex/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/codex/plugins/acme/LICENSE b/tests/golden/default/codex/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/codex/plugins/acme/README.md b/tests/golden/default/codex/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/codex/plugins/acme/assets/icon.png b/tests/golden/default/codex/plugins/acme/assets/icon.png new file mode 100644 index 0000000..d1ed0c8 Binary files /dev/null and b/tests/golden/default/codex/plugins/acme/assets/icon.png differ diff --git a/tests/golden/default/codex/plugins/acme/bin/server b/tests/golden/default/codex/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/codex/plugins/acme/hooks/hooks.json b/tests/golden/default/codex/plugins/acme/hooks/hooks.json new file mode 100644 index 0000000..fd02484 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + } + ] + } +} diff --git a/tests/golden/default/codex/plugins/acme/mcp.json b/tests/golden/default/codex/plugins/acme/mcp.json new file mode 100644 index 0000000..4417b24 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/mcp.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "acme": { + "type": "stdio", + "command": "./bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "streamable-http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/codex/plugins/acme/plugin.json b/tests/golden/default/codex/plugins/acme/plugin.json new file mode 100644 index 0000000..8074b81 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/plugin.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "acme", + "version": "1.2.3", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "extensions": { + "com.openai": { + "interface": { + "displayName": "Acme", + "category": "Productivity" + } + } + } +} diff --git a/tests/golden/default/codex/plugins/acme/scripts/run.sh b/tests/golden/default/codex/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/codex/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/default/codex/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..1078736 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +In Codex, group the changes by feature and fix. diff --git a/tests/golden/default/codex/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/default/codex/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/codex/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/default/copilot/.claude-plugin/marketplace.json b/tests/golden/default/copilot/.claude-plugin/marketplace.json new file mode 100644 index 0000000..b162ee8 --- /dev/null +++ b/tests/golden/default/copilot/.claude-plugin/marketplace.json @@ -0,0 +1,26 @@ +{ + "name": "golden-plugins", + "metadata": { + "description": "Plugins whose output is pinned byte for byte.", + "version": "1.2.3", + "keywords": [ + "golden", + "test" + ] + }, + "owner": { + "name": "Golden" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme for Copilot.", + "version": "1.2.3", + "skills": [ + "./skills/release-notes" + ], + "mcpServers": ".mcp.json" + } + ] +} diff --git a/tests/golden/default/copilot/.github/plugin/marketplace.json b/tests/golden/default/copilot/.github/plugin/marketplace.json new file mode 100644 index 0000000..b162ee8 --- /dev/null +++ b/tests/golden/default/copilot/.github/plugin/marketplace.json @@ -0,0 +1,26 @@ +{ + "name": "golden-plugins", + "metadata": { + "description": "Plugins whose output is pinned byte for byte.", + "version": "1.2.3", + "keywords": [ + "golden", + "test" + ] + }, + "owner": { + "name": "Golden" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme for Copilot.", + "version": "1.2.3", + "skills": [ + "./skills/release-notes" + ], + "mcpServers": ".mcp.json" + } + ] +} diff --git a/tests/golden/default/copilot/.pluginpack/copilot.json b/tests/golden/default/copilot/.pluginpack/copilot.json new file mode 100644 index 0000000..e427fdb --- /dev/null +++ b/tests/golden/default/copilot/.pluginpack/copilot.json @@ -0,0 +1,40 @@ +{ + "version": 2, + "target": "copilot", + "files": [ + ".claude-plugin/marketplace.json", + ".github/plugin/marketplace.json", + "CONTRIBUTING.md", + "README.md", + "plugins/acme/.github/plugin/plugin.json", + "plugins/acme/.mcp.json", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/agents/helper.agent.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/hooks/hooks.json", + "plugins/acme/plugin.json", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".claude-plugin/marketplace.json": "7959535c7d4f15dbbe875f2a68297900bb16daacacd7c4b33a861040dddde2ac", + ".github/plugin/marketplace.json": "7959535c7d4f15dbbe875f2a68297900bb16daacacd7c4b33a861040dddde2ac", + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "plugins/acme/.github/plugin/plugin.json": "82aa29a8d5cb896860bf6fd92cf42bd498dec846c93943d27681c7c52085ec2b", + "plugins/acme/.mcp.json": "ca303b1525ecca62ef783a67180f84ea8f58665bb132c0db2e8c78a9e9b02105", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/agents/helper.agent.md": "f98ea67be542b0a45b4b493c086c36fef4bb7b18c3a9ed957370083b1b0962f4", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/hooks/hooks.json": "ffa7f7bd137c04c26f889c25e3ea9cad84596c619f55a2f6324369c9a413c94d", + "plugins/acme/plugin.json": "82aa29a8d5cb896860bf6fd92cf42bd498dec846c93943d27681c7c52085ec2b", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/copilot/CONTRIBUTING.md b/tests/golden/default/copilot/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/copilot/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/copilot/README.md b/tests/golden/default/copilot/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/copilot/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/copilot/plugins/acme/.github/plugin/plugin.json b/tests/golden/default/copilot/plugins/acme/.github/plugin/plugin.json new file mode 100644 index 0000000..03aeddd --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/.github/plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "acme", + "version": "1.2.3", + "description": "Acme for Copilot.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "category": "Productivity", + "agents": "agents/", + "skills": "skills/", + "hooks": "hooks/hooks.json", + "mcpServers": ".mcp.json" +} diff --git a/tests/golden/default/copilot/plugins/acme/.mcp.json b/tests/golden/default/copilot/plugins/acme/.mcp.json new file mode 100644 index 0000000..730fed8 --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "acme": { + "type": "stdio", + "command": "${PLUGIN_ROOT}/bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/copilot/plugins/acme/LICENSE b/tests/golden/default/copilot/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/copilot/plugins/acme/README.md b/tests/golden/default/copilot/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/copilot/plugins/acme/agents/helper.agent.md b/tests/golden/default/copilot/plugins/acme/agents/helper.agent.md new file mode 100644 index 0000000..8d32edd --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/agents/helper.agent.md @@ -0,0 +1,6 @@ +--- +name: helper +description: Find documents that answer a question. +--- + +Search and cite sources. diff --git a/tests/golden/default/copilot/plugins/acme/assets/icon.png b/tests/golden/default/copilot/plugins/acme/assets/icon.png new file mode 100644 index 0000000..d1ed0c8 Binary files /dev/null and b/tests/golden/default/copilot/plugins/acme/assets/icon.png differ diff --git a/tests/golden/default/copilot/plugins/acme/bin/server b/tests/golden/default/copilot/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/copilot/plugins/acme/hooks/hooks.json b/tests/golden/default/copilot/plugins/acme/hooks/hooks.json new file mode 100644 index 0000000..fd02484 --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + } + ] + } +} diff --git a/tests/golden/default/copilot/plugins/acme/plugin.json b/tests/golden/default/copilot/plugins/acme/plugin.json new file mode 100644 index 0000000..03aeddd --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "acme", + "version": "1.2.3", + "description": "Acme for Copilot.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "category": "Productivity", + "agents": "agents/", + "skills": "skills/", + "hooks": "hooks/hooks.json", + "mcpServers": ".mcp.json" +} diff --git a/tests/golden/default/copilot/plugins/acme/scripts/run.sh b/tests/golden/default/copilot/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/copilot/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/default/copilot/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/default/copilot/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/default/copilot/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/copilot/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/default/cursor/.cursor-plugin/marketplace.json b/tests/golden/default/cursor/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..cd0481e --- /dev/null +++ b/tests/golden/default/cursor/.cursor-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "golden-plugins", + "owner": { + "name": "Golden" + }, + "metadata": { + "description": "Plugins whose output is pinned byte for byte.", + "keywords": [ + "golden", + "test" + ] + }, + "plugins": [ + { + "name": "acme", + "source": "plugins/acme", + "description": "Acme from the source manifest." + } + ], + "version": "1.2.3" +} diff --git a/tests/golden/default/cursor/.pluginpack/cursor.json b/tests/golden/default/cursor/.pluginpack/cursor.json new file mode 100644 index 0000000..0575863 --- /dev/null +++ b/tests/golden/default/cursor/.pluginpack/cursor.json @@ -0,0 +1,42 @@ +{ + "version": 2, + "target": "cursor", + "files": [ + ".cursor-plugin/marketplace.json", + "CONTRIBUTING.md", + "README.md", + "plugins/acme/.cursor-plugin/plugin.json", + "plugins/acme/.mcp.json", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/agents/helper.agent.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/hooks/hooks.json", + "plugins/acme/rules/cursor-only.mdc", + "plugins/acme/rules/style.mdc", + "plugins/acme/scripts/pluginpack-update-check.sh", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".cursor-plugin/marketplace.json": "b36379764a5a320c8c59cbec1194be620032d8929286ab3239e5f45222a04317", + "CONTRIBUTING.md": "5d81cc62707c5592ad3fd46205b8b45d3aa25e2a1e6e18d59c14b3e801217bc7", + "README.md": "2e67a1226ce201bd5560b910ec05652241bf38ec364fc639d5173ae433980e5e", + "plugins/acme/.cursor-plugin/plugin.json": "17110335b7450c7c195abf50702584def0eaf68c38629976b70600959cc5c6a2", + "plugins/acme/.mcp.json": "e5eb8a5785f1395b17759a0519533736ab96cdce9430a2be7eba63f0d806b98f", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/agents/helper.agent.md": "f98ea67be542b0a45b4b493c086c36fef4bb7b18c3a9ed957370083b1b0962f4", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/hooks/hooks.json": "2964500e86d837c20982917061fdf2972946b893c682779f1677aafb1afd9b6d", + "plugins/acme/rules/cursor-only.mdc": "e735f55eb2c55458eeb055e07462474bb95d4a0052efecf687a4248938875821", + "plugins/acme/rules/style.mdc": "8d5532f408bbf1d24877ce3afb4cdf15114628ab98235787d1957bfe425546ee", + "plugins/acme/scripts/pluginpack-update-check.sh": "555083e54f172c755b824e825890ae87841ce27154717508ad18ed0274a84ed3", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/default/cursor/CONTRIBUTING.md b/tests/golden/default/cursor/CONTRIBUTING.md new file mode 100644 index 0000000..d4cfe83 --- /dev/null +++ b/tests/golden/default/cursor/CONTRIBUTING.md @@ -0,0 +1 @@ +Edit shared/, not the generated output. diff --git a/tests/golden/default/cursor/README.md b/tests/golden/default/cursor/README.md new file mode 100644 index 0000000..a459aab --- /dev/null +++ b/tests/golden/default/cursor/README.md @@ -0,0 +1,3 @@ +# Golden plugins + +Start by reading the changelog. diff --git a/tests/golden/default/cursor/plugins/acme/.cursor-plugin/plugin.json b/tests/golden/default/cursor/plugins/acme/.cursor-plugin/plugin.json new file mode 100644 index 0000000..1c601af --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/.cursor-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "acme", + "displayName": "Acme", + "version": "1.2.3", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "category": "Productivity", + "rules": "./rules/", + "agents": "./agents/", + "skills": "./skills/", + "hooks": "./hooks/", + "mcpServers": "./.mcp.json" +} diff --git a/tests/golden/default/cursor/plugins/acme/.mcp.json b/tests/golden/default/cursor/plugins/acme/.mcp.json new file mode 100644 index 0000000..dd3c805 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "acme": { + "command": "${CURSOR_PLUGIN_ROOT}/bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/default/cursor/plugins/acme/LICENSE b/tests/golden/default/cursor/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/default/cursor/plugins/acme/README.md b/tests/golden/default/cursor/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/default/cursor/plugins/acme/agents/helper.agent.md b/tests/golden/default/cursor/plugins/acme/agents/helper.agent.md new file mode 100644 index 0000000..8d32edd --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/agents/helper.agent.md @@ -0,0 +1,6 @@ +--- +name: helper +description: Find documents that answer a question. +--- + +Search and cite sources. diff --git a/tests/golden/default/cursor/plugins/acme/assets/icon.png b/tests/golden/default/cursor/plugins/acme/assets/icon.png new file mode 100644 index 0000000..d1ed0c8 Binary files /dev/null and b/tests/golden/default/cursor/plugins/acme/assets/icon.png differ diff --git a/tests/golden/default/cursor/plugins/acme/bin/server b/tests/golden/default/cursor/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/default/cursor/plugins/acme/hooks/hooks.json b/tests/golden/default/cursor/plugins/acme/hooks/hooks.json new file mode 100644 index 0000000..5468a83 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/hooks/hooks.json @@ -0,0 +1,21 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + } + ], + "sessionStart": [ + { + "command": "sh ./scripts/pluginpack-update-check.sh", + "timeout": 15 + } + ] + }, + "version": 1 +} diff --git a/tests/golden/default/cursor/plugins/acme/rules/cursor-only.mdc b/tests/golden/default/cursor/plugins/acme/rules/cursor-only.mdc new file mode 100644 index 0000000..8afa401 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/rules/cursor-only.mdc @@ -0,0 +1,6 @@ +--- +description: Cursor only. +alwaysApply: true +--- + +Only in Cursor. diff --git a/tests/golden/default/cursor/plugins/acme/rules/style.mdc b/tests/golden/default/cursor/plugins/acme/rules/style.mdc new file mode 100644 index 0000000..47b1a62 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/rules/style.mdc @@ -0,0 +1,6 @@ +--- +description: House style. +alwaysApply: false +--- + +Use short sentences. diff --git a/tests/golden/default/cursor/plugins/acme/scripts/pluginpack-update-check.sh b/tests/golden/default/cursor/plugins/acme/scripts/pluginpack-update-check.sh new file mode 100644 index 0000000..e095e34 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/scripts/pluginpack-update-check.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Generated by pluginpack — session-start update check for "acme". +# Compares the installed version against the latest git tag of the plugin repo, +# throttled to one network check per 24h (cache shared across plugins from the +# same repo). Fail-open by design: any problem exits 0 with no output. +[ "${PLUGINPACK_NO_UPDATE_CHECK:-}" = "1" ] && exit 0 +[ -n "${CI:-}" ] && exit 0 +command -v git >/dev/null 2>&1 || exit 0 + +INSTALLED='1.2.3' +REPO_URL='https://github.com/example/golden-plugins' + +# Only plain dotted-numeric versions compare meaningfully with sort below. +case "$INSTALLED" in + "" | *[!0-9.]* | .* | *.) exit 0 ;; +esac + +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/pluginpack" +KEY=$(printf '%s' "$REPO_URL" | cksum | cut -d ' ' -f 1) || exit 0 +CACHE_FILE="$CACHE_DIR/latest-$KEY" +now=$(date +%s) || exit 0 +latest="" +cached_at=0 +if [ -f "$CACHE_FILE" ]; then + read -r cached_at latest <"$CACHE_FILE" || true +fi +case "$cached_at" in + "" | *[!0-9]*) cached_at=0 ;; +esac +if [ $((now - cached_at)) -ge 86400 ]; then + mkdir -p "$CACHE_DIR" 2>/dev/null || exit 0 + # Timestamp sentinel before fetching, so sibling plugins don't race-fetch. + printf '%s %s\n' "$now" "" >"$CACHE_FILE" 2>/dev/null || true + tags=$(GIT_TERMINAL_PROMPT=0 git -c http.connectTimeout=5 \ + -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=10 \ + ls-remote --tags --refs "$REPO_URL" 2>/dev/null) || exit 0 + # Highest stable semver tag; "v" prefix tolerated, prereleases ignored. + latest=$(printf '%s\n' "$tags" | + sed -n 's#.*refs/tags/v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$#\1#p' | + sort -t . -k 1,1n -k 2,2n -k 3,3n | tail -n 1) + printf '%s %s\n' "$now" "$latest" >"$CACHE_FILE" 2>/dev/null || true +fi +[ -n "$latest" ] || exit 0 +[ "$latest" = "$INSTALLED" ] && exit 0 +newest=$(printf '%s\n%s\n' "$INSTALLED" "$latest" | + sort -t . -k 1,1n -k 2,2n -k 3,3n | tail -n 1) +[ "$newest" = "$latest" ] || exit 0 +printf '{"additional_context":"The installed Cursor plugin \\"acme\\" is outdated (installed v%s, latest v%s). Briefly let the user know they can update it from Cursor'\''s plugin settings."}\n' "$INSTALLED" "$latest" +exit 0 diff --git a/tests/golden/default/cursor/plugins/acme/scripts/run.sh b/tests/golden/default/cursor/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/default/cursor/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/default/cursor/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/default/cursor/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/default/cursor/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/default/cursor/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/variants/agent-plugins/.pluginpack/agent-plugins.json b/tests/golden/variants/agent-plugins/.pluginpack/agent-plugins.json new file mode 100644 index 0000000..55ddb9d --- /dev/null +++ b/tests/golden/variants/agent-plugins/.pluginpack/agent-plugins.json @@ -0,0 +1,26 @@ +{ + "version": 2, + "target": "agent-plugins", + "files": [ + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/mcp.json", + "plugins/acme/plugin.json", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/mcp.json": "f6d7a3e17bd9f611b74858306a38f4904a0dba89da8e069a9e09ddadc6cd7b73", + "plugins/acme/plugin.json": "656661b644afd8b9960fa5ee047a25a3304dc9cde25abf630448dffdf828f777", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/variants/agent-plugins/plugins/acme/LICENSE b/tests/golden/variants/agent-plugins/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/variants/agent-plugins/plugins/acme/README.md b/tests/golden/variants/agent-plugins/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/variants/agent-plugins/plugins/acme/assets/icon.png b/tests/golden/variants/agent-plugins/plugins/acme/assets/icon.png new file mode 100644 index 0000000..d1ed0c8 Binary files /dev/null and b/tests/golden/variants/agent-plugins/plugins/acme/assets/icon.png differ diff --git a/tests/golden/variants/agent-plugins/plugins/acme/bin/server b/tests/golden/variants/agent-plugins/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/variants/agent-plugins/plugins/acme/mcp.json b/tests/golden/variants/agent-plugins/plugins/acme/mcp.json new file mode 100644 index 0000000..4417b24 --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/mcp.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "acme": { + "type": "stdio", + "command": "./bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "streamable-http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/variants/agent-plugins/plugins/acme/plugin.json b/tests/golden/variants/agent-plugins/plugins/acme/plugin.json new file mode 100644 index 0000000..8587c8b --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/plugin.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "acme", + "version": "1.2.3", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ] +} diff --git a/tests/golden/variants/agent-plugins/plugins/acme/scripts/run.sh b/tests/golden/variants/agent-plugins/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/variants/agent-plugins/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/golden/variants/codex/.agents/plugins/marketplace.json b/tests/golden/variants/codex/.agents/plugins/marketplace.json new file mode 100644 index 0000000..9b30483 --- /dev/null +++ b/tests/golden/variants/codex/.agents/plugins/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "golden-plugins", + "interface": { + "displayName": "Golden Plugins" + }, + "plugins": [ + { + "name": "acme", + "source": "./plugins/acme", + "description": "Acme from the source manifest.", + "version": "1.2.3", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/tests/golden/variants/codex/.pluginpack/codex.json b/tests/golden/variants/codex/.pluginpack/codex.json new file mode 100644 index 0000000..73f9aed --- /dev/null +++ b/tests/golden/variants/codex/.pluginpack/codex.json @@ -0,0 +1,30 @@ +{ + "version": 2, + "target": "codex", + "files": [ + ".agents/plugins/marketplace.json", + "plugins/acme/.codex-plugin/plugin.json", + "plugins/acme/.mcp.json", + "plugins/acme/LICENSE", + "plugins/acme/README.md", + "plugins/acme/assets/icon.png", + "plugins/acme/bin/server", + "plugins/acme/hooks/hooks.json", + "plugins/acme/scripts/run.sh", + "plugins/acme/skills/release-notes/SKILL.md", + "plugins/acme/skills/release-notes/references/template.txt" + ], + "sha256": { + ".agents/plugins/marketplace.json": "15c5be6a27be85175cf6b75a6aa357cd3244c4f049140e9283f05558714b7229", + "plugins/acme/.codex-plugin/plugin.json": "381feb8471c085bfdfd7a0085d24dbcfdda566228c59538c13c83cd62be1b26c", + "plugins/acme/.mcp.json": "f3c91992c02ed1cb0767accf66bf8e7bca72408b401771c6c9ab5c5c55fc6aa1", + "plugins/acme/LICENSE": "adc37366f403835c1470ab2df93d3837d4719372fc1ef8593d922e06f033f8b2", + "plugins/acme/README.md": "bd8dd9498117b96048242a906bd2112379227d573726b62ea4bbcba214a24469", + "plugins/acme/assets/icon.png": "71fde3856a298a052c6edb5155638d8422114e55bf0e28374d90dea8be99ef6c", + "plugins/acme/bin/server": "9210d306feecb440d7bd86c82b2ddfae0bb2b706571ed3962f566a7f3f3cf83d", + "plugins/acme/hooks/hooks.json": "ffa7f7bd137c04c26f889c25e3ea9cad84596c619f55a2f6324369c9a413c94d", + "plugins/acme/scripts/run.sh": "a4e0317eafab5cf1bc4a0041c7c8aeb6ece56fe72e7b2b3017a8a6574614cd35", + "plugins/acme/skills/release-notes/SKILL.md": "ab3912f7c2cd1515c291a59d91205364a66b41566095e4202ce8842c59d21b6d", + "plugins/acme/skills/release-notes/references/template.txt": "0528c3e23a7f67ba714dc08d17d0fa4d1d71584c3a52caa2d344d5f030db441a" + } +} diff --git a/tests/golden/variants/codex/plugins/acme/.codex-plugin/plugin.json b/tests/golden/variants/codex/plugins/acme/.codex-plugin/plugin.json new file mode 100644 index 0000000..f806271 --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/.codex-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "acme", + "version": "1.2.3", + "description": "Acme from the source manifest.", + "author": { + "name": "Golden", + "email": "golden@example.com" + }, + "homepage": "https://example.com/golden", + "repository": "https://github.com/example/golden-plugins", + "license": "MIT", + "keywords": [ + "golden", + "test" + ], + "skills": "./skills/", + "hooks": "./hooks/hooks.json", + "mcpServers": "./.mcp.json" +} diff --git a/tests/golden/variants/codex/plugins/acme/.mcp.json b/tests/golden/variants/codex/plugins/acme/.mcp.json new file mode 100644 index 0000000..4ac170d --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "acme": { + "type": "stdio", + "command": "./bin/server", + "args": [ + "--stdio" + ], + "env": { + "ACME_MODE": "plugin" + } + }, + "docs": { + "type": "streamable-http", + "url": "https://mcp.example.com/mcp" + } + } +} diff --git a/tests/golden/variants/codex/plugins/acme/LICENSE b/tests/golden/variants/codex/plugins/acme/LICENSE new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/LICENSE @@ -0,0 +1 @@ +MIT diff --git a/tests/golden/variants/codex/plugins/acme/README.md b/tests/golden/variants/codex/plugins/acme/README.md new file mode 100644 index 0000000..a417b90 --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/README.md @@ -0,0 +1,3 @@ +# Acme + +Start by reading the changelog. diff --git a/tests/golden/variants/codex/plugins/acme/assets/icon.png b/tests/golden/variants/codex/plugins/acme/assets/icon.png new file mode 100644 index 0000000..d1ed0c8 Binary files /dev/null and b/tests/golden/variants/codex/plugins/acme/assets/icon.png differ diff --git a/tests/golden/variants/codex/plugins/acme/bin/server b/tests/golden/variants/codex/plugins/acme/bin/server new file mode 100644 index 0000000..a43ea4e --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/bin/server @@ -0,0 +1,2 @@ +#!/bin/sh +echo server diff --git a/tests/golden/variants/codex/plugins/acme/hooks/hooks.json b/tests/golden/variants/codex/plugins/acme/hooks/hooks.json new file mode 100644 index 0000000..fd02484 --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo ready" + } + ] + } + ] + } +} diff --git a/tests/golden/variants/codex/plugins/acme/scripts/run.sh b/tests/golden/variants/codex/plugins/acme/scripts/run.sh new file mode 100644 index 0000000..85ba14d --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/scripts/run.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo run diff --git a/tests/golden/variants/codex/plugins/acme/skills/release-notes/SKILL.md b/tests/golden/variants/codex/plugins/acme/skills/release-notes/SKILL.md new file mode 100644 index 0000000..ff888d6 --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/skills/release-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: release-notes +description: Draft release notes from a list of changes. +--- + +# Release notes + +Start by reading the changelog. + +Group the changes by feature and fix. diff --git a/tests/golden/variants/codex/plugins/acme/skills/release-notes/references/template.txt b/tests/golden/variants/codex/plugins/acme/skills/release-notes/references/template.txt new file mode 100644 index 0000000..937d151 --- /dev/null +++ b/tests/golden/variants/codex/plugins/acme/skills/release-notes/references/template.txt @@ -0,0 +1,3 @@ +## Features + +## Fixes diff --git a/tests/lifecycle-journeys.test.ts b/tests/lifecycle-journeys.test.ts new file mode 100644 index 0000000..9cfab3a --- /dev/null +++ b/tests/lifecycle-journeys.test.ts @@ -0,0 +1,688 @@ +/** + * Journey tests for the current authoring shape: canonical `shared/` + * sources, `overrides//`, project partials and + * `repositories/` files, walked through the whole lifecycle a user + * runs — build, edit a skill, delete a skill, add a skill, rebuild, prune, + * clean — for all six targets at once. + * + * After every step the whole project tree must be exactly: the authored + * sources, byte for byte as the author left them; every hand-written file in + * the output; and, for each target, the managed files the current config + * emits plus its managed manifest. So a step that touches a source, deletes a + * hand-written file, leaves a stale file behind or misses a file fails here. + * Component tests elsewhere check each piece; these check that the pieces + * still compose across rebuilds. + */ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Project, type ProjectArgs } from "fixturify-project"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { build } from "../src/build.js"; +import { clean, prune } from "../src/cleanup.js"; +import { validateOutput } from "../src/adapters.js"; +import { exists, toPosix, walkFiles } from "../src/fs.js"; +import { managedManifestPath, readManagedManifest } from "../src/managed.js"; +import type { Artifact, TargetName } from "../src/types.js"; + +type DirJSON = NonNullable; + +// Each journey runs several real builds and validations. The longest take +// 3-4s locally, and CI runners run the whole suite in parallel, so vitest's +// 5s default is too tight for this file. +vi.setConfig({ testTimeout: 60_000 }); + +const allTargets: TargetName[] = [ + "cursor", + "claude", + "antigravity", + "copilot", + "codex", + "agent-plugins", +]; + +const indexPath = path.resolve("src/index.ts"); + +let project: Project | undefined; +let warnings: string[] = []; + +beforeEach(() => { + warnings = []; + vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await project?.dispose(); + project = undefined; +}); + +function skill(name: string, body: string): string { + return `---\nname: ${name}\ndescription: The ${name} skill.\n---\n\n${body}\n`; +} + +/** Where one target writes, and which optional pieces of the shape it uses. */ +type Placement = { + outDir: string; + /** Extra target-level config, such as `pluginRoot`. */ + target?: string; + /** Extra plugin-level config, such as `path`. */ + plugin?: string; + repositoryFiles: boolean; +}; + +const codexEntry = `entry: { policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity" },`; + +function targetBlock(target: TargetName, placement: Placement): string { + const pluginEntry = + target === "agent-plugins" + ? `clients: { codex: { ${codexEntry} } },` + : target === "codex" + ? codexEntry + : ""; + return `"${target}": { + outDir: "${placement.outDir}", + ${placement.repositoryFiles ? `repositoryFiles: "repositories/${target}",` : ""} + ${target === "agent-plugins" ? "clients: { codex: {}, copilot: {} }," : ""} + ${placement.target ?? ""} + plugins: { + acme: { + source: "shared/acme", + overrides: "overrides/${target}/acme", + ${placement.plugin ?? ""} + ${pluginEntry} + }, + }, + },`; +} + +function configSource(targets: string): string { + return `import { defineConfig } from "${indexPath}"; + +export default defineConfig({ + name: "acme-plugins", + version: "1.0.0", + source: { partials: "partials" }, + metadata: { + description: "Acme agent plugins.", + author: { name: "Acme" }, + owner: { name: "Acme" }, + license: "MIT", + repository: "https://github.com/acme/acme-plugins", + }, + targets: { + ${targets} + }, +}); +`; +} + +/** Files every journey project authors, apart from the config. */ +function authoredFiles(): DirJSON { + const overrides: DirJSON = {}; + const repositories: DirJSON = {}; + for (const target of allTargets) { + overrides[target] = { + acme: { + assets: { + // Replaces the shared icon, and adds a target-only file. + "icon.svg": `${target}\n`, + [`${target}-note.txt`]: `Only in ${target}.\n`, + }, + }, + }; + repositories[target] = { + "README.md": `# Acme for ${target}\n\n{{> auth}}\n`, + }; + } + return { + partials: { "auth.md": "Try OAuth first, then fall back to a token.\n" }, + shared: { + acme: { + skills: { + alpha: { "SKILL.md": skill("alpha", "{{> auth}}") }, + beta: { "SKILL.md": skill("beta", "Beta body.") }, + }, + agents: { + "helper.agent.md": + "---\nname: helper\ndescription: Find documents.\n---\n\nSearch and cite.\n", + }, + assets: { "icon.svg": "shared\n" }, + mcp: { + "config.json": JSON.stringify( + { + mcpServers: { + acme: { + type: "stdio", + command: "node", + args: ["./start.mjs"], + }, + }, + }, + null, + 2, + ), + "pluginpack.json": JSON.stringify( + { files: { "start.mjs": "start.mjs" } }, + null, + 2, + ), + "start.mjs": "console.log('acme');\n", + // MCP source that must not ship. + src: { "server.ts": "export {};\n" }, + }, + }, + }, + overrides, + repositories, + }; +} + +async function makeProject(config: string, files: DirJSON): Promise { + project = new Project("pluginpack-journey", "1.0.0", { + files: { "pluginpack.config.ts": config, ...files }, + }); + await project.write(); + return project.baseDir; +} + +/** + * Every file under `dir` (relative, posix) with its contents, sorted. + * Skips the `package.json` fixturify writes at the project root. + */ +async function readTree(dir: string): Promise> { + const tree = new Map(); + if (!(await exists(dir))) { + return tree; + } + for (const file of await walkFiles(dir)) { + const relative = toPosix(path.relative(dir, file)); + if (relative !== "package.json") { + tree.set(relative, await readFile(file, "utf8")); + } + } + return new Map([...tree].sort(([a], [b]) => (a < b ? -1 : 1))); +} + +async function writeFiles( + root: string, + files: Record, +): Promise { + for (const [relative, contents] of Object.entries(files)) { + await mkdir(path.dirname(path.join(root, relative)), { recursive: true }); + await writeFile(path.join(root, relative), contents); + } +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Asserts each hand-written file is still on disk with the bytes it was written with. */ +async function expectSurvives( + root: string, + files: Record, +): Promise { + for (const [relative, contents] of Object.entries(files)) { + expect( + await readFile(path.join(root, relative), "utf8"), + `${relative} should survive`, + ).toBe(contents); + } +} + +/** Root-relative path of a file inside a target's output. */ +function inOutput(root: string, artifact: Artifact, relative: string): string { + return toPosix(path.relative(root, path.join(artifact.outDir, relative))); +} + +/** + * Asserts the project tree is exactly `sources` plus `unmanaged` plus each + * artifact's managed files (with the bytes the artifact emitted) and managed + * manifest, and that each manifest lists exactly the artifact's paths. + */ +async function expectProjectTree( + root: string, + step: string, + sources: Map, + unmanaged: Record, + artifacts: Artifact[], +): Promise { + const expected = new Map([ + ...sources, + ...Object.entries(unmanaged), + ]); + for (const artifact of artifacts) { + for (const managedPath of artifact.managedPaths) { + const value = artifact.files.get(managedPath); + expected.set( + inOutput(root, artifact, managedPath), + typeof value === "string" || value === undefined + ? value + : Buffer.from(value).toString("utf8"), + ); + } + expected.set( + inOutput(root, artifact, managedManifestPath(artifact.target)), + undefined, + ); + const manifest = await readManagedManifest( + artifact.outDir, + artifact.target, + ); + expect(manifest?.files, `${step}: ${artifact.target} manifest`).toEqual( + artifact.managedPaths, + ); + } + const onDisk = await readTree(root); + expect([...onDisk.keys()], `${step}: project tree`).toEqual( + [...expected.keys()].sort(), + ); + for (const [relative, contents] of expected) { + if (contents !== undefined) { + expect(onDisk.get(relative), `${step}: ${relative}`).toBe(contents); + } + } +} + +async function expectValid(target: TargetName, outDir: string): Promise { + const result = await validateOutput(target, outDir); + expect( + result.issues.filter((issue) => issue.level === "error"), + `${target} validate errors`, + ).toEqual([]); + expect(result.ok).toBe(true); +} + +/** The managed path ending with `suffix`; throws if the artifact has none. */ +function findOutput(artifact: Artifact, suffix: string): string { + const found = artifact.managedPaths.find((managedPath) => + managedPath.endsWith(suffix), + ); + if (!found) { + throw new Error( + `${artifact.target} emitted no path ending with ${suffix}: ${artifact.managedPaths.join(", ")}`, + ); + } + return found; +} + +const layouts: Record> = { + "one outDir per target": Object.fromEntries( + allTargets.map((target) => [ + target, + { outDir: `out/${target}`, repositoryFiles: true }, + ]), + ) as Record, + // README "Recommended Shape": cursor and claude share the repository root, + // so only cursor publishes repositories/ (both would write README.md). + "the README's recommended shape": { + cursor: { + outDir: ".", + plugin: `path: "plugins/cursor/acme",`, + repositoryFiles: true, + }, + claude: { + outDir: ".", + target: `pluginRoot: "plugins/claude",`, + repositoryFiles: false, + }, + antigravity: { outDir: "plugins/antigravity", repositoryFiles: true }, + copilot: { outDir: "plugins/copilot", repositoryFiles: true }, + codex: { outDir: "plugins/codex", repositoryFiles: true }, + "agent-plugins": { outDir: "plugins/agent-plugins", repositoryFiles: true }, + }, +}; + +describe.each(Object.entries(layouts))( + "current authoring shape across all six targets, %s", + (_name, layout) => { + it("survives build, edit, delete, add, rebuild, prune and clean", async () => { + const root = await makeProject( + configSource( + allTargets + .map((target) => targetBlock(target, layout[target])) + .join("\n"), + ), + authoredFiles(), + ); + // Everything on disk before the first build is authored. + const sources = await readTree(root); + const editSource = async ( + relative: string, + contents: string | undefined, + ): Promise => { + if (contents === undefined) { + await rm(path.join(root, relative)); + sources.delete(relative); + } else { + await writeFiles(root, { [relative]: contents }); + sources.set(relative, contents); + } + }; + + // --- build ------------------------------------------------------- + let artifacts = await build({ cwd: root }); + expect(artifacts.map((artifact) => artifact.target).sort()).toEqual( + [...allTargets].sort(), + ); + + // Hand-written files: one in each output root, one beside a skill + // that stays, and one inside the directory of the skill that will be + // deleted. + const unmanaged: Record = {}; + for (const artifact of artifacts) { + const alpha = findOutput(artifact, "skills/alpha/SKILL.md"); + const beta = findOutput(artifact, "skills/beta/SKILL.md"); + const files = { + [`NOTES-${artifact.target}.md`]: `Notes for ${artifact.target}.\n`, + [path.posix.join(path.posix.dirname(alpha), "LOCAL.md")]: + "Local alpha notes.\n", + [path.posix.join(path.posix.dirname(beta), "LOCAL.md")]: + "Local beta notes.\n", + }; + await writeFiles(artifact.outDir, files); + for (const [relative, contents] of Object.entries(files)) { + unmanaged[inOutput(root, artifact, relative)] = contents; + } + } + + const checkStep = async (step: string): Promise => { + await expectProjectTree(root, step, sources, unmanaged, artifacts); + for (const artifact of artifacts) { + await expectValid(artifact.target, artifact.outDir); + } + }; + await checkStep("build"); + + for (const artifact of artifacts) { + const alpha = String( + artifact.files.get(findOutput(artifact, "skills/alpha/SKILL.md")), + ); + // The partial resolved. + expect(alpha).toContain("Try OAuth first, then fall back to a token."); + expect(alpha).not.toContain("{{>"); + // The repository file was published, with its partial resolved. + if (layout[artifact.target].repositoryFiles) { + expect(String(artifact.files.get("README.md"))).toBe( + `# Acme for ${artifact.target}\n\nTry OAuth first, then fall back to a token.\n`, + ); + } + // The override replaced the shared icon and added a target-only file. + expect( + String(artifact.files.get(findOutput(artifact, "assets/icon.svg"))), + ).toBe(`${artifact.target}\n`); + findOutput(artifact, `assets/${artifact.target}-note.txt`); + // MCP shipped its declared file, not its source. + findOutput(artifact, "start.mjs"); + expect( + artifact.managedPaths.filter((managedPath) => + managedPath.includes("server.ts"), + ), + ).toEqual([]); + } + // One package serves both clients: a marketplace each, one package. + const multi = artifacts.find( + (artifact) => artifact.target === "agent-plugins", + )!; + expect(multi.managedPaths).toContain(".agents/plugins/marketplace.json"); + expect(multi.managedPaths).toContain(".github/plugin/marketplace.json"); + findOutput(multi, "com.github.copilot/agents/helper.agent.md"); + expect( + multi.managedPaths.filter((managedPath) => + managedPath.endsWith("plugin.json"), + ), + ).toHaveLength(1); + + // --- edit a skill, rebuild --------------------------------------- + await editSource( + "shared/acme/skills/alpha/SKILL.md", + skill("alpha", "Edited alpha.\n\n{{> auth}}"), + ); + artifacts = await build({ cwd: root }); + await checkStep("edit"); + for (const artifact of artifacts) { + expect( + String( + artifact.files.get(findOutput(artifact, "skills/alpha/SKILL.md")), + ), + ).toContain("Edited alpha."); + } + + // --- delete a skill, rebuild ------------------------------------- + await editSource("shared/acme/skills/beta/SKILL.md", undefined); + artifacts = await build({ cwd: root }); + await checkStep("delete"); + for (const artifact of artifacts) { + expect( + artifact.managedPaths.filter((managedPath) => + managedPath.includes("skills/beta/"), + ), + ).toEqual([]); + } + + // --- add a skill, rebuild ---------------------------------------- + await editSource( + "shared/acme/skills/gamma/SKILL.md", + skill("gamma", "Gamma body."), + ); + artifacts = await build({ cwd: root }); + await checkStep("add"); + const gammaPaths = new Map( + artifacts.map((artifact) => [ + artifact.target, + findOutput(artifact, "skills/gamma/SKILL.md"), + ]), + ); + + // --- delete a skill, prune without rebuilding -------------------- + await editSource("shared/acme/skills/gamma/SKILL.md", undefined); + const before = await readTree(root); + const dryRun = await prune({ cwd: root, dryRun: true }); + expect(await readTree(root)).toEqual(before); + for (const result of dryRun) { + expect( + result.entries.map((entry) => entry.path), + `${result.target} prune --dry-run`, + ).toEqual([gammaPaths.get(result.target)]); + } + const pruned = await prune({ cwd: root }); + expect( + Object.fromEntries( + pruned.map((result) => [ + result.target, + result.entries.map((entry) => entry.path), + ]), + ), + ).toEqual( + Object.fromEntries( + [...gammaPaths].map(([target, gamma]) => [target, [gamma]]), + ), + ); + for (const artifact of artifacts) { + expect( + await exists( + path.join(artifact.outDir, gammaPaths.get(artifact.target)!), + ), + ).toBe(false); + } + // Rebuild: nothing left to remove, and a second prune finds nothing. + artifacts = await build({ cwd: root }); + await checkStep("prune"); + const again = await prune({ cwd: root }); + expect(again.flatMap((result) => result.entries)).toEqual([]); + + // --- clean ------------------------------------------------------- + const cleaned = await clean({ cwd: root }); + expect(cleaned.map((result) => result.target).sort()).toEqual( + [...allTargets].sort(), + ); + await expectProjectTree(root, "clean", sources, unmanaged, []); + await expectSurvives(root, unmanaged); + + // Nothing in this journey edited generated output, so nothing should + // have warned about overwriting or deleting an edited file. + expect( + warnings.filter((warning) => /edited|changed since/i.test(warning)), + ).toEqual([]); + }); + }, +); + +/** + * Moving a plugin between the `codex` target and an `agent-plugins` package + * that lists `clients.codex`, in one output directory. Both targets write + * `.agents/plugins/marketplace.json` and `plugins//plugin.json`, so + * they cannot be configured over one outDir at the same time; the move is a + * config change between builds. + */ +describe("codex <-> agent-plugins clients.codex handoff in one outDir", () => { + const configs = { + codex: `codex: { + outDir: "out", + plugins: { acme: { source: "shared/acme", ${codexEntry} } }, + },`, + "agent-plugins": `"agent-plugins": { + outDir: "out", + clients: { codex: {}, copilot: {} }, + plugins: { acme: { source: "shared/acme", clients: { codex: { ${codexEntry} } } } }, + },`, + } as const; + type Side = keyof typeof configs; + const handoffs: [Side, Side][] = [ + ["codex", "agent-plugins"], + ["agent-plugins", "codex"], + ]; + const notes = { "out/NOTES.md": "Hand-written.\n" }; + + const handoffFiles = (): DirJSON => ({ + partials: { "auth.md": "Unused here.\n" }, + shared: { + acme: { + skills: { alpha: { "SKILL.md": skill("alpha", "Alpha body.") } }, + // Codex ships hooks; an agent-plugins package has nowhere to put them. + hooks: { + "hooks.json": `${JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "echo hi" }] }, + ], + }, + })}\n`, + }, + }, + }, + }); + + /** + * Rewrites `pluginpack.config.ts` in place to build one side, as a user + * editing the config would, and records the new bytes as authored. + */ + async function useConfig( + root: string, + sources: Map, + side: Side, + ): Promise { + const contents = configSource(configs[side]); + await writeFiles(root, { "pluginpack.config.ts": contents }); + sources.set("pluginpack.config.ts", contents); + } + + /** The project after building `from` and adding a hand-written file. */ + async function builtWith(from: Side) { + const root = await makeProject(configSource(""), handoffFiles()); + const sources = await readTree(root); + await useConfig(root, sources, from); + const [first] = await build({ cwd: root }); + expect(first.target).toBe(from); + await writeFiles(root, notes); + await expectProjectTree(root, "first build", sources, notes, [first]); + await expectValid(from, path.join(root, "out")); + return { root, sources }; + } + + /** Builds `to`, checks its output is complete and valid, then cleans it. */ + async function buildAndCleanNew( + root: string, + sources: Map, + to: Side, + ): Promise { + const [second] = await build({ cwd: root }); + expect(second.target).toBe(to); + await expectProjectTree(root, "build new", sources, notes, [second]); + await expectValid(to, path.join(root, "out")); + expect(second.managedPaths.includes("plugins/acme/hooks/hooks.json")).toBe( + to === "codex", + ); + + await clean({ cwd: root }); + await expectProjectTree(root, "clean new", sources, notes, []); + } + + it.each(handoffs)( + "%s -> %s: clean the old target first, then build the new one", + async (from, to) => { + const { root, sources } = await builtWith(from); + + // The documented move: clean the old target while it is still + // configured, then switch the config and build the new one. + await clean({ cwd: root, target: from }); + await expectProjectTree(root, "clean old", sources, notes, []); + + await useConfig(root, sources, to); + await buildAndCleanNew(root, sources, to); + }, + ); + + // Switching first leaves the old target's manifest in `out`, and no + // configured target would prune the files it lists (codex's hooks inside + // an agent-plugins package, say, which fail validation). build and prune + // refuse, name the manifest and say which clean to run; that clean finds + // the manifest even though the old target is no longer configured. + it.each(handoffs)( + "%s -> %s: switching the config without cleaning refuses until the old target is cleaned", + async (from, to) => { + const { root, sources } = await builtWith(from); + + await useConfig(root, sources, to); + const before = await readTree(root); + // Names the stale manifest, then the command that removes it. + const refusal = new RegExp( + `${escapeRegExp(`${from}.json (${from})`)}[\\s\\S]*${escapeRegExp(`pluginpack clean --target ${from}`)}`, + ); + await expect(build({ cwd: root }), "build refuses").rejects.toThrow( + refusal, + ); + await expect(prune({ cwd: root }), "prune refuses").rejects.toThrow( + refusal, + ); + // Refusing touched nothing. + expect(await readTree(root)).toEqual(before); + + const cleaned = await clean({ cwd: root, target: from }); + expect(cleaned.map((result) => result.target)).toEqual([from]); + await expectProjectTree(root, "clean old", sources, notes, []); + + await buildAndCleanNew(root, sources, to); + }, + ); +}); + +// A long-lived process calling the programmatic API (a watcher, a test +// harness) must build the config on disk, not a cached copy of an earlier one. +it("build() reads pluginpack.config.ts again after it changes", async () => { + const placement = { outDir: "out", repositoryFiles: false }; + const root = await makeProject( + configSource(targetBlock("claude", placement)), + authoredFiles(), + ); + const [first] = await build({ cwd: root }); + expect(first.target).toBe("claude"); + await clean({ cwd: root }); + await writeFiles(root, { + "pluginpack.config.ts": configSource(targetBlock("cursor", placement)), + }); + const [second] = await build({ cwd: root }); + expect(second.target).toBe("cursor"); +}); diff --git a/tests/readme-examples.test.ts b/tests/readme-examples.test.ts new file mode 100644 index 0000000..2132efb --- /dev/null +++ b/tests/readme-examples.test.ts @@ -0,0 +1,517 @@ +/** + * Runs the README's shell and config examples with the built CLI, so a + * documented command or config that stops working fails CI instead of + * shipping. `markdown-code check` only keeps four blocks in sync with + * `snippets/`; it runs nothing. + * + * Every fenced bash/sh/ts/json block in README.md must appear in `inventory` + * below, either as run here, run by another check, or not runnable with a + * reason. A new block fails the inventory test until someone decides which. + * + * Overlap: the Quick Start blocks are run by `tests/readme-quick-start.test.ts` + * (added on fix/target-conformance, which also makes the Quick Start + * complete), and its `npm install` by `scripts/verify-packed-install.mjs`. + * This file covers the rest: the CLI Reference examples, Install Snippet, CI + * Change Detection, Components, One package for several clients, MCP + * Directory and the Programmatic API import list. + */ +import { execFileSync } from "node:child_process"; +import { + cp, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { exists, toPosix } from "../src/fs.js"; + +const repoRoot = fileURLToPath(new URL("../", import.meta.url)); +const cli = path.join(repoRoot, "dist/cli.js"); + +type Block = { key: string; lang: string; body: string }; + +/** + * Fenced blocks by ` > #`, counting per heading + * and language. Only languages a reader might paste and run are listed. + */ +function readmeBlocks(readme: string): Block[] { + const blocks: Block[] = []; + const counts = new Map(); + let heading = ""; + let fence: { lang: string; lines: string[] } | undefined; + for (const line of readme.split("\n")) { + if (fence) { + if (line.startsWith("```")) { + if (["bash", "sh", "ts", "json"].includes(fence.lang)) { + const base = `${heading} > ${fence.lang}`; + const n = (counts.get(base) ?? 0) + 1; + counts.set(base, n); + blocks.push({ + key: `${base} #${n}`, + lang: fence.lang, + body: fence.lines.join("\n"), + }); + } + fence = undefined; + } else { + fence.lines.push(line); + } + } else if (line.startsWith("```")) { + fence = { lang: line.slice(3).split(/\s/)[0], lines: [] }; + } else if (/^#{1,6} /.test(line)) { + heading = line.replace(/^#+ /, ""); + } + } + return blocks; +} + +/** How each README block is covered. `run` blocks are executed in this file. */ +const inventory: Record = { + "Quick Start > bash #1": + "verify-packed-install.mjs installs the packed tarball", + "Quick Start > ts #1": "readme-quick-start.test.ts (fix/target-conformance)", + "Quick Start > bash #2": + "readme-quick-start.test.ts (fix/target-conformance)", + "Components > ts #1": "run", + "One package for several clients > ts #1": "run", + "Update Check (claude, cursor) > ts #1": + "not runnable: a fragment with `...` placeholders", + "Install Snippet > bash #1": "run", + "MCP Directory > json #1": "run", + "Legacy Additional Plugin-Root Files > json #1": + "not run: legacy 0.10 sources, covered by core.test.ts", + "Partials > ts #1": + "not runnable: a fragment; partials are run by lifecycle-journeys.test.ts", + "CI Change Detection > bash #1": "run", + "Programmatic API > ts #1": "run", + "Claude Code > bash #1": + "not runnable: host CLI commands; install-snippet.test.ts checks install-info", + "Antigravity > bash #1": + "not runnable: host CLI commands; install-snippet.test.ts checks install-info", + ...Object.fromEntries( + [ + "init", + "build", + "validate", + "diff", + "prune", + "clean", + "install-info", + "docs", + ].map((command) => [ + `\`${command}\` > bash #1`, + "not runnable: a usage synopsis; its Examples are run", + ]), + ), +}; + +let readme = ""; +let blocks: Block[] = []; +let workspace = ""; + +function block(key: string): string { + expect(inventory[key], key).toBe("run"); + const found = blocks.find((candidate) => candidate.key === key); + if (!found) { + throw new Error(`README.md has no block "${key}".`); + } + return found.body; +} + +/** Runs `pluginpack ` with the built CLI; throws with its output on a non-zero exit. */ +function pluginpack(cwd: string, args: string[]): string { + try { + return execFileSync(process.execPath, [cli, ...args], { + cwd, + encoding: "utf8", + stdio: "pipe", + env: { ...process.env, CI: "1" }, + }); + } catch (error) { + const failed = error as { stdout?: string; stderr?: string }; + throw new Error( + `pluginpack ${args.join(" ")} failed in ${cwd}:\n${failed.stdout ?? ""}${failed.stderr ?? ""}`, + { cause: error }, + ); + } +} + +/** `pluginpack a b c` from a README line, as argv. */ +function argv(command: string): string[] { + const trimmed = command.trim(); + expect(trimmed).toMatch(/^pluginpack /); + return trimmed.slice("pluginpack ".length).split(/\s+/); +} + +/** + * Every file under `dir` with its contents, keyed by posix relative path. + * Skips `node_modules`, which here is a symlink to this repository. + */ +async function readTree(dir: string): Promise> { + const tree: Record = {}; + const walk = async (current: string): Promise => { + for (const entry of await readdir(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory() && entry.name !== "node_modules") { + await walk(full); + } else if (entry.isFile()) { + tree[toPosix(path.relative(dir, full))] = await readFile(full, "utf8"); + } + } + }; + if (await exists(dir)) { + await walk(dir); + } + return Object.fromEntries(Object.entries(tree).sort()); +} + +async function writeFiles( + root: string, + files: Record, +): Promise { + for (const [relative, contents] of Object.entries(files)) { + await mkdir(path.dirname(path.join(root, relative)), { recursive: true }); + await writeFile(path.join(root, relative), contents); + } +} + +/** + * A project that imports the real local package the way a README config + * does (`from "@gleanwork/pluginpack"`), with its config and files. + */ +async function makeProject( + name: string, + files: Record, +): Promise { + const dir = path.join(workspace, name); + await writeFiles(dir, { "package.json": '{ "type": "module" }\n', ...files }); + await mkdir(path.join(dir, "node_modules/@gleanwork"), { recursive: true }); + await symlink( + repoRoot, + path.join(dir, "node_modules/@gleanwork/pluginpack"), + "dir", + ); + return dir; +} + +const sharedAcme = { + "shared/acme/skills/release-notes/SKILL.md": + "---\nname: release-notes\ndescription: Draft release notes.\n---\n\nGroup changes.\n", + "shared/acme/agents/search-assistant.agent.md": + "---\nname: search-assistant\ndescription: Find documents.\n---\n\nSearch and cite.\n", + "shared/acme/commands/summarize.md": + "---\nname: summarize\ndescription: Summarize the open file.\n---\n\nSummarize it.\n", +}; + +const codexEntry = `entry: { policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity" },`; + +/** One project configuring every target that `install-info` and the CLI examples name. */ +const cliConfig = `import { defineConfig } from "@gleanwork/pluginpack"; + +export default defineConfig({ + name: "acme-plugins", + version: "0.1.0", + metadata: { + description: "Acme agent plugins.", + author: { name: "Acme" }, + license: "MIT", + repository: "https://github.com/acme/acme-plugins", + }, + targets: { + cursor: { outDir: "out/cursor", plugins: { acme: { source: "shared/acme" } } }, + claude: { outDir: "out/claude", plugins: { acme: { source: "shared/acme" } } }, + antigravity: { outDir: "out/antigravity", plugins: { acme: { source: "shared/acme" } } }, + copilot: { outDir: "out/copilot", plugins: { acme: { source: "shared/acme" } } }, + codex: { outDir: "out/codex", plugins: { acme: { source: "shared/acme", ${codexEntry} } } }, + }, +}); +`; + +beforeAll(async () => { + readme = await readFile(path.join(repoRoot, "README.md"), "utf8"); + blocks = readmeBlocks(readme); + workspace = await mkdtemp(path.join(os.tmpdir(), "pluginpack-readme-")); +}); + +afterAll(async () => { + if (workspace) await rm(workspace, { recursive: true, force: true }); +}); + +it("classifies every README shell and config block", () => { + const keys = blocks.map((candidate) => candidate.key); + expect(new Set(keys).size).toBe(keys.length); + const unclassified = keys.filter((key) => !(key in inventory)); + expect( + unclassified, + "New README blocks: run each in tests/readme-examples.test.ts, or add it to `inventory` with the reason it is not run here.", + ).toEqual([]); + expect( + Object.keys(inventory).filter((key) => !keys.includes(key)), + "`inventory` names README blocks that no longer exist.", + ).toEqual([]); +}); + +describe("CLI Reference examples", () => { + /** Examples listed under each `### \`command\`` heading of the generated reference. */ + function cliExamples(): Map { + const reference = readme + .split("")[1] + ?.split("")[0]; + expect(reference).toBeDefined(); + const examples = new Map(); + for (const section of reference!.split(/^### /m).slice(1)) { + const command = /^`([^`]+)`/.exec(section)![1]; + const list = section.split("Examples:")[1]?.split("Exit codes:")[0] ?? ""; + examples.set( + command, + [...list.matchAll(/^- `(pluginpack [^`]+)`$/gm)].map( + (match) => match[1], + ), + ); + } + return examples; + } + + it("lists examples for every command", () => { + const examples = cliExamples(); + expect([...examples.keys()]).toEqual([ + "init", + "build", + "validate", + "diff", + "prune", + "clean", + "install-info", + "docs", + ]); + for (const [command, list] of examples) { + expect(list.length, command).toBeGreaterThan(0); + for (const example of list) { + expect(argv(example)[0]).toBe(command); + } + } + }); + + it("runs every example and each exits 0 in the state it documents", async () => { + const examples = cliExamples(); + const project = await makeProject("cli/project", { + "pluginpack.config.ts": cliConfig, + ...sharedAcme, + }); + // `validate --dir ../cursor-plugins` and `diff --against + // ../cursor-plugins` document a sibling output repository. + const prepare = (): void => { + pluginpack(project, ["build"]); + pluginpack(project, [ + "build", + "--target", + "cursor", + "--out-dir", + "../cursor-plugins", + ]); + }; + + let needsBuild = true; + for (const [command, list] of examples) { + for (const example of list) { + const args = argv(example); + if (command === "init") { + // init documents a fresh directory. + const fresh = path.join(workspace, "cli/init"); + await mkdir(fresh, { recursive: true }); + pluginpack(fresh, args); + expect(await exists(path.join(fresh, "pluginpack.config.ts"))).toBe( + true, + ); + continue; + } + if (command === "docs") { + // docs rewrites ./README.md; run it on a copy of the real one. + const docsDir = path.join(workspace, "cli/docs"); + await mkdir(docsDir, { recursive: true }); + await cp( + path.join(repoRoot, "README.md"), + path.join(docsDir, "README.md"), + ); + pluginpack(docsDir, args); + expect( + await readFile(path.join(docsDir, "README.md"), "utf8"), + example, + ).toBe(readme); + continue; + } + // Build the documented starting state when the last example tore it down. + if (needsBuild) { + prepare(); + needsBuild = false; + } + const before = await readTree(path.dirname(project)); + const output = pluginpack(project, args); + const after = await readTree(path.dirname(project)); + if (args.includes("--dry-run")) { + expect(after, `${example} must not write`).toEqual(before); + } + if (command === "install-info") { + expect(output, example).toContain("acme/acme-plugins"); + } + if (command === "clean" && !args.includes("--dry-run")) { + needsBuild = true; + // clean with no --target removes every configured target's output. + expect( + Object.keys(after).filter((file) => + file.startsWith("project/out/"), + ), + example, + ).toEqual([]); + } + } + } + }, 30_000); +}); + +it("runs the Install Snippet and CI Change Detection commands", async () => { + const project = await makeProject("snippets/project", { + "pluginpack.config.ts": cliConfig, + ...sharedAcme, + }); + for (const line of block("Install Snippet > bash #1").split("\n")) { + expect(pluginpack(project, argv(line))).toContain("acme/acme-plugins"); + } + const diffs = block("CI Change Detection > bash #1").split("\n"); + expect(diffs.length).toBeGreaterThan(0); + for (const line of diffs) { + const args = argv(line); + const target = args[args.indexOf("--target") + 1]; + const against = args[args.indexOf("--against") + 1]; + pluginpack(project, ["build", "--target", target, "--out-dir", against]); + pluginpack(project, args); + // And the documented point of the command: a stale repo fails. + await writeFile( + path.join(project, "shared/acme/skills/release-notes/SKILL.md"), + "---\nname: release-notes\ndescription: Changed.\n---\n\nChanged.\n", + ); + expect(() => pluginpack(project, args)).toThrow(/failed/); + await writeFile( + path.join(project, "shared/acme/skills/release-notes/SKILL.md"), + sharedAcme["shared/acme/skills/release-notes/SKILL.md"], + ); + } +}); + +it("builds and validates the Components include/exclude config", async () => { + const config = block("Components > ts #1"); + const project = await makeProject("components", { + "pluginpack.config.ts": config, + ...sharedAcme, + "shared/acme/README.md": "# Acme\n", + }); + pluginpack(project, ["build"]); + for (const target of ["antigravity", "claude"]) { + pluginpack(project, ["validate", "--target", target]); + } + const tree = Object.keys(await readTree(project)); + // antigravity includes commands and static files; claude excludes commands. + expect(tree).toContain("plugins/antigravity/acme/commands/summarize.md"); + expect(tree).toContain("plugins/antigravity/acme/README.md"); + expect(tree.filter((file) => file.startsWith("plugins/claude/"))).not.toEqual( + [], + ); + expect( + tree.filter( + (file) => file.startsWith("plugins/claude/") && file.includes("commands"), + ), + ).toEqual([]); +}); + +it("builds and validates the One package for several clients config", async () => { + const fragment = block("One package for several clients > ts #1"); + expect(fragment.trimStart()).toMatch(/^targets: \{/); + const project = await makeProject("clients", { + "pluginpack.config.ts": `import { defineConfig } from "@gleanwork/pluginpack"; + +export default defineConfig({ + name: "acme-plugins", + version: "0.1.0", + metadata: { description: "Acme agent plugins.", author: { name: "Acme" }, license: "MIT" }, + ${fragment} +}); +`, + ...sharedAcme, + }); + pluginpack(project, ["build"]); + pluginpack(project, ["validate", "--target", "agent-plugins"]); + const out = path.join(project, "dist/agent-plugins"); + const tree = Object.keys(await readTree(out)); + expect(tree).toContain(".agents/plugins/marketplace.json"); + expect(tree).toContain(".github/plugin/marketplace.json"); + expect(tree).toContain( + "plugins/acme/com.github.copilot/agents/search-assistant.agent.md", + ); + const codex = JSON.parse( + await readFile(path.join(out, ".agents/plugins/marketplace.json"), "utf8"), + ) as { interface?: unknown; plugins: { category?: string }[] }; + expect(codex.interface).toEqual({ displayName: "Acme" }); + expect(codex.plugins[0].category).toBe("Productivity"); +}); + +it("ships exactly the files the MCP Directory pluginpack.json declares", async () => { + const declared = JSON.parse(block("MCP Directory > json #1")) as { + files: Record; + }; + // The tree the section shows, with the declared map. + const project = await makeProject("mcp", { + "pluginpack.config.ts": cliConfig, + ...sharedAcme, + "shared/acme/mcp/config.json": JSON.stringify({ + mcpServers: { + acme: { type: "stdio", command: "node", args: ["./mcp/start.mjs"] }, + }, + }), + "shared/acme/mcp/pluginpack.json": JSON.stringify(declared), + "shared/acme/mcp/start.mjs": "import './dist/index.js';\n", + "shared/acme/mcp/dist/index.js": "export {};\n", + "shared/acme/mcp/src/index.ts": "export {};\n", + "shared/acme/mcp/tests/index.test.ts": "export {};\n", + }); + pluginpack(project, ["build"]); + const tree = Object.keys(await readTree(path.join(project, "out"))); + for (const target of [ + "cursor", + "claude", + "antigravity", + "copilot", + "codex", + ]) { + pluginpack(project, ["validate", "--target", target]); + const shipped = tree + .filter((file) => file.startsWith(`${target}/`) && file.includes("mcp/")) + .map((file) => file.slice(file.indexOf("mcp/"))) + .sort(); + expect(shipped, target).toEqual(Object.keys(declared.files).sort()); + } +}); + +it("exports every function the Programmatic API import list names", async () => { + const imports = block("Programmatic API > ts #1"); + const names = /import \{([\s\S]*?)\} from "@gleanwork\/pluginpack"/ + .exec(imports)![1] + .split(",") + .map((name) => name.trim()) + .filter(Boolean); + expect(names.length).toBeGreaterThan(5); + const project = await makeProject("api", { + "check.mjs": `${imports}\nconst api = { ${names.join(", ")} };\nconsole.log(JSON.stringify(Object.entries(api).filter(([, value]) => typeof value !== "function").map(([name]) => name)));\n`, + }); + const notFunctions = execFileSync(process.execPath, ["check.mjs"], { + cwd: project, + encoding: "utf8", + }); + expect(JSON.parse(notFunctions)).toEqual([]); +});