Today, there are seven plugins in my Eigenwise Toolshed marketplace. Install any one of them on its own and it works on its own. Install the right second one and something quietly gets better, without either plugin having been told the other existed.
Most of that wiring converges on one of them. Sidequest is the ticket board, and it’s the peer the others reach for: Model Gateway hands it models it never had to know about, Observability pulls cost and ticket data straight out of its database, Workbench audits its boards and worktrees at session start. Three plugins reaching for the same neighbour, and not one manifest in that repo names another plugin. That was on purpose, and the constraint is where all the interesting design ended up going.
First of a few notes from the shed. Starting with the modularity, because it’s the part I’d most want someone else to steal.
Where the Official Road Ends #
Claude Code has a real plugin dependency system, and it’s better than I expected when I went looking. As of August 2026, plugin.json
takes a dependencies array:
{
"name": "deploy-kit",
"version": "3.1.0",
"dependencies": [
"audit-logger",
{ "name": "secrets-vault", "version": "~2.1.0" }
]
}
Ranges resolve against git tags, and when several plugins constrain the same dependency it intersects the ranges and throws range-conflict
if they can’t agree. It also blocks cross-marketplace dependencies unless the root marketplace allowlists the target, enables transitively, refuses to disable something another enabled plugin still needs, and ships claude plugin prune
for the orphans. As of August 2026, no other agent CLI documents anything close, and I went and looked at Codex CLI, Gemini CLI, Cursor and Copilot CLI before saying that.
It’s a hard dependency, though. A plugin whose dependency isn’t satisfied gets disabled until you fix it. Which is right for “this cannot function without that.” It’s no use at all for “this gets better when that happens to be around,” and that second one is the case I actually had.
The runtime side gives you nothing to work with. There’s no enumeration call, and nothing in the environment a hook receives names any plugin but itself: you get CLAUDE_PLUGIN_ROOT
, CLAUDE_PLUGIN_DATA
and CLAUDE_PLUGIN_OPTION_<KEY>
, all scoped to the plugin the hook came from, and that’s the entire list. Somebody asked for the whole package back in October 2025 (issue 9444), and the hard half is the half that got built. Graceful degradation was one bullet in that request, and as of August 2026 it’s still one bullet.
So the optional half is yours to build. Here’s what I ended up with.
Ask the Registry, Not the Manifest #
Claude Code keeps its install ledger at ~/.claude/plugins/installed_plugins.json
. Keys are name@marketplace
, and each value is an array, one entry per scope and project:
{
"plugins": {
"sidequest@eigenwise-toolshed": [
{ "scope": "project",
"projectPath": "C:\dev\Cantizans",
"installPath": "…/plugins/cache/eigenwise-toolshed/sidequest/4.49.0",
"version": "4.49.0",
"lastUpdated": "2026-08-13T18:46:30.481Z" }
]
}
}
It’s plain JSON at a stable path. Five of my seven plugins read it, and mine currently lists 40.
The array is the bit that catches people. Same plugin, one entry per scope: user
applies everywhere, a project
entry only applies to that path. You have to resolve before you can answer “is it installed here”. Worth knowing too that this file isn’t documented anywhere, so treat it as stable-in-practice rather than promised. Mine already survived a rename from installed_plugins_v2.json
and the migration code still ships.
The trap worth naming, because it cost me a real bug: an enabledPlugins entry in settings.json is not proof of an install. It can be
true
while the registry has no matching entry at all. Before Sidequest checked for that, dispatch cheerfully handed back a spawn spec and the executor came back with no board tools at all, then burned three runs hunting its own tool list for them. The refusal message now says that sentence word for word, because the error surfaces at dispatch and
the actual problem is two files away.
Resolve, Don’t Import #
You cannot require('observability')
from inside another plugin. Plugin roots aren’t on each other’s module paths and there’s no shared package between them. You can require()
an absolute path, though, and the registry just handed you one.
Workbench repairs Observability’s statusline that way:
// The statusline belongs to the observability plugin. Resolve its setup module from
// the install registry rather than importing it, so Workbench keeps working for the
// people who never installed observability.
function observabilitySetup(home) {
const registryPath = path.join(home, '.claude', 'plugins', 'installed_plugins.json');
let registry;
try { registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch { return null; }
const installs = registry?.plugins?.[OBSERVABILITY_PLUGIN];
if (!Array.isArray(installs)) return null;
for (const install of installs) {
if (!install?.installPath) continue;
const script = path.join(install.installPath, 'bin', 'setup-observability.js');
if (fs.existsSync(script)) {
try { return require(script); } catch { return null; }
}
}
return null;
}
The caller is one line: if (!setup) return [];
. No peer, no work, no error. Somebody who installed Workbench and nothing else never learns this function exists.
That comment is close to the whole article. You get the coupling by looking the path up at runtime, so you never have to declare it anywhere, and nothing falls over when it isn’t there.
Publish a Catalog and Version Its Schema #
The richest link in the shed is between Model Gateway and Sidequest, and it’s a file that belongs to neither of them.
Model Gateway writes ~/.claude/model-gateway/catalog.json
with every non-Anthropic model it can currently reach. Sidequest reads that file to decide where a ticket can be routed. The writer’s source says who’s on the other end:
// sidequest (same marketplace) auto-discovers Codex models by reading this
// file: ~/.claude/model-gateway/catalog.json. Shape is a frozen contract
// (see plugins/sidequest/lib/discovery.js)
Three things about it earned their keep, and the last one is what I’d actually press on someone.
The file lives on neutral ground. Under ~/.claude
, not inside either plugin’s directory. Either side can be uninstalled without stranding the other, and a version bump doesn’t move it.
The reader takes three schema versions, not one. Sidequest accepts 2, 3 and 4, and branches on what it got: schema 4 has a providers
map covering several backends, older ones only knew about Codex. An old gateway next to a new board keeps working. The writer guards the other direction and refuses to overwrite a catalog whose schema is newer than the one it understands, so an old gateway can’t clobber a new file.
The catalog carries readiness, separately from existence. This is the one I’d most want to pass on. “Installed” and “working right now” are different facts, and a dependency declaration can only ever express the first:
export function providerReadiness(provider: string): ProviderReadiness | null {
for (const root of discoveryRoots()) {
for (const { relPath, schemas } of CATALOG_SOURCES) {
const catalog = validCatalog(readJsonSafe(path.join(root, relPath)), schemas);
if (!catalog) continue;
…
}
}
return null;
}
A gateway that’s installed but not signed in is present and useless. Declaring a hard dependency on it would have told me nothing about that, and the whole point of the routing is that it fails honestly.
Which is why the failure here is deliberately loud. When a ticket is routed to a non-Anthropic model and the gateway can’t confirm it’s ready, Sidequest refuses and says No Anthropic fallback was used.
Quietly running that ticket on an Anthropic model instead would have worked fine, and it would have spent tokens I never asked it to spend. Silent success on the wrong provider is worse than a refusal, and that sentence is in four of the six readiness refusals so nobody has to guess.
I know the versioned file is worth the ceremony because I also did the lazy version. Workbench used to check whether the gateway was healthy by shelling out to its doctor
command and grepping the human-readable output for the word running
. Then the gateway changed its own wording to answering /v1/models
, and Workbench started reporting a perfectly healthy gateway as down. It shipped, and it came back at me as a health-check report from a different project entirely. The fix took a real parser plus a drift test that reads the peer’s source and fails the build if the phrasings move again. A peer’s human output is a UI, and it will change on you. A schema-versioned JSON file is a contract, and that’s the difference.
Every Missing Peer Path Does Less, Never Fails #
Every place one plugin reaches for another, there’s a null path, and every null path does less work instead of raising. Registry unreadable, peer module throws on require, catalog missing, schema unrecognized, board database absent: all of it lands on return []
or return null
and the session carries on.
Two comments in the freshness hooks say why:
// A read-only audit must never stop Claude Code from starting.
// Unknown local state and hook failures must never block a user prompt.
That’s the deal you’re making when you detect peers by reading files. You’re consuming input you don’t control, written by a program that might be a different version than the one you tested against. So you validate every field on the way in, and you treat “I couldn’t tell” and “it isn’t there” as the same answer.
It isn’t free, though. Because plugins can’t share a module, I have three hand-written copies of parseSemver, one in each plugin that needs to compare a version. Two of my plugins also generate launcher scripts, and each one carries its own
compareVersions
inlined in a template string, byte-identical to the other, because a generated file can’t require anything either. Small helpers get copy-pasted, and I keep them in sync by hand.There’s a related trap that bit me more than once. The plugin code you loaded is not the plugin code on disk. Plugins load at session start, so an update mid-session leaves you running a version that has already been replaced. Anything that detects a peer has to decide whether it cares, and mine care enough to refuse to dispatch across an incompatible version skew.
What This Buys #
The setup story is a separate piece, so I’ll keep it to one paragraph: Quartermaster is the plugin whose whole job is working out what a project is short of. It reads the repo, mines your session history, then asks before it installs anything, including plugins that aren’t mine, like Context7 for live library docs or whichever language server matches your stack. It records what you said no to so it never pitches that again, and it comes back a pass later to check whether what it added actually helped. That one deserves its own article and it’s going to get one.
None of it requires anything else. The gateway alone just gives you more rows in your model picker. The board alone is a board. Put them on the same machine and tickets start landing on Codex and Grok, because one side published a file and the other side knew how to read it. Neither was ever told about the other.
One last thing on method, since it’s the habit that made most of this checkable. There’s a standing rule in my setup that the source of truth is the Claude Code binary, and it exists because I once accepted “no reachable hook/tool control surface” on an assistant’s say-so, and that one unchecked sentence decided the entire shape of a ticket. It’s a single Bun-compiled executable, about 300 MB, and it greps as text.
The rule bites hardest on negative claims, because “there’s no way to do X” is the one that quietly caps what you’ll even attempt. The check that turned it into a rule cost about six greps, and it ended up confirming the sentence I’d doubted, which is the whole argument: verifying is cheap enough that taking the claim on faith is never the efficient move. Two cautions, both learned the annoying way: string hits without their surrounding context will absolutely talk you into a feature that doesn’t exist, and this is reading strings out of a program on your own machine, not decompiling or republishing anything.
The honest footnote is that the docs held up better than I expected. I went looking for contradictions between what’s documented and what ships, and found none. What the binary gave me was file names and shapes, which is exactly the layer the docs don’t cover and exactly the layer this kind of work needs.
Build the optional path first. Get that right and your plugin composes with things that don’t exist yet, including other people’s.