A plugin bundle is a directory of Claude-format extension content — skills, slash commands and agent presets in one folder, shipped and installed as a unit. candy-code reads bundles. It does not install registries, resolve dependencies, or run anything a bundle ships beyond the content its existing loaders already understand. That reader-first boundary is the whole page in one sentence; the two surfaces a bundle may carry that candy-code deliberately never execute are documented below, and they are declined by design, not pending work.
A directory is a bundle exactly when it carries a manifest:
my-bundle/
├── .claude-plugin/plugin.json (a bare plugin.json at the root is accepted too)
├── skills/<name>/SKILL.md → the skill loaders
├── commands/<name>.md → the slash-command pipeline
├── agents/<name>.md → the agent-preset registry
├── hooks/… → DECLINED, never registered
└── .mcp.json → DECLINED for auto-start, offered for import
The manifest needs a name; version and description are shown where
displayed. A directory with no manifest is not a bundle and is passed over
quietly — that is what a git checkout of anything looks like from here. A
manifest that exists but will not parse is reported by name at launch: the
difference between "not a bundle" and "broken install" deserves a line.
Three roots are scanned, and each bundle carries the tier its root implies:
| Root | Tier | Trust |
|---|---|---|
<project>/.candy-code/bundles/ |
project |
confined to the checkout, exactly like a cloned .claude/skills tree |
~/.candy-code/bundles/ |
user |
the destination candy-code bundles install writes to; a symlink inside it may point anywhere in home and nowhere outside it |
every pluginPaths entry |
user |
the operator's explicit word: a listed path is trusted as given, and may be either one bundle or a container of bundles |
A bundle whose path resolves outside the tree it was found in (a project symlink pointing at someone else's home, say) is refused, named, and reported through the same launch-refusal channel as every other project-tier escape.
pluginPaths lives in your user config and is user-tier only — a checked-out
repository cannot add to it. See SETTINGS.md.
Bundles map onto the existing loaders rather than opening a parallel
plugin system, and a bundle's content lands at every loader's lowest
precedence seat: a plain .claude/skills, .claude/commands or
.claude/agents directory always beats a bundle's same-named entry, per tier.
For slash commands the beat is no longer a shadow: bundle rows are namespaced
<bundle>:<cmd>, so two bundles shipping the same file both
survive, and the flat spelling is admitted only while no other row claims it.
- Skills (
skills/<name>/SKILL.md) load through the same discovery as every foreign skill and appear badged[bundle]in the skill listing, with their own colour token so a bundled skill is never mistaken for a native one. - Slash commands (
commands/<name>.md) ride the foreign-command discovery that already backs.claude/commands— same frontmatter and$ARGUMENTSrules; rows are named/<bundle>:<cmd>with the flat/<cmd>kept only while unclaimed, so areview.mdarriving in a second bundle replaces nothing. The!`cmd`form expands to a refusal instead of a shell, see the declined surfaces below and COMMANDS.md. - Agent presets (
agents/<name>.md) register like any other foreign preset; the file format is the Claude one by definition. A bundle preset loses a name collision to any plain foreign directory, then to the project tier as usual.
Everything the precedence section of SKILLS.md says about shadowing applies per-surface; the bundle badge is how you tell which side of a shadow you are looking at.
A bundle may ship things candy-code will not run. The first two declines fire once per session at launch, naming the bundles affected, as ordinary system rows in the transcript:
-
Hooks. Bundle hook content is never registered — neither the
hooks/directory, nor ahookspath declared in the manifest, nor an inlinehooksobject insideplugin.json(the declined-surface reader answers with the manifest path —hooksPaths()names where the hooks ride inside it — while the decline notice names the affected bundles). Lifecycle hooks execute arbitrary programs at every session event; the hooks that do run are declared in the project's ownhooks.yamlunder HOOKS.md trust, not smuggled in by a third-party folder. -
.mcp.json— and its manifest twins. A bundle's MCP declarations are never auto-started: neither a shipped.mcp.json, nor the manifest'smcp/mcpServerskeys as path strings, nor an inlinemcpServersobject insideplugin.json(the declined-surface reader answers with the manifest path —mcpFile()names where that declaration lives — while the decline notice names the affected bundles). The only filecandy-codestarts servers from is the project root's.mcp.json, behind its trust gate (MCP.md). If you want a bundle's servers, read the file yourself and import deliberately:candy-code mcp import claude <path-to-bundle>/.mcp.json
which prints a config block and writes nothing, exactly as it does for any other foreign MCP file.
-
Shell forms. A
!`cmd`inside a bundle's command file is refused at expansion — in the transcript, not by a launch notice. The tier the bundle happens to install under decides nothing, andtrustedProjectCommandsdoes not unlock it either (the COMMANDS.md tier table carries the row). A per-origin opt-in is the planned follow-up. If those surfaces are unacceptable to you, the bundle is a stranger; deleting its directory takes it all back. -
Script tools. A bundle cannot contribute script tools at all: there is no bundle-side surface for them. A script tool is declared only by the person's own account directory (the ruling declined bundles explicitly), so its trust is the same subject the rest of the user tier has. No decline notice exists for this one because the surface does not.
A script tool is one small JSON file in the user tier's tools/ directory
under ~/.candy-code — the shape, the validation rules and the user__
prefix live in PERMISSIONS.md and the tool list at launch.
What matters from the plugin side:
- every tool it declares reaches the model under the wire name
user__<name>, anduser__*is write-classed — it asks wherever a judgment is made (PERMISSIONS.md); - the directory is read ONCE at launch; adding or editing a file takes effect at the next start, like the MCP roster;
- the project tier has no script-tool surface — a
tools/directory inside a repository is never read, which is the same law that keeps a bundle's.mcp.jsonfrom auto-starting; - bundles run only their commands, skills, agents and deliberately imported MCP declarations.
candy-code bundles list # every bundle this directory sees,
# per-surface counts and both declines
candy-code bundles install <path> # copy a bundle directory into
# ~/.candy-code/bundles/
candy-code bundles install <git-url> # depth-one clone into the same place
candy-code bundles install <plugin>@<marketplace>
# gated install from an added marketplace
candy-code bundles marketplace add <path|git-url>
# pin a marketplace catalog under ~/.candy-code/
candy-code bundles marketplace list # the added rows and their pins
candy-code bundles marketplace remove <name>
candy-code bundles marketplace update [name]
# re-clone, diff, move the pin
candy-code bundles update [bundle] # re-install recorded bundles from their rows
candy-code bundles search <query> # every catalog entry matching a needle
candy-code bundles recommend # entries the project's own files point atlist answers without a provider, an API key or a terminal, and
--output-format json emits the machine document. install refuses anything
without a manifest, never overwrites (an existing name is an error, not a
backup prompt), and names the installed bundle after its manifest name when
it has one — a name beginning with a dot is refused, because discovery's glob
never sees a dot-named directory and an invisible install is worse than none.
Both arms land content-only trees: the copy skips every .git directory and
the clone sheds its own before the rename. A git clone runs as a bounded,
non-interactive child: --depth 1,
no tags, terminal prompts and askpass disabled, killed with its process group
if it overstays. Its only destination is the user bundle root — a clone can
land in ~/.candy-code/bundles/<name> and nowhere else, under a name that can
not traverse out of it.
A marketplace is a directory whose .claude-plugin/marketplace.json names
itself and lists plugins, each with a source: a path strictly inside the
checkout, a github owner/repo, or a plain https git URL.
bundles marketplace add clones or opens such a source, reads the catalog,
resolves a full 40-hex commit for it, and writes one registry row under
~/.candy-code/marketplaces.json; remote checkouts land beside it under
~/.candy-code/marketplaces/<name>/ with the .git stripped. A source with
no resolvable commit is refused outright — a marketplace nobody can pin is a
catalog every install from it would silently drift on.
That pin is the install gate. bundles install demo@marketplace only runs if
marketplace is a registered row with a pinned commit and an entry named
demo; otherwise it refuses with the sentence naming why. A gated install
stages exactly like a direct one (path copy or depth-one clone, both
content-only), then writes a provenance record
(<bundle>/.candy-code-source.json) naming the marketplace, the entry and
the commit — the row bundles update later refreshes against.
bundles marketplace update re-clones the row's source into a scratch beside
the old checkout, prints which plugins arrived and left the catalog, swaps
the checkout by rename, and only then moves the pin. If the catalog renamed
itself the update refuses — the registry name is the identity every install
record keys on. Local-path rows answer "update it in place" and do nothing
else. bundles update (with or without a bundle name) re-installs each
recorded bundle from its row: the old tree moves aside, the fresh tree lands
under the name, and the aside copy is removed last and without ever following
a link, so no upstream tree can aim a removal at your home directory. A
bundle whose upstream renamed itself is refused rather than re-keyed, and
bundles installed from a bare path or URL simply carry no record — the scan
answers that they were left alone.
bundles search <query> and bundles recommend read the same catalogs and
write nothing: the first matches name, source, description, category and
keywords; the second scores catalog keywords against the signal words of the
project files sitting in the working directory (a package.json points at
the JavaScript-family keywords, a Dockerfile at the container family), and
never names an entry whose bundle is already installed. Both answer to
--output-format json.
And a booting candy-code in a project whose files match an added
marketplace's catalog prints one line — Bundles matching this project: name@marketplace, … — once per project per marketplace pin; nothing is
installed. The once-per-pin stamp lives as the launchRecommendations key
inside ~/.candy-code/marketplaces.json; the registry loader ignores it and
any later marketplace add/remove/repin drops it, re-arming exactly one fresh
row. Nothing is ever fetched to build it.
marketplace remove retires the row; its checkout directory stays on disk,
because deleting trees is not what a remove verb is for.
- SKILLS.md — discovery roots and precedence for skill content
- COMMANDS.md — the slash-command file format bundles reuse
- MCP.md — what
.mcp.jsonis, and what trusting one means - HOOKS.md — the hook system bundles deliberately do not join
- SETTINGS.md — the
pluginPathskey