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') +})