From 1be58d8e5994a4e2714d209e2a2a7fe153b200e9 Mon Sep 17 00:00:00 2001 From: Karan Date: Wed, 28 Oct 2026 14:51:56 -0400 Subject: [PATCH 1/2] docs: the Anthropic directory submission and the lockfile's re-validation (listings/mcp.md section 3) What the portal said on 2026-10-28: the first validation at e911b25 (7 passed, 4 warnings, 2 holds), the listing and data-handling answers, the push webhook, the scan and review state; then the lockfile commit 68507fa, found by the scheduled check, validated with "Launcher locked" and held on a second reason, "Dependencies install from a lockfile", which a reviewer clears. Co-Authored-By: Claude Fable 5.1 --- docs/adoption/listings/mcp.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/adoption/listings/mcp.md b/docs/adoption/listings/mcp.md index 7d2c215..3242d42 100644 --- a/docs/adoption/listings/mcp.md +++ b/docs/adoption/listings/mcp.md @@ -147,6 +147,36 @@ support.claude.com "Anthropic Software Directory Policy". 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. ## 4. punkpeye/awesome-mcp-servers From 74e79b4ba1c16a373222509c7fad0e790ceb4894 Mon Sep 17 00:00:00 2001 From: Karan Date: Wed, 28 Oct 2026 15:35:44 -0400 Subject: [PATCH 2/2] @genclass/mcp: the plugin's repository vendors the released server; the lockfile is gone scripts/plugin-repo.mjs fetches @genclass/mcp at package.json's version from npm (npm install --ignore-scripts into a temporary prefix; --server takes a local build) and writes it as server/cli.mjs with the package's bin next to it; the assembled .mcp.json and hook run node ${CLAUDE_PLUGIN_ROOT}/server/genclass-mcp.mjs. plugin/package.json and plugin/package-lock.json are removed: Anthropic's directory held the lockfile install for review next to the npx launcher hold, and its hold text names built files inside the plugin, run from the plugin root with no lockfile, as the way out. The plugin folder in GenClass-lib and the Agent Plugins mcp.json keep npx (nothing built is committed here; that format has no plugin-root variable). The sync workflow treats the script's exit code 3 (the version is not on npm yet) as nothing to do. manifests.test.ts checks the assembled tree against the plugin folder, the vendored server byte for byte, the absence of package.json, lockfile and node_modules, and runs the vendored server. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- docs/adoption/listings/mcp.md | 44 +++--- packages/mcp/CHANGELOG.md | 34 +++-- packages/mcp/plugin/README.md | 19 +-- packages/mcp/plugin/package-lock.json | 26 ---- packages/mcp/plugin/package.json | 9 -- packages/mcp/scripts/plugin-repo.mjs | 175 +++++++++++++++++---- packages/mcp/test/manifests.test.ts | 210 +++++++++++++------------- 8 files changed, 311 insertions(+), 208 deletions(-) delete mode 100644 packages/mcp/plugin/package-lock.json delete mode 100644 packages/mcp/plugin/package.json 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 3242d42..ca8373d 100644 --- a/docs/adoption/listings/mcp.md +++ b/docs/adoption/listings/mcp.md @@ -124,25 +124,25 @@ 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 @@ -177,6 +177,12 @@ support.claude.com "Anthropic Software Directory Policy". 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); });