Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
]
}
4 changes: 3 additions & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -10,6 +10,8 @@
"repository": "https://github.com/pmndrs/claude-code-plugin",
"license": "MIT",
"keywords": [
"pmndrs",
"poimandres",
"react-three-fiber",
"drei",
"zustand",
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
50 changes: 38 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:

Expand All @@ -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.
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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\""
}
}
4 changes: 3 additions & 1 deletion skills/docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<lib>/index`, via
`ReadMcpResourceTool`. It returns one `<path> - <title>` 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`.
Expand Down
14 changes: 14 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
70 changes: 70 additions & 0 deletions test/helpers.mjs
Original file line number Diff line number Diff line change
@@ -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'))
: []
91 changes: 91 additions & 0 deletions test/manifests.test.mjs
Original file line number Diff line number Diff line change
@@ -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}`)
}
})
58 changes: 58 additions & 0 deletions test/skills.test.mjs
Original file line number Diff line number Diff line change
@@ -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')
})
Loading