diff --git a/AGENTS.md b/AGENTS.md index 5dd91e3..76398bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Default mode is `observe` (report only); `guard` is opt-in; `heal` is experiment | `realapps/` | 128 real apps run in headless Chromium with the real runtime: training rows and the never-worse sweep | | `training/` | vocabulary pruning, curriculum (`curriculum/rt.py` mirrors the runtime's situation renderer), Azure training, ONNX export, `EVAL.md` | | `demos/` | six demo apps with a chaos backend and Playwright trials; `bench/heal/` runs them per mode | -| `packages/mcp/` | `@genclass/mcp`: the MCP server for coding agents (bridge on 127.0.0.1:47357, store, explore verdicts, replays, regression-test generation, 9 tools incl. `genclass_scan`, which runs the runtime CLI's `scan`); no runtime deps; `test/e2e/race.e2e.mjs` is the real-browser check (not in CI's main job). `plugin/` is the Claude Code plugin (also an Agent Plugins package); `server.json`, the plugin, `.claude-plugin/`, `.cursor-plugin/`, `gemini-extension.json`, `llms-install.md` and the READMEs pin its version (`npm run sync -w @genclass/mcp` after a version bump; `test/manifests.test.ts` fails otherwise); `scripts/plugin-repo.mjs` assembles genclass-dev/genclass-plugin (the plugin at a repository root with its marketplace files, for the Claude directory's 50 MiB archive limit, Codex, Gemini CLI, `gh skill`; that repository re-syncs daily from main and is never edited by hand) | +| `packages/mcp/` | `@genclass/mcp`: the MCP server for coding agents (bridge on 127.0.0.1:47357, store, explore verdicts, replays, regression-test generation, 9 tools incl. `genclass_scan`, which runs the runtime CLI's `scan`); no runtime deps; `test/e2e/race.e2e.mjs` is the real-browser check (not in CI's main job). `plugin/` is the Claude Code plugin (also an Agent Plugins package); `server.json`, the plugin, `.claude-plugin/`, `.cursor-plugin/`, `gemini-extension.json`, `llms-install.md` and the READMEs pin its version (`npm run sync -w @genclass/mcp` after a version bump; `test/manifests.test.ts` fails otherwise); `scripts/plugin-repo.mjs` assembles genclass-dev/genclass-plugin (the plugin at a repository root with its marketplace files and the released server vendored from npm under `server/`, which `.mcp.json` and the hook there run with `node` from `${CLAUDE_PLUGIN_ROOT}`, for the Claude directory's 50 MiB archive limit and its holds on `npx` launchers and lockfiles, Codex, Gemini CLI, `gh skill`; that repository re-syncs daily from main and is never edited by hand) | | `packages/sv/` | `@genclass/sv`: the Svelte CLI community add-on (`npx sv add @genclass`): adds the runtime, the `/auto` import first in `src/hooks.client`, the dev-only overlay and (default) the telemetry opt-out meta in `src/app.html`, all with the `genclass:init` marker so `remove` undoes it; bundles `@sveltejs/sv-utils`, `sv` is a peer; `test/e2e/` runs sv's add-on harness (pnpm, Chromium) | | `packages/playwright/` | `@genclass/playwright`: the fixture (`src/fixture.ts`, `install.ts`, `page.ts`, `chaos.ts`, `races.ts`: puts the runtime's script-tag build on every `page` in observe mode with telemetry off, seeded chaos on fetch/XHR responses with `page.route`, witnesses through the agent entry with every hold off, one `genclass-races/1` file per test in `test-results/genclass/`) and the reporter + pure summariser that read those files and write `genclass-report.md` for the PR; peer deps only (`@playwright/test`, `@genclass/runtime`); `test/e2e/run.mjs` is the real-browser check (its own workflow) | | `packages/nuxt/`, `packages/astro/` | `@genclass/nuxt` (a Nuxt module, `@nuxt/kit`, built with `@nuxt/module-builder`) and `@genclass/astro` (an Astro integration, `injectScript("page", ...)`): from options `{ mode, token?, telemetry?, devtools? }` they generate the runtime's own one line (`/auto` or `/auto/guard`) plus a `window.GENCLASS_CONFIG` module imported before it; browser only, nothing on the server; `@genclass/runtime` is a peer dependency. The generated code is pure (`src/source.ts`, `src/index.ts`) and the tests check it exactly, then build one fixture app. Workflow: `integrations.yml` (Node 24) | diff --git a/docs/adoption/listings/mcp.md b/docs/adoption/listings/mcp.md index 7d2c215..ca8373d 100644 --- a/docs/adoption/listings/mcp.md +++ b/docs/adoption/listings/mcp.md @@ -124,29 +124,65 @@ support.claude.com "Anthropic Software Directory Policy". token); the optional dashboard read is the only network request off the machine. Compliance: contact email and the four acknowledgements. Updates: GitHub push webhook. - **What the checks will say.** Local run of `claude plugin validate --profile directory` on the assembled - repository (Claude Code 2.1.311, 2026-10-28, with and without the lockfile below): passes with nothing to report; - the lockfile check is the portal's alone. The portal's first validation (2026-10-28, `main` at e911b25, before the - lockfile) passed with one warning, "Launcher lock missing" on `mcpServers.genclass` in `.mcp.json`: ship a - `package-lock.json` (`npm ci`) or `uv.lock` next to the manifest that declares the server; exact versions, - registry sources and hashes are required for the Verified badge. `package.json` and `package-lock.json` at the - plugin root answer it (lockfileVersion 3; `@genclass/mcp` at the plugin's version with its registry tarball and - sha512 integrity; `packages/mcp/test/manifests.test.ts` keeps them at `package.json`'s version, and the daily sync - carries them to the plugin repository). The portal also held the pinned launchers for a reviewer, "Runs a pinned - npx or uvx package" (2 findings: `.mcp.json` and the hook run `npx -y @genclass/mcp@0.2.1`), whose text prefers - "a package that ships a lockfile" (which it now does) or the package's readable source vendored into the plugin - and run from `${CLAUDE_PLUGIN_ROOT}`; whether the lockfile lifts the hold is the reviewer's call. Possibly also - "MCP server command wasn't read" because the server is not a file inside the plugin folder. An unpinned launcher - would block; the manifests test keeps both pins equal to `package.json`'s version. The way to avoid the hold for - certain is to ship the built server (`dist/cli.js`, 125 KB, no dependencies) inside the plugin folder and run - `node ${CLAUDE_PLUGIN_ROOT}/...`; that means committing a build output, so it is left to the owner. Between a - version bump and its release the lock still names the previous release (npm does not serve the new one yet), and a - daily sync in that window carries that mismatch to the plugin repository until the post-release sync. Claude Code - 2.1.311 installs a plugin's lockfile when it installs the plugin (`npm ci --ignore-scripts` in the plugin's cache - folder), so installing the plugin now also fetches `@genclass/mcp` there; the launchers still run `npx`. + repository and on `packages/mcp/plugin` (Claude Code 2.1.311, 2026-10-28): passes with nothing to report; the + launcher checks are the portal's alone. The portal's first validation (2026-10-28, `main` at e911b25) passed with + one warning, "Launcher lock missing" on `mcpServers.genclass` in `.mcp.json` (a `package-lock.json` or `uv.lock` + next to the manifest that declares the server; exact versions, registry sources and hashes are required for the + Verified badge), and held the pinned launchers for a reviewer, "Runs a pinned npx or uvx package" (2 findings: + `.mcp.json` and the hook ran `npx -y @genclass/mcp@0.2.1`). A lockfile at the plugin root (68507fa) turned the + warning into the note "Launcher locked" and added a second hold, "Dependencies install from a lockfile" (the dated + bullets below). The hold text names the way to avoid both: the package's built files inside the plugin, each under + 256 KiB, run from `${CLAUDE_PLUGIN_ROOT}`, with no `package.json` or lockfile. That is what the plugin repository + now is: `scripts/plugin-repo.mjs` fetches the released `@genclass/mcp` at the plugin's version from npm and vendors + its `dist/cli.js` (about 150 KB, no dependencies) as `server/cli.mjs` with the package's command next to it, and + `.mcp.json` and the hook run `node ${CLAUDE_PLUGIN_ROOT}/server/genclass-mcp.mjs`; the lockfile is gone. The copy + of the plugin inside GenClass-lib keeps `npx` (nothing built is committed there), and the Agent Plugins `mcp.json` + keeps `npx` everywhere, since that format has no plugin-root variable. An unpinned launcher would block; the + manifests test keeps the `npx` pins equal to `package.json`'s version and checks the assembled tree. Between a + version bump and its release the sync script exits 3 (npm does not serve the new version yet) and the plugin + repository stays at the previous release until the first sync after the publish. Whether a vendored server still + draws a hold is the reviewer's call; "MCP server command wasn't read" has not appeared. + - **Policy items.** Privacy policy URL: `privacyPolicyUrl` in `plugin.json` (the plugin README's Privacy section). Support: `supportUrl` (GitHub issues). Tool annotations: every tool has `title`, `readOnlyHint`, `destructiveHint` and `openWorldHint`. Test account: none needed (no account exists); for a reviewer's page to test against, the end-to-end app is `packages/mcp/test/e2e/app`. Example prompts: the three above. +- **Submitted (2026-10-28).** From Karanvir's Max account (the browser pane's sign-in; the listing belongs to that + account's organization), repository `genclass-dev/genclass-plugin`, `main` at e911b25, plugin page + `claude.ai/directory/manage/plugins/20878d40-42c5-4300-a1d6-f09b9cc291eb`. The portal's validation: 7 checks + passed, 4 warnings, 2 policy holds. The warnings: `documentationUrl`, `supportUrl` and `privacyPolicyUrl` in + `plugin.json` ("the directory reads them, Claude Code ignores them, no action needed") and "Launcher lock missing" + (above). Notes, not warnings: the server is local, so not on claude.ai; the agent-plugins `plugin.json` is ignored; + the icon passed without a code check. "MCP server command wasn't read" did not appear. The holds: "Runs a pinned npx + or uvx package", one for `.mcp.json` and one for the hook. Listed on Claude Code and Cowork; the Claude apps surface + was unticked because the skill says the chat app cannot run the server. Data handling: no personal data, no skill + sends data elsewhere, nothing retained, not for under-18s. Contact: mehar@daybot.ca. Auto-publish was switched on, + but the page says it does not apply until an Anthropic reviewer has published a version. Updates: the push webhook + ("Set up push updates" on the plugin's page gives the payload URL and a secret shown once; "Rotate secret" on its + Settings tab replaces it) was added to the repository the same day with + `gh api -X POST repos/genclass-dev/genclass-plugin/hooks` (content type JSON, the push event only, active); + GitHub's ping delivery returned 200 and the page shows "Webhook connected". State: the security scan passed within + 4 minutes of submitting; since then "In review" ("Held for: Content policy review", reason "Runs a pinned npx or + uvx package"); "Live" needs a reviewer, and the first version always does. +- **Lockfile re-validation (2026-10-28).** PR #125 (merged as d15192a) reached the plugin repository through the sync + workflow as 68507fa (`package.json` and `package-lock.json` added, the README updated). The webhook's push delivery + returned 200 and the page showed "last push just now", and "Check for new commits" was pressed, but the directory + took the new version "via scheduled check" about 9 minutes after the push (the page says its own check runs about + every 6 hours with or without a webhook). It fetched 17 files (177.7 kB); validation "Passed with warnings: 4 + notes", and "Launcher lock missing" became the note "Launcher locked"; the scan passed in 2 minutes; compared with + e911b25, "2 new findings, 1 fixed". The version is held for a reviewer with two reasons now: "Runs a pinned npx or + uvx package" (2 findings, as before) and the new "Dependencies install from a lockfile" on `package-lock.json`: + "No action needed: a reviewer clears this hold. Keep a lockfile that pins an npx-launched server; to avoid the + install entirely, bundle the dependencies into built files of under 256 KiB each and remove both package.json and + the lockfile." So the lockfile earns the lock note, not fewer holds; only the vendored server (above) avoids both. + The three `plugin.json` URL fields, "Uses hooks" and "Runs a local program for an MCP server" are listed as + warnings for information only. The tracked branch cannot change while the plugin is under review. +- **Vendored server (2026-10-28).** After the second hold, the plugin repository was changed to what the hold text + asks for (the layout above): `.mcp.json` and the hook run the released server vendored under `server/` with `node` + from `${CLAUDE_PLUGIN_ROOT}`, and the plugin ships no `package.json` or lockfile. Locally, the assembled repository + passes `claude plugin validate --profile directory`, the vendored server answers `--version` and an MCP + `initialize`, and the hook exits silently without a bridge. The directory reads the result at its next check after + the sync pushes it; what it says is recorded here then. ## 4. punkpeye/awesome-mcp-servers diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 1e7abcf..fc252c8 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -2,20 +2,26 @@ ## Unreleased -- **The plugin ships the launcher's lockfile.** `plugin/package.json` (private; its one dependency is `@genclass/mcp` - at this version) and `plugin/package-lock.json` (lockfileVersion 3: the version, its registry tarball and sha512 - integrity) sit next to `.mcp.json`. Anthropic's directory asked for them on the plugin's first validation - (2026-10-28, the warning "Launcher lock missing": a lockfile with exact versions, registry sources and hashes is - required for the Verified badge), and its policy hold on pinned `npx` launchers prefers "a package that ships a - lockfile". Both files reach [genclass-dev/genclass-plugin](https://github.com/genclass-dev/genclass-plugin) through - its daily sync. Claude Code (2.1.311) installs what a plugin's lockfile lists when it installs the plugin - (`npm ci --ignore-scripts --workspaces=false --no-audit --no-fund` in the plugin's cache folder, so - `node_modules/@genclass/mcp` appears there); the launchers still run `npx -y @genclass/mcp@`. - `test/manifests.test.ts` keeps both files at `package.json`'s version and requires the integrity. Only - `npm install --package-lock-only --ignore-scripts` in `plugin/` writes the lock, and only once npm serves the - version, so a release is: bump, `npm run sync -w @genclass/mcp`, tag; after the publish, the same sync again (it - regenerates the lock from npm) and a commit. In between, the test accepts the previous release's lock while npm - does not serve the new version (it asks `npm view` in that state only). +- **The plugin's repository vendors the server, and the lockfile is gone.** In + [genclass-dev/genclass-plugin](https://github.com/genclass-dev/genclass-plugin), `.mcp.json` and the hook run + `node ${CLAUDE_PLUGIN_ROOT}/server/genclass-mcp.mjs` instead of `npx -y @genclass/mcp@`: `server/cli.mjs` + is the published package's `dist/cli.js` at the plugin's version, byte for byte (`scripts/plugin-repo.mjs` fetches + it from npm with `npm install --ignore-scripts` into a temporary prefix; `--server ` takes a local + build instead), and `server/genclass-mcp.mjs` is the package's `bin/genclass-mcp.mjs` importing it. Why: Anthropic's + directory holds a plugin whose launcher runs `npx ` for a reviewer ("Runs a pinned npx or uvx package"), + and the lockfile this line had added for its "Launcher lock missing" warning (`plugin/package.json` and + `plugin/package-lock.json`) earned the note "Launcher locked" but a second hold, "Dependencies install from a + lockfile" (2026-10-28). The directory's own text names the way out: the package's built files inside the plugin, + each under 256 KiB, run from `${CLAUDE_PLUGIN_ROOT}`, with no `package.json` or lockfile; so both files are removed + again. The plugin folder in GenClass-lib keeps the `npx` launchers (nothing built is committed here, and the + marketplace in GenClass-lib still lists it), and the Agent Plugins `mcp.json` keeps `npx` everywhere, because that + format has no plugin-root variable (Codex and Cursor read it). The daily sync vendors the release of `package.json`'s + version; between a bump and its release the script exits 3 and the repository stays as it is (its workflow treats + that as nothing to do), so a release is: bump, `npm run sync -w @genclass/mcp`, tag, publish; the next sync vendors + the new build. `test/manifests.test.ts` checks the assembled tree (the plugin folder byte for byte except the two + launchers, the server byte for byte, no `package.json`, lockfile or `node_modules`, every file under 256 KiB) and + that the vendored server runs. + - **The plugin has a repository of its own, [genclass-dev/genclass-plugin](https://github.com/genclass-dev/genclass-plugin)**: the plugin folder at the root, with marketplace files for Claude Code (`.claude-plugin/marketplace.json`), Codex (`.agents/plugins/marketplace.json`) and Cursor, the Gemini CLI manifest and the licence, assembled by diff --git a/packages/mcp/plugin/README.md b/packages/mcp/plugin/README.md index 82dafaf..cc34442 100644 --- a/packages/mcp/plugin/README.md +++ b/packages/mcp/plugin/README.md @@ -27,17 +27,18 @@ page of the dev server. The page side needs `@genclass/runtime` 0.3.0-beta.1 or | part | file | what it does | |---|---|---| -| MCP server `genclass` | `.mcp.json` | runs `npx -y @genclass/mcp@0.2.1`: nine tools (`genclass_status`, `genclass_findings`, `genclass_explain`, `genclass_explore`, `genclass_replay`, `genclass_analytics`, `genclass_setup`, `genclass_scan`, `genclass_clear`) and a bridge on 127.0.0.1:47357 that your app's pages connect to | -| `UserPromptSubmit` hook | `hooks/hooks.json` | runs `npx -y @genclass/mcp@0.2.1 hook` before each prompt: up to five findings recorded since the last prompt are added to the prompt as context | +| MCP server `genclass` | `.mcp.json` | runs the GenClass MCP server, `@genclass/mcp@0.2.1` (vendored as `server/cli.mjs` in the plugin repository and run with `node`; the copy inside GenClass-lib runs `npx -y @genclass/mcp@0.2.1`): nine tools (`genclass_status`, `genclass_findings`, `genclass_explain`, `genclass_explore`, `genclass_replay`, `genclass_analytics`, `genclass_setup`, `genclass_scan`, `genclass_clear`) and a bridge on 127.0.0.1:47357 that your app's pages connect to | +| `UserPromptSubmit` hook | `hooks/hooks.json` | runs the same server's `hook` command before each prompt: up to five findings recorded since the last prompt are added to the prompt as context | | skill `genclass` | `skills/genclass/SKILL.md` | the procedure once Claude reaches for the tools (the explore arguments and Playwright pairing, how to read a report, what to say when done, what to do when the tools are missing); its description carries the triggers (an intermittent frontend bug; after changing code that fetches, submits, autosaves, polls or debounces; before calling a UI change done), as do the server's instructions. Without the plugin, `npx -y @genclass/mcp@0.2.1 init` writes the same file to `.claude/skills/genclass/SKILL.md` | -The launcher is pinned to the plugin's version, so the plugin runs the exact release it was reviewed with. -`package.json` and `package-lock.json` next to `.mcp.json` record what that launcher resolves to (the version, its -registry tarball and its sha512 integrity), for the directory's checks and for a Claude Code that installs a plugin's -lockfile (`npm ci --ignore-scripts`) when it installs the plugin. +In the plugin repository the server is vendored: `server/cli.mjs` is the published `@genclass/mcp@0.2.1` build (its +`dist/cli.js`, byte for byte, fetched from npm by the daily sync), `server/genclass-mcp.mjs` is the package's command +importing it, and `.mcp.json` and the hook run them with `node` from `${CLAUDE_PLUGIN_ROOT}`, so installing the plugin +installs nothing and runs the exact release it was reviewed with; there is no `package.json` or lockfile. The copy of +this folder inside GenClass-lib runs `npx -y @genclass/mcp@0.2.1` instead (nothing built is committed there). `plugin.json` and `mcp.json` at this folder's root are the same server in the [Agent Plugins](https://agent-plugins.org) format, for Cursor, Codex and other clients that read it (server and skill, -no hook). +no hook); that format has no plugin-root variable, so they keep `npx`. ## What the hook sends, and where @@ -73,8 +74,8 @@ diagnostics, which `GenClass.init({ telemetry: false })` turns off ([PRIVACY.md] This folder is `packages/mcp/plugin` in [genclass-dev/GenClass-lib](https://github.com/genclass-dev/GenClass-lib). The repository [genclass-dev/genclass-plugin](https://github.com/genclass-dev/genclass-plugin) is this folder at a -repository root with the marketplace files, assembled by `node packages/mcp/scripts/plugin-repo.mjs` and re-synced -daily, so issues and pull requests go to GenClass-lib. +repository root with the marketplace files and the vendored server, assembled by +`node packages/mcp/scripts/plugin-repo.mjs` and re-synced daily, so issues and pull requests go to GenClass-lib. ## License diff --git a/packages/mcp/plugin/package-lock.json b/packages/mcp/plugin/package-lock.json deleted file mode 100644 index 4dc6fb5..0000000 --- a/packages/mcp/plugin/package-lock.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "genclass-plugin", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "genclass-plugin", - "license": "Apache-2.0", - "dependencies": { - "@genclass/mcp": "0.2.1" - } - }, - "node_modules/@genclass/mcp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@genclass/mcp/-/mcp-0.2.1.tgz", - "integrity": "sha512-Y/Rb96g0JI/uWeYIuEUMTiBNduNc1XeCAw101x+/SkcAQawAo+MhdsDCwuYIMGrPDBrzkV5sP8bdow538iCLvQ==", - "license": "Apache-2.0", - "bin": { - "genclass-mcp": "bin/genclass-mcp.mjs" - }, - "engines": { - "node": ">=20" - } - } - } -} diff --git a/packages/mcp/plugin/package.json b/packages/mcp/plugin/package.json deleted file mode 100644 index 2bd3f89..0000000 --- a/packages/mcp/plugin/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "genclass-plugin", - "private": true, - "description": "The launcher's lockfile. .mcp.json and hooks/hooks.json run npx -y @genclass/mcp@; package-lock.json next to this file records what that resolves to (exact version, registry source, integrity), which Anthropic's plugin directory reads. Nothing installs this package: the plugin runs the server through npx.", - "license": "Apache-2.0", - "dependencies": { - "@genclass/mcp": "0.2.1" - } -} diff --git a/packages/mcp/scripts/plugin-repo.mjs b/packages/mcp/scripts/plugin-repo.mjs index 4b31e7d..af207ef 100644 --- a/packages/mcp/scripts/plugin-repo.mjs +++ b/packages/mcp/scripts/plugin-repo.mjs @@ -1,13 +1,28 @@ #!/usr/bin/env node // Assembles the plugin's own repository, genclass-dev/genclass-plugin, from this package: the plugin folder -// (packages/mcp/plugin) at the repository root, the marketplace files that point at that root (Claude Code, Codex, -// Cursor), the Gemini CLI manifest, the licence, and the workflow that re-runs this script there daily. Why a second -// repository: Anthropic's directory stops validating a repository whose GitHub archive is over 50 MiB (GenClass-lib's -// is 51), and Codex, Gemini CLI, `gh skill` and the skills CLI clone or read a whole repository for one plugin. -// Invariants: everything in that repository comes from here (its sync.yml overwrites all but .git and .github), the -// copied files are byte-identical to the package's, and every version is package.json's. No dependencies. -// Usage: node packages/mcp/scripts/plugin-repo.mjs (the directory is emptied first) -import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +// (packages/mcp/plugin) at the repository root with the server vendored into it, the marketplace files that point at +// that root (Claude Code, Codex, Cursor), the Gemini CLI manifest, the licence, and the workflow that re-runs this +// script there daily. Why a second repository: Anthropic's directory stops validating a repository whose GitHub +// archive is over 50 MiB (GenClass-lib's is 51), and Codex, Gemini CLI, `gh skill` and the skills CLI clone or read a +// whole repository for one plugin. Why the server is vendored there: the directory holds a plugin whose launcher runs +// `npx ` for a reviewer, and a lockfile next to that launcher adds a second hold; its own text names the way +// out, the package's built files inside the plugin (each under 256 KiB) run from ${CLAUDE_PLUGIN_ROOT}, with no +// package.json or lockfile. So in that repository `.mcp.json` and the hook run +// `node ${CLAUDE_PLUGIN_ROOT}/server/genclass-mcp.mjs`: server/cli.mjs is the published package's dist/cli.js at +// package.json's version, byte for byte (fetched from npm with nothing run; `--server ` takes a local +// build instead, for the tests and offline runs), and server/genclass-mcp.mjs is the package's bin/genclass-mcp.mjs +// with its import pointed at that file. The plugin folder in GenClass-lib keeps the npx launchers (the marketplace +// there lists it too, and nothing built is committed here), and the Agent Plugins mcp.json keeps npx everywhere, +// because that format has no plugin-root variable (Codex and Cursor read it). +// Invariants: everything in that repository comes from here (its sync.yml overwrites all but .git and .github); the +// copied files are byte-identical to the package's except the two Claude Code launchers; every version is +// package.json's; the vendored server is the release of that version. Exit code 3 means npm does not serve +// package.json's version yet (between a bump and its release): the repository then stays at its previous sync. +// No dependencies. +// Usage: node packages/mcp/scripts/plugin-repo.mjs [--server ] (the directory is emptied first) +import { execFileSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,11 +33,34 @@ const V = pkg.version; /** The repository on GitHub. */ export const REPO = "genclass-dev/genclass-plugin"; +/** The published package the server is vendored from. */ +export const PACKAGE = pkg.name; +/** The vendored server's folder in that repository and its two files: the package's bin, and its dist/cli.js (as + * .mjs, so Node runs it as a module without a package.json next to it). */ +export const SERVER_DIR = "server"; +export const SERVER_BIN = `${SERVER_DIR}/genclass-mcp.mjs`; +export const SERVER_JS = `${SERVER_DIR}/cli.mjs`; +/** The bin's path as `.mcp.json` and the hook write it: Claude Code expands ${CLAUDE_PLUGIN_ROOT} to the installed + * plugin's folder in both. */ +export const SERVER_PATH = "${CLAUDE_PLUGIN_ROOT}/" + SERVER_BIN; +/** The hook's command (a shell line, so the path is quoted). */ +export const HOOK_COMMAND = `node "${SERVER_PATH}" hook`; +/** The directory reads no file over this size; a larger one is held for review. */ +export const FILE_LIMIT = 256 * 1024; +/** The exit code for a version npm does not serve yet. */ +export const EXIT_UNPUBLISHED = 3; /** The plugin's description in the marketplace entries (the monorepo's marketplace says the same). */ const DESCRIPTION = "The GenClass MCP server, a prompt hook that brings new findings to each prompt, and a skill for intermittent and timing-dependent frontend bugs."; +/** Never copied from the plugin folder: system files, and anything that would make the directory install something. */ +const SKIPPED = /(^|[\\/])(\.DS_Store|Thumbs\.db|package\.json|package-lock\.json|node_modules)$/; +/** npm's codes for a version the registry does not serve (`npm install`: ETARGET; `npm view`: E404). */ +const UNPUBLISHED = new Set(["ETARGET", "E404"]); const json = (v) => `${JSON.stringify(v, null, 2)}\n`; +/** Thrown when npm does not serve the package at the wanted version. */ +export class Unpublished extends Error {} + /** The files the script writes, by path in the output directory. Pure: a function of the version. */ export function generated(version) { return { @@ -51,9 +89,11 @@ export function generated(version) { plugins: [{ name: "genclass", source: "./", description: DESCRIPTION, version, category: "development" }], }), ".gitignore": ".DS_Store\nThumbs.db\n", - ".github/workflows/sync.yml": `# Keeps this repository equal to what packages/mcp/scripts/plugin-repo.mjs in genclass-dev/GenClass-lib assembles. -# Nothing here is edited by hand: this workflow overwrites everything but .git and .github (the default token cannot -# change workflow files, so this file is updated from GenClass-lib by a person). + ".github/workflows/sync.yml": `# Keeps this repository equal to what packages/mcp/scripts/plugin-repo.mjs in genclass-dev/GenClass-lib assembles +# (that script fetches the released server from npm and vendors it under server/). Nothing here is edited by hand: +# this workflow overwrites everything but .git and .github (the default token cannot change workflow files, so this +# file is updated from GenClass-lib by a person). Exit code 3 from the script means GenClass-lib's version is not on +# npm yet (between a bump and its release): the repository then stays as it is. name: Sync from GenClass-lib on: schedule: @@ -77,9 +117,11 @@ jobs: - name: Assemble from GenClass-lib run: | SHA=$(git -C lib rev-parse --short HEAD) - node lib/packages/mcp/scripts/plugin-repo.mjs out - rsync -a --delete --exclude .git --exclude .github --exclude lib --exclude out out/ ./ + rc=0; node lib/packages/mcp/scripts/plugin-repo.mjs out || rc=$? + if [ "$rc" = 0 ]; then rsync -a --delete --exclude .git --exclude .github --exclude lib --exclude out out/ ./; fi rm -rf lib out + if [ "$rc" = 3 ]; then echo "GenClass-lib's version is not on npm yet: nothing to do until the release"; exit 0; fi + [ "$rc" = 0 ] || exit "$rc" echo "SHA=$SHA" >> "$GITHUB_ENV" - name: Commit and push when something changed run: | @@ -91,24 +133,103 @@ jobs: }; } -/** Writes the repository's tree to `out`: the plugin folder, LICENSE, gemini-extension.json, then the generated files. */ -export function assemble(out, version = V) { - rmSync(out, { recursive: true, force: true }); - mkdirSync(out, { recursive: true }); - cpSync(join(pkgDir, "plugin"), out, { recursive: true, filter: (p) => !/(^|[\\/])(\.DS_Store|Thumbs\.db)$/.test(p) }); - for (const f of ["LICENSE", "gemini-extension.json"]) cpSync(join(repo, f), join(out, f)); - for (const [file, body] of Object.entries(generated(version))) { - mkdirSync(dirname(join(out, file)), { recursive: true }); - writeFileSync(join(out, file), body); +/** The two Claude Code launchers with the vendored server in place of npx: `.mcp.json`'s genclass server runs node on + * the bin, the hook that mentions the package runs the same with `hook`; everything else in both files stays. Pure: + * the plugin folder's manifests in (parsed), the repository's texts out. */ +export function vendored(mcp, hooks) { + const events = Object.fromEntries( + Object.entries(hooks.hooks ?? {}).map(([event, groups]) => [ + event, + groups.map((g) => ({ ...g, hooks: g.hooks.map((h) => (/@genclass\/mcp\b/.test(h.command ?? "") ? { ...h, command: HOOK_COMMAND } : h)) })), + ]), + ); + return { + ".mcp.json": json({ ...mcp, mcpServers: { ...mcp.mcpServers, genclass: { command: "node", args: [SERVER_PATH] } } }), + "hooks/hooks.json": json({ ...hooks, hooks: events }), + }; +} + +/** The package's bin with its import pointed at the vendored cli.mjs, and a note after the shebang. Pure. */ +export function vendoredBin(bin, version) { + const from = 'from "../dist/cli.js"'; + if (!bin.includes(from)) throw new Error(`bin/genclass-mcp.mjs no longer imports ../dist/cli.js; update plugin-repo.mjs`); + const note = + `// Vendored from ${PACKAGE}@${version} by packages/mcp/scripts/plugin-repo.mjs in genclass-dev/GenClass-lib: cli.mjs next\n` + + `// to this file is that package's dist/cli.js, byte for byte, and this file is its bin/genclass-mcp.mjs importing it.\n`; + const i = bin.startsWith("#!") ? bin.indexOf("\n") + 1 : 0; + return bin.slice(0, i) + note + bin.slice(i).replace(from, 'from "./cli.mjs"'); +} + +/** Runs npm with its output captured: `code` is "ok", npm's error code, or "unknown" when it gave none. */ +function npm(args, cwd) { + try { + return { code: "ok", out: execFileSync("npm", args, { cwd, stdio: "pipe", timeout: 120_000, shell: process.platform === "win32" }).toString() }; + } catch (e) { + const out = `${e.stdout ?? ""}${e.stderr ?? ""}` || e.message; + return { code: /(?:npm (?:error|ERR!) code |"code":\s*")(E[A-Z0-9_]+)/.exec(out)?.[1] ?? "unknown", out }; + } +} + +/** The published package at `version`, installed by npm into a temporary prefix with no script run (so what it + * resolves is checked against the registry's integrity). Returns the package's folder and the prefix to remove. + * Throws Unpublished when npm does not serve that version. */ +export function fetchPackage(version) { + const tmp = mkdtempSync(join(tmpdir(), "genclass-plugin-server-")); + const r = npm(["install", `${PACKAGE}@${version}`, "--prefix", tmp, "--ignore-scripts", "--no-audit", "--no-fund", "--no-package-lock", "--no-save"], tmp); + if (r.code !== "ok") { + rmSync(tmp, { recursive: true, force: true }); + if (UNPUBLISHED.has(r.code)) throw new Unpublished(`npm does not serve ${PACKAGE}@${version} (${r.code}); after the release, the sync vendors it`); + throw new Error(`npm could not fetch ${PACKAGE}@${version} (${r.code}):\n${r.out}`); + } + return { dir: join(tmp, "node_modules", ...PACKAGE.split("/")), tmp }; +} + +/** Writes the repository's tree to `out`: the plugin folder (minus what SKIPPED names), LICENSE, gemini-extension.json, + * the generated files, the two vendored launchers and the server from `server` (a package folder with bin/ and dist/, + * at `version`; fetched from npm when not given). */ +export function assemble(out, { version = V, server } = {}) { + const fetched = server ? null : fetchPackage(version); + try { + const src = server ?? fetched.dir; + const srcVersion = JSON.parse(readFileSync(join(src, "package.json"), "utf8")).version; + if (srcVersion !== version) throw new Error(`${src} is ${PACKAGE}@${srcVersion}, not ${version}`); + const cli = readFileSync(join(src, "dist", "cli.js")); + if (cli.length > FILE_LIMIT) throw new Error(`dist/cli.js is ${cli.length} bytes; the directory reads nothing over ${FILE_LIMIT}`); + const bin = vendoredBin(readFileSync(join(src, "bin", "genclass-mcp.mjs"), "utf8"), version); + rmSync(out, { recursive: true, force: true }); + mkdirSync(out, { recursive: true }); + cpSync(join(pkgDir, "plugin"), out, { recursive: true, filter: (p) => !SKIPPED.test(p) }); + for (const f of ["LICENSE", "gemini-extension.json"]) cpSync(join(repo, f), join(out, f)); + const mcp = JSON.parse(readFileSync(join(out, ".mcp.json"), "utf8")); + const hooks = JSON.parse(readFileSync(join(out, "hooks", "hooks.json"), "utf8")); + const files = { ...generated(version), ...vendored(mcp, hooks), [SERVER_BIN]: bin, [SERVER_JS]: cli }; + for (const [file, body] of Object.entries(files)) { + mkdirSync(dirname(join(out, file)), { recursive: true }); + writeFileSync(join(out, file), body); + } + return { server: src, serverBytes: statSync(join(out, SERVER_JS)).size }; + } finally { + if (fetched) rmSync(fetched.tmp, { recursive: true, force: true }); } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const out = process.argv[2]; - if (!out) { - console.error("usage: node packages/mcp/scripts/plugin-repo.mjs "); + const args = process.argv.slice(2); + const i = args.indexOf("--server"); + const server = i >= 0 ? args.splice(i, 2)[1] : undefined; + const out = args[0]; + if (!out || (i >= 0 && !server)) { + console.error("usage: node packages/mcp/scripts/plugin-repo.mjs [--server ]"); process.exit(2); } - assemble(resolve(out)); - console.log(`assembled ${REPO} ${V} in ${resolve(out)}`); + try { + const r = assemble(resolve(out), { server: server && resolve(server) }); + console.log(`assembled ${REPO} ${V} in ${resolve(out)} (server: ${server ? r.server : `${PACKAGE}@${V} from npm`}, ${r.serverBytes} bytes)`); + } catch (e) { + if (e instanceof Unpublished) { + console.error(e.message); + process.exit(EXIT_UNPUBLISHED); + } + throw e; + } } diff --git a/packages/mcp/test/manifests.test.ts b/packages/mcp/test/manifests.test.ts index c4d3a02..c8204f2 100644 --- a/packages/mcp/test/manifests.test.ts +++ b/packages/mcp/test/manifests.test.ts @@ -4,18 +4,14 @@ // (`npm run sync -w @genclass/mcp`) the test rewrites those files from package.json instead of failing. The same sync // keeps the repo-root copy of the skill (skills/genclass/SKILL.md, what skill directories such as skills.sh read) // byte-identical to the plugin's. The second describe checks the skill and the server's instructions: the -// instructions carry the triggers, the skill the procedure (docs/runtime/AGENT.md section 4.2). -// The plugin folder also ships the launcher's lockfile for Anthropic's directory (its "Launcher lock missing" warning: -// a package.json and package-lock.json next to the manifest that declares the server, with exact versions, registry -// sources and hashes): plugin/package.json names @genclass/mcp at package.json's version, plugin/package-lock.json -// what npm resolved that to. Only `npm install --package-lock-only --ignore-scripts` in packages/mcp/plugin writes the -// lock, and only once npm serves the version, so a version bump leaves it at the previous release until the new one -// is published: the lock pin allows that while npm does not serve package.json's version (asked with `npm view` in -// that state only; an unreachable npm counts as unknown, and the lagging lock passes with a note that vitest shows -// with --silent=false), fails once npm serves it, and the sync regenerates the lock with that npm command. A release -// is therefore: bump, sync, tag; after the publish, sync again and commit. +// instructions carry the triggers, the skill the procedure (docs/runtime/AGENT.md section 4.2). The last describe +// checks what scripts/plugin-repo.mjs assembles for the plugin's own repository, genclass-dev/genclass-plugin: the +// plugin folder byte for byte except the two Claude Code launchers, which there run the server vendored under +// server/ (the package's bin and dist/cli.js: from `--server ` here, this package when it is built and a +// stub otherwise; from npm in the daily sync), and nothing Anthropic's directory holds a plugin for: no package.json, +// no lockfile, nothing to install. import { execFileSync } from "node:child_process"; -import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -31,13 +27,11 @@ const SYNC = process.env.GENCLASS_SYNC === "1"; type Json = Record; -/** A file and what it must hold for version V: a JSON edit (compared as data), a text edit (compared as text), or - * the launcher's lockfile, which only npm writes (checkLock; the header says when it may lag). */ +/** A file and what it must hold for version V: a JSON edit (compared as data) or a text edit (compared as text). */ interface Pin { file: string; json?: (cur: Json) => Json; text?: (cur: string) => string; - lock?: true; } /** `@genclass/mcp@` in prose and code blocks. */ @@ -68,9 +62,6 @@ const PINS: Pin[] = [ file: "packages/mcp/plugin/hooks/hooks.json", json: (j) => ({ ...j, hooks: { ...j.hooks, UserPromptSubmit: j.hooks.UserPromptSubmit.map((g: Json) => ({ ...g, hooks: g.hooks.map((h: Json) => (/@genclass\/mcp\b/.test(h.command) ? { ...h, command: hookCommand(V) } : h)) })) } }), }, - // the launcher's lockfile (see the header): package.json names the version, the lock what npm resolved it to - { file: "packages/mcp/plugin/package.json", json: (j) => ({ ...j, dependencies: { ...j.dependencies, [PACKAGE]: V } }) }, - { file: "packages/mcp/plugin/package-lock.json", lock: true }, { file: "packages/mcp/plugin/plugin.json", json: (j) => ({ ...j, version: V }) }, { file: "packages/mcp/plugin/mcp.json", json: (j) => ({ ...j, mcpServers: { ...j.mcpServers, genclass: { type: "stdio", ...serverConfig(V) } } }) }, { file: ".cursor-plugin/marketplace.json", json: (j) => ({ ...j, plugins: j.plugins.map((p: Json) => (p.name === "genclass" ? { ...p, version: V } : p)) }) }, @@ -91,83 +82,9 @@ function read(file: string): string { return readFileSync(join(repo, file), "utf8"); } -const PLUGIN_DIR = join(pkgDir, "plugin"); -const REGISTRY = "https://registry.npmjs.org/"; -const EXACT = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/; -/** npm's codes for a version the registry does not serve (`npm view`: E404; `npm install`: ETARGET). */ -const UNPUBLISHED = new Set(["E404", "ETARGET"]); - -/** Runs npm in `cwd` with its output captured: `code` is "ok", npm's error code, or "unknown" when it gave none. */ -function npm(args: string[], cwd: string): { code: string; out: string } { - try { - return { code: "ok", out: execFileSync("npm", args, { cwd, stdio: "pipe", timeout: 60_000, shell: process.platform === "win32" }).toString() }; - } catch (e) { - const err = e as { stdout?: Buffer; stderr?: Buffer; message: string }; - const out = `${err.stdout ?? ""}${err.stderr ?? ""}` || err.message; - return { code: /(?:npm (?:error|ERR!) code |"code":\s*")(E[A-Z0-9_]+)/.exec(out)?.[1] ?? "unknown", out }; - } -} - -/** Whether npm serves @genclass/mcp at `v`; null when npm could not say (unreachable, for one). */ -function npmServes(v: string): boolean | null { - const r = npm(["view", `${PACKAGE}@${v}`, "version", "--json"], PLUGIN_DIR); - if (r.code === "ok") return r.out.includes(`"${v}"`); - if (UNPUBLISHED.has(r.code)) return false; - console.warn(`manifests.test: npm could not say whether ${PACKAGE}@${v} is published (${r.code}), so the plugin's lockfile is checked at its own version only\n${r.out}`); - return null; -} - -/** What the lock must hold at version `v` (the directory's words: exact versions, registry sources and hashes): - * lockfileVersion 3, the root's one dependency on @genclass/mcp at v, that entry resolved to v's registry tarball, and - * every entry with an exact version, a registry source and a sha512 integrity. The problems; none when it holds. */ -function lockProblems(lock: Json, v: string): string[] { - const out: string[] = []; - if (lock.lockfileVersion !== 3) out.push(`lockfileVersion ${JSON.stringify(lock.lockfileVersion)}, want 3`); - const deps = lock.packages?.[""]?.dependencies; - if (JSON.stringify(deps) !== JSON.stringify({ [PACKAGE]: v })) out.push(`root dependencies ${JSON.stringify(deps)}, want ${PACKAGE} at ${v}`); - const main = `node_modules/${PACKAGE}`; - const entries = Object.entries(lock.packages ?? {}).filter(([k]) => k !== "") as [string, Json][]; - if (!entries.some(([k]) => k === main)) out.push(`no ${main} entry`); - for (const [k, e] of entries) { - if (!EXACT.test(String(e.version))) out.push(`${k}: version ${JSON.stringify(e.version)} is not exact`); - if (typeof e.resolved !== "string" || !e.resolved.startsWith(REGISTRY)) out.push(`${k}: resolved ${JSON.stringify(e.resolved)} is not a registry source`); - if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(String(e.integrity))) out.push(`${k}: no sha512 integrity`); - } - const m = lock.packages?.[main]; - if (m && m.version !== v) out.push(`${main}: version ${m.version}, want ${v}`); - const tarball = `${REGISTRY}${PACKAGE}/-/${PACKAGE.split("/")[1]}-${v}.tgz`; - if (m && m.resolved !== tarball) out.push(`${main}: resolved ${m.resolved}, want ${tarball}`); - return out; -} - -/** The lock pin: at V, or, between a version bump and its release while npm does not serve V, at the previous release - * and consistent at that version. With GENCLASS_SYNC=1 a lock that is not at V is regenerated by npm first. */ -function checkLock(file: string): void { - const path = join(repo, file); - const lock = (): Json => (existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {}); - let problems = lockProblems(lock(), V); - let unpublished = false; - if (problems.length && SYNC) { - const r = npm(["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], PLUGIN_DIR); - if (r.code === "ok") problems = lockProblems(lock(), V); - else if (UNPUBLISHED.has(r.code)) unpublished = true; - else throw new Error(`npm could not regenerate ${file} (${r.code}):\n${r.out}`); - } - if (problems.length) { - const own = lock().packages?.[`node_modules/${PACKAGE}`]?.version as string | undefined; - if (own && own !== V && EXACT.test(own) && (unpublished || npmServes(V) !== true)) { - console.warn(`manifests.test: ${file} is at ${own} while npm does not serve ${PACKAGE}@${V}; after the release, npm run sync -w @genclass/mcp regenerates it`); - expect(lockProblems(lock(), own), `${file} at ${own}`).toEqual([]); - return; - } - } - expect(problems, `${file}: npm install --package-lock-only --ignore-scripts in packages/mcp/plugin writes it (npm run sync -w @genclass/mcp does that once npm serves ${PACKAGE}@${V})`).toEqual([]); -} - describe("versions and pinned launchers (every manifest names package.json's version)", () => { for (const p of PINS) { it(p.file, () => { - if (p.lock) return checkLock(p.file); const cur = read(p.file); if (p.json) { const want = p.json(JSON.parse(cur)); @@ -178,7 +95,7 @@ describe("versions and pinned launchers (every manifest names package.json's ver if (SYNC && want !== cur) writeFileSync(join(repo, p.file), want); else expect(cur).toBe(want); } - }, p.lock ? 120_000 : undefined); // the lock pin may run npm twice + }); } it("the server reports package.json's version", () => { @@ -204,6 +121,10 @@ describe("versions and pinned launchers (every manifest names package.json's ver for (const k of ["homepage", "documentationUrl", "supportUrl", "privacyPolicyUrl"]) expect(p[k], k).toMatch(/^https:\/\//); }); + it("the plugin folder ships no package.json, lockfile or node_modules (the directory holds a lockfile install for review; the plugin's repository vendors the server instead)", () => { + for (const f of ["package.json", "package-lock.json", "node_modules"]) expect(existsSync(join(pkgDir, "plugin", f)), f).toBe(false); + }); + it("every client block names the pinned package and nothing else", () => { for (const c of CLIENTS) { const b = c.block(V); @@ -252,7 +173,7 @@ describe("the genclass skill and the server's instructions (AGENT.md section 4.2 // the triggers live in the description and in INSTRUCTIONS; the body does not restate them expect(body).not.toContain('"sometimes"'); expect(body).not.toMatch(/\bUse when\b/); - expect(skill).not.toContain("\u2014"); + expect(skill).not.toContain("—"); }); it("the root copy for skill directories (skills/genclass/SKILL.md) is byte-identical to the plugin's", () => { @@ -263,7 +184,7 @@ describe("the genclass skill and the server's instructions (AGENT.md section 4.2 expect(INSTRUCTIONS.length).toBeLessThan(4096); for (const w of SYMPTOMS) expect(INSTRUCTIONS.slice(0, 500), w).toContain(w); expect(INSTRUCTIONS).toContain("genclass skill"); - expect(INSTRUCTIONS).not.toContain("\u2014"); + expect(INSTRUCTIONS).not.toContain("—"); }); }); @@ -287,18 +208,76 @@ function walk(dir: string, rel = ""): string[] { }); } +/** A package folder with the shape plugin-repo.mjs vendors (package.json, bin/genclass-mcp.mjs, dist/cli.js) at V, + * whose runCli only prints the version. For when this package is not built. */ +function stubPackage(): string { + const dir = mkdtempSync(join(tmpdir(), "genclass-mcp-stub-")); + mkdirSync(join(dir, "dist")); + mkdirSync(join(dir, "bin")); + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: PACKAGE, version: V })); + writeFileSync(join(dir, "dist", "cli.js"), `var VERSION = "${V}";\nasync function runCli(args, io) {\n io.out(\`\${VERSION}\\n\`);\n return 0;\n}\nexport { runCli };\n//# sourceMappingURL=cli.js.map\n`); + writeFileSync(join(dir, "bin", "genclass-mcp.mjs"), readFileSync(join(pkgDir, "bin", "genclass-mcp.mjs"))); + return dir; +} + describe("the plugin's own repository (packages/mcp/scripts/plugin-repo.mjs assembles genclass-dev/genclass-plugin)", () => { + const plugin = join(pkgDir, "plugin"); + const script = join(pkgDir, "scripts", "plugin-repo.mjs"); + // the server to vendor: this package when it is built (CI builds before the tests), a stub of the same shape otherwise + const server = existsSync(join(pkgDir, "dist", "cli.js")) ? pkgDir : stubPackage(); const out = mkdtempSync(join(tmpdir(), "genclass-plugin-repo-")); - execFileSync(process.execPath, [join(pkgDir, "scripts", "plugin-repo.mjs"), out], { stdio: "pipe" }); + execFileSync(process.execPath, [script, out, "--server", server], { stdio: "pipe" }); const at = (f: string) => readFileSync(join(out, f), "utf8"); - - it("is the plugin folder at the root, byte for byte, plus the licence and the Gemini manifest", () => { - const plugin = join(pkgDir, "plugin"); - for (const f of walk(plugin)) expect(readFileSync(join(out, f)), f).toEqual(readFileSync(join(plugin, f))); + /** The two files the repository changes from the plugin folder. */ + const LAUNCHERS = [".mcp.json", "hooks/hooks.json"]; + const BIN = "${CLAUDE_PLUGIN_ROOT}/server/genclass-mcp.mjs"; + + it("is the plugin folder at the root, byte for byte except the two Claude Code launchers, plus the licence and the Gemini manifest", () => { + const files = walk(plugin); + for (const f of LAUNCHERS) expect(files).toContain(f); + for (const f of files) if (!LAUNCHERS.includes(f)) expect(readFileSync(join(out, f)), f).toEqual(readFileSync(join(plugin, f))); expect(at("LICENSE")).toBe(read("LICENSE")); expect(at("gemini-extension.json")).toBe(read("gemini-extension.json")); }); + it(".mcp.json and the hook run the vendored server from ${CLAUDE_PLUGIN_ROOT}, and nothing else in them changes", () => { + const mcp = JSON.parse(at(".mcp.json")); + const srcMcp = JSON.parse(readFileSync(join(plugin, ".mcp.json"), "utf8")); + expect(mcp.mcpServers.genclass).toEqual({ command: "node", args: [BIN] }); + expect({ ...mcp, mcpServers: { ...mcp.mcpServers, genclass: srcMcp.mcpServers.genclass } }).toEqual(srcMcp); + const hooks = JSON.parse(at("hooks/hooks.json")); + const srcHooks = JSON.parse(readFileSync(join(plugin, "hooks/hooks.json"), "utf8")); + const entries = hooks.hooks.UserPromptSubmit.flatMap((g: Json) => g.hooks); + expect(entries.map((h: Json) => h.command)).toEqual([`node "${BIN}" hook`]); + expect(entries[0]).toMatchObject({ type: "command", timeout: srcHooks.hooks.UserPromptSubmit[0].hooks[0].timeout }); + for (const f of LAUNCHERS) { + expect(at(f), f).not.toContain("npx"); + expect(at(f), f).not.toContain("@genclass/mcp"); + } + }); + + it("server/cli.mjs is the package's dist/cli.js byte for byte, server/genclass-mcp.mjs its bin importing that file", () => { + expect(readFileSync(join(out, "server/cli.mjs"))).toEqual(readFileSync(join(server, "dist", "cli.js"))); + const bin = at("server/genclass-mcp.mjs"); + const orig = readFileSync(join(server, "bin", "genclass-mcp.mjs"), "utf8"); + expect(bin.startsWith("#!/usr/bin/env node\n")).toBe(true); + expect(bin).toContain(`Vendored from ${PACKAGE}@${V}`); + expect(bin).toContain('from "./cli.mjs"'); + expect(bin).not.toContain("../dist/cli.js"); + // the two note lines after the shebang are the only addition + const lines = bin.split("\n"); + expect([lines[0], ...lines.slice(3)].join("\n")).toBe(orig.replace('from "../dist/cli.js"', 'from "./cli.mjs"')); + }); + + it("the vendored server runs: --version prints package.json's version", () => { + const v = execFileSync(process.execPath, [join(out, "server/genclass-mcp.mjs"), "--version"], { stdio: "pipe" }).toString().trim(); + expect(v).toBe(V); + }); + + it("the Agent Plugins mcp.json keeps the pinned npx launcher (that format has no plugin-root variable; Codex and Cursor read it)", () => { + expect(JSON.parse(at("mcp.json")).mcpServers.genclass).toEqual({ type: "stdio", ...serverConfig(V) }); + }); + it("its marketplace files point at the root and carry package.json's version", () => { const claude = JSON.parse(at(".claude-plugin/marketplace.json")); expect(claude.name).toBe("genclass"); @@ -309,21 +288,46 @@ describe("the plugin's own repository (packages/mcp/scripts/plugin-repo.mjs asse expect(cursor.plugins[0]).toMatchObject({ name: "genclass", source: "./", version: V }); }); - it("has nothing Anthropic's directory blocks or holds: no symlinks, no system files, every non-image file under 256 KiB, 512 files or fewer", () => { - // claude.com/docs/plugins/pre-submission-checklist + it("has nothing Anthropic's directory blocks or holds: no symlinks, no system files, no package.json, lockfile or node_modules, every non-image file under 256 KiB, 512 files or fewer", () => { + // claude.com/docs/plugins/pre-submission-checklist, and the directory's hold texts of 2026-10-28 const files = walk(out); expect(files.length).toBeLessThanOrEqual(512); for (const f of files) { expect(lstatSync(join(out, f)).isSymbolicLink(), `${f} is a symlink`).toBe(false); - expect(/(^|\/)(\.DS_Store|Thumbs\.db|desktop\.ini|__MACOSX)/.test(f), f).toBe(false); + expect(/(^|\/)(\.DS_Store|Thumbs\.db|desktop\.ini|__MACOSX|package\.json|package-lock\.json|node_modules)(\/|$)/.test(f), f).toBe(false); if (!/\.(png|jpe?g|gif|webp|svg|woff2?|ttf|otf)$/i.test(f)) expect(statSync(join(out, f)).size, `${f} over 256 KiB`).toBeLessThanOrEqual(256 * 1024); } }); - it("its workflow re-runs this script from GenClass-lib and touches nothing by hand", () => { + it("its workflow re-runs this script from GenClass-lib, stands still while npm does not serve the version, and touches nothing by hand", () => { const y = at(".github/workflows/sync.yml"); expect(y).toContain("repository: genclass-dev/GenClass-lib"); expect(y).toContain("node lib/packages/mcp/scripts/plugin-repo.mjs out"); expect(y).toContain("--exclude .git --exclude .github"); + expect(y).toContain('[ "$rc" = 3 ]'); + expect(y).toContain("rm -rf lib out"); }); + + it("without --server, vendors the release from npm (or exits 3 while npm does not serve package.json's version)", () => { + const dir = mkdtempSync(join(tmpdir(), "genclass-plugin-repo-npm-")); + try { + execFileSync(process.execPath, [script, dir], { stdio: "pipe", timeout: 120_000 }); + } catch (e) { + const err = e as { status?: number | null; stdout?: Buffer; stderr?: Buffer }; + const text = `${err.stdout ?? ""}${err.stderr ?? ""}`; + if (err.status === 3) { + console.warn(`manifests.test: ${text.trim()}`); + return; + } + if (/E(?:NOTFOUND|AI_AGAIN|CONNRESET|TIMEDOUT|NETUNREACH|CONNREFUSED)|FETCH_ERROR/.test(text)) { + console.warn(`manifests.test: npm is unreachable, so the fetch path of plugin-repo.mjs is not checked\n${text}`); + return; + } + throw e; + } + const cli = readFileSync(join(dir, "server/cli.mjs"), "utf8"); + expect(cli).toContain(`VERSION = "${V}"`); + expect(cli.length).toBeGreaterThan(10_000); + expect(existsSync(join(dir, "server/genclass-mcp.mjs"))).toBe(true); + }, 150_000); });