From c49eb3e21b39c9056662755fc77b5cba2df4e3f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 06:03:33 +0000 Subject: [PATCH] Frame the repo as the Poimandres plugin, not the docs plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifests and the README both read as though documentation lookup were what this plugin is. It is what it does first. Everything that follows — more skills, subagents, hooks — would have arrived as an exception to its own description. So the framing states the shape without promising components that do not exist: one install carrying Poimandres tooling, of which docs lookup is today's whole content. The marketplace descriptions gain a "currently", `pmndrs` and `poimandres` join the keywords, and the README splits what grows (components) from what does not (plumbing). Also fixes the server name the docs skill hands to ReadMcpResourceTool. Bundled in a plugin, the server registers as `plugin:pmndrs:docs`; the bare `docs` key from .mcp.json is not a name anything answers to, and the failure is a silent empty read rather than an error. Adds the test harness these edits need, on Node's built-in runner with no dependencies, plus CI. It covers the wiring that fails silently at runtime rather than loudly at load: the plugin name agreeing across both manifests (it is baked into every scoped MCP tool name), a component naming the server it reads resources from, no server declared and then used by nothing, skill names matching their directories, and the README listing every component shipped. That last one is the one that matters as components accumulate — a capability nobody can find is a capability nobody uses. Each check was mutation-tested: breaking the invariant fails the test that claims to guard it, and no other. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TnvYiz7aGhnBTK2tzExHis --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 4 +- .github/workflows/test.yml | 16 ++++++ README.md | 50 +++++++++++++----- package.json | 10 ++++ skills/docs/SKILL.md | 4 +- test/cli.test.mjs | 14 +++++ test/helpers.mjs | 70 +++++++++++++++++++++++++ test/manifests.test.mjs | 91 +++++++++++++++++++++++++++++++++ test/skills.test.mjs | 58 +++++++++++++++++++++ 10 files changed, 305 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 package.json create mode 100644 test/cli.test.mjs create mode 100644 test/helpers.mjs create mode 100644 test/manifests.test.mjs create mode 100644 test/skills.test.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 418bf3d..011ac28 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,9 +9,9 @@ { "name": "pmndrs", "source": "./", - "description": "Answers questions about react-three-fiber, drei and zustand from the official docs.pmnd.rs pages instead of from memory.", + "description": "Poimandres tooling for Claude Code. Currently: answers questions about react-three-fiber, drei and zustand from the official docs.pmnd.rs pages instead of from memory.", "category": "documentation", - "tags": ["react-three-fiber", "drei", "zustand", "three.js"] + "tags": ["pmndrs", "poimandres", "react-three-fiber", "drei", "zustand", "three.js"] } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b05943c..ba782f5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pmndrs", - "description": "Answers questions about react-three-fiber, drei and zustand from the official docs.pmnd.rs pages instead of from memory.", + "description": "Poimandres tooling for Claude Code. Currently: answers questions about react-three-fiber, drei and zustand from the official docs.pmnd.rs pages instead of from memory.", "version": "0.1.0", "author": { "name": "Poimandres", @@ -10,6 +10,8 @@ "repository": "https://github.com/pmndrs/claude-code-plugin", "license": "MIT", "keywords": [ + "pmndrs", + "poimandres", "react-three-fiber", "drei", "zustand", diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2a75609 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm test diff --git a/README.md b/README.md index d70cf49..cfa299a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # pmndrs — a Claude Code plugin -Makes Claude read the [pmndrs docs](https://docs.pmnd.rs) before answering -questions about **react-three-fiber**, **drei** and **zustand**, instead of -recalling an API that may have moved. +The Poimandres plugin for Claude Code: one install that carries whatever makes +Claude work well on a pmndrs codebase. + +That is a shape, not a promise. Today it does one thing — it makes Claude read +the [pmndrs docs](https://docs.pmnd.rs) before answering questions about +**react-three-fiber**, **drei** and **zustand**, instead of recalling an API +that may have moved. What lands next is whatever earns its place: more skills, +subagents, hooks. They land here rather than in a plugin each, so that adopting +Poimandres tooling stays one install and one thing to keep current. ## Install @@ -17,7 +23,7 @@ Or from inside a session: `/plugin marketplace add pmndrs/claude-code-plugin`, t The plugin bundles an MCP server, so Claude Code asks you to approve it on first use. It is the public docs.pmnd.rs endpoint — no auth, no credentials. -## Use +## Documentation lookup Nothing to invoke. Ask normally: @@ -26,27 +32,47 @@ Nothing to invoke. Ask normally: and Claude reads the docs index, fetches the page, and answers with a link. `/pmndrs:docs` runs the same lookup on demand. +The docs server advertises eleven libraries but only serves four — +react-three-fiber, drei, zustand, and the pmndrs/docs site itself. The other +seven (a11y, react-postprocessing, uikit, xr, prai, viverse, leva) publish no +`llms-full.txt` upstream, so the skill tells Claude to say so rather than +retry. Widen it the day those dumps appear. + ## What's inside -| File | | +Components, which is what grows: + +| | | |---|---| -| `.mcp.json` | the `docs` MCP server → `https://docs.pmnd.rs/api/mcp` | | `skills/docs/SKILL.md` | when to look things up, and how — index resource first, then `get_page_content` | + +And the plumbing, which mostly doesn't: + +| | | +|---|---| +| `.mcp.json` | the `docs` MCP server → `https://docs.pmnd.rs/api/mcp` | | `.claude-plugin/plugin.json` | the plugin manifest | | `.claude-plugin/marketplace.json` | lets this repo be added as a marketplace | - -The docs server advertises eleven libraries but only serves four — -react-three-fiber, drei, zustand, and the pmndrs/docs site itself. The other -seven (a11y, react-postprocessing, uikit, xr, prai, viverse, leva) publish no -`llms-full.txt` upstream, so the skill tells Claude to say so rather than -retry. Widen it the day those dumps appear. +| `test/` | checks the components are wired to the manifests | ## Develop ```bash claude --plugin-dir . # load this checkout directly claude plugin validate . # check the manifests +npm test # check the components (no dependencies) ``` `/reload-plugins` picks up edits without restarting; `SKILL.md` changes apply immediately. + +`npm test` runs Node's built-in test runner over `test/` — no install step. It +checks the things that fail silently at runtime rather than loudly at load: that +a component addressing a bundled MCP server uses the scoped name it registers +under once installed, that the plugin name still matches between the two +manifests it is derived from, that no server is declared and then used by +nothing, and that this README lists every component shipped. + +That last one is the rule to keep as components accumulate: a capability nobody +can find is a capability nobody uses, so the README table is enforced rather +than maintained by good intentions. diff --git a/package.json b/package.json new file mode 100644 index 0000000..626fe35 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "@pmndrs/claude-code-plugin", + "private": true, + "type": "module", + "description": "Tests for the pmndrs Claude Code plugin. The plugin itself is plain markdown and JSON — nothing here is published or installed.", + "license": "MIT", + "scripts": { + "test": "node --test \"test/*.test.mjs\"" + } +} diff --git a/skills/docs/SKILL.md b/skills/docs/SKILL.md index c7ed8d2..a0a96ea 100644 --- a/skills/docs/SKILL.md +++ b/skills/docs/SKILL.md @@ -16,7 +16,9 @@ indexes as MCP *resources* and one tool, `get_page_content(lib, path)`. 1. **Read the index resource** for the library: `docs:///index`, via `ReadMcpResourceTool`. It returns one ` - ` line per page. - If the server name is ambiguous, `ListMcpResourcesTool` shows which server + Installed as a plugin, the server registers under the scoped name + `plugin:pmndrs:docs` rather than the bare key `docs` — pass that as `server`. + `ListMcpResourcesTool` with no argument shows the live name and which server serves the `docs://` URIs. 2. **Fetch the page** with this plugin's `get_page_content`, passing a `path` copied verbatim from that index and the same `lib`. diff --git a/test/cli.test.mjs b/test/cli.test.mjs new file mode 100644 index 0000000..ea29687 --- /dev/null +++ b/test/cli.test.mjs @@ -0,0 +1,14 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { root } from './helpers.mjs' + +const claude = spawnSync('claude', ['--version'], { encoding: 'utf8' }) +const available = claude.status === 0 + +test('claude plugin validate accepts this checkout', { skip: available ? false : 'claude CLI not installed' }, () => { + const result = spawnSync('claude', ['plugin', 'validate', '.'], { cwd: root, encoding: 'utf8' }) + const output = `${result.stdout}${result.stderr}` + assert.equal(result.status, 0, output) + assert.doesNotMatch(output, /\berror\b/i, output) +}) diff --git a/test/helpers.mjs b/test/helpers.mjs new file mode 100644 index 0000000..a3a0da0 --- /dev/null +++ b/test/helpers.mjs @@ -0,0 +1,70 @@ +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const root = join(dirname(fileURLToPath(import.meta.url)), '..') + +export const readJson = (...p) => JSON.parse(readFileSync(join(root, ...p), 'utf8')) + +/** + * Minimal YAML front matter reader — enough for the subset Claude Code agent and + * skill files use: `key: value`, block lists, and inline `[a, b]` lists. Values + * are returned as strings or arrays of strings, never coerced. + */ +export function frontMatter(source) { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source) + if (!match) throw new Error('no front matter block') + + const fields = {} + let key = null + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue + + const item = /^\s*-\s+(.*)$/.exec(line) + if (item) { + if (!key) throw new Error(`list item outside a key: ${line}`) + fields[key] = [...(Array.isArray(fields[key]) ? fields[key] : []), unquote(item[1])] + continue + } + + const pair = /^([A-Za-z][\w-]*):\s?(.*)$/.exec(line) + if (!pair) continue // a wrapped value; the fields we assert on are single-line + key = pair[1] + const value = pair[2].trim() + fields[key] = value === '' ? [] : parseScalar(value) + } + + return { fields, body: source.slice(match[0].length) } +} + +const unquote = (v) => v.replace(/^['"]|['"]$/g, '').trim() + +function parseScalar(value) { + if (value.startsWith('[') && value.endsWith(']')) { + const inner = value.slice(1, -1).trim() + return inner ? inner.split(',').map(unquote) : [] + } + return unquote(value) +} + +/** Front matter list fields accept either `a, b` on one line or a block list. */ +export const asList = (value) => + value === undefined ? [] : Array.isArray(value) ? value : value.split(',').map((v) => v.trim()).filter(Boolean) + +export function loadMarkdown(dir, file) { + const path = join(root, dir, file) + return { path: `${dir}/${file}`, ...frontMatter(readFileSync(path, 'utf8')) } +} + +export const agents = () => + existsSync(join(root, 'agents')) + ? readdirSync(join(root, 'agents')).filter((f) => f.endsWith('.md')).map((f) => loadMarkdown('agents', f)) + : [] + +export const skills = () => + existsSync(join(root, 'skills')) + ? readdirSync(join(root, 'skills'), { withFileTypes: true }) + .filter((e) => e.isDirectory() && existsSync(join(root, 'skills', e.name, 'SKILL.md'))) + .map((e) => loadMarkdown(`skills/${e.name}`, 'SKILL.md')) + : [] diff --git a/test/manifests.test.mjs b/test/manifests.test.mjs new file mode 100644 index 0000000..6660462 --- /dev/null +++ b/test/manifests.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { readJson, root, agents, skills, asList } from './helpers.mjs' + +const plugin = readJson('.claude-plugin', 'plugin.json') +const marketplace = readJson('.claude-plugin', 'marketplace.json') +const mcp = readJson('.mcp.json') + +test('plugin manifest declares a name and a semver version', () => { + assert.match(plugin.name, /^[a-z0-9][a-z0-9-]*$/) + assert.match(plugin.version, /^\d+\.\d+\.\d+/) + assert.ok(plugin.description?.length > 20, 'description carries the plugin listing') +}) + +test('marketplace entry agrees with the plugin manifest', () => { + const entry = marketplace.plugins.find((p) => p.source === './') + assert.ok(entry, 'marketplace must publish this checkout') + // The plugin name is baked into every scoped MCP tool name + // (mcp__plugin_<plugin>_<server>__<tool>), so a rename here silently breaks + // every agent tools list. Keep the two manifests in step. + assert.equal(entry.name, plugin.name) +}) + +test('bundled MCP servers are fully specified', () => { + const servers = Object.entries(mcp.mcpServers ?? {}) + assert.ok(servers.length > 0, '.mcp.json must declare at least one server') + for (const [name, config] of servers) { + assert.match(name, /^[a-z0-9][a-z0-9-]*$/, `server key ${name} should be a plain slug`) + assert.ok(config.type, `server ${name} declares a transport`) + if (['http', 'sse', 'ws'].includes(config.type)) { + assert.match(config.url, /^https:\/\//, `server ${name} must be reached over https`) + } else { + assert.ok(config.command, `stdio server ${name} declares a command`) + } + } +}) + +test('README lists every component file the plugin ships', () => { + const readme = readFileSync(join(root, 'README.md'), 'utf8') + const shipped = [ + '.mcp.json', + '.claude-plugin/plugin.json', + '.claude-plugin/marketplace.json', + ...skills().map((s) => s.path), + ...agents().map((a) => a.path), + ] + for (const path of shipped) { + assert.ok(readme.includes(path), `README should document ${path}`) + } +}) + +const scope = (s) => s.replace(/[^A-Za-z0-9_-]/g, '_') +const components = () => [...skills(), ...agents()] + +test('every bundled MCP server is reached by some component', () => { + // Which component is deliberately not pinned: a server added for a future + // skill has no business being documented by an existing one. What matters is + // that nothing in .mcp.json is dead config. + for (const key of Object.keys(mcp.mcpServers ?? {})) { + const scoped = `plugin:${plugin.name}:${key}` + const prefix = `mcp__plugin_${scope(plugin.name)}_${scope(key)}__` + const users = components().filter( + (c) => c.body.includes(scoped) || asList(c.fields.tools).some((t) => t.startsWith(prefix)), + ) + assert.ok(users.length > 0, `server "${key}" is declared but no skill or agent uses it`) + } +}) + +test('components that read MCP resources name the server they read from', () => { + // ReadMcpResourceTool takes a configured server name. Bundled in a plugin, + // that name is plugin:<plugin>:<server> — the bare key from .mcp.json is not + // a name anything answers to. + const scoped = Object.keys(mcp.mcpServers ?? {}).map((key) => `plugin:${plugin.name}:${key}`) + for (const component of components()) { + const uses = component.body.includes('ReadMcpResourceTool') || asList(component.fields.tools).includes('ReadMcpResourceTool') + if (!uses) continue + assert.ok( + scoped.some((name) => component.body.includes(name)), + `${component.path} reads MCP resources without naming a server; expected one of ${scoped.join(', ')}`, + ) + } +}) + +test('every marketplace source resolves on disk', () => { + for (const entry of marketplace.plugins) { + if (typeof entry.source !== 'string' || !entry.source.startsWith('.')) continue + assert.ok(existsSync(join(root, entry.source)), `missing source ${entry.source}`) + } +}) diff --git a/test/skills.test.mjs b/test/skills.test.mjs new file mode 100644 index 0000000..35508a9 --- /dev/null +++ b/test/skills.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { skills } from './helpers.mjs' + +const all = skills() + +test('the plugin ships at least one skill', () => { + assert.ok(all.length > 0) +}) + +for (const skill of all) { + const { fields, body, path } = skill + const directory = path.split('/')[1] + + test(`${path}: name matches its directory`, () => { + // Without a matching front matter name the skill is invoked under the + // install directory name, which for a marketplace install is a version + // string that changes on every update. + assert.equal(fields.name, directory) + assert.ok(fields.description?.length > 60, 'the description is the trigger') + }) + + test(`${path}: links are https`, () => { + for (const url of body.match(/https?:\/\/[^\s)`>]+/g) ?? []) { + assert.match(url, /^https:/, `${url} must be https`) + } + }) +} + +const docs = all.find((s) => s.fields.name === 'docs') + +test('the docs skill only sends readers to pmndrs docs and repos', () => { + // Specific to this skill, not a house rule: its whole job is to replace a + // remembered API with the published one, so an off-site link is a lookup + // that went somewhere it cannot vouch for. + for (const url of docs.body.match(/https:\/\/[^\s)`>]+/g) ?? []) { + assert.match(url, /^https:\/\/(docs\.pmnd\.rs|github\.com\/pmndrs)/, `${url} points outside pmndrs`) + } +}) + +test('the docs skill states which libraries are served, and when that was checked', () => { + assert.ok(docs, 'skills/docs/SKILL.md is where this plugin started') + + const rows = [...docs.body.matchAll(/^\|\s*`([a-z0-9-]+)`\s*\|\s*(\d+)\s*\|/gm)] + assert.ok(rows.length >= 3, 'the coverage table should list the served libraries with page counts') + for (const [, lib, pages] of rows) { + assert.ok(Number(pages) > 0, `${lib} is listed as served but has no pages`) + } + + // The empty-library list is a claim about the outside world; it needs a date + // so a reader can tell how much to trust it. + const checked = /checked\s+(\d{4})-(\d{2})-(\d{2})/.exec(docs.body) + assert.ok(checked, 'the uncovered-libraries claim must carry a checked-on date') + const [, y, m, d] = checked + const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d))) + assert.equal(date.getUTCDate(), Number(d), 'checked-on date is a real date') + assert.ok(date.getTime() <= Date.now(), 'checked-on date is not in the future') +})