{"slug": "lessons-learned-from-building-modular-extendable-claude-code-plugins", "title": "Lessons Learned from Building Modular, Extendable Claude Code Plugins", "summary": "As of August 2026, Anthropic's Claude Code plugin system supports hard dependencies but lacks optional or soft dependencies, prompting developer Eigenwise to build a modular plugin marketplace where five of seven plugins read the undocumented install ledger at ~/.claude/plugins/installed_plugins.json to enable graceful degradation. The Eigenwise Toolshed marketplace includes plugins like Sidequest, Model Gateway, Observability, and Workbench, which interoperate without any manifest naming another plugin, relying on the registry to resolve optional integrations.", "body_md": "# Lessons Learned from Building Modular, Extendable Claude Code Plugins\n\nToday, there are seven plugins in my [Eigenwise Toolshed](https://github.com/Eigenwise/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.\n\nMost 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.\n\nFirst of a few notes from the shed. Starting with the modularity, because it’s the part I’d most want someone else to steal.\n\n## Where the Official Road Ends\n\n*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`\n\ntakes a [ dependencies array](https://code.claude.com/docs/en/plugin-dependencies):\n\n```\n{\n  \"name\": \"deploy-kit\",\n  \"version\": \"3.1.0\",\n  \"dependencies\": [\n    \"audit-logger\",\n    { \"name\": \"secrets-vault\", \"version\": \"~2.1.0\" }\n  ]\n}\n```\n\nRanges resolve against git tags, and when several plugins constrain the same dependency it **intersects the ranges** and throws `range-conflict`\n\nif 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`\n\nfor 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.\n\nIt’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.\n\nThe 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`\n\n, `CLAUDE_PLUGIN_DATA`\n\nand `CLAUDE_PLUGIN_OPTION_<KEY>`\n\n, 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](https://github.com/anthropics/claude-code/issues/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.\n\nSo the optional half is yours to build. Here’s what I ended up with.\n\n## Ask the Registry, Not the Manifest\n\n*Claude Code* keeps its install ledger at `~/.claude/plugins/installed_plugins.json`\n\n. Keys are `name@marketplace`\n\n, and each value is an **array**, one entry per scope and project:\n\n```\n{\n  \"plugins\": {\n    \"sidequest@eigenwise-toolshed\": [\n      { \"scope\": \"project\",\n        \"projectPath\": \"C:\\dev\\Cantizans\",\n        \"installPath\": \"…/plugins/cache/eigenwise-toolshed/sidequest/4.49.0\",\n        \"version\": \"4.49.0\",\n        \"lastUpdated\": \"2026-08-13T18:46:30.481Z\" }\n    ]\n  }\n}\n```\n\nIt’s plain JSON at a stable path. **Five of my seven plugins read it**, and mine currently lists 40.\n\nThe array is the bit that catches people. Same plugin, one entry per scope: `user`\n\napplies everywhere, a `project`\n\nentry 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`\n\nand the migration code still ships.\n\nThe 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\n\n`true`\n\nwhile 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\n\n**the actual problem is two files away**.\n\n## Resolve, Don’t Import\n\nYou cannot `require('observability')`\n\nfrom inside another plugin. **Plugin roots aren’t on each other’s module paths** and there’s no shared package between them. You **can** `require()`\n\nan absolute path, though, and the registry just handed you one.\n\nWorkbench repairs Observability’s statusline that way:\n\n```\n// The statusline belongs to the observability plugin. Resolve its setup module from\n// the install registry rather than importing it, so Workbench keeps working for the\n// people who never installed observability.\nfunction observabilitySetup(home) {\n  const registryPath = path.join(home, '.claude', 'plugins', 'installed_plugins.json');\n  let registry;\n  try { registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch { return null; }\n  const installs = registry?.plugins?.[OBSERVABILITY_PLUGIN];\n  if (!Array.isArray(installs)) return null;\n  for (const install of installs) {\n    if (!install?.installPath) continue;\n    const script = path.join(install.installPath, 'bin', 'setup-observability.js');\n    if (fs.existsSync(script)) {\n      try { return require(script); } catch { return null; }\n    }\n  }\n  return null;\n}\n```\n\nThe caller is one line: `if (!setup) return [];`\n\n. **No peer, no work, no error.** Somebody who installed Workbench and nothing else never learns this function exists.\n\nThat 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.\n\n## Publish a Catalog and Version Its Schema\n\nThe richest link in the shed is between Model Gateway and Sidequest, and it’s a **file that belongs to neither of them**.\n\nModel Gateway writes `~/.claude/model-gateway/catalog.json`\n\nwith 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:\n\n```\n// sidequest (same marketplace) auto-discovers Codex models by reading this\n// file: ~/.claude/model-gateway/catalog.json. Shape is a frozen contract\n// (see plugins/sidequest/lib/discovery.js)\n```\n\nThree things about it earned their keep, and the last one is what I’d actually press on someone.\n\n**The file lives on neutral ground.** Under `~/.claude`\n\n, not inside either plugin’s directory. Either side can be uninstalled without stranding the other, and a version bump doesn’t move it.\n\n**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`\n\nmap 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.\n\n**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:\n\n```\nexport function providerReadiness(provider: string): ProviderReadiness | null {\n  for (const root of discoveryRoots()) {\n    for (const { relPath, schemas } of CATALOG_SOURCES) {\n      const catalog = validCatalog(readJsonSafe(path.join(root, relPath)), schemas);\n      if (!catalog) continue;\n      …\n    }\n  }\n  return null;\n}\n```\n\nA 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.\n\nWhich 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.`\n\nQuietly 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.\n\nI 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`\n\ncommand and grepping the human-readable output for the word `running`\n\n. Then the gateway changed its own wording to `answering /v1/models`\n\n, 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.\n\n## Every Missing Peer Path Does Less, Never Fails\n\nEvery 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 []`\n\nor `return null`\n\nand the session carries on.\n\nTwo comments in the freshness hooks say why:\n\n```\n// A read-only audit must never stop Claude Code from starting.\n// Unknown local state and hook failures must never block a user prompt.\n```\n\nThat’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.\n\nIt 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\n\n`compareVersions`\n\ninlined 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.\n\n## What This Buys\n\nThe 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.\n\nNone 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.\n\nOne 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.\n\nThe 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.\n\nThe 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.\n\n**Build the optional path first.** Get that right and your plugin composes with things that don’t exist yet, including other people’s.", "url": "https://wpnews.pro/news/lessons-learned-from-building-modular-extendable-claude-code-plugins", "canonical_source": "https://eigenwise.io/writing/modular-extendable-claude-code-plugins", "published_at": "2026-08-16 00:00:00+00:00", "updated_at": "2026-08-16 18:11:43.542328+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-agents"], "entities": ["Anthropic", "Claude Code", "Eigenwise", "Eigenwise Toolshed", "Sidequest", "Model Gateway", "Observability", "Workbench"], "alternates": {"html": "https://wpnews.pro/news/lessons-learned-from-building-modular-extendable-claude-code-plugins", "markdown": "https://wpnews.pro/news/lessons-learned-from-building-modular-extendable-claude-code-plugins.md", "text": "https://wpnews.pro/news/lessons-learned-from-building-modular-extendable-claude-code-plugins.txt", "jsonld": "https://wpnews.pro/news/lessons-learned-from-building-modular-extendable-claude-code-plugins.jsonld"}}