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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ 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.

A question that spans several pages — a drei helper, the R3F hook under it, and
the zustand store behind that — goes to the `pmndrs:docs-lookup` agent instead,
which reads the pages in its own context and reports back the signatures. One
page stays inline: the round trip costs more than the index it would save.

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
Expand All @@ -45,6 +50,7 @@ Components, which is what grows:
| | |
|---|---|
| `skills/docs/SKILL.md` | when to look things up, and how — index resource first, then `get_page_content` |
| `agents/docs-lookup.md` | the read-only subagent for wide lookups; quotes pages verbatim, cannot edit files |

And the plumbing, which mostly doesn't:

Expand All @@ -71,7 +77,8 @@ 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.
nothing, that an agent's `tools` entries resolve to real tools, 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
Expand Down
43 changes: 43 additions & 0 deletions agents/docs-lookup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
name: docs-lookup
description: Reads pages from docs.pmnd.rs and reports back what they actually say. Use for a wide lookup — a question spanning three or more pages, or two or more libraries — so the index listings and page bodies stay out of the main context. A single page is cheaper to read inline.
model: haiku
maxTurns: 12
skills:
- docs
tools: ListMcpResourcesTool, ReadMcpResourceTool, mcp__plugin_pmndrs_docs__get_page_content
color: cyan
---

You look things up in the official pmndrs documentation and report what the
pages say. You do not write code, review it, or give an opinion on the caller's
approach — you are the reading half of someone else's task.

The `docs` skill is preloaded above: it lists which libraries the server
actually serves and how to walk the index. Follow it. If it did not load, read
`docs://<lib>/index` first anyway — paths are not guessable and an invented one
just fails.

The MCP server is bundled by this plugin, so it registers under the scoped name
`plugin:pmndrs:docs`. Pass that as the `server` argument to the resource tools;
`ListMcpResourcesTool` with no argument shows the live name if it ever differs.

## What to return

The caller will write code from your answer and will not see the pages you
read. Anything you compress, they compress too.

- Quote signatures, prop names, type parameters and option keys **verbatim**.
Never paraphrase an API. A prop you rename in passing becomes a bug in their
file.
- Link every page you used: `https://docs.pmnd.rs/<lib><path>`.
- Keep the prose around the quotes short. Answer the question asked; skip the
tour of the rest of the page.
- If the docs do not cover it, say exactly that and stop. Do not fill the gap
from memory — the caller can do that themselves, and they need to know the
answer is not from the docs.
- If the index for a library comes back empty, report it as uncovered and point
at `https://github.com/pmndrs/<lib>`. Do not retry the page fetch.

When several parts of the question are independent, read the pages for all of
them before answering, and group the answer part by part.
15 changes: 15 additions & 0 deletions skills/docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ case-sensitive.
Indexes are small (16–134 lines); reading one is cheap and usually enough to
tell whether the docs cover the question at all.

## When to hand it off

Do the lookup inline by default. One index plus one page is smaller than the
round trip through another agent, and quoting from a page you read yourself is
more faithful than quoting a summary of it.

Hand off to the `docs-lookup` agent when the reading is wide: three or more
pages, or two or more libraries in one question — a drei helper plus the R3F
hook it wraps plus the zustand store behind them. There the index listings and
page bodies would crowd out the code you are actually working on, and the agent
reads them in its own context and reports back.

Whatever it reports is a doc quote, not an answer: check it still addresses the
question, and keep its links.

## What the server actually serves

Four libraries, despite the eleven its tool schema advertises:
Expand Down
123 changes: 123 additions & 0 deletions test/agents.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readJson, agents, skills, asList } from './helpers.mjs'

const plugin = readJson('.claude-plugin', 'plugin.json')
const mcp = readJson('.mcp.json')

// mcp__plugin_<plugin-name>_<server-name>__<tool-name>, with any character
// outside A-Za-z0-9_- replaced by _.
const scope = (s) => s.replace(/[^A-Za-z0-9_-]/g, '_')
const serverKeys = Object.keys(mcp.mcpServers ?? {})
const prefixes = serverKeys.map((key) => `mcp__plugin_${scope(plugin.name)}_${scope(key)}__`)

// Built-in tools this plugin's agents are allowed to name. An unlisted entry is
// far more likely a typo than a tool we meant to grant: a tools list that
// resolves to nothing makes the agent fail to launch.
const BUILTIN = new Set([
'Agent', 'Bash', 'Edit', 'Glob', 'Grep', 'ListMcpResourcesTool', 'NotebookEdit',
'Read', 'ReadMcpResourceTool', 'Skill', 'TodoWrite', 'WebFetch', 'WebSearch', 'Write',
])

const MODELS = new Set(['sonnet', 'opus', 'haiku', 'fable', 'inherit'])
const COLORS = new Set(['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan'])

