How to add a plugin to this repository and what the gates will check before it
ships. Read this with claude-code.md (platform conventions)
and ../decisions/ (why the tree looks like this).
A plugin is a directory under plugins/. Every directory there is installable
as-is — there is no separate release tree, no packing step, and no second copy of
anything.
| Path | Required | Authored or generated |
|---|---|---|
plugin.config.ts |
yes | authored — the metadata source of truth |
.claude-plugin/plugin.json |
yes | generated by npm run generate:plugins |
skills/<skill>/SKILL.md |
when the plugin teaches behavior | authored |
commands/, agents/, hooks/hooks.json, assets/, .mcp.json |
optional | authored |
README.md |
optional | authored |
package.json, tsconfig.json |
only if the plugin builds or tests | authored |
src/ |
only for a plugin with a CLI | authored |
dist/ |
only for a plugin with a CLI | generated by npm run build, committed |
node_modules/ is never committed, and plugins/config-center/ui/dist/ is the
Vite intermediate, not a shipped file.
mkdir -p plugins/<name>/skills/<name>- Write
plugin.config.ts(see Metadata). - Write
skills/<name>/SKILL.mdwhose frontmatter has at leastnameanddescription. npm run generate:plugins && npm run validate:plugins.
No package.json is needed: plugin.config.ts already carries the name,
version and description, and a second copy only invites drift.
-
Put the entry at
src/<name>.ts, and inpackage.json:"scripts": { "build": "bash ../../scripts/build-plugin.sh <name> src/<name>.ts", "dev": "tsx src/<name>.ts", "typecheck": "tsc --noEmit" }
-
npm run buildwritesplugins/<name>/dist/<name>.mjsand that file is committed: this repository is installed as a git marketplace, so consumers cannot build it. -
Refer to it from skill text as
${CLAUDE_PLUGIN_ROOT}/dist/<name>.mjs.npm run validate:claude-layoutfails if the path does not exist. -
Anything the bundle reads at runtime — a
.proto, a generated catalog — belongs indist/beside the bundle. There is no shadow copy elsewhere. -
Add dependencies to the plugin's
package.json. esbuild inlines them, so an installed plugin never needsnpm install; the shared credential layer comes from the workspace package@agent-plugins/config-center.
A plugin that stores credentials should serve the shared browser form instead of asking the user to edit JSON.
-
Declare the form as plain data in
src/config-ui.ts, exportingCONFIG_UI:import type { ConfigUIOptions } from '@agent-plugins/config-center'; export const CONFIG_UI: ConfigUIOptions = { setupCommand: 'config --ui', reason: 'Why the plugin cannot run unconfigured, in the user’s terms.', spec: { root: 'page', elements: { /* … */ } }, };
Keep the module free of side effects:
npm run validate:config-uiimports it without running your CLI, which is why the CLI entry is not a valid home. -
Open it from the CLI with
openConfigUI(name, CONFIG_UI)(explicit) orrequireConfigWithSetup(name, CONFIG_UI)(opens it when config is missing or incomplete and hands back the reloaded config).validatereturningtruemeans the config is incomplete, andsetupCommandis the command the error messages tell the reader to run when the form is skipped — it defaults tosetup, so set it when your entry point differs. -
The vocabulary is fixed by
catalog-contract.ts: five components (Header,Section,Collection,Field,SaveBar) and six field types (text,password,select,number,textarea,checkbox).validate:config-uirejects anything else, plus unreachable elements andchildrenentries that name a missing element. -
A
Collectionelement needs two declarations, and they are the same fact in two shapes. The form keeps a list (spec.stateholdsconnections: [{ _name: 'default', … }]) and the config file keeps an object keyed by that name (connections: { default: { … } }), so a collection the form renders must also appear incollections: [{ statePath: '/connections' }]. That mapping is what converts between the two on load and on save, and it is what makes the save replace the whole list: the form is showing every entry, so an entry the user deleted has to disappear from the file. -
The shared HTML ships inside your bundle, and only when the bundle serves it:
scripts/build-plugin.shcopies it after a build that contains the marker, andnpm run validate:config-uifails a plugin that serves the form without shipping the copy, or ships it without serving it.npm run buildhandles it — do not copy the HTML by hand. -
Skill text must stand alone: never tell the reader to consult another plugin's skill for a rule, because installing one plugin must be enough.
The steps above need your own bundle. A skill-only plugin — the work is done by an independently installed command-line tool — cannot serve a form that way. For those, config-center renders the form from a spec file your skill ships, and drives the tool through its bridge:
- Write a plain-JSON spec file beside your
SKILL.mddeclaringplugin(the storage directory name),form(the same spec shape as above),command(the tool's executable),env(environment variable name → configuration key),requiredKeys/requiredAny, andreason. Copyplugins/config-center/examples/toolx.spec.jsonand replace its values; its field-by-field guide isplugins/config-center/examples/README.md. - In your skill text, name the spec file's location and give the two commands:
config-center edit --spec <file> <plugin>(first-time setup, background task) andconfig-center run --spec <file> <plugin> <args…>(every command afterwards, credentials injected as theenvvariables). Config-center opens the form by itself whenever a run finds required values missing. - The spec carries field names, variable names and shapes — never secret values. It ships with the skill (committed in the plugin directory), so the installed plugin is self-contained.
openConfigUI starts a server on 127.0.0.1 with an OS-assigned port, prints the
URL to stderr (stdout stays clean for the plugin's own output) and waits. The
server answers GET / with the bundled HTML plus four inline globals — the spec,
the current state, a CSRF token and the plugin name — and it answers the form's
POST /save, which converts the state back to the config shape, merges it over the
stored file (your collection paths excepted, see step 4), writes it, replies
{ ok: true } and shuts itself down. handle.done then resolves true, or false
if the session timed out or was closed: nothing was saved, and the caller decides
what to do.
For a headless run — a test, a screenshot, a preview — set
AGENT_PLUGINS_NO_BROWSER=1 to skip the browser and AGENT_PLUGINS_UI_TIMEOUT_MS
to shorten the wait, and always point AGENT_PLUGINS_CACHE_DIR at a scratch
directory so the run cannot read or rewrite the operator's real credentials.
| Field | Notes |
|---|---|
name |
must equal the directory name |
version |
also lives in package.json and in the CLI's .version(…) when it declares one |
description |
shown in plugin.json; keep it to what the plugin does |
author, keywords |
optional |
marketplace.description |
optional longer wording for the marketplace listing; omit it when description already reads well there |
A version is declared by hand in plugin.config.ts, in package.json when the
plugin has one, and in the CLI's own .version() when it declares one.
npm run validate:plugin-metadata compares all of them, along with the generated
manifest and marketplace entry, and fails on any mismatch.
npm run generate:plugins # rewrite plugin.json + the marketplace entry
npm run build # shared config UI first, then every bundle
npm run validate:plugins # every gate below
bun test ./.github/scripts/tests
bash scripts/dev.sh <name> # launch Claude Code against the plugin directory| Gate | Catches |
|---|---|
validate:plugin-metadata |
a manifest, marketplace entry or version that no longer matches plugin.config.ts |
validate:claude-layout |
a missing/extra file in .claude-plugin/, malformed hooks or MCP config, any ${CLAUDE_PLUGIN_ROOT} path the plugin does not ship, and skill/command/agent frontmatter that is not valid YAML with the fields Claude Code reads |
validate:config-ui |
a form spec the renderer cannot draw, a plugin that serves the form without shipping the HTML (or ships it without serving it), and stale HTML copies |
validate:persistence |
a runtime source that takes a storage location from the temporary directory, the home directory or the working directory instead of the shared cache root |
validate:marketplace |
a malformed or duplicate marketplace entry; the generated entries are already compared byte for byte by validate:plugin-metadata |
validate:no-secrets |
credential material in any human-authored surface |
CI validate-generated |
a committed artifact under plugins/*/dist that no longer matches its source |
-
npm run validate:pluginspasses -
npm run buildleaves no diff underplugins/*/dist - if the plugin stores credentials,
config --uiopens the form and a save round-trips - the CLI runs from a copy of the plugin directory with no
node_modules