claude-plugin-catalog
v1.0.0
Published
Walk a Claude Code plugin tree, parse every SKILL.md into a structured catalog, and render MDX docs pages — with a tolerant YAML fallback for frontmatter that strict parsers reject.
Maintainers
Readme
claude-plugin-catalog
Reads a directory of Claude Code plugins, turns every SKILL.md into a plain
JavaScript object, and renders those objects as MDX pages you can drop into a
static site generator.
It exists because I kept writing the same 200 lines in every docs site that needed to document a plugin marketplace, and because the naive version of those 200 lines breaks on real input. Skill authors write frontmatter for humans, not for YAML parsers, and a docs build that dies on one malformed argument hint is not much use.
Install
npm install claude-plugin-catalogNode 18 or newer. One runtime dependency, gray-matter.
Using it
import { buildCatalog, renderSkillMdx } from 'claude-plugin-catalog';
import { mkdirSync, writeFileSync } from 'node:fs';
const catalog = buildCatalog('/path/to/marketplace/plugins');
for (const plugin of catalog.plugins) {
mkdirSync(`src/content/docs/skills/${plugin.name}`, { recursive: true });
for (const skill of plugin.skills) {
writeFileSync(
`src/content/docs/skills/${plugin.name}/${skill.name}.mdx`,
renderSkillMdx(skill)
);
}
}The layout it expects under the plugins directory:
<plugin>/.claude-plugin/plugin.json name, version, description
<plugin>/.mcp.json mcpServers keys
<plugin>/skills/<skill>/SKILL.md frontmatter + prose
<plugin>/scripts/<skill>/ presence marks the skill "compound"
<plugin>/agents/*.md agent frontmatterEverything in that list is optional. A plugin with no .mcp.json gets an
empty mcpServers array, a missing plugin.json gives version: "unknown",
and a skill directory without a SKILL.md is skipped rather than reported as
an empty skill.
The frontmatter fallback
This is the part worth the install.
Skill authors write argument hints like this:
argument-hint: [--page=Name] [--module=a|b] [--severity=INFO|WARN]YAML sees the leading [, decides it is reading a flow sequence, hits the
second [, and throws can not read an implicit mapping pair. On the
marketplace this was extracted from, six of fifty-seven skills failed strict
parsing for exactly this reason. Under the obvious implementation that is a
dead docs build, or six missing pages.
So parseSkill tries strict YAML first and falls back to a line-by-line
key: value reader when strict parsing throws. The fallback recovers the hint
verbatim, brackets and pipes intact, and sets looseFrontmatter: true on the
result so you can tell the difference. It also emits a warning, through
console.warn by default or through a warn function you pass in.
The fallback only understands single-line scalars. If a file that strict YAML already rejected also had a multi-line value, that value is lost. That is the intended trade.
There is a quieter version of the same problem. A hint with one group,
[--severity=INFO|WARN], is valid flow-sequence syntax, so strict parsing
succeeds and hands back ['--severity=INFO|WARN']. Stringify that and the
brackets the author wrote are gone. When the strict parse returns a non-string
for argument-hint, the raw line wins instead.
Phases
Skills can be grouped into phases for navigation. The grouping is yours, not the library's:
const phases = {
Plan: ['scope-work', 'estimate'],
Build: ['build-docs', 'tag-release'],
Verify: ['audit-links'],
};
const catalog = buildCatalog(pluginsDir, { phases, defaultPhase: 'Unclassified' });Leave phases out and every skill comes back Unclassified. No taxonomy is
bundled, and nothing is read from disk at import time.
Templates
renderSkillMdx and renderAgentMdx ship a default layout, and both take
body and frontmatter overrides so you are not stuck with it:
renderSkillMdx(skill, {
descriptionLimit: 200,
frontmatter: (s, { truncate }) => ({
title: s.name,
description: truncate(s.description, 200),
sidebar_label: s.name,
order: s.isCompound ? 0 : 1,
}),
body: (s, { escape }) => `# ${s.name}\n\n${escape(s.description)}`,
});body may be a function or a literal string. frontmatter may be a function
or an object. Quoting and escaping frontmatter values stays the library's job
whatever you pass, so a custom template cannot accidentally emit YAML that
will not parse. Escaping body prose is the template's job, which is what
escape is for: MDX reads a bare < as a JSX tag and a bare { as an
expression, and both will fail a build.
renderMdx({ frontmatter, body }) is available directly if you want the
safety without either default layout.
What it deliberately gets right
Four things here are the whole reason this is a package instead of a snippet.
Nothing is read from disk when you import it. The version this was
extracted from loaded its phase map with a top-level readFileSync of a file
that sat outside the published tree. It worked in the repo and threw ENOENT
the moment anyone installed it. The phase map is now an argument with an empty
default, and there is a test that copies the source files somewhere with no
siblings and imports them there.
Descriptions are truncated before they are escaped, not after. Escaping
first turns a " into a two-character \", and a 160-character cut can land
between those two characters. The surviving backslash escapes the closing
quote and the frontmatter no longer parses. Both renderers go through one code
path that truncates the raw text first, and both are tested against a quote
sitting exactly on the boundary.
Titles are quoted. A skill named deploy: staging written as an unquoted
YAML scalar is invalid frontmatter. Every value, title included, is quoted and
escaped.
The gray-matter cache is bypassed. gray-matter keeps a global cache
keyed on file content and stores the entry before it parses. When parsing
throws, that leaves a cached entry holding empty data, and every later call
with the same content returns it without throwing. The fallback never fires
and the skill comes back blank. Two plugins shipping identical skill files is
enough to hit it. Passing an options object opts out of the cache.
Output is also deterministic. Skills, agents and MCP server names are sorted,
plugin order is the order you asked for, and generatedAt is null unless
you set it, so regenerating docs produces a byte-identical diff when nothing
changed.
API
buildCatalog(pluginsDir, pluginNames?, options?)— walk the tree. OmitpluginNamesto discover every directory. Options:phases,defaultPhase,generatedAt,warn.parseSkill(markdown, { plugin, warn })— oneSKILL.mdto an object. Never throws.classifyPhase(name, phaseMap?, { defaultPhase })andcreatePhaseClassifier(phaseMap, options).renderSkillMdx(skill, options?),renderAgentMdx(agent, options?),renderMdx({ frontmatter, body }).escapeMdxBody(text),truncateScalar(text, limit),escapeYamlDoubleQuoted(text)for custom templates.parseFrontmatterLoose(markdown)if you want the tolerant reader on its own.
Tests
npm testNode's built-in runner, no test framework. Every behaviour described above has a test that fails if it regresses, including one that asserts strict YAML really does reject the sample argument hint, so the fallback test cannot quietly become a tautology.
License
MIT
