I built a plugin dependency resolver. It's 650 lines of Python across five files, no external dependencies beyond the MCP library, and it solves a problem I kept running into: Claude Code plugins depend on capabilities from other plugins, and different users have different environments.

The system is called softwaresoftware.

A user on Linux has notify-send. A user on macOS has osascript. A user on WSL has neither in a useful way. I needed a resolver that could figure out which provider fits — without hardcoding platform logic, without asking the user to choose, and without breaking in restricted environments where half the expected tools are missing.

The three previous posts in this series covered the principles: thin interfaces and fat contracts, semantic contracts that work because AI reasons over them, composition that degrades gracefully under environmental constraints. This post is where those ideas become code.

the first problem: detection

I can't run detection logic that lives inside a plugin you haven't installed yet. That's the bootstrap problem. And I can't hardcode provider-specific detection in the resolver — that turns the resolver into a junk drawer that grows with every new provider.

The answer was generic probes plus declarative metadata.

I wrote eight probe primitives in probes.py: probe_os(), probe_shell(), probe_binary(name), probe_port(host, port), probe_env(name), probe_mcp(name), probe_plugin(name), probe_file(path). That's 144 lines total. These primitives know nothing about any specific plugin. They check facts about the machine.

Providers declare their environment requirements as metadata in the marketplace:

{
  "name": "notify-linux",
  "provides": ["notification"],
  "environment": {"os": "linux", "binary": "notify-send"}
}

The resolver reads the metadata, runs only the probes that candidates need via gather_facts(), and checks the results against each provider's requirements. New provider? Add a JSON entry. The resolver never changes.

I think this is the most important property of the system.

how thin can the contracts be?

Traditional plugin systems define capability contracts as API signatures. You implement sendNotification(message: string, urgency: enum) with exactly that name and those parameters. I went a different direction.

A capability contract in softwaresoftware describes behavior:

{
  "name": "notification",
  "behavior": {
    "input": "A message string and optional hints dict",
    "output": "Confirmation of delivery with channel details",
    "deterministic": "Delivery must succeed or return explicit failure"
  },
  "hints": {"urgency": {"type": "string", "enum": ["low", "normal", "critical"]}}
}

No mandated tool names. No fixed signatures. A provider implements the notification capability with whatever tool name makes sense — send_notification, desktop_alert, whatever. Claude handles routing at runtime based on available tools and the contract description.

This works because there's AI in the execution path. The consumer skill says "use the notification capability to alert the user with message X and urgency Y." Claude figures out which installed tool satisfies that. I covered this idea in the second post — AI-powered providers change the abstraction calculus. You can leave contracts thin and trust the runtime to do the matching.

requires vs optional

A plugin declares two lists: requires and optional.

Required capabilities must be satisfied or the plugin can't function. Optional capabilities expand what the plugin can do. On a locked-down machine where the browser provider can't run, the optional dependency falls away. The plugin still runs with a narrower feature set.

I spent some time thinking about whether to add priority levels or conditional dependency groups. I didn't. Two lists — required and optional — cover every case I've hit so far. That might change. But I'd rather promote from a simple model to a complex one based on evidence than start complex.

the five files

probes.py — 144 lines. Eight environment detection primitives plus a dispatch table and gather_facts(), which runs only the probes that candidates actually need. No wasted work.

registry.py — 143 lines. Reads installed_plugins.json, settings.json, marketplace.json, and capabilities/*.json. Nine public functions. No caching — every read is fresh. I wanted the resolver to always reflect the current state of the machine. A plugin could be installed or removed between two calls. Caching would make that stale.

resolver.py — 201 lines. This is the core engine. check_dependencies() diffs what a plugin needs against what's installed. resolve() finds and ranks providers by environment match. get_install_plan() auto-selects the best provider for each missing capability. verify() does a pass/fail check. detect_environment() returns a full snapshot of the machine.

contracts.py — 72 lines. Validates that providers actually declare the capabilities they claim and that contracts exist. It's a sanity check layer.

server.py — 90 lines. A FastMCP server exposing seven tools to Claude. Each tool is a thin wrapper around a resolver or registry function. The server file is almost entirely boilerplate.

walking through a resolve

Say a user installs a plugin called "cardwatch" that monitors product stock. Its marketplace entry has "requires": ["notification"].

Here's what happens.

check_dependencies("cardwatch") reads the marketplace entry, finds the notification requirement, checks if any installed plugin provides it. Nothing does. It returns {"missing": ["notification"]}.

resolve("notification") gathers all marketplace plugins with "provides": ["notification"]. It collects their environment requirements — notify-linux wants {"os": "linux", "binary": "notify-send"}, notify-macos wants {"os": "darwin"}. It runs gather_facts() with just those requirements. On my Linux machine, os:linux is true, binary:notify-send is true, os:darwin is false. notify-linux ranks first.

get_install_plan("cardwatch") takes that ranking and produces an ordered install list: install notify-linux first (it provides notification), then cardwatch. No user decision required. The environment made the choice.

The sort order in resolve() is two keys: matched providers first, then installed providers first. That's one line of code:

ranked.sort(key=lambda p: (not p["match"], not p["installed"]))

I went back and forth on more sophisticated ranking — weighting by number of matched conditions, preferring providers with fewer requirements. I kept it simple. Match or don't. Installed or not. Two bits of information, one sort.

what I chose not to build

No version constraints. Plugins don't declare "I need notification >= 2.0." The capability contract is the compatibility layer. If the contract changes, that's a new capability name.

No dependency graph resolution. The resolver handles one level of dependencies — what does this plugin need, and who provides it. If a provider itself has dependencies, you'd run the resolver again for that provider. I don't do transitive resolution in one pass. I don't think I need it yet.

No lock files. No deterministic builds. This isn't npm. It's a runtime capability matcher for an AI tool. The environment is the lock file — same machine, same probes, same result.

the ecosystem bet

The architecture is ready. The contracts are defined. I have a resolver that can detect environments, match providers, and generate install plans without any provider-specific code.

The question is whether the pattern holds as real plugins start composing against it. Does the two-list model (requires/optional) cover enough cases? Do eight probe primitives handle every environment condition that matters? Do semantic contracts actually route correctly when there are five providers for the same capability with subtle behavioral differences?

I don't have complete answers yet.

I keep coming back to the same bet from the first post: thin interfaces survive, fat contracts evolve. The HTTP analogy holds. Keep the core stable, let the sideband hints grow, promote proven patterns to core based on evidence. 650 lines gives me a foundation to test that bet. If I'm wrong, the blast radius is small. If I'm right, adding the next fifty providers is fifty JSON entries and zero code changes.