From how-plugins-work
Use when diagnosing Unknown command errors, slash-command autocomplete misses, cross-agent skill or plugin marketplace sync, or any confusion about how plugin and skill names resolve in Claude Code, Codex, and future coding agents. Living document explaining plugin naming, skill resolution, multi-agent adapter boundaries, marketplace aliases, runtime caches, and the plugin:skill invocation pattern, based on empirical testing.
How this skill is triggered — by the user, by Claude, or both
Slash command
/how-plugins-work:how-plugins-workThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
<post-update-broadcast>
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
node "${CLAUDE_PLUGIN_ROOT}/bin/check-broadcast"
fi
If the command produces output, the how-plugins-work plugin was updated since the last time you saw the broadcast on this machine. Show the output verbatim in a markdown block, prefixed with one short sentence ("how-plugins-work was updated; here is what changed."). Then continue with the rest of this skill.
If the command produces no output, say nothing about updates and proceed.
The helper writes the sentinel only when stdout was non-empty, so a silent run does not mark the version as seen. Codex currently has no equivalent post-update broadcast path in this plugin; skip this block silently there.
A living document on how plugin and skill names flow through Claude Code, Codex, and future coding-agent systems. Based on empirical testing in Claude Code 2.1.x plus local Codex CLI verification.
A skill in a marketplace plugin has three independent names:
plugin.json > name): determines the namespace.skills/): determines the identity.name or the @marketplace identifier): determines the source.These three names are completely independent of each other. Claude Code and Codex combine them in different ways in different places.
| Context | What appears | Example |
|---|---|---|
claude plugin list | <plugin>@<marketplace> | how-plugins-work@example-tools |
claude plugin install | <plugin>@<marketplace> | claude plugin install how-plugins-work@example-tools |
settings.json enabledPlugins | "<plugin>@<marketplace>": true | "how-plugins-work@example-tools": true |
installed_plugins.json key | "<plugin>@<marketplace>" | "how-plugins-work@example-tools": [...] |
| Plugin cache path | cache/<marketplace>/<plugin>/<version>/skills/<skill>/ | cache/example-tools/how-plugins-work/<version>/skills/how-plugins-work/ |
skill-budget SOURCE column | <plugin> | how-plugins-work |
skill-budget NAME column | <skill> | how-plugins-work |
| System-reminder skill list | <plugin>:<skill> | how-plugins-work:how-plugins-work |
| TUI autocomplete | /<plugin>:<skill> | /how-plugins-work:how-plugins-work |
| Skill tool invocation | Skill("<plugin>:<skill>") or bare Skill("<skill>") | Skill("how-plugins-work") |
| Slash command (bare) | /<skill> (if unique) | /how-plugins-work |
| Agent tool invocation | subagent_type: "<plugin>:<name>" | subagent_type: "gurus:sonnet-max" |
| Plugin-shipped agent source | packages/<plugin>/agents/<name>.md | packages/gurus/agents/sonnet-max.md |
Plugin name appears in five contexts: plugin list, settings.json, installed_plugins.json, skill-budget SOURCE, and as namespace prefix in system-reminders and autocomplete.
Skill name appears in three contexts: skill-budget NAME, as suffix after the colon in system-reminders, and as bare slash command.
Marketplace name appears in two contexts: after the @ sign in plugin list and settings.json. Never in the skill invocation itself.
The <plugin>:<skill> combination is how the model sees the skill in system-reminders and how it calls the Skill tool. When plugin and skill share the same name, you get how-plugins-work:how-plugins-work. The bare shortcut /how-plugins-work works when there are no name conflicts.
Codex uses the generated skills/<skill>/SKILL.md runtime body for each
Codex-compatible skill, but it reads Codex metadata instead of Claude metadata.
Local CLI help verified on 2026-06-09:
| Context | What appears | Example |
|---|---|---|
| Marketplace add | local path or Git source | codex plugin marketplace add ./ |
| Plugin install | <plugin>@<marketplace> | codex plugin add how-plugins-work@example-tools |
| Marketplace index | .agents/plugins/marketplace.json | name: example-tools |
| Plugin source path | plugins[].source.path | ./packages/how-plugins-work |
| Plugin manifest | .codex-plugin/plugin.json | generated from .claude-plugin/plugin.json |
| Skill body | skills/<skill>/SKILL.md | generated Codex-compatible runtime body |
| Active install facts | codex plugin list --json | pluginId, version, enabled, source.path |
Important differences from Claude:
.agents/plugins/, not
.claude-plugin/..codex-plugin/plugin.json.description with
YAML-special punctuation must use a folded scalar.SKILL.md, then run bin/plugin-adapters build ..user-invocable stay in the shared
source for Claude, but Codex receives a generated sanitized copy when needed.codex plugin list --json is the authoritative local view for what Codex
has installed and enabled right now. The working tree may already contain a
newer generated manifest while the installed version still reports the old
value; refresh the install before treating the new skill text as live.The unique key is <plugin.json name>@<marketplace>. The plugin name comes from plugin.json, not from the directory name. If two packages have the same name in their plugin.json, they claim the same key and overwrite each other on install.
Two different plugins in the same marketplace may contain a skill with the same name. They are namespaced: pluginA:review vs pluginB:review. But bare /review then becomes ambiguous.
superpowers@claude-plugins-official and superpowers@example-tools can coexist (different keys). But Skill("superpowers:brainstorming") contains no marketplace, so if both have a brainstorming skill the resolution is unpredictable. Avoid plugin names that already exist in other installed marketplaces.
Optional. When present it must match the directory name. If they do not match, documented bugs exist: the model cannot find the skill on invocation (anthropics/claude-code#22063). The directory name is always the source of truth.
Always set explicitly. Although the binary code (below) suggests the default is true, in practice skills without explicit user-invocable: true do not always appear in autocomplete. Always set the field explicitly: true for slash commands, false for skills that are model-triggered only.
Binary code from Claude Code 2.1.92 (the default true is not reliable for plugins):
T = H["user-invocable"] === void 0 ? !0 : G0H(H["user-invocable"])
When true: the model cannot auto-activate the skill based on context. The skill is then only reachable via explicit slash command. Useful for skills that should never be auto-triggered (e.g. /clipboard, /saysay). Reduces the active context budget in skill-budget.
The flag replaces "Use ONLY when..." prose in the description. A description like Use ONLY when the operator types /foo. Do not auto-invoke. <what it does> is two layers of the same intent: the prose tries to talk Claude out of auto-triggering, while the harness already enforces it via disable-model-invocation: true. Pick the flag, drop the prose, and let the description describe what the skill does. Every "Use ONLY when..." token costs every session that loads the skill list, forever; the flag costs nothing.
What the model sees in the skill list and uses to decide auto-invocation. Two anti-patterns to avoid:
disable-model-invocation: true, the slash command is the only way in, so the prefix is redundant. When the skill is model-triggerable, the trigger lives in the auto-invocation criteria the rest of the description describes; restating the slash form is noise.Lean reference: dont-do-that:just-a-question describes what the skill enforces in two short sentences and parks the rest in the body.
Claude Code's frontmatter parser is lenient: a plain scalar description: that contains : (colon-space), starts with a quote, or holds other YAML-special punctuation still loads, because the parser just grabs the rest of the line. Strict YAML parsers (Ruby's psych, and the Codex .codex-plugin toolchain that reads the same SKILL.md) reject it with mapping values are not allowed in this context. The two readers then disagree on a file that looked fine in Claude Code.
Keep every frontmatter value valid under a strict parser so the file means the same thing in every consumer. When a description contains : , a leading quote, or a leading # / | / > / @, use a folded block scalar instead of an inline plain scalar:
description: >-
Speech mode: Claude speaks every response aloud via macOS say. /saysay off to exit.
>- folds the indented lines into one space-joined string and strips the trailing newline, so the parsed value is byte-identical to the intended one-liner, colons and quotes preserved. A plain one-line scalar stays fine when the text carries no YAML-special punctuation; reach for >- only when it does. Verify with a strict parser before shipping:
ruby -ryaml -e 'YAML.safe_load(File.read(ARGV[0]).split(/^---\s*$/)[1])' SKILL.md
Agent Skills are the portable layer. Keep skills/<skill>/SKILL.md as the shared source whenever the workflow can mean the same thing across Claude Code, Codex, and other skills-aware clients. Do not copy the skill body into an agent-specific tree just because a second client needs different installation metadata.
When the workflow itself differs per agent, use suffixed sources instead of runtime branching inside one skill body:
| Source file | Meaning |
|---|---|
SKILL.md | Agent-agnostic source. Use only when the same instructions are valid for every target agent. |
SKILL.claude.md | Claude-specific source. |
SKILL.codex.md | Codex-specific source. |
Both Claude and Codex still require a runtime file named SKILL.md; the suffix
is a source convention, not a harness feature. build writes each agent's
available suffixed source as that agent's runtime SKILL.md, and check fails
when a suffixed source has not been materialized or when a stale target remains.
A suffixed source may exist as a pair when both agents support the workflow with
different instructions, or singly when only one agent has the required runtime
capability. A suffixless SKILL.md is never a fallback inside an
agent-specific skill; it means the skill is truly multi-agent compatible.
Plugins may also be single-agent at runtime. When a plugin has no Codex skill source and no explicit Codex runtime payload, the generated Codex marketplace omits the plugin instead of serving an empty or misleading command surface.
Plugin and marketplace manifests are adapter layers. Claude Code and Codex both load skills/, but they do not use the same manifest and marketplace files:
| Layer | Shared source | Claude Code adapter | Codex adapter |
|---|---|---|---|
| Skill body | skills/<skill>/SKILL.md or suffixed source | generated/loaded as SKILL.md | generated/loaded as SKILL.md |
| Plugin manifest | package identity and component paths | .claude-plugin/plugin.json | .codex-plugin/plugin.json |
| Marketplace index | curated plugin list | .claude-plugin/marketplace.json | .agents/plugins/marketplace.json |
| Runtime cache | none; cache is output | ~/.claude/plugins/cache/... | ~/.codex/plugins/cache/... |
Plugin-level runtime payloads follow the same rule. A suffixless/shared source
is valid only when both agents can consume it with the same semantics. When a
payload is runtime-specific, keep the source explicit: agents/ is the Claude
subagent payload in this marketplace, while a future Codex-specific subagent
payload should live in agents.codex/ and be materialized into generated
Codex runtime agents/.
Every duplicated-looking file should have one explicit role:
If a file is a generated target, the repo should expose the usual three verbs:
<sync-tool> build
<sync-tool> check
<sync-tool> diff
build rewrites adapter files from the source, check exits non-zero on drift, and diff shows the exact generated change. This is the minimum contract that makes metadata duplication acceptable: a reviewer can tell whether the duplicate is another truth or a projection.
When Codex needs sanitized skills or agent-specific skill sources, the
marketplace should point at .agents/plugins/generated/<plugin>. That
directory becomes Codex's plugin root. Any helper that a generated skill
invokes must therefore exist under the generated root too; the source package
under packages/<plugin>/ is no longer in the runtime path.
Minimum copy rules:
bin/ into the generated target when a skill calls plugin helper
commands.CHANGELOG.md for bin/check-broadcast; without it the helper is present
but silent in Codex.agents/ into generated Codex targets by default.
In this marketplace, agents/ is a Claude runtime payload because Claude
consumes the source package directly. A generated Codex package should carry
agents/ only from an explicit Codex-specific source such as
agents.codex/, materialized as runtime agents/ by the adapter builder.skills/<skill>/ alongside
the materialized SKILL.md.hooks/lib/ when Codex-facing workflows install git-native hooks or
otherwise call shared hook libraries. Copy the full hook payload only through
an explicit Codex hook source: hooks/hooks.codex.json is materialized as the
generated package's runtime hooks/hooks.json, alongside the dispatcher,
guards, and libraries. Do not copy Claude's hooks/hooks.json merely because
the source package has it; a Claude hook manifest in a Codex package is inert
at best and misleading at worst.check compare the full generated file set, not only SKILL.md and
manifest files. A stale generated helper that no longer exists in the source
is drift.If a global gitignore ignores dot-directories, the generated marketplace can look correct on disk while new files stay invisible to Git. Repositories that track Codex adapter targets should explicitly unignore them:
!.agents/
!.agents/plugins/
!.agents/plugins/**
Keep shared skill text in agent-neutral language when possible: "the active agent", "the shell-command tool", "the plugin root", "the installed cache". Use Claude-specific names only when the behavior is genuinely Claude-specific, such as /reload-plugins, Skill("<plugin>:<skill>"), CLAUDE_PLUGIN_ROOT, or Claude hook events.
When a shared skill has a Claude-only block and still wants to be packaged for other clients, prefer one of these shapes:
SKILL.claude.md, SKILL.codex.md) when the workflow
semantics differ per agent or only one agent has the required runtime
capability.Do not remove Claude frontmatter or hook guidance merely to satisfy another client. If another client needs stricter metadata, generate the stricter view or manifest beside the Claude source.
Shared skill text should describe capability contracts before concrete runtime routes. If the workflow needs something like "keep this loop moving," "get an independent review," "drive a browser," "send a notification," or "persist this state," write the invariant and the expected outcome, then let the active host satisfy it with whatever tooling exists there. Claude might use a plugin skill, an Agent tool, or a cron helper; Codex might use a goal loop, browser tool, or different delegation surface; a future agent may have another mechanism.
Hard-code another skill, plugin, MCP server, or helper only when that dependency is itself the public API the workflow is about. Otherwise, name it as a runtime-specific example under a generic host-owned contract. This keeps cross-agent skills portable: they share the idea and the evidence requirements, not one agent's exact implementation path.
Public plugin documentation may define the generic sync contract. Private or project-specific generators may consume that contract and even assume this skill is installed. The reverse dependency is not allowed: a public plugin should not name a private synchronizer, private path, personal doctrine repo, or local machine convention.
A skill cannot change the session model. The model the user chose at session start (or via /model) runs through all turns, including turns fired by cron. A skill that outputs /model <name> as text behaves like a fake user input, is unreliable, and persists after the skill run, corrupting the user session.
Subagents can. The Agent/Task tool accepts a model parameter (haiku, sonnet, opus). A subagent runs in a separate conversation context with its own model, returns a result, and does not touch the session model. This is the correct mechanism for:
Rule of thumb: session model = head, subagent = hand. Give subagents work that requires no interpretation (running commands, reading files and returning them raw, scraping gh). Keep interpretation and decisions on the session model.
Effort cannot be set per invocation. The Agent tool only accepts model inline, not effort. The only route to run a subagent at effort: max (or any other level) is via a plugin-shipped or user-level agent definition with the effort frontmatter field. See "Plugin-shipped subagents" below.
In addition to skills, a plugin can also ship subagent definitions under packages/<plugin>/agents/<name>.md. This is simultaneously the only way to make a pre-configured model + effort combination available for runtime spawn, because the Agent tool only accepts model inline.
Supported: name, description, model (sonnet/opus/haiku), effort (low/medium/high/xhigh/max). Ignored for security reasons when the agent comes from a plugin: hooks, mcpServers, permissionMode. If those fields are needed, copy the agent definition to ~/.claude/agents/ or .claude/agents/.
Example (empirically working in a plugin marketplace):
---
name: sonnet-max
description: Generic subagent pinned to Sonnet at maximum effort.
model: sonnet
effort: max
---
Execute the invoker's prompt and return the result.
Plugin-shipped agents follow the same <plugin>:<name> namespace as skills. Call via the Agent/Task tool with subagent_type: "<plugin>:<name>". For the example agent in packages/gurus/agents/sonnet-max.md: subagent_type: "gurus:sonnet-max".
Bare name does NOT work. Unlike skills, where /how-plugins-work resolves as a bare slash command when unique, the Agent tool always requires the namespaced form for plugin-shipped agents. Empirical confirmation in Claude Code 2.1.92: subagent_type: "sonnet-max" fails, subagent_type: "gurus:sonnet-max" works.
Do not use claude agents as a plugin-shipped-agent listing check. In current
Claude Code CLI builds, claude agents manages background agent sessions, and
claude agents --json prints active/completed sessions rather than the
plugin-shipped subagent catalog.
Three useful checks, from lightest to heaviest:
claude plugins validate packages/<plugin>. Catches manifest and hook
schema problems in the local package. It does not prove the agent can spawn.
Live local spawn test via claude -p --plugin-dir ./packages/<plugin>.
claude -p --plugin-dir ./packages/<plugin> \
--allow-dangerously-skip-permissions --output-format json \
"Use the Task tool with subagent_type '<plugin>:<name>'. Ask for the string PING_42."
The JSON output contains a modelUsage section with the configured model as a separate key (e.g. claude-sonnet-4-6). Two models in modelUsage (session + subagent) is the strongest evidence that the subagent was truly spawned with the desired model. The effort value is not visible in modelUsage or elsewhere in the CLI output; for that it rests on a documentation assumption.
Installed-cache live spawn test. After claude plugins update <plugin>@<marketplace> or a fresh install, run the same claude -p command
without --plugin-dir to verify the installed cache copy.
What claude -p does and does not test for cron-driven features. Print mode is one-shot: one prompt, one answer, session over. The cron itself does not fire in -p (it lives on an idle interactive REPL), so auto-triggering ticks is ruled out. What -p can do well: test per-tick behavior by supplying a pre-constructed state and asking the session to "follow the Instructions for the current Phase as if a tick was fired". For the autonomous rover: write a stub loopfile with the desired Phase and (optionally) an aged timestamp in the Log, then start claude -p "Read .autonomous/X.md and act on the current Phase as if a cron tick just fired.". That validates fuse/timeout/backoff logic without waiting for real wallclock. To also confirm cron-firing itself, fall back to a fresh interactive session (claude in a new terminal or iTerm2 pane). Claude has shell access and can spawn -p itself; do not dictate this to the user when you can run it yourself.
Claude Code: claude plugins marketplace add ./ re-points an existing
marketplace alias to the local path, provided .claude-plugin/marketplace.json
claims the same alias. After that, claude plugins update <plugin>@<marketplace> pulls from the local working copy instead of the remote.
Useful for end-to-end testing of plugin changes without pushing first.
Codex: codex plugin marketplace add ./ registers the local marketplace source,
and codex plugin add <plugin>@<marketplace> installs from that configured
marketplace. Codex reads .agents/plugins/marketplace.json, follows
plugins[].source.path, and then reads the package .codex-plugin/plugin.json.
Gotcha 1: cascade-uninstall on marketplace remove. claude plugin marketplace remove <alias> does not only remove the marketplace configuration; it also uninstalls every plugin that was installed via that alias. Empirically tested in Claude Code 2.1.92: a marketplace with 18 installed plugins crashed to 0 after a single remove. Re-adding the marketplace does not automatically restore the plugins; each plugin must be explicitly re-invoked with claude plugin install <plugin>@<alias>. For a local dev session where you switch between path-based and remote-based marketplace with the same alias: this means a re-install of every plugin that comes from that alias, not just a config change.
Reverting to remote: silent overwrite, no cascade. The symmetric path from path-based back to remote works without marketplace remove: claude plugins marketplace add <owner>/<repo> overwrites the existing alias's source.source field in-place from directory to github, provided marketplace.json's name claims the same alias again. Empirically tested in Claude Code 2.1.119: all 18 installed plugins from that alias remained intact; no cascade-uninstall, no re-install batch needed. The old path field stays as residue in the settings.json record, but the active source.source: github wins and claude plugins update pulls from the remote from that point. Same silent-overwrite mechanism as the gotcha-free overwrite to path-based, just in reverse.
source must be a real subpathThe plugins[*].source field in marketplace.json passes through two layers, and the difference between them can be misleading (Claude Code 2.1.119, empirical against epologee/apples):
"source": "." fails with Invalid input on claude plugin marketplace add. "source": "./" succeeds and the marketplace lands in ~/.claude/settings.json under extraKnownMarketplaces. The schema therefore rejects the bare dot but accepts the slash variant."./" survives schema validation but does not resolve. Symptoms:
claude plugin marketplace list does not show the marketplace.claude plugin marketplace update <name> says Marketplace not found.claude plugin install <plugin>@<marketplace> fails with Plugin "<plugin>" not found in marketplace.enabledPlugins has <plugin>@<marketplace>: true even though nothing ever installed.Conclusion. source must be a real subdirectory, not the marketplace root. Working forms in this setup: "./packages/<plugin>" or "./plugins/<plugin>". Single-plugin repo where the plugin claims the root: move .claude-plugin/plugin.json and skills/ to e.g. ./packages/<plugin>/ and update source accordingly. The marketplace-level .claude-plugin/marketplace.json stays at repo root.
Local-vs-remote is not a factor. The schema test was only run against a local directory, but both local and remote marketplaces in the active setup already use subpaths. The rule is source-independent.
Diagnostic signal chain. When claude plugin marketplace add succeeds but claude plugin marketplace list does not show the marketplace and install fails with "Plugin not found in marketplace", source is the first thing to verify. Schema pass does not imply runtime pass.
There is no native "deprecate" or "remove" mechanism for a single plugin. marketplace.json has no deprecated, removed, or tombstone field; the entry schema is source, category, tags, strict, version plus manifest fields. Deleting a plugin's entry does not uninstall it for anyone who already has it.
What deleting the entry actually does (Claude Code 2.x, plus open issues anthropics/claude-code#17061, #37865, #9537, #23839):
installed_plugins.json keeps <plugin>@<marketplace>, enabledPlugins keeps true, the cache directory stays, and its hooks keep firing. A byte-identical successor installed from another marketplace then double-enforces against the orphan.claude plugins update <plugin>@<marketplace> afterwards errors with "Plugin not found in marketplace".claude plugins uninstall <plugin>@<marketplace>. claude plugins marketplace remove <marketplace> cascades, uninstalling every plugin from that marketplace at once, so it is wrong for a one-at-a-time migration.Because the platform never auto-cleans on entry removal, a clean migration needs a deprecation protocol you build. The tombstone is that protocol: instead of hard-deleting, hollow the plugin to a husk that stops behaving and announces its own removal.
Tombstone shape (verified against the earlier git-discipline migration):
skills/, all guard/library hooks, bin/, and tests. Removing the hooks is what kills any conflict with the successor (the orphaned-hook double-enforcement above). If the source marketplace pairs per-plugin update-broadcast machinery (a changelog plus a helper script) with a sync gate, drop both together so the gate stays quiet.SessionStart hook. Ship hooks/hooks.json with one SessionStart (matcher startup|resume) command that prints the uninstall and install lines to stdout, which Claude Code injects as context.plugin.json and the marketplace.json entry both lead with DEPRECATED and the exact claude plugins uninstall / install commands, so the husk reads as a tombstone in /plugin and in plugin list.claude plugins update <plugin>@<marketplace> actually delivers the stripped husk (hooks gone) and the SessionStart notice. Delete the entry and the consumer is back to a silent orphan.Why this works at all: hook loading is registration-driven, not catalog-driven. At session start the agent loads hooks from every plugin registered in installed_plugins.json and enabled in enabledPlugins; the marketplace catalog is only consulted at install/update time. Deleting an entry changes neither registration, so the old hooks keep loading forever. The tombstone cleans up by replacement, not removal: claude plugins update rewrites the registration's installPath to the husk snapshot, and the next session load finds no guard hooks there to load. Verified empirically: after updating the earlier git-discipline migration tombstone, the active cache held four files and zero guards. Two delivery conditions remain: the update must actually arrive (a user who never updates and has no auto-update keeps the full old version, hooks included; no marketplace mechanism can reach them), and already-running sessions keep the old hook set until a restart or /reload-plugins.
Division of labour with a marketplace-level announcement: a marketplace-wide changelog or news channel carries the migration story (pull-based, read on demand), while the per-plugin tombstone is the proactive push that nags a still-installed copy until the user removes it.
Precondition before hollowing out a public plugin: the successor marketplace must already be public and carry actionable migration instructions for external users. Hollowing a plugin that points users at a marketplace they cannot add strands them. Verify with gh repo view <owner>/<successor> --json visibility and confirm the successor plugin is registered in its marketplace.json before tombstoning.
Scope: tombstones exist for consumers you cannot see. A single-user, local-only marketplace has exactly one consumer, so a plain entry-plus-package delete combined with a local claude plugins uninstall is already the complete removal; building a husk there is ceremony without an audience.
The env section of ~/.claude/settings.json exports variables to child bash processes that Claude Code spawns. Those values are not visible in Claude's conversation context. A skill that wants to condition behavior on an env var must query the value via bash. Claude does not "know" the value on its own and will guess.
Two anti-patterns that often occur together:
VAR is not set, do X" without a prescribed bash step that queries the value. Claude must then realize a check is needed and usually guesses "unset".Pattern: one explicit "RUN THIS FIRST" step that combines bash check and action and prints a marker output that the next step branches on. No condition line elsewhere in the markdown that leans on implicit knowledge about an env value.
# First action of every invocation:
state="${VAR:-unset}"
[ "$state" = "on" ] && do_the_thing &
echo "state=$state"
Then a decision table driven by state, not by markdown prose:
state | Next action |
|---|---|
on | Continue normally; no reveal |
off | Continue normally; no reveal |
unset | Append one-time reveal-PS at end |
First-run reveal via the env var itself. An elegant mute without a state file on disk: on/off both suppress the hint, absent shows it once. Only robust if step 1 hard-reads the value; otherwise the elegance breaks and the hint is shown randomly.
Empirically observed in whywhy v1.0.10 (2026-04-22): the reveal-PS appeared for a user who had had WHYWHY_JINGLE=on in settings for 3 days, while the jingle did not play. Both symptoms of Claude not having read the env value: the reveal condition guessed "unset", and the afplay fence was not executed.
Session-lifetime footnote. Env updates in settings.json are only seen by new Claude Code sessions. A session that started before a settings commit keeps the old values until restart. When diagnosing strange behavior ("var is set to on but skill behaves as unset"), compare the session start time with the commit that added the var before blaming the skill itself.
Marketplace repos should be symlink-free. Every skill lives in one place under packages/<plugin>/skills/<skill>/, without shared source via symlinks. Pre-commit and CI reject symlinks in the repo. The reason is Windows: Git for Windows has core.symlinks=false as default, so on clone symlinks are converted to text files containing the target path, and runtime resolution in Claude Code fails. A symlink-free layout works on macOS, Linux, and Windows without extra consumer setup.
Anthropic docs do mention that Claude Code preserves symlinks in the install cache (Plugins reference, Plugin caching and file resolution), but that requires the symlinks to survive the clone in the first place. The three alternatives explored in an earlier experiment (git-subdir, rsync -aL materialization via release branch, CLAUDE_CODE_PLUGIN_SEED_DIR) all turned out to require more consumer setup than a flat, symlink-free layout. The repo is aligned accordingly.
Hooks (SessionStart, PreToolUse, PostToolUse, Stop, and the other lifecycle events) do NOT live in plugin.json. A hooks key in plugin.json is rejected by claude plugins validate with hooks: Invalid input, and on install silently stripped without a runtime error. The working path is a separate <plugin>/hooks/hooks.json (or a custom location via "hooks": "./path" in plugin.json).
The schema has a double hooks nesting that is easy to get wrong. Working example for SessionStart:
{
"description": "Optional: surfaces in claude plugins inspect.",
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/install.sh"
}
]
}
]
}
}
The outer "hooks" is the object that groups events; within each event entry there is a second "hooks": [...] array containing the actual commands. Omit that nesting and the plugin validates but the hook array comes through the validator as the wrong type.
matcher is always a regex string, not an object. For PreToolUse / PostToolUse it matches on tool name ("Bash", "Edit|Write"). For SessionStart it matches on source: one of startup, resume, clear, compact (or a pipe combination like "startup|resume"). Omitting matcher means fire on all sources; for an install hook that has nothing to do on /clear or an auto-compact, "matcher": "startup|resume" is the efficient choice.
Confirmed in Claude Code 2.x: claude plugins validate accepts both a missing matcher and the regex string. The object format {"source": [...]} does NOT work, despite some LLM suggestions naming that form.
claude plugins validate <path> is the canonical pre-ship sanity check for every plugin manifest or hooks change. It runs against the local source path (not the install cache) and catches schema violations that would otherwise only become visible on a colleague's first install, often silently.
claude plugins validate ./packages/<plugin>
Run it after EVERY change to plugin.json or hooks/hooks.json. Not a replacement for a real install test, but a free first filter.
The version field in plugin.json is updated automatically by the marketplace
pre-commit hook. The format is 1.0.{commits} where {commits} is the number
of commits that touched packages/<name>/ or skills/<name>/. In
multi-agent repos, the same hook must rebuild generated Codex manifests after a
Claude manifest version bump; otherwise .claude-plugin/plugin.json and
.codex-plugin/plugin.json drift in the same commit.
Claude Code installs a plugin from the repo subpath specified in marketplace.json (usually packages/<plugin>/) and drops the full contents of that subpath into the cache. That means: .claude-plugin/, skills/, the plugin-level README.md, bin/, and hooks/ (including the hooks/hooks.json manifest plus all hook scripts) all land there. Files outside the subpath (for example the repo-root README.md or the repo-root bin/) do not come along, because the plugin source starts at packages/<plugin>/, not at the repo root.
Empirically tested against a cached marketplace plugin:
$HOME/.claude/plugins/cache/<marketplace>/<plugin>/<version>/
├── .claude-plugin/
│ └── plugin.json
├── README.md (plugin-level, not repo-root)
├── bin/
│ └── relative-cron (consumer-facing helper)
└── skills/
└── <skill>/...
Older cache versions of the same plugin may have a different layout, depending on what was in the repo at the time of that install. Inspecting a cache against an old version proves nothing about the current source layout; test against a fresh claude plugins update.
The authoritative source for "which version is running now" is ~/.claude/plugins/installed_plugins.json:
jq -r '.plugins["<plugin>@<marketplace>"][0].installPath' ~/.claude/plugins/installed_plugins.json
That path is the plugin root in the cache, not the repo root. It contains .claude-plugin/, skills/, bin/ (if the plugin source has it), and the plugin-level README.md. Concrete path templates:
| Target | Correct path | Wrong path |
|---|---|---|
| Skill resource | $installPath/skills/<skill>/<file> | $installPath/packages/<plugin>/skills/<skill>/<file> |
| Bin script | $installPath/bin/<script> | $installPath/packages/<plugin>/bin/<script> |
| Plugin manifest | $installPath/.claude-plugin/plugin.json | (no other) |
| Hooks manifest | $installPath/hooks/hooks.json | $installPath/.claude-plugin/plugin.json (see Hooks section) |
| Hook script | $installPath/hooks/<script> (referenced via ${CLAUDE_PLUGIN_ROOT}/hooks/<script> in hooks.json) | (absolute paths; do not work cross-machine) |
The packages/<plugin>/ prefix only exists in the source repo, not in the cache. The ls -1dt ... | head -1 trick against ~/.claude/plugins/cache/<marketplace>/<plugin>/ points to the same path but relies on mtime ordering and is therefore not stable; the jq lookup works deterministically.
For Codex, use the structured plugin list. It reports both installed version and active source path:
codex plugin list --json \
| jq -r '.installed[] | select(.pluginId == "<plugin>@<marketplace>") | .source.path'
That path is Codex's active plugin root. For a local marketplace in development
it may be a generated directory in the working tree, for example
.agents/plugins/generated/<plugin>; for other marketplace sources it may be a
cache snapshot. Do not infer freshness from the working tree alone. Compare the
reported version with the generated .codex-plugin/plugin.json; if Codex
still reports an older version after a commit or rebuild, refresh the
marketplace/install before testing the skill in a new Codex thread.
Skills that need to call their own helper binaries should resolve the active
root, not hard-code ${CLAUDE_PLUGIN_ROOT} into a Codex path. A shared skill
can use a small resolver with agent-specific branches:
resolve_plugin_root() {
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
printf '%s\n' "$CLAUDE_PLUGIN_ROOT"
return 0
fi
if command -v codex >/dev/null 2>&1; then
root="$(codex plugin list --json \
| jq -er '.installed[] | select(.pluginId == "<plugin>@<marketplace>") | .source.path')" \
|| return 1
printf '%s\n' "$root"
return 0
fi
return 1
}
/reload-plugins and /reload-skills: re-read the installed set, do not update it/reload-plugins is a TUI slash command that re-loads the currently-installed plugin set into the running session without a full restart. Its output looks like:
Reloaded: 32 plugins · 0 skills · 7 agents · 8 hooks · 0 plugin MCP servers · 0 plugin LSP servers
What it does: re-reads installed_plugins.json and re-binds every plugin, agent, hook, skill, and MCP/LSP server from each plugin's active installPath (the cache snapshot). It is the in-session alternative to the session restart that the troubleshooting steps below otherwise flag with 🚦.
What it does NOT do: it is not claude plugins install / update. It does not bump a cache version, does not rewrite installPath or lastUpdated, and does not re-resolve a directory-source marketplace against your working tree.
Empirically tested in Claude Code 2.1.156, with a marketplace registered as a directory source pointing at the working copy. The working tree carried a new helper function in a hook file, committed on local main, while the active install still pointed at the previous cached version. After /reload-plugins:
installPath and lastUpdated; no new cache directory appeared.installPath still served the old code: grepping the cached hook file found no new helper function, even though the working-tree file had it.Conclusion: editing a directory-backed marketplace's working tree and running /reload-plugins does NOT make the edit live; the reload re-loads the same stale cache snapshot. To pick up working-tree edits you must first claude plugins update <plugin>@<marketplace>, which copies the current working tree into a fresh cache version and rewrites installPath; only then does /reload-plugins (or a restart) load the new code. /reload-plugins usefully replaces the restart in the second half of that loop, never the update in the first half. The update snapshots the working tree as-is, including any uncommitted changes, so land or stash unrelated work first if you want a clean snapshot.
/reload-skills is the skill-catalog counterpart and behaves the same way against the same cache snapshots. Its output looks like:
Reloaded skills: 148 skills available (no changes)
It re-reads the full skill catalog (every plugin's skills/ plus user-level skills) and reports the count, with a (no changes) suffix when the reloaded set is byte-identical to what was already loaded. It is complementary to /reload-plugins: the plugin reload re-binds plugins, agents, hooks, and MCP/LSP servers but reported 0 skills, while the skill reload owns the 148 skills. The same empirical run confirmed it does not pull working-tree edits either: after /reload-skills, how-plugins-work stayed at version 1.0.25 with the identical installPath, and the new section marker was still absent from the active cache SKILL.md while present in the working tree. So (no changes) here means "the installed cache snapshot is unchanged", not "your working-tree edits were checked and skipped". Reload never looks at the working tree. The update-then-reload loop above is the same for skills.
Both reload commands act on the session you type them in. A running background
agent (claude agents / /agents, dispatched with claude --bg) is a
separate process that loaded its plugins at its own start, and
/reload-plugins cannot reach it. After claude plugins update, those agents
keep running the old version until their process restarts. In Claude Code, the
Claude-only /restart-claude-agents command in this plugin does that restart:
it stops each background agent and resumes its session with
claude --bg --resume, re-applying the agent's original launch flags from its
job state, so a fresh process loads the current plugins while the agent keeps
its conversation, permissions, and goal.
Observed symptom: user types /rover (or /autonomous:rover) and Claude Code replies Unknown command. Diagnose and fix. Do not narrate steps for the user to execute; Claude has shell access and can run the same commands. Dictating install commands is condescending when Claude can just install.
Step 0 (mandatory, no exceptions). Run claude plugins list yourself before forming any hypothesis. This command is the single source of truth. If the plugin is absent, every theory about prefixing, namespacing, or skill resolution is noise.
Plugin not listed. Run claude plugins install <plugin>@<marketplace>. The Claude process inherits the CLI so this just works. The only thing that is not Claude's to do is loading the freshly-installed plugin: the user runs /reload-plugins (in-session, no restart) or restarts the session. Flag that with 🚦 and wait for user go.
Plugin listed but disabled. Patch ~/.claude/settings.json enabledPlugins to "<plugin>@<marketplace>": true. This is a user-level file; ask first before editing.
Marketplace source out of date. If the plugin only exists on a local branch that the marketplace source (GitHub or local path) has not seen, the install will fail. Fix the source: push the branch (requires user go) or re-point the marketplace at the working copy.
Skill missing user-invocable: true. Without the flag the skill is model-triggered only and no slash command appears. Edit the frontmatter.
Skill name collision across enabled plugins. Bare /<skill> only resolves when unique. Use /<plugin>:<skill> via autocomplete.
Stale cache path. Cached versions live under ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/. A long-running session may point at an older cached skill set. Flag 🚦 for a restart, or /reload-plugins to re-bind the installed set in-session (note: reload re-reads the same installPath; it does not pick up working-tree edits without a prior claude plugins update, see the /reload-plugins section).
Never advise the user to prefix or de-prefix a slash command without having run step 0. "Namespacing is required" is a guess when the actual failure is almost always install state, enable state, or a stale session. And never dictate claude plugins install ... at the user; run it.
Observed symptom: a Codex session does not see a plugin or skill expected from a local marketplace.
codex plugin marketplace list and confirm the marketplace name and root.codex plugin list --json and confirm the plugin appears under the
expected marketplace, with the expected version, enabled, and
source.path.codex plugin add <plugin>@<marketplace> if the plugin is not installed.codex plugin marketplace upgrade <marketplace> before reinstalling when the remote changed. For local
marketplace development, rebuild the generated adapters first.bin/plugin-adapters check .. Drift means the Codex adapter files are stale;
a clean check means the omission is intentional single-agent coverage.codex plugin add cannot find the plugin, inspect
.agents/plugins/marketplace.json first, then the package
.codex-plugin/plugin.json.Do not inspect ~/.codex/plugins/cache as source. It is a runtime snapshot.
codex plugin --help, 2026-06-09Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub epologee/laicluse-agent-fieldkit --plugin how-plugins-work