// Ignored for plugin-shipped agents — present here they read as configuration
// that does something, and it does not.
const IGNORED_IN_PLUGINS = ['hooks', 'mcpServers', 'permissionMode']

const all = agents()

test('the plugin ships at least one agent', () => {
assert.ok(all.length > 0)
})

for (const agent of all) {
const { fields, body, path } = agent

test(`${path}: identity is well formed`, () => {
assert.match(fields.name, /^[a-z0-9][a-z0-9-]*$/)
assert.ok(
path.endsWith(`/${fields.name}.md`),
`file name should match the declared name (${fields.name})`,
)
// The description is the whole basis on which Claude decides to delegate.
assert.ok(fields.description?.length > 60, 'description must say what it does and when to use it')
assert.ok(body.trim().length > 200, 'an agent needs a real system prompt')
})

test(`${path}: only uses front matter that plugin agents honour`, () => {
for (const field of IGNORED_IN_PLUGINS) {
assert.ok(!(field in fields), `${field} is ignored for plugin-shipped agents`)
}
if (fields.model) assert.ok(MODELS.has(fields.model) || fields.model.startsWith('claude-'))
if (fields.color) assert.ok(COLORS.has(fields.color))
if (fields.isolation) assert.equal(fields.isolation, 'worktree')
if (fields.maxTurns) assert.match(String(fields.maxTurns), /^\d+$/)
})

test(`${path}: every tool name resolves`, () => {
const tools = asList(fields.tools)
assert.ok(tools.length > 0, 'an unrestricted docs agent would inherit Write and Edit')

for (const tool of tools) {
if (!tool.startsWith('mcp__')) {
assert.ok(BUILTIN.has(tool.replace(/\(.*\)$/, '')), `unknown built-in tool: ${tool}`)
continue
}
// A bare server key never resolves for a plugin-bundled server, and the
// failure is silent until the agent runs with no docs access at all.
for (const key of serverKeys) {
assert.ok(!tool.startsWith(`mcp__${key}__`), `${tool} uses the bare server key, not the plugin scope`)
}
const prefix = prefixes.find((p) => tool.startsWith(p))
assert.ok(prefix, `${tool} matches no bundled server; expected one of ${prefixes.join(', ')}`)
assert.ok(tool.length > prefix.length, `${tool} names no tool after the scope`)
}
})

test(`${path}: MCP tools it grants are ones the skills document`, () => {
const documented = skills().map((s) => s.body).join('\n')
for (const tool of asList(fields.tools)) {
const prefix = prefixes.find((p) => tool.startsWith(p))
if (!prefix) continue
const short = tool.slice(prefix.length)
assert.ok(documented.includes(short), `${short} is granted but no SKILL.md explains it`)
}
})

test(`${path}: preloaded skills exist`, () => {
const available = new Map(skills().map((s) => [s.fields.name, s]))
for (const name of asList(fields.skills)) {
// A missing skill is skipped with a debug-log warning, so the agent would
// ship silently stripped of the protocol it is built around.
assert.ok(available.has(name.replace(/^.*:/, '')), `preloaded skill "${name}" does not exist`)
}
})

test(`${path}: does not restate what the skill owns`, () => {
// Which libraries the server actually serves lives in SKILL.md, with a
// checked-on date. A second copy here is a copy that goes stale unnoticed.
assert.doesNotMatch(body, /^\|.*\b(react-three-fiber|drei|zustand)\b/m, 'library table belongs in SKILL.md')
assert.doesNotMatch(body, /llms-full\.txt/, 'coverage caveats belong in SKILL.md')
})

test(`${path}: names the MCP server by its scoped form`, () => {
for (const key of serverKeys) {
const scoped = `plugin:${plugin.name}:${key}`
if (!body.includes(scoped)) continue
// If it explains the scoped name at all, it must not also hand out the
// bare key as if it were usable.
assert.doesNotMatch(body, new RegExp(`\`${key}\`\\s+(server|as the \`server\`)`))
}
})
}

test('agent names do not collide with skill names', () => {
// Both surface under the same `pmndrs:<name>` scoped identifier, one at `/`
// and one at `@`. Sharing a name makes the two indistinguishable in prose.
const skillNames = new Set(skills().map((s) => s.fields.name))
for (const agent of all) {
assert.ok(!skillNames.has(agent.fields.name), `${agent.fields.name} is both an agent and a skill`)
}
})
8 changes: 7 additions & 1 deletion test/skills.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { skills } from './helpers.mjs'
import { skills, agents } from './helpers.mjs'

const all = skills()

Expand Down Expand Up @@ -56,3 +56,9 @@ test('the docs skill states which libraries are served, and when that was checke
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')
})

test('the docs skill tells Claude when to delegate, and to an agent that exists', () => {
const names = agents().map((a) => a.fields.name)
const referenced = names.filter((name) => docs.body.includes(name))
assert.ok(referenced.length > 0, `SKILL.md should route wide lookups to one of: ${names.join(', ')}`)
})
Loading