Authoring Plugins¶
ai-rulez generate --plugin packages your .ai-rulez/ project into distributable
plugin bundles and a marketplace index for the Claude, Cursor, Codex, Gemini,
Kimi, OpenCode, Factory, and Hermes runtimes, plus opt-in Pi, Copilot, and Agent Plugins packages.
Where the normal generate writes in-repo
assistant config, --plugin produces installable artifacts other people can add to
their own tools — reaching users who never run ai-rulez.
Your skills, commands, and agents are the payload; the [plugin] block adds the
packaging metadata.
Authoring vs. installing
The [plugin] / [marketplace] blocks documented here are the producer
(authoring) side. They are distinct from the [[plugins]] / [[marketplaces]]
arrays, which are the consumer side. Neither array writes an output file:
[[plugins]] is still accepted but generate warns that it has no effect, and
[[marketplaces]] is recorded only. To enable plugins, use
[claude.settings] manage = true with enable_plugins for Claude Code and
[plugins."name@marketplace"] enabled = true in .codex/config.toml for Codex. See
Consumer plugins for the fields.
To turn a generated bundle into a checksummed release archive and upload it to a GitHub release, see
Publish (ai-rulez publish).
Quick start¶
# .ai-rulez/config.toml
version = "5.0"
name = "my-tool"
description = "My project."
[plugin]
name = "my-tool"
description = "What my tool does."
version = "1.0.0"
license = "MIT"
homepage = "https://github.com/me/my-tool"
keywords = ["mcp", "tooling"]
[plugin.author]
name = "Jane Doe"
email = "jane@example.com"
[[plugin.mcp]]
name = "my-tool"
command = "${PLUGIN_ROOT}/scripts/mcp-launch.sh"
args = ["serve"]
ai-rulez generate --plugin # write the bundles
ai-rulez generate --plugin --dry-run # preview what would be written
What gets generated¶
| Runtime | Manifest | Notes |
|---|---|---|
| Claude | .claude-plugin/plugin.json |
skills/commands/agents auto-discovered from top-level dirs |
| Cursor | .cursor-plugin/plugin.json (+ hooks/hooks.json) |
bundles its own skills/, commands/ |
| Codex | .codex-plugin/plugin.json (+ .mcp.json) |
MCP referenced via external file; rich interface block |
| Gemini | gemini-extension.json |
inline MCP + hooks, context file reference |
| Kimi | kimi.plugin.json |
sessionStart, skillInstructions, interface |
| OpenCode | .opencode/plugins/<plugin-name>.js (+ package.json, .opencode/ai-rulez-content.js, bundled .opencode/{skills,commands,agents}/) |
OpenCode v2 { id, setup } adapter; copies the authored entrypoint or emits a scaffold that registers the bundled content |
| Factory | .factory-plugin/plugin.json |
metadata-only |
| Hermes | .hermes/plugins/<plugin-name>/ and .hermes/package/ |
project plugin plus buildable Python entry-point package |
| Pi | package.json, .pi/skills/, .pi/prompts/ |
skills and rendered prompt templates; opt-in |
| Agent Plugins | plugin.json, skills/, mcp.json |
portable Agent Plugins 1.0.0 package; opt-in |
| Copilot | plugin.json, skills/, mcp.json, com.github.copilot/agents/<name>.agent.md, .github/plugin/marketplace.json |
GitHub Copilot plugin in the Agent Plugins 1.0 layout; opt-in |
The marketplace index (.claude-plugin/marketplace.json) is emitted alongside when claude is
among the bundle's runtimes. (A monorepo root also emits a Codex index at
.agents/plugins/marketplace.json; see the monorepo section.) The copilot runtime writes its own
index, and the Codex and Cursor indexes for a single-plugin repository are opt-in, see below.
Runtime coverage¶
What each runtime bundles, checked against the vendors' documentation on 2026-10-04. A dash means ai-rulez does not emit it: either the runtime has no such component, or the vendor documents no file format for it (ai-rulez does not guess formats).
| Runtime | Skills | Commands | Agents | Hooks | MCP | Marketplace index | Vendor documentation |
|---|---|---|---|---|---|---|---|
| Claude | yes | yes | yes | yes | yes | yes | plugins, marketplaces |
| Cursor | yes | yes | - | yes | manifest | opt-in ([plugin.cursor] marketplace, [marketplace] cursor_index) |
plugins, reference |
| Codex | yes | - | - | - | yes | monorepo roots; single plugin opt-in ([plugin.codex] marketplace) |
build plugins |
| Copilot | yes | - | yes | - | yes | yes (.github/plugin/marketplace.json) |
CLI plugin reference |
| Gemini | - | opt-in ([plugin.gemini] commands) |
- | yes | yes | - | extension reference, custom commands |
| Pi | yes | prompt templates | - | - | - | - | packages |
| Agent Plugins | yes | - | - | - | yes | - | agent-plugins.org |
Kimi, OpenCode, Factory and Hermes are unchanged. Known gaps, all because the format is undocumented
or outside the runtime: Codex and Copilot commands and hooks, Copilot rules and LSP, Gemini agents,
policies and hooks/hooks.json (hooks are inlined in gemini-extension.json), Cursor
agents/rules.
Codex: root plugin.json¶
Codex documents a root plugin.json in the Agent Plugins format as the preferred manifest for new
packages and keeps .codex-plugin/plugin.json as a compatibility fallback. The Codex interface
block moves under extensions.com.openai, skills live in the fixed skills/ directory and MCP servers
in a root mcp.json whose entries need a transport type (a renamed .mcp.json is not enough).
The default stays the legacy layout so existing bundles do not change:
[plugin.codex]
manifest = "root" # legacy (default) | root | both
marketplace = true # also write .agents/plugins/marketplace.json for this single-plugin repo
both writes the two manifests side by side. The root layout needs an Agent Plugins plugin name
(lowercase alphanumerics, - and .). When codex and agent-plugins (or copilot) are bundled
together they share one root plugin.json; the Codex overlay is only written when the codex runtime
is part of the bundle. The single-plugin Codex index uses source = { source = "local", path = "./" }.
Codex documents that a marketplace may list one plugin and that paths start with ./ and stay inside
the marketplace root; ./ itself as the plugin path was not exercised against a running Codex.
With source = "agents" the .agents directory belongs to the project: generate --plugin skips
.agents/plugins/marketplace.json with a warning instead of writing it (see
.agents as a source).
Copilot¶
runtimes = ["copilot"] (opt-in) emits the Agent Plugins 1.0 layout that Copilot CLI and the cloud
agent read: a root plugin.json with the standard's $schema, skills/<name>/, a root mcp.json,
custom agents at com.github.copilot/agents/<name>.agent.md (copied verbatim) and a single-plugin
marketplace at .github/plugin/marketplace.json (name, owner, metadata, plugins[], which
Copilot searches after a root marketplace.json). Copilot lists com.github.copilot/commands/ and
com.github.copilot/hooks/hooks.json but documents neither file format, so commands and hooks are not
emitted and a warning says so. Enabling plugins (enabledPlugins in .github/copilot/settings.json) is
not generated: the vendor page does not show the settings syntax.
Cursor marketplace¶
Cursor documents .cursor-plugin/marketplace.json for multi-plugin repositories (name, owner.name,
optional metadata, plugins[] with name and a relative source directory). It is opt-in:
[plugin.cursor] marketplace = true for a single-plugin repository (source is .), and
[marketplace] cursor_index = true next to the Claude index for members and domain plugins.
Gemini extension settings¶
Gemini filters sensitive host environment variables before launching extensions. Declare the
values your MCP server needs as settings; Gemini supplies the values at runtime and stores
sensitive = true values in its keychain. Never put credential values or defaults in source.
[[plugin.gemini.settings]]
name = "Provider key"
description = "Credential for the provider used by this MCP server."
env_var = "OPENAI_API_KEY"
sensitive = true
The generated manifest uses native settings[].envVar keys. Names and environment variables
must be unique; environment variable names use letters, digits and underscores. Gemini's
extensions install ./plugin --skip-settings skips installation prompts. Configure the chosen
provider later with gemini extensions config <plugin-name>, before making provider requests.
There is no authored credential value, default, or required flag.
See the Gemini extension settings reference.
Gemini commands¶
[plugin.gemini] commands = true also bundles each command as commands/<name>.toml (a required
prompt, an optional description); the Claude-style $ARGUMENTS becomes Gemini's {{args}}. Shell
(!{...}) and file (@{...}) injections are not translated.
Skills and their supporting assets are copied verbatim from your source. Commands are converted
where the runtime needs another format, including Gemini commands and Pi prompt templates. The one exception
is content written in Open Knowledge Format (frontmatter with type: and an x-ai-rulez: block): in every
runtime's bundle the SKILL.md, command or agent file gets native frontmatter instead, with type, title
and x-ai-rulez removed and the entries of x-ai-rulez.metadata (name, description, license, ...) at the
top level, so the host can read them. The body and your source file are not changed.
Pi packages¶
Pi is opt-in; the eight default plugin runtimes are unchanged. Add it explicitly to your plugin:
[plugin]
name = "my-tool"
version = "1.0.0"
description = "Skills and prompts for my tool."
repository = "https://github.com/acme/my-tool"
runtimes = ["pi"]
The package contains .pi/skills/<name>/ with each skill's supporting assets and
.pi/prompts/<name>.md rendered from authored commands, preserving supported Pi front matter.
Its root package.json lists each generated skill directory and prompt file explicitly and includes
the pi-package keyword. The npm files list includes the generated payload, so unrelated contributor
files under .pi/ or .opencode/ are not bundled or loaded as Pi resources. For a
GitHub repository, the generated name is @<owner>/pi-<plugin-name> (@acme/pi-my-tool above).
Commit the generated bundle before installing it through Git; local packages also need no registry.
The layout is configurable through [plugin.pi]:
[plugin.pi]
skills_dir = "skills" # default: .pi/skills
package_json = "merge" # default: generate
package_name = "ghost-library-skills" # default: @<owner>/pi-<plugin-name>
skills_entry = "directory" # default: each
skills_dir writes each bundled skill once, into that tree, and points pi.skills at it. Set it to
skills when the repository already ships a root skills/ tree for Claude, Codex and OpenCode: every
runtime then writes the same copy instead of Pi carrying a second one under .pi/skills/. Prompt
templates stay under .pi/prompts/ either way.
package_json = "merge" updates only the root package.json keys ai-rulez owns — name, version,
description, author, repository, keywords, pi and files — and keeps every other key, so a
repository whose package.json also holds devDependencies, scripts or packageManager does not
lose them. An authored name is kept unless package_name is set. ai-rulez verify --plugin checks those owned keys only. The default, generate, replaces
the whole file as before.
In a combined Pi and OpenCode bundle the OpenCode runtime also owns type, main, exports, dependencies
(its @opencode/plugin entry is added to the authored ones), homepage, license and packageManager, and
refreshes them on every run; ai-rulez verify --plugin checks them too. It overwrites nothing else: if the two
runtimes render a different name, version, description or any other shared key, generation fails with
"runtime npm manifests disagree".
package_name sets the npm name explicitly (a lowercase npm name, scoped or not) instead of
@<owner>/pi-<plugin-name>; it also wins in a combined Pi and OpenCode package.
skills_entry = "directory" writes the skills directory once: pi.skills becomes ["./skills"]
(Pi resolves the directory to the skills inside it) and files gets "skills/" instead of every
skill file, so adding a skill does not change package.json. The default, each, lists every skill.
files lists README.md and assets/ only when they exist in the bundle.
With runtimes = ["pi", "opencode"], both runtimes share one package.json: its name remains
@<owner>/opencode-<plugin-name>, OpenCode's entry points and dependencies are retained, and Pi's
resource metadata is added. Runtime order does not change the output.
The Pi plugin generator bundles skills and prompts only. It does not generate extensions, MCP integration, agents, hooks, or statuslines; configured unsupported components produce a warning. The Pi project preset is separate and does not create an installable package.
For npm distribution, the publisher uses <configured-scope>/<plugin-name>,
replacing the generated name. For example:
ai-rulez publish --to npm --npm-scope @acme --public --dry-run
# After reviewing the plan and completing publishing prerequisites:
ai-rulez publish --to npm --npm-scope @acme --public --execute --yes
pi install npm:@acme/my-tool
Publication to npm with pi-package enables discovery in the Pi package catalog.
Git installation needs no registry. No Pi marketplace JSON is generated; Pi-only multi-plugin
publishing is not supported by the current publisher, which requires a Claude marketplace index.
Symlinks in a skill directory¶
A skill directory is copied whole into the bundle, and a symlink inside it follows the same policy as plain
generate (see Symlinks in content): a link whose resolved target is
inside the project is followed and its content copied, with the target's mode; any other link is skipped with a
warning that names the link and the reason. A link is never dropped silently.
Shared resources across skills¶
Skills in one plugin often share the same reference docs and templates. [plugin] shared_resources
lists directories (relative to the plugin's content root — [plugin] content_root when set, otherwise
the config directory) that are copied into every bundled skill, for every runtime:
[plugin]
name = "acme.tools"
version = "1.0.0"
description = "Acme review tooling."
shared_resources = ["references", "templates"]
Each directory lands inside the skill at skills/<name>/references/…, so a skill still works once it
is installed on its own. The copies are recorded in .ai-rulez-generated.json like the skill's own
files, so verify --plugin reports a changed or missing copy as drift. A relative link from SKILL.md
that climbs out of the skill to the shared directory (../../references/x.md) is rewritten to the copy
(references/x.md); a link that resolves inside the skill already (references/x.md) is left alone.
A name clash between a shared file and a file the skill already has is an error, not a silent
overwrite.
OpenCode adapter¶
The adapter targets OpenCode v2, whose plugin API differs from v1: a plugin default-exports
a definition { id, setup(ctx) } (Plugin.define from @opencode/plugin is the identity
helper for it), and v1 plugins (an exported function that returns hooks) do not run. Put
OpenCode-specific tools and hooks in .ai-rulez/opencode/index.js. The generator copies that
source verbatim to .opencode/plugins/<plugin-name>.js, generates the package.json metadata
(main and exports point at that file; it depends on @opencode/plugin), and bundles the
plugin's skills, commands, and agents under .opencode/.
OpenCode only scans its own config directories, so it never discovers the .opencode/skills,
commands and agents of an npm-installed plugin. The generated entrypoint therefore calls
registerBundledContent(ctx) from the generated .opencode/ai-rulez-content.js, which
registers the bundled content through the skill, command, agent and MCP transforms. The helper
reads .opencode/ai-rulez-bundle.json, which holds the plugin's MCP servers and the agent
settings resolved at generation time.
- MCP servers become OpenCode
local(command array plusenvironment) orremote(url) servers. At runtime${PLUGIN_ROOT}expands to the installed plugin directory and${NAME}toprocess.env.NAME(empty when unset), so no install path or secret is written into the package. Remoteheadersare not bundled, as for the other runtimes. Any top-level source path an MCP server references as${PLUGIN_ROOT}/<path>(command, args, env or url), such asscripts/for${PLUGIN_ROOT}/scripts/run.sh, is added to the generatedpackage.jsonfileslist when it exists in the plugin source; a missing one is warned about and not listed. The other runtimes publish the repository tree or have no file list, so they have no equivalent gap. - Agents take their model through the same resolution as the
opencodepreset (opencode_model, thendefaults.model_by_preset.opencode, thenmodel). Aprovider/modelid passes through, and#variant(or the effort tier) becomes the model variant. A bare alias such assonnetis omitted with one warning so the agent inherits the session model.description,mode(defaultall),hidden,temperatureandtop_pare mapped too;toolsandpermissionare not carried by ai-rulez agents and so are not mapped. If you supply your own.ai-rulez/opencode/index.jsand the plugin bundles content, call it fromsetup:
import { registerBundledContent } from "../ai-rulez-content.js";
export default {
id: "my-plugin",
async setup(ctx) {
await registerBundledContent(ctx);
},
};
The generated entrypoint does not import @opencode/plugin at runtime: OpenCode installs no
dependencies for local .opencode/plugins/ files, so the import would fail to resolve. Keep
shared MCP settings in their normal .ai-rulez sources; the opencode preset writes them to
opencode.json.
When the source entrypoint is absent, generation emits a documented module that only registers the bundled content. It keeps the plugin loadable and tells you where to create the user-owned source; it does not guess tool schemas, subprocess arguments, or business logic.
Using the generated plugin¶
Checked against OpenCode 2.0.20 with a local server. These routes load the plugin; <name> is the
plugin name and <bundle> the directory generate --plugin wrote into.
- Copy it into a project. OpenCode loads every plugin in a project's
.opencode/plugins/. From<bundle>, copy into the consuming project:.opencode/plugins/<name>.js,.opencode/ai-rulez-content.js,.opencode/ai-rulez-bundle.json,.opencode/skills/,.opencode/commands/and.opencode/agents/(keeping those paths), plus every top-level directory an MCP server reaches through${PLUGIN_ROOT}(for examplescripts/) at the project root:${PLUGIN_ROOT}is the parent of the.opencodedirectory that holdsai-rulez-content.js. Restart OpenCode or reload its configuration. - Point
opencode.jsonat a directory. Add"plugins": ["<dir>"](an absolute path, a path starting./or../, or afile://URL) where<dir>is a copy of<bundle>that also has anindex.jsat its root:
OpenCode loads the directory's root index.js. It ignores main and exports of the generated
package.json, so a bare <bundle> is not loaded, and a path to a file is refused
(configured plugin path must be a directory).
3. Put it in a project as a directory plugin. Copy the bundle's files as in route 1, but
save the entrypoint as .opencode/plugins/<name>/index.js and change its helper import to
../../ai-rulez-content.js.
The generated package.json (main, exports, files) exists for publishing to npm. Installing
from npm was not exercised for this release, so follow the OpenCode plugin documentation for that route.
Two properties of the bundled MCP servers are worth knowing before you install a third-party plugin:
${NAME}in a server's command, arguments, environment or URL is expanded from the OpenCode process environment when the plugin loads. A plugin runs inside that process and can read all of its environment variables, so install only plugins you trust and do not treat${VAR}as a boundary.- Remote
headersare never bundled (generation warns), and a project server withenabled = falseis carried into the OpenCode bundle asdisabled: true. The other runtimes have no such flag in their bundles, so a disabled server is bundled enabled for them and generation warns about it.
v1 plugin warning and migration¶
OpenCode v2 does not run v1 plugins, and it only logs the refusal to its server log
(Plugin must export a default definition with an id and an effect or setup function). ai-rulez
therefore warns, once per file and without failing generation, when it finds a v1-shaped plugin:
- the authored
.ai-rulez/opencode/index.jswhile generating a plugin bundle; - any file in the project's
.opencode/plugin/or.opencode/plugins/directories, and any local path in theplugin/pluginsarray ofopencode.json(c)(npm package names are never fetched), while generating theopencodepreset.
A file counts as v1 when it default-exports a function, exports an async function or arrow
function, or imports the v1 @opencode-ai/plugin package, and has no v2 marker
(Plugin.define(...), or an id property, written with any value or shorthand, together with
setup/effect). Comments are removed before the check, but text inside string literals is kept,
so a glob such as "src/**/*.js" does not hide the code after it. To migrate, rename plugin
to plugins in the config, move the file to .opencode/plugins/, and replace the exported
function with a default export { id, async setup(ctx) { ... } } that registers hooks and
transforms on ctx. See the official
V1 plugin migration guide and the
plugins guide.
The opencode preset itself is separate from plugin authoring. It emits AGENTS.md,
.opencode/skills/, and .opencode/agents/, and — only when [[mcp_servers]] are configured — a
native opencode.json owning $schema and the mcp.<name> entries, merged so any other keys in a
hand-authored opencode.json are preserved.
Hermes adapter¶
Put Hermes-specific registrations in .ai-rulez/hermes/index.py. The generator
copies it to .hermes/plugins/<plugin-name>/hermes.py, writes a compatible
__init__.py plus plugin.yaml, and
bundles the plugin's skills, commands, and agents. The same adapter and content are
packaged under .hermes/package/src/<name>_hermes_plugin/; its generated
pyproject.toml publishes as <name>-hermes-plugin. If the source is absent, it emits
a documented no-op register(ctx) scaffold. Hermes project plugins are trusted code
and require HERMES_ENABLE_PROJECT_PLUGINS=true.
To reuse another canonical adapter instead of .ai-rulez/hermes/index.py, configure
a safe project-relative source:
Declare Python dependencies in the same authoring block:
Each entry must be a valid PEP 508 requirement that names a package from an index.
Package extras, version constraints, and environment markers are supported.
Direct references (name @ url) are rejected, because Hermes does not install them
for directory plugins. Invalid requirements are rejected during configuration validation,
before generation.
The generator preserves the authored strings in the wheel's project.dependencies
and in python_dependencies in both Hermes plugin manifests.
An omitted or empty list leaves the outputs dependency-free.
Installing the built wheel lets the Python installer resolve these dependencies. For a directory plugin, Hermes handles the manifest's dependency declaration through its normal dependency-consent flow. Neither generation nor importing the adapter installs packages. A Python dependency does not install browser binaries, credentials, or other external prerequisites; document those separately.
By default, plugin payload comes from the resolved ai-rulez content tree. To keep
distributable content separate from developer governance, set a safe project-relative
content root containing skills/, commands/, and agents/:
Generated-file provenance¶
Generated JavaScript, TypeScript, Python, and Markdown files include an ai-rulez warning plus
deterministic BLAKE3 Content-Hash value (and Source-Hash with [header] hashes = "full"). Markdown headers follow
YAML frontmatter so skill discovery remains valid.
Set [plugin] provenance_headers = false to write those files byte-identical to their sources
instead, the way [plugin] include files are already copied. The .ai-rulez-generated.json
sidecar still records every output's content hash, so verify --plugin, obsolete pruning and
publish --emit agent-plugins keep working. A header-less file ai-rulez did not record stays
treated as hand-written.
Run ai-rulez verify --plugin to verify every output recorded by the provenance
sidecar without modifying or regenerating the package.
The sidecar also records whether each output is executable ("mode": "0755", else "0644"), and verify --plugin reports a
file whose executable bit differs as drift of kind mode, so a bundled script that lost it (a zip round trip,
core.fileMode=false) is caught before it ships and fails with "permission denied". A sidecar written by an
earlier release records no modes; it still verifies, and the next generate --plugin adds them. A sidecar written on
Windows records no modes either, because its file systems have no executable bit; regenerate on Linux or macOS if
the bundle ships scripts.
Reusable hook catalogs can run plugin checks across mixed repositories with
ai-rulez generate --recursive --plugin --if-configured and
ai-rulez verify --recursive --plugin --if-configured. Both commands exit successfully
without doing work when the configuration has neither a producer [plugin]
block nor a [marketplace] block with members.
Recursive commands treat a marketplace root as one producer. The root processes
its members and aggregate indexes; member configs are not processed again in
parallel. Standalone [plugin] producers are still processed individually.
Consumer-only [[plugins]] and [[marketplaces]] declarations are skipped.
--if-configured does not suppress generation or verification errors.
See Poly Hooks to add non-mutating plugin verification to a repository's pre-commit stage.
Strict JSON manifests and binary assets cannot safely contain comments. Each plugin
therefore includes .ai-rulez-generated.json, which records the same source hash and
a sorted content-hash entry for every generated output. Monorepo roots include a
separate sidecar covering the aggregate marketplace files. Validators can recompute
these hashes without adding unsupported fields to runtime manifests.
MCP launch variable¶
Write MCP launch commands once with the canonical ${PLUGIN_ROOT}; each runtime
rewrites it to the form it expects — ${CLAUDE_PLUGIN_ROOT} (Claude),
${extensionPath} (Gemini), plugin-relative ./ (Cursor, Codex, Kimi, Agent Plugins), or
the canonical variable unchanged (OpenCode, Factory).
Hooks¶
Declare lifecycle hooks once; they render into each runtime's hook format with the
correct root variable (${CURSOR_PLUGIN_ROOT:-.} for Cursor hooks, and so on).
Each hook action requires exactly one of command or script:
command: An executable that already exists in the consumer's environment. Passed through verbatim.script: A project-relative file ai-rulez bundles into the plugin'shooks/directory. The bundled script is addressable as${PLUGIN_ROOT}/hooks/<basename>and survives a fresh clone before generation has run. Withoutargsthe command is written double-quoted ("${CLAUDE_PLUGIN_ROOT}/hooks/<basename>") so an install path with a space does not split it; withargsthe handler is exec form and the path is written bare. Usescriptfor self-contained bootstrap hooks.
Additional fields:
args: Array of arguments passed to the command/script. When set, the runtime spawns the executable directly (no shell).timeout: Handler timeout in seconds; zero uses the runtime default.async: Whether the handler runs asynchronously (default: false).if: Restricts the handler to matching tool calls, in permission-rule syntax — one rule such as"Bash(git *)"or"Edit(*.ts)", with no boolean operators and no expression language. Claude Code evaluates it only onPreToolUse,PostToolUse,PostToolUseFailure,PermissionRequestandPermissionDenied; on any other event a handler carryingifnever runs at all, so it cannot be used to make a bootstrap hook conditional.validatewarns whenifappears on an event that ignores it.status_message: Message shown to the user while the handler runs (useful for blocking bootstrap scripts). Rendered asstatusMessagein the runtime's hook file.
Example with bundled script. A bootstrap hook decides for itself whether its work is already done — there is no declarative guard for SessionStart:
[[plugin.hooks]]
event = "SessionStart"
matcher = "startup"
[[plugin.hooks.hooks]]
script = "scripts/bootstrap.sh"
args = ["--check"]
timeout = 30
status_message = "Bootstrapping plugin on first use..."
async = false
Example with external command:
[[plugin.hooks]]
event = "PreToolUse"
[[plugin.hooks.hooks]]
command = "my-tool validate"
async = true
Event names must match a Claude Code lifecycle event (SessionStart, Setup, PreToolUse, PostToolUse, etc.). An unknown event produces a warning rather than an error, so a config written for a newer Claude Code keeps working on an older ai-rulez. Events in HookEventsWithoutMatcher (such as UserPromptSubmit, PostToolBatch, Stop) have no matchable subject, so a declared matcher is silently ignored at runtime.
Restricting runtimes¶
The pi, copilot, and agent-plugins runtimes are opt-in. The default set remains
Claude, Cursor, Codex, Gemini, Kimi, OpenCode, Factory, and Hermes. Enable Agent Plugins explicitly:
It emits a root plugin.json, a root skills/ directory, and (when the plugin
declares MCP servers) a root mcp.json, matching the Agent Plugins standard
(1.0.0, or 1.1.0 with spec = "1.1.0"). The package is built and validated by the
spec-validated library described in Agent Plugins. The plugin name must satisfy the standard's naming rules (1–64
lowercase alphanumerics/-/., alphanumeric ends, no -- or ..). Commands,
agents, hooks, and marketplaces are outside Agent Plugins v1 and are not emitted
for this runtime.
Single plugin vs. monorepo¶
A repo with one [plugin] block publishes a single-plugin marketplace whose sole
entry has source: "./".
For a monorepo hosting several plugins, the root config declares only a
[marketplace] with members; each member is its own ai-rulez project:
# root .ai-rulez/config.toml
[marketplace]
name = "acme"
description = "Curated Acme plugins."
members = ["plugins/alpha", "plugins/beta"]
[marketplace.owner]
name = "Acme Inc"
email = "dev@acme.example"
Each member (plugins/alpha/.ai-rulez/config.toml, …) defines its own [plugin]
block. generate --plugin renders every member's bundle under its directory and
emits a single root marketplace index — .claude-plugin/marketplace.json (and the Codex
index at .agents/plugins/marketplace.json) — listing each with source: "./plugins/<name>".
Members do not emit their own marketplace index.
A single-plugin repo's bundle root is the repository root, so every path a host
loads by default from a plugin root ships to each user who installs it. validate
--strict and verify --plugin warn with AR9T0 root-component-shipped about
each such path that exists at the root and is not part of the bundle —
.mcp.json, hooks/, agents/, commands/, settings.json, bin/, and the
other Claude Code and Codex plugin paths — naming the host that loads it. A
maintainer's own development .mcp.json or agents/ directory would otherwise be
installed for everyone. Set [plugin] content_root to keep the authored payload
separate, or move the plugin into a monorepo member (plugins/<name>/) so its
root is not the repository root.
The check looks only under the plugin's own root, never in a parent directory. A
root plugin.json is on the list (Codex prefers it as the manifest); a root
CLAUDE.md was considered and is not, because Claude Code does not load a
CLAUDE.md from a plugin root as context.
The agent-plugins and copilot runtimes write the shared root plugin.json (and the root mcp.json when the
plugin declares MCP servers), which Codex also reads. AR9T0 therefore treats those two runtimes like the Codex
root layout: with either of them in runtimes, a root plugin.json or mcp.json that ai-rulez writes is
recognized as part of the bundle and not reported, even when Codex is on the legacy layout (before, only the Codex
root layout counted, so these files were reported as shipped by accident).
Several plugins from one root¶
One root can define any number of plugins, each selecting its own skills, commands, agents,
MCP servers and hooks from the root's sources, and generate --plugin and verify --plugin
process them all in one run. The nested-root layout above is not needed for this; use it only
when each plugin should be its own ai-rulez project.
# .ai-rulez/config.toml
[[plugins_authoring]]
name = "macos-notifications"
version = "1.2.0"
description = "Desktop notifications."
runtimes = ["claude"]
skills = ["notify-*"] # root content, by name or glob
commands = ["notify"]
mcp_servers = ["moon-ear"] # names of root [[mcp_servers]]; none by default
[plugins_authoring.author]
name = "Tooling"
[[plugins_authoring]]
name = "moon-ear"
runtimes = ["claude", "codex"]
skills = ["moon-*"]
agents = ["helper"]
domains = ["audio"] # a domain's skills, commands and agents
An entry bundles only what it selects: an empty selector means nothing, so no skill is
copied into every plugin by accident. Fields: name, description, version, category,
keywords, default_enabled, runtimes, skills, commands, agents, domains,
mcp_servers, hooks (the [[hooks]] shape), author, license, homepage,
repository and relevance. Every plugin is written to plugins/<name>/ (under the
marketplace's output_dir) and listed in a marketplace index, named after the project when
no marketplace is declared; see Several marketplaces.
[[marketplace.plugins]] entries are the same definition, nested in the [marketplace]
block, and accept the same fields. Use one spelling per plugin. [plugin] (one bundle of the
whole root) cannot be combined with [[plugins_authoring]]: move its metadata into an entry
that selects the root content (skills = ["*"]).
To give each definition its own file, and its own CODEOWNERS entry, put it in
.ai-rulez/plugins/<name>.toml with the entry's keys at the top level. The file loads by
default; name may be omitted, and when present it must equal the file name:
A key the entry does not know, or a plugins/ or marketplaces/ path that cannot be listed, fails the load
with an error naming the file. A plugin defined both inline and in a file is an error. A layer (config.<name>.toml, see
Layered configuration) can add an entry, override
one by name, or drop it with remove = true without restating the others; the same holds
for [[marketplace.plugins]].
Selecting one plugin: generate --plugin --plugin-name moon-ear writes only that plugin's
bundle and leaves the marketplace indexes alone (a partial index would not match the full
one); verify --plugin --plugin-name moon-ear checks only it. verify --plugin --format json
adds a plugins array with each plugin's status, and each entry of drift names its
plugin and marketplace.
Not available per definition yet: the runtime-specific tables of [plugin] ([plugin.codex],
[plugin.pi], [plugin.interface], ...), include, shared_resources and content_root.
A plugin that needs them stays a [plugin] in its own nested root.
Several marketplaces from one root¶
Each [[marketplaces_authoring]] entry is one marketplace index that lists plugin definitions
by name, so one plugin can be offered by several marketplaces:
[[marketplaces_authoring]]
name = "acme-public"
description = "Published plugins"
plugins = ["macos-notifications", "moon-ear"] # names of plugin definitions; empty lists nothing
output_dir = "marketplaces/public"
[[marketplaces_authoring]]
name = "acme-internal"
plugins = ["moon-ear"]
output_dir = "marketplaces/internal"
cursor_index = true
generate --plugin writes, per marketplace, <output_dir>/.claude-plugin/marketplace.json (plus
the Codex index when a listed plugin targets Codex, and the Cursor index with cursor_index),
its own provenance sidecar, and <output_dir>/plugins/<name>/ for each plugin it lists, so
each directory can be published on its own. A plugin listed by two marketplaces is written
into both. output_dir must be unique and no marketplace's directory may sit inside another's (an empty
output_dir is the project root, which holds every other directory); two marketplaces, or two plugins, writing one path
is an error. [marketplace] and [[marketplaces_authoring]] cannot be combined. With only
[[plugins_authoring]] and no marketplace, one index named after the project lists them all.
generate --plugin --marketplace-name acme-public renders one marketplace with its plugins,
and verify --plugin --marketplace-name acme-public checks it. verify --plugin --format json
adds plugins and marketplaces arrays with a status for each, and the drift entries name
both, so a failing run says which marketplace drifted.
One file per marketplace works like it does for plugins: .ai-rulez/marketplaces/<name>.toml
holds the entry's keys at the top level, and the file name must equal name. A layer adds,
overrides or removes (remove = true) a marketplace by name.
publish reads the Claude index closest to the project root, so with several marketplaces it
publishes only that one (see Not yet implemented).
Domain content and per-domain plugins¶
A project that keeps its content in .ai-rulez/domains/<name>/ can ship it as plugins
without hand-made member projects. Everything here is opt-in; a config without these keys
generates exactly what it did before.
Bundle domain content into the [plugin] bundle¶
A [plugin] bundle holds the root skills, commands and agents only. List domains in
include_domains (names or globs) to bundle theirs too:
On a name collision the root item wins, then the domain earlier in name order. Builtin domains are never bundled.
One plugin per domain¶
[marketplace]
name = "acme"
description = "Acme skill packs"
output_dir = "tools/acme-marketplace" # default: the project root
[marketplace.from_domains]
name_prefix = "acme-" # plugin "acme-backend" from domains/backend
exclude = ["scratch"] # include defaults to every domain
version = "1.0.0" # default: the [plugin] version, else 1.0.0
default_enabled = false # marketplace entry defaultEnabled
runtimes = ["claude"] # default: the [plugin] runtimes, else all
generate --plugin writes <output_dir>/plugins/<name>/ for each domain (skills, commands
and agents, plus the runtime manifests and a provenance sidecar) and one
<output_dir>/.claude-plugin/marketplace.json whose entries use source: "./plugins/<name>",
relative to the marketplace root as Claude Code requires. The Codex index
(.agents/plugins/marketplace.json) is written only when some plugin targets codex.
Domain names are lower-cased to form the plugin name; a name that still is not valid is an
error (use exclude). A domain without bundleable content is skipped with a warning.
verify --plugin checks the marketplace root and every plugin directory.
When a plugin's domain disappears (or its declaration is removed), the next
generate --plugin deletes the files it generated for that plugin and the directories they
leave empty. A directory counts as generated only when it carries ai-rulez's provenance
sidecar, so a hand-made directory under plugins/ is never touched, and a file inside a stale
directory that ai-rulez did not generate is kept (with a warning). generate --plugin --dry-run
lists each removal as delete-stale:, and verify --plugin fails while a stale generated
directory exists. Removing the whole [marketplace] domain-plugin configuration cannot be
detected, because the output root is no longer known; delete that tree by hand.
Obsolete files inside a bundle¶
When a skill, command or agent is removed from the sources, the next generate --plugin also
deletes the files it generated in every bundle that stays (for every runtime), then the
directories they leave empty. The previous run's .ai-rulez-generated.json is the record: only a
file it lists, that the new plan no longer contains, and whose content still matches the recorded
hash is deleted. An obsolete file you edited since, or one reached through a symlink that leaves
the project, is kept with a warning, and a file the sidecar never listed is never touched.
generate --plugin --dry-run prints delete-stale: <path> for each deletion and
keep-obsolete: <path> for each kept file, and verify --plugin fails with
obsolete generated plugin file while such a file exists. A kept file keeps its ownership
record in the sidecar (its obsolete map), so later generate and verify runs still recognise
and report it until you reconcile it into the sources or delete it by hand.
Unrecorded files inside a generated skill directory¶
A file inside a generated skill directory that the plan does not write (a stray notes.md, a
leftover from an editor) ships to everyone who installs the bundle. The next generate --plugin
removes it, the same way it removes obsolete recorded files; --dry-run prints
delete-stale: <path>. Only directories holding a generated SKILL.md are searched: a
hand-made skill directory, and the config directory and [plugin] content_root (your sources),
are never touched. verify --plugin reports such a file as drift of kind extra, and a recorded
output that is a symlink (even one whose target has the recorded content) as kind symlink.
generate --plugin (and --dry-run) refuses to write through a symlink, whether the output file
or any parent directory of it is the link, and fails with an error naming the link; the target is
never changed. verify --plugin reports a symlinked directory on an output path as drift of kind
symlink too. Replace the link with a regular file or directory.
When the bundle's skills directory (for example skills/ or .pi/skills) holds nothing but the
bundle, declare it owned:
The skills directory is then the parent of each generated skill directory, and everything in it the
bundle does not generate is unrecorded: verify --plugin reports a skill directory left over from a
rename, a hand-added one, or a stray file directly under it as drift of kind extra, and
generate --plugin removes it. The default is false: a skill directory or file the bundle does
not generate is never touched. A Pi skills_dir inside the config directory or content_root is
rejected with skills_dir_owned, since that tree holds your sources.
Whenever generate --plugin deletes an unrecorded file it also removes the directories that leaves
empty, deepest first, and stops at the first directory that still holds anything.
Linked git worktrees¶
With [claude.settings] manage = true the generated extraKnownMarketplaces entry defaults to a
relative directory source (./<output_dir>). Claude Code's documentation does not describe how
such a source resolves in a linked worktree (git worktree add); the behaviour reported for it,
and assumed by the generator's own source comment, is that it resolves against the main checkout.
Vendor documentation that does cover worktrees says project-local settings follow the main
checkout, so treat edits to plugin content in a linked worktree as not visible to Claude Code until
they reach the main checkout, unless you point the marketplace somewhere absolute. ai-rulez
validate prints one warning when the directory source is relative and the project is in a linked
worktree (detected with git rev-parse --git-dir versus --git-common-dir). To test plugin changes
from a worktree, set [claude.settings.marketplace_source] to an absolute directory path or a
github/git source. The warning is advisory: nothing fails. This behaviour was not verified
against a running Claude Code; it is phrased as a caveat for that reason.
Placement report¶
ai-rulez list --placement (--profile, --format json) prints every skill and command of the profile
with its destination: core (written to the assistants' own directories) or plugin (kept out of
them by [placement]), the domain it came from, and the plugins that bundle it. A plugin-only item
is flagged when no plugin bundles it, or when none of its plugins is enabled through
[claude.settings] enable_plugins and no [marketplace.catalog_skill] lists it, because then
nothing tells anyone the plugin exists.
Version drift¶
Claude Code's documentation says a client that installed a plugin from a git-hosted marketplace
keeps its cached copy until the plugin's version string changes (a plugin that declares no version
is tracked by commit, and a plugin loaded in place from a local marketplace is not controlled by
version). ai-rulez validate therefore reports AR961 plugin-version-drift (warning)
when a generated plugin's content differs from its committed provenance sidecar at HEAD while the
version in its manifest is unchanged. Bump version, or omit it to track commits.
Version source¶
version_from takes the version from a project-relative manifest instead of a literal version,
so a release bump lives in one place:
[plugin]
name = "my-tool"
description = "What my tool does."
version_from = "package.json" # version, pyproject.toml -> project.version, Cargo.toml -> package.version
The resolved value replaces version in every generated manifest, in ai-rulez.lock, and in the
comparison verify --plugin and PluginVersionDrift make, so a bump in the source manifest shows
as drift until the bundle is regenerated. version and version_from are mutually exclusive;
ai-rulez validate reports a missing file, a missing version key, or both set at once.
The [plugin] block is optional in this mode. When present it supplies defaults (author,
license, homepage, repository, version, category, keywords, runtimes) and is not bundled on
its own; declare a plugin for the root content with [[marketplace.plugins]] instead.
owner falls back to the [plugin] author. members and domain plugins may be combined,
but then output_dir must be unset.
Hand-declared plugins mix domains and root content, and replace a derived plugin of the same name:
[[marketplace.plugins]]
name = "acme-essentials"
skills = ["development-standards", "personal-*"] # root content, by name or glob
domains = [] # domains, by name or glob
default_enabled = true
[marketplace.plugins.relevance] # when Claude Code suggests the plugin
topic = "Terraform"
[marketplace.plugins.relevance.signals]
cli = ["terraform"]
files_read = ["**/*.tf"]
Entries carry version, category, keywords, defaultEnabled and relevance
(cwd, cli, hosts, files_read, manifest_deps signals, at least one). These are the
fields Claude Code documents for a marketplace entry; claude plugin validate accepts the output.
Monorepo members entries stay minimal.
Keep a skill out of .claude/skills¶
By default every root and domain skill and command is written to .claude/skills. The
[placement] block moves some to plugin-only:
[placement]
default = "core" # core (today) or plugin
plugin = ["domains/*"] # names or globs; "domains/<domain>/<name>" also matches
core = ["domains/backend/python-conventions", "git-*"]
honor_targets = true # skills: apply frontmatter `targets`, as commands always do
An item's frontmatter placement: core|plugin wins, then core, then plugin, then default.
The key is removed from the generated frontmatter. Placement changes only the Claude preset's
.claude/skills output; plugin bundles always contain the item, so a plugin-only skill stays
reachable through the plugin that bundles it. Agents are not affected, and other presets
keep writing every skill. With honor_targets a skill whose targets do not name claude
is not written to .claude/skills.
Register the marketplace in .claude/settings.json¶
[claude.settings]
manage = true # default false
register_marketplace = true # default when managing: extraKnownMarketplaces.<marketplace.name>
enable_plugins = ["acme-essentials"] # enabledPlugins."acme-essentials@acme": true
disable_plugins = [] # ... : false
# marketplace_source = { source = "github", repo = "acme/skills" } # default: a directory source at output_dir
# auto_update = true
Only those entries are owned, with the same machinery as mcpServers: every other key, marketplace
and plugin entry in the file survives byte-for-byte, an entry dropped from the config is removed
on the next generate, and clean removes what was written. A hand-written entry with the same
key is replaced by the configured value. The default directory source is the marketplace
root relative to the repository ("./tools/acme-marketplace"); Claude Code resolves it against the
main checkout. extraKnownMarketplaces takes effect only after the user trusts the folder.
Relative-path plugins listed in enabledPlugins load from the marketplace without an install
step; the key cannot force an install of a plugin from an external source.
Catalog skill¶
Generates a skill listing each plugin, its default state, the skills it bundles and how to
install or enable it (claude plugin marketplace add, claude plugin install,
enabledPlugins). It is written like a root skill to the preset skill outputs and is never bundled into a plugin. A
root skill with the same name wins.
Not yet implemented¶
- Publishing each marketplace separately:
publishwrites only the Claude index closest to the project root.
Field reference¶
[plugin]: name (lowercase letters, digits, ., _ and -, starting with a letter or digit, no ..; it
becomes a directory and file name in every runtime), version (required unless version_from is set);
version_from (project-relative manifest whose version replaces version: package.json,
pyproject.toml or Cargo.toml), display_name, description, homepage,
repository, license, category, brand_color, icon, logo, keywords,
tags, runtimes, skills_dir_owned (declare the skills directory fully owned by the bundle), spec (Agent Plugins version, 1.0.0 or 1.1.0), include_domains, include_evals (bundle eval cases, see Evals), content_root (project-relative directory of plugin-only
skills/, commands/, and agents/), provenance_headers (write the generated-file header into
bundled Markdown/JS/TS/PY outputs; default true), shared_resources (directories relative to the
content root copied into every bundled skill, see Shared resources across skills).
Sub-tables: [plugin.author] (name/email/url),
[[plugin.mcp]], [[plugin.hooks]] (+ [[plugin.hooks.hooks]]),
[plugin.statusline] (script/command, Claude-only), [plugin.interface]
(Codex/Kimi UI block), [plugin.gemini] (context_file_name), [plugin.kimi]
(skill_instructions/session_start_skill), [plugin.hermes]
(source/requires_python), [plugin.pi] (skills_dir, package_json, package_name, skills_entry; see
Pi packages).
[[plugins_authoring]]: one plugin definition (see Several plugins from one root);
[[marketplaces_authoring]]: name, description, owner, plugins (names), output_dir, cursor_index.
[marketplace]: name (required); description, members, output_dir, [marketplace.owner],
[marketplace.from_domains], [[marketplace.plugins]] and [marketplace.catalog_skill] (see
Domain content and per-domain plugins). Related top-level
tables: [placement] and [claude.settings].