From f3f31859e06205381c87a43db598acf246425bc0 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 15 Jun 2026 08:04:32 +0300 Subject: [PATCH 1/8] RFC: Plugin lifecycle management in ToolHive Proposes adding plugin (multi-primitive bundle) lifecycle management to ToolHive, mirroring the existing skills system: build, push, install, uninstall, list, info, validate, and marketplace generation. Plugins are packaged as reproducible OCI artifacts (dev.toolhive.plugins.v1), reusing the shared toolhive-core OCI primitives, the SQLite entries table, the registry provider seam, groups, the git resolver, and the multi-client PathResolver. Centers on what makes plugins different from skills: an executable surface (hooks + bundled MCP servers), addressed via a pre-install component inventory, signature verification over the OCI Referrers API, and install-time audit events. Co-Authored-By: Claude Opus 4.8 --- rfcs/THV-XXXX-plugins-lifecycle-management.md | 625 ++++++++++++++++++ 1 file changed, 625 insertions(+) create mode 100644 rfcs/THV-XXXX-plugins-lifecycle-management.md diff --git a/rfcs/THV-XXXX-plugins-lifecycle-management.md b/rfcs/THV-XXXX-plugins-lifecycle-management.md new file mode 100644 index 0000000..49ef6f3 --- /dev/null +++ b/rfcs/THV-XXXX-plugins-lifecycle-management.md @@ -0,0 +1,625 @@ +# RFC-XXXX: Plugin Lifecycle Management in ToolHive + +- **Status**: Draft +- **Author(s)**: Juan Antonio Osorio (@JAORMX) +- **Created**: 2026-06-15 +- **Last Updated**: 2026-06-15 +- **Target Repository**: toolhive (with supporting changes in toolhive-core and toolhive-registry-server) +- **Related Issues**: N/A + +## Summary + +This RFC proposes adding **plugin** lifecycle management to ToolHive, mirroring the existing skills system. A *plugin* is the cross-vendor "bundle of primitives" unit pioneered by Claude Code — a directory declared by a `.claude-plugin/plugin.json` manifest that bundles any combination of slash commands, subagents, Agent Skills, hooks, MCP server configurations, and LSP servers. ToolHive will let users **build** a plugin directory into a reproducible OCI artifact, **push** it to any OCI registry, **install** it to one or more AI clients (from a registry name, OCI reference, or `git://` URL), and **list/info/uninstall** it — using the same registry, OCI, groups, and storage infrastructure that already serves skills and MCP servers. As a bridge to native client tooling, ToolHive can also generate a `marketplace.json` from a set of OCI-distributed plugins. + +## Problem Statement + +ToolHive already manages two of the three artifact classes an AI coding workflow consumes: **MCP servers** (as containers) and **skills** (as OCI artifacts, see [RFC-0030](./THV-0030-skills-lifecycle-management.md) and `docs/arch/12-skills-system.md`). The third class — **plugins** — is now the de-facto packaging unit across the ecosystem, and ToolHive has no story for it. + +- **Plugins are the industry-convergent bundle unit.** Within ~6 months of Claude Code's October 2025 launch, near-identical bundle systems shipped in Cursor (`.cursor-plugin/plugin.json`), OpenAI Codex CLI (`.codex-plugin/plugin.json` — which even falls back to reading `.claude-plugin/plugin.json`), GitHub Copilot (`.github/plugin.json`), Gemini CLI (`gemini-extension.json`), and AWS Kiro ("Powers"). The component vocabulary (`skills`, `commands`, `agents`, `hooks`, `mcpServers`) has converged even though the manifests are not interoperable. +- **There is no OCI-based distribution for multi-primitive plugin bundles anywhere.** Plugins are distributed exclusively through Git/GitHub marketplaces (`marketplace.json`). Docker `cagent` packages single agents as OCI; ToolHive packages single skills as OCI; the official MCP Registry stays a metadata layer over container images. **No one packages a full multi-primitive plugin bundle as an immutable, content-addressable, signable OCI artifact.** This is an open niche ToolHive is uniquely positioned to fill. +- **Teams cannot govern or distribute plugins.** Without ToolHive, plugin distribution means hand-curated GitHub repos with no digest pinning, no signing, no central catalog, no scoped install, and no inventory of what is installed across a fleet of developer machines. +- **Plugins carry executable code, which raises the stakes.** Unlike skills (pure Markdown instructions), a plugin bundles **hooks** (shell commands fired on lifecycle events) and **MCP server configs** (executable processes). Installing a plugin is a code-trust decision today made with zero supply-chain tooling. ToolHive's content-addressable + signed-artifact model is exactly the missing control plane. + +Skills proved the pattern. Plugins are the natural next artifact class, and the skills system was deliberately built on a generic foundation (`entries` table with an `entry_type` discriminator, `x/dev.toolhive/` registry namespace, shared OCI primitives) that anticipates exactly this extension. + +## Goals + +- Enable lifecycle management of plugins through dedicated `thv plugin` commands: `build`, `push`, `install`, `uninstall`, `list`, `info`, `validate`, and `builds`. +- Package a plugin directory tree (manifest + all component directories) into a **reproducible, content-addressable OCI artifact**, distributable through any OCI-compliant registry. +- Support installation from three sources, exactly as skills do: registry name (registry lookup), OCI reference, and `git://` URL (including `@ref` and `#subdir`). +- Reuse — not duplicate — the skills foundation: shared OCI primitives in `toolhive-core`, the SQLite `entries` table, the registry provider seam, the groups system, the git resolver, scopes, and multi-client `PathResolver` abstraction. +- Validate a plugin bundle before packaging or installing: `plugin.json` schema, each bundled component (reusing the skills validator for bundled skills), and filesystem safety. +- Surface the **executable surface** of a plugin (hooks, MCP server commands) to the user before install, and support digest pinning and signature verification. +- Bridge OCI distribution to native client tooling by generating a `marketplace.json` from a set of OCI-distributed plugins. + +## Non-Goals + +- **Authoring plugins.** ToolHive packages and distributes plugins; it does not scaffold or generate plugin content. `claude plugin init` and equivalents own authoring. +- **Running a plugin's runtime behavior.** ToolHive does not execute hooks, dispatch slash commands, or interpret the manifest at the client's runtime — the AI client does that. ToolHive's job ends at placing a validated bundle in the right location (and, optionally, managing the lifecycle of bundled MCP servers — see "Managed MCP servers from plugins" as a *forward-looking* extension, explicitly out of scope for v1 core). +- **A cross-client plugin format.** ToolHive distributes the existing `plugin.json` bundle format; it does not define a new manifest schema. Multi-manifest fan-out (emitting `.cursor-plugin/`, `.codex-plugin/`, etc.) is a packaging-time concern that can be layered on later. +- **Auto-updates / daemon-driven reconciliation.** Same posture as skills; post-MVP. +- **Kubernetes operator integration.** Plugins are client-side developer artifacts; CLI/API first. A `MCPPlugin` CRD, if ever warranted, is a separate RFC. +- **Hosting a marketplace.** ToolHive *generates* `marketplace.json` and serves plugin metadata via its registry API; it does not run a hosted public marketplace service. + +## Proposed Solution + +### High-Level Design + +The design is intentionally isomorphic to the skills system. Where skills have `pkg/skills` + `pkg/skills/skillsvc` + `toolhive-core/oci/skills`, plugins gain `pkg/plugins` + `pkg/plugins/pluginsvc` + `toolhive-core/oci/plugins`, sharing the same lower-level tar/gzip/extraction primitives, the same `entries` storage parent, the same registry provider seam, and the same git resolver. + +```mermaid +graph TB + subgraph Sources["Plugin Sources"] + OCI[OCI Registry
ghcr.io, ECR, ...] + Git[Git Repository
git://github.com/org/repo#subdir] + Local[Local Directory
.claude-plugin/plugin.json + components] + RegAPI[Registry API
Plugin Catalog] + end + + subgraph Service["ToolHive Plugin Service (pkg/plugins/pluginsvc)"] + SVC[PluginService] + Lookup[PluginLookup
registry name resolution] + GitRes[GitResolver
shared with skills] + OCIClient[OCI RegistryClient] + Packager[PluginPackager] + Installer[Installer
extract + validate] + Store[(SQLite
entries + installed_plugins)] + MKT[Marketplace Generator] + end + + subgraph FS["Client Filesystem"] + UserPlug["user scope
(per-client plugin dir)"] + ProjPlug["project scope
(per-client plugin dir)"] + end + + subgraph Access["Access Layer"] + CLI[thv plugin CLI] + API[REST API
/api/v1beta/plugins] + HTTP[Plugins HTTP Client] + end + + OCI --> OCIClient + Git --> GitRes + RegAPI --> Lookup + Local --> Packager + + CLI --> SVC + API --> SVC + HTTP --> API + + SVC --> Lookup & GitRes & OCIClient & Packager & Installer & Store & MKT + Installer --> UserPlug & ProjPlug + MKT -.generates marketplace.json.-> OCI + + style SVC fill:#90caf9,stroke:#1565c0,stroke-width:2px + style Store fill:#e3f2fd + style UserPlug fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px + style ProjPlug fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px + style CLI fill:#fff9c4 + style API fill:#fff9c4 +``` + +### Core Concepts + +#### What a plugin is, in ToolHive's model + +A plugin is a directory whose **only required file** is `.claude-plugin/plugin.json` (the manifest), with component directories living at the *plugin root* (not inside `.claude-plugin/`): + +``` +my-plugin/ +├── .claude-plugin/ +│ └── plugin.json # the manifest — the ONLY file in this dir +├── commands/*.md # slash commands +├── agents/*.md # subagents +├── skills//SKILL.md # bundled Agent Skills +├── hooks/hooks.json # lifecycle hooks (EXECUTABLE surface) +├── .mcp.json # MCP server configs (EXECUTABLE surface) +├── scripts/ bin/ # supporting executables referenced by hooks +└── LICENSE, CHANGELOG.md +``` + +The manifest fields ToolHive reads for packaging/cataloging (all optional except `name`): + +| Field | Used for | +|-------|----------| +| `name` (required, kebab-case) | identity, OCI tag default, command namespacing (`name:command`) | +| `version` | semver; OCI tag, upgrade detection (falls back to git SHA if absent) | +| `description` | catalog display, OCI annotation | +| `author` (object `{name,email,url}`) | catalog display, `org.opencontainers.image.authors` | +| `homepage`, `repository` (string), `license`, `keywords` | catalog metadata, `org.opencontainers.image.*` | +| `commands`, `agents`, `skills`, `hooks`, `mcpServers`, `lspServers` | component discovery / executable-surface inventory | + +ToolHive treats the manifest as **opaque-but-validated**: it parses the fields it understands, validates structural safety, records a metadata summary, and packages the whole tree verbatim. Unknown top-level fields are preserved (the manifest may double as a Cursor/Codex/npm manifest). + +#### Installation scopes and clients + +Identical model to skills: + +- **User scope** (default): available across all of the user's projects. +- **Project scope** (`--scope project --project-root .` or auto-detected git root): available only within a project. + +A `PathResolver` maps `(client, plugin-name, scope, project-root)` → install path per client, reusing the skills `PathResolver` pattern. When no `--clients` flag is passed, all plugin-supporting clients detected on the host are targeted (matching skills behavior). + +#### The install-target decision (the one genuinely new problem) + +Skills have a trivial install target: drop `/SKILL.md` into `~/.claude/skills/`. Plugins are harder, because Claude Code's *native* plugin install path involves a marketplace cache (`~/.claude/plugins/cache/`) plus an `enabledPlugins` entry in `settings.json` keyed `name@marketplace` — state ToolHive would have to mutate and own. + +There is, however, a much cleaner mechanism that Claude Code already supports: a **skills-directory plugin**. *Any* folder under a skills directory that contains `.claude-plugin/plugin.json` is loaded in-place as a plugin (reported as `@skills-dir`) with **no marketplace registration and no settings mutation** — this is exactly what `claude plugin init` produces. This is the install target ToolHive should use for v1: + +- **v1 (recommended): in-place skills-directory plugin.** ToolHive extracts the plugin tree to `//` (e.g. `~/.claude/skills//` for user scope). Claude Code auto-loads it. No `settings.json` mutation, the install/uninstall lifecycle is pure filesystem (mirroring skills exactly), and `${CLAUDE_PLUGIN_ROOT}` resolves correctly because the client computes it from the load location. +- **Alternative / future: marketplace-cache install.** Extract to `~/.claude/plugins/cache////` and add an `enabledPlugins` entry. More faithful to native plugins (versioned cache, enable/disable scoping) but requires owning `settings.json` state and the `name@marketplace` keying. Deferred. + +The `PathResolver` abstracts this per client, so a client whose only viable target is a marketplace cache can opt into the second strategy without changing the service layer. **This is flagged as an open question** — see Open Questions #1. + +### OCI Artifact Format + +Plugins reuse the skills OCI machinery with a distinct artifact type. A new `toolhive-core/oci/plugins` package mirrors its sibling `oci/skills`, and the **shared, artifact-agnostic primitives are lifted into a common subpackage** (see Component Changes) rather than copied. + +**Structure** (identical shape to skills — image index → per-platform manifests → image config + one shared tar.gz layer): + +``` +OCI Image Index (application/vnd.oci.image.index.v1+json, artifactType: dev.toolhive.plugins.v1) +└── Image Manifest (per platform; artifactType: dev.toolhive.plugins.v1) + ├── Image Config (application/vnd.oci.image.config.v1+json — metadata in labels) + └── Content Layer (application/vnd.oci.image.layer.v1.tar+gzip, title "plugin.tar.gz") + └── +``` + +**Media / artifact types:** + +| Element | Value | +|---------|-------| +| Artifact type (index + manifest) | `dev.toolhive.plugins.v1` | +| Manifest | `application/vnd.oci.image.manifest.v1+json` | +| Config | `application/vnd.oci.image.config.v1+json` | +| Layer | `application/vnd.oci.image.layer.v1.tar+gzip` | +| Index | `application/vnd.oci.image.index.v1+json` | + +**Config labels** (read at install) and **manifest annotations** (read by registry UIs), using the `dev.toolhive.plugins.*` namespace, mirroring `dev.toolhive.skills.*`: + +- `dev.toolhive.plugins.name`, `.description`, `.version`, `.license` +- `dev.toolhive.plugins.files` — JSON array of all packaged file paths +- `dev.toolhive.plugins.components` — JSON object summarizing the bundle, e.g. `{"commands":3,"agents":1,"skills":2,"hooks":4,"mcpServers":1}` — the *executable-surface inventory* surfaced by `thv plugin info` before install +- `org.opencontainers.image.created`, `.authors`, `.source`, `.licenses`, `.version` + +> **Design decision — single tar.gz layer vs. one layer per primitive.** ToolHive's skills use a single tar.gz layer; precedents split both ways (Helm/OPA: single tarball; Tekton/ORAS/Conftest: one typed layer per item with `org.opencontainers.image.title`). For v1 we choose the **single tar.gz layer** for consistency with skills, simplicity, and reproducibility. Per-primitive layers (enabling dedup and per-component addressability/signing) are a forward-compatible evolution — the artifact type can gain a `dev.toolhive.plugins.v2` with multi-layer semantics without breaking v1 consumers. See Alternatives Considered. + +**Reproducible packaging** (identical discipline to skills): deterministic tar (sorted entries, normalized mode/`ModTime` via `SOURCE_DATE_EPOCH`, UID/GID 0), deterministic gzip (`BestCompression`, OS byte 255, empty name/comment), so identical content always yields an identical digest — the precondition for digest pinning and signature verification. + +**Dependencies.** Plugins may declare dependencies (the manifest has a `dependencies` array). These are recorded as a `dev.toolhive.plugins.requires` annotation (JSON array of OCI references), mirroring `dev.toolhive.skills.requires`. Resolution of transitive plugin dependencies is **post-MVP** (Non-Goal-adjacent); v1 records but does not auto-install them. + +### Detailed Design + +#### Component Changes + +**New: `toolhive-core/oci/plugins`** — `PluginPackager`, `RegistryClient`, `Store` (rooted at `toolhive/plugins` under `xdg.DataHome`), `ArtifactTypePlugin`, labels/annotations. Built on ORAS, Docker credential store auth — same as `oci/skills`. + +**Refactor: extract shared OCI primitives.** The tar/gzip/extraction/validation helpers in `oci/skills` (`CreateTar`, `Compress`, `DecompressWithLimit`, `ExtractTarWithLimit`, the `validatingTarget` pull-hardening, the local-build marker `dev.stacklok.toolhive.local-build`) are already skill-agnostic and were explicitly designed to generalize. Lift them into a shared `toolhive-core/oci/internal` (or `oci/artifact`) package consumed by both `oci/skills` and `oci/plugins`. This is convergence, not duplication. + +**New: `pkg/plugins`** — types (`PluginManifest`, `Scope`, `PathResolver`, `InstalledPlugin`), `PluginService` interface, parser (`plugin.json`), validator, installer (extraction), options/DTOs. + +**New: `pkg/plugins/pluginsvc`** — the service implementation, structurally mirroring `pkg/skills/skillsvc`: `build.go`, `push` (in build.go), `install.go`, `install_oci.go`, `install_git.go`, `install_registry`, `uninstall.go`, `list.go`, `info`, `content.go`, `oci.go`, `local_build_marker.go`, `marketplace.go` (new — generator). + +**Reuse as-is:** `pkg/skills/gitresolver` (git `git://` resolution, SSRF + host-scoped auth) — generalize its name to `pkg/vcs/gitresolver` or consume it directly; `pkg/groups` (add a `Plugins []string` field + `AddPluginToGroup`/`RemovePluginFromAllGroups`, mirroring skills); `pkg/client` (extend the client metadata with plugin-path fields and a `SupportsPlugins` flag). + +#### CLI Commands + +All under the `thv plugin` namespace, mirroring `thv skill`. (Plugin commands require `thv serve`, like skills.) + +``` +thv plugin +├── install [name|oci-ref|git-url] Install from registry, OCI, or git +├── uninstall [name] Remove an installed plugin +├── list List installed plugins (table/json/yaml) +├── info [name] Show details incl. executable-surface inventory +├── validate [path] Validate a plugin directory +├── build [path] Build plugin dir into a local OCI artifact +├── push [reference] Push a built artifact to an OCI registry +├── builds List local builds +│ └── remove [tag] Delete a local build +└── marketplace + └── generate [refs...] Generate a marketplace.json from OCI refs +``` + +Selected flags (consistent with `thv skill`): + +``` +thv plugin install [flags] + --clients strings Target client(s) (default: all plugin-supporting clients) + --scope string user | project (default "user") + --project-root string Project root for project scope + --group string Add plugin to a group + --version string Version/tag to resolve for a plain name + --force Overwrite an existing unmanaged plugin + --require-signature Refuse to install unless a valid signature is present + --format string table | json | yaml + +thv plugin build [flags] + --tag string OCI tag/reference (default: name[:version] from plugin.json) + --platform strings default linux/amd64,linux/arm64 + +thv plugin info [flags] # prints components incl. hooks & MCP commands +thv plugin marketplace generate [...] --name --owner [-o marketplace.json] +``` + +**`thv plugin info` output** deliberately foregrounds the executable surface: + +``` +NAME my-plugin +VERSION v1.2.0 +SOURCE ghcr.io/org/plugins/my-plugin:v1.2.0 +DIGEST sha256:abc123… +SIGNATURE verified (cosign, ghcr.io/org/.github) # or: none +COMPONENTS commands=3 agents=1 skills=2 hooks=4 mcpServers=1 +HOOKS PreToolUse → scripts/scan.sh + PostToolUse → scripts/log.sh +MCP SERVERS my-server → node ./mcp/index.js +``` + +#### API Changes + +REST surface mirrors skills, mounted at `/api/v1beta/plugins`: + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/` | List installed plugins (filter scope, client, project_root, group) | +| `POST` | `/` | Install a plugin | +| `GET` | `/{name}` | Get plugin info (incl. component inventory) | +| `DELETE` | `/{name}` | Uninstall a plugin | +| `POST` | `/validate` | Validate a plugin directory/manifest | +| `POST` | `/build` | Build plugin to OCI artifact | +| `POST` | `/push` | Push a built plugin | +| `GET` | `/builds` | List local builds | +| `DELETE` | `/builds/{tag}` | Delete a local build | +| `GET` | `/content` | Get manifest + file listing for a reference (preview without install) | +| `POST` | `/marketplace` | Generate a marketplace.json for a set of references | + +Browsing (registry catalog), mirroring `registry_v01_skills.go`, under the proprietary extension namespace: + +| Method | Path | +|--------|------| +| `GET` | `/registry/{name}/v0.1/x/dev.toolhive/plugins` | +| `GET` | `/registry/{name}/v0.1/x/dev.toolhive/plugins/{namespace}/{pluginName}` | + +The Go service interface: + +```go +// pkg/plugins/service.go +type PluginService interface { + List(ctx context.Context, opts ListOptions) ([]InstalledPlugin, error) + Install(ctx context.Context, opts InstallOptions) (*InstallResult, error) + Uninstall(ctx context.Context, opts UninstallOptions) error + Info(ctx context.Context, opts InfoOptions) (*PluginInfo, error) + Validate(ctx context.Context, path string) (*ValidationResult, error) + Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) + Push(ctx context.Context, opts PushOptions) error + ListBuilds(ctx context.Context) ([]LocalBuild, error) + DeleteBuild(ctx context.Context, tag string) error + GetContent(ctx context.Context, opts ContentOptions) (*PluginContent, error) + GenerateMarketplace(ctx context.Context, opts MarketplaceOptions) (*Marketplace, error) +} +``` + +Registry provider seam (`pkg/registry/provider.go`) gains plugin methods with **no-op defaults on `BaseProvider`** (the exact extension pattern skills used): + +```go +type Provider interface { + // ... existing server + skill methods ... + ListAvailablePlugins() ([]types.Plugin, error) + GetPlugin(namespace, name string) (*types.Plugin, error) + SearchPlugins(query string) ([]types.Plugin, error) +} +``` + +#### Configuration Changes + +A bundled MCP server config inside a plugin (`.mcp.json`) is packaged verbatim; ToolHive does not rewrite it in v1. Example of what travels inside the artifact (unchanged Claude Code format): + +```json +{ + "mcpServers": { + "my-server": { "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/mcp/index.js"] } + } +} +``` + +The `marketplace generate` output (bridging OCI → native client tooling): + +```json +{ + "name": "acme-internal", + "owner": { "name": "Acme Platform", "url": "https://acme.example" }, + "plugins": [ + { + "name": "my-plugin", + "source": { "source": "github", "repo": "acme/my-plugin", "ref": "v1.2.0", "sha": "<40-hex>" }, + "description": "…", + "version": "v1.2.0" + } + ] +} +``` + +> Note: native `marketplace.json` `source` types are git-based (`github`/`url`/`git-subdir`/`npm`/relative path) — they do **not** include OCI references. The generator therefore emits git sources derived from the plugin's `repository`/source metadata, with `sha` pinning when known. ToolHive's *own* registry/CLI consumes the OCI artifact directly; the generated marketplace is the interoperability shim for clients that only speak the native format. + +#### Data Model Changes + +Reuse the generic `entries` parent table (the `entry_type` discriminator was designed for exactly this — see `docs/arch/12-skills-system.md` storage section). Add a new migration creating `installed_plugins` (and a satellite for the component inventory), referencing `entries(id)`; `oci_tags` is reused unchanged. + +```sql +-- migrations/00X_create_plugins.sql +-- entries (existing): id, entry_type ('plugin'), name, created_at, updated_at, UNIQUE(entry_type, name) + +CREATE TABLE installed_plugins ( + id INTEGER PRIMARY KEY, + entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + scope TEXT NOT NULL, -- user | project + project_root TEXT NOT NULL DEFAULT '', + reference TEXT NOT NULL, -- OCI ref or git URL + tag TEXT, + digest TEXT, -- for upgrade detection + pinning + version TEXT, + description TEXT, + author TEXT, + signature TEXT, -- verification status/issuer, nullable + client_apps BLOB, -- JSONB []string + components BLOB, -- JSONB component inventory + status TEXT NOT NULL, -- installed | pending | failed + installed_at TEXT NOT NULL, + UNIQUE (entry_id, scope, project_root) +); + +CREATE TABLE plugin_dependencies ( + installed_plugin_id INTEGER NOT NULL REFERENCES installed_plugins(id) ON DELETE CASCADE, + dep_name TEXT NOT NULL, + dep_reference TEXT NOT NULL, + dep_digest TEXT, + PRIMARY KEY (installed_plugin_id, dep_reference) +); +``` + +A natural hardening step (called out in the skills doc as a TODO): introduce a typed `EntryType` constant set in `pkg/storage` (`"skill"`, `"plugin"`) instead of the current stringly-typed literal. + +### Install / Build / Push flows + +These are the skills flows with a plugin manifest in place of `SKILL.md`. The install dispatch is identical: + +```mermaid +flowchart TD + A[thv plugin install ] --> B{Reference type?} + B -->|git://| C[Git resolver: clone + extract subdir] + B -->|has / : or @| D[OCI pull] + B -->|plain name| E[Local store, then registry lookup] + E --> D + C --> V[Validate plugin.json + components + fs safety] + D --> P[Pull → verify digest → decompress → extract tar.gz] + P --> SIG{--require-signature?} + SIG -->|yes| SV[Verify cosign signature via Referrers API] + SIG -->|no| V + SV --> V + V --> SC[Supply-chain check: manifest name == repo last component] + SC --> W[Write to client plugin dir, sanitize perms, no symlinks/traversal] + W --> R[Create DB record] + R --> G{--group?} + G -->|yes| GA[Add to group] + G -->|no| Done[Done] + GA --> Done + + style A fill:#e3f2fd + style Done fill:#c8e6c9 + style V fill:#fff3e0 + style W fill:#fff3e0 + style SV fill:#fff3e0 +``` + +- **Per-plugin locking** keyed `(scope, name, projectRoot)`, as skills do. +- **Supply-chain check**: the manifest `name` must equal the last path component of the OCI repository (mirrors the skills check). +- **Extraction safety** is inherited wholesale from the shared primitives (no symlinks/hardlinks, no `..`/absolute paths, perms masked to 0644, size/file-count caps, pre- and post-extraction filesystem verification). + +## Security Considerations + +**This is the section where plugins genuinely differ from skills.** A skill is inert Markdown read by the model. A plugin bundles **hooks** (shell commands executed deterministically by the client on lifecycle events) and **MCP server configs** (executable processes). **Installing a plugin is granting code-execution trust.** The whole point of putting plugins on ToolHive's content-addressable + signed OCI rail is to bring supply-chain controls to a decision currently made with none. + +### Threat Model + +| Threat | Description | Likelihood | Impact | +|--------|-------------|------------|--------| +| Malicious hook | Plugin ships a `hooks.json` whose command exfiltrates data or runs on `PreToolUse`/`SessionStart` | Medium | **Critical** | +| Malicious bundled MCP server | `.mcp.json` points at a server that does anything | Medium | **Critical** | +| Trojaned update | A new tag for a trusted plugin adds a hook | Medium | High | +| Registry compromise / tag mutation | Attacker overwrites a tag with a malicious bundle | Low | High | +| Path traversal / symlink in archive | Bundle writes outside the install dir or overwrites sensitive files | Medium | High | +| Typosquatting | `my-plugin` vs `my-plugln` in a catalog | Medium | High | +| Prompt-injection via bundled instructions | Commands/agents/skills carry adversarial instructions | Medium | Medium | +| Inventory blind spot | Fleet has plugins with hooks nobody audited | High | Medium | + +### Authentication and Authorization + +- Registry auth delegates to Docker credential helpers (`~/.docker/config.json`); no new credential store, identical to skills. +- No privilege escalation by ToolHive: extracted files are written with sanitized, non-executable-by-default perms (0644) under the user's own directories. Hooks become executable only because the *client* runs them — ToolHive never executes plugin code during install. + +### Data Security + +- Plugin artifacts and metadata are public content; no secrets are stored by the plugin manager. +- `userConfig`-style secrets (a plugin manifest can declare config options, some `sensitive`) are **not** populated by ToolHive at install; they remain the client's keychain concern. ToolHive must never persist values for `sensitive` config options. (Open Question #3.) + +### Input Validation + +| Input | Validation | Rejection | +|-------|------------|-----------| +| Plugin name | `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`, no consecutive hyphens | invalid chars/length | +| `plugin.json` | well-formed JSON; correct field *types* (e.g. `keywords` must be array — a string is a hard error, matching client behavior); `name` present | malformed / wrong types | +| Component paths in manifest | must be relative, must start with `./`, no `../` | traversal | +| Archive entries | no `..`, no absolute paths, no symlinks/hardlinks, non-regular files rejected | traversal / link attacks | +| OCI reference | standard parse + name-matches-repo supply-chain check | malformed / mismatch | +| git ref | shell-injection char rejection; SSRF host checks | injection / SSRF | +| Bundled SKILL.md | reuse the skills validator | invalid frontmatter | + +### Secrets Management + +- No secrets stored by the plugin manager; registry credentials via Docker helpers, accessed on demand, never passed into plugins or recorded in metadata. + +### Audit and Logging + +Like skills, plugins are client-side constructs that bypass ToolHive's proxy middleware, so they are not captured by the existing HTTP audit path. **Plugins raise the audit stakes** (executable surface), so: + +- v1 logs structured events from the CLI/API service layer: `plugin_installed`, `plugin_uninstalled`, `plugin_upgraded`, `plugin_built`, `plugin_pushed`, each with name, digest, source, signature status, and the component inventory (notably hook count). This gives a fleet a queryable inventory of *what executable plugin surface is installed where*. +- A full audit-architecture for client-side artifacts remains the same separate-RFC concern flagged in RFC-0030, but the install-time event log is in scope here because the executable surface makes "who installed what hook" a real security need. + +### Mitigations + +| Threat | Mitigation (v1) | Future | +|--------|-----------------|--------| +| Malicious hook / MCP server | `thv plugin info` surfaces the **executable-surface inventory** (hooks + MCP commands) before install; install-time event logging | policy: refuse hooks not on an allowlist; org policy profiles | +| Trojaned update | digest recorded; upgrades show digest change; component-inventory diff on upgrade | require re-approval when hook set changes | +| Registry compromise / tag mutation | content-addressable digests; `--require-signature` verifies cosign signatures via the **OCI Referrers API** (`subject` + `GET /v2//referrers/`) | signature required by default; org trust roots | +| Path traversal / symlink | shared extraction hardening (reused from skills) | — | +| Typosquatting | registry catalog is curated; supply-chain name==repo check | namespace ownership verification | +| Managed MCP isolation | (forward-looking) running bundled MCP servers *via thv* gains network isolation + permission profiles instead of the client spawning them unsandboxed | first-class "managed MCP from plugin" | +| Inventory blind spot | SQLite inventory + `thv plugin list --format json` across fleet | dashboards / cloud UI | + +## Alternatives Considered + +### Alternative 1: Treat a plugin as "just another skill" + +- **Description**: Reuse the skills artifact type and pipeline unchanged; a plugin is a skill whose directory happens to contain `plugin.json`. +- **Pros**: Zero new code. +- **Cons**: Conflates two artifact classes with different validation, different metadata, and critically different *risk* (executable vs inert). The catalog could not distinguish them; the executable-surface inventory and signature-required-by-policy story would have no home. +- **Why not chosen**: Skills and servers were deliberately kept as separate systems sharing infrastructure; plugins follow that precedent. Distinct `artifactType` + table + CLI namespace, shared primitives. + +### Alternative 2: One OCI layer per primitive (Tekton/ORAS model) + +- **Description**: Each command/agent/skill/hook/MCP-config becomes its own typed layer with `org.opencontainers.image.title`. +- **Pros**: Per-component addressability, dedup across plugins, per-component signing. +- **Cons**: More complex packaging/extraction, diverges from the skills single-layer model, marginal benefit at current scale. +- **Why not chosen for v1**: Single tar.gz layer matches skills and is simpler; the artifact type is versioned so a future `dev.toolhive.plugins.v2` can adopt multi-layer without breaking v1. + +### Alternative 3: Don't use OCI; mirror native Git marketplaces only + +- **Description**: ToolHive just adds/install-from `marketplace.json` Git repos. +- **Pros**: Native, zero packaging. +- **Cons**: No digest pinning, no signing, no immutable artifacts, no unified catalog with servers/skills, no content-addressable supply chain — i.e. abandons ToolHive's entire value proposition. +- **Why not chosen**: OCI is the differentiator. We *generate* marketplace.json as a bridge (best of both). + +### Alternative 4: Marketplace-cache install with settings.json mutation (v1) + +- **Description**: Install exactly as native plugins do (cache dir + `enabledPlugins`). +- **Pros**: Faithful to native lifecycle (versioned cache, enable/disable). +- **Cons**: ToolHive must own and mutate client `settings.json` across scopes and the `name@marketplace` keying; brittle and client-specific. +- **Why not chosen for v1**: The in-place skills-directory-plugin mechanism gives a pure-filesystem lifecycle identical to skills. Marketplace-cache install is kept as a per-client `PathResolver` strategy for later. + +### Alternative 5: JSON-file state instead of SQLite + +- **Description**: Per-plugin metadata + index files (as RFC-0030 originally proposed for skills). +- **Pros**: Human-inspectable. +- **Cons**: The ecosystem has since standardized on SQLite (RFC-0041, the `entries` table). Diverging would fragment storage. +- **Why not chosen**: Reuse the `entries`/`entry_type` foundation that was built for this. + +## Compatibility + +### Backward Compatibility + +- **Additive only.** New `thv plugin` namespace; existing `thv`, `thv skill`, and MCP server commands are untouched. +- **Storage**: new tables off the existing `entries` parent via a new migration; no change to existing skills/server tables. +- **toolhive-core**: new `oci/plugins` package; the shared-primitive extraction is an internal refactor that preserves `oci/skills`'s public behavior (covered by existing tests). +- **Manifest fidelity**: plugins are packaged verbatim, so a plugin installed via ToolHive is byte-identical to one installed natively (same `plugin.json`, same component layout) — a plugin remains usable without ToolHive. + +### Forward Compatibility + +- **Versioned artifact type** (`dev.toolhive.plugins.v1`) leaves room for multi-layer v2. +- **`entry_type` discriminator** already accommodates further artifact classes. +- **`PathResolver` per client** lets the marketplace-cache install strategy land later without service changes. +- **Provider no-op defaults** let registries adopt plugin catalogs incrementally. +- **Referrers-based signing** composes with future SBOM/provenance attachments on the same digest. +- **Managed-MCP-from-plugin** can be added as an opt-in install behavior (run bundled `.mcp.json` servers through `thv` with isolation) without changing the artifact format. + +## Implementation Plan + +### Phase 1: Shared foundation (toolhive-core) +- Extract artifact-agnostic tar/gzip/extraction/`validatingTarget`/local-build-marker primitives from `oci/skills` into a shared subpackage; keep `oci/skills` behavior intact. +- Add `oci/plugins`: `ArtifactTypePlugin`, labels/annotations, `Packager`, `RegistryClient`, `Store`. + +### Phase 2: Core service + validation (toolhive) +- `pkg/plugins` types, `plugin.json` parser, validator (reusing skills validator for bundled skills), installer (extraction). +- `pkg/plugins/pluginsvc` with `Build`, `Push`, `Validate`, local `Store`, local-build marker. +- New SQLite migration + `PluginStore`; introduce typed `EntryType`. + +### Phase 3: Install / list / info / uninstall +- OCI install (`install_oci.go`), git install (reuse resolver), registry-name install. +- `List`, `Info` (with executable-surface inventory), `Uninstall`; groups integration (`Plugins []string` + helpers). +- Multi-client `PathResolver`: implement the in-place skills-directory-plugin strategy. + +### Phase 4: API + CLI + content preview +- REST endpoints under `/api/v1beta/plugins`; HTTP client (`pkg/plugins/client`). +- `thv plugin` cobra commands; `GetContent` preview. + +### Phase 5: Registry catalog + marketplace bridge + signing +- `Plugin` type in toolhive-core registry types; provider methods + no-op defaults; `/v0.1/x/dev.toolhive/plugins` routes; `PluginsClient`; toolhive-registry-server support. +- `marketplace generate`. +- `--require-signature` via cosign + Referrers API. + +### Dependencies +- ORAS (`oras.land/oras-go/v2`), already used by skills. +- Existing `pkg/skills/gitresolver`, `pkg/groups`, `pkg/client`, `pkg/storage/sqlite`. +- cosign/sigstore libraries for Phase 5 signature verification. + +## Testing Strategy + +- **Unit**: `plugin.json` parser/validator (incl. wrong-type rejection), packager determinism (identical digest for identical input), extraction safety, component-inventory extraction, marketplace generator. +- **Integration**: each CLI command against a mock OCI registry; build→push→install→list→uninstall round-trip; git install with subdir/ref; group membership. +- **E2E**: full workflow against a real registry (GHCR) and verification that an installed plugin loads in Claude Code (skills-directory-plugin path). +- **Compatibility**: byte-identical round-trip (native plugin → build → install → compare tree). +- **Security**: path-traversal/symlink/oversized-archive fixtures; supply-chain name-mismatch rejection; signature-required failure paths; injection/SSRF in git refs. +- **Performance**: large-bundle packaging within size caps; reproducibility under `SOURCE_DATE_EPOCH`. + +## Documentation + +- New `docs/arch/14-plugins-system.md` mirroring `12-skills-system.md`. +- `thv plugin` CLI reference; user guide "Managing Plugins with ToolHive"; "Distributing plugins via OCI and generating a marketplace". +- Security guide: "Understanding a plugin's executable surface" + signature workflow. +- Client support matrix (which clients support plugins and via which install strategy). +- Update the `toolhive-cli-user` skill to cover `thv plugin`. + +## Open Questions + +1. **Install target per client.** v1 recommends the in-place skills-directory-plugin strategy (no `settings.json` mutation). Should any v1 client instead use the marketplace-cache + `enabledPlugins` strategy, and do we need a dedicated plugins directory distinct from the skills directory to avoid `thv skill list` / `thv plugin list` overlap? +2. **Component-inventory diff on upgrade.** Should an upgrade that *adds* a hook or MCP server require explicit re-approval (`--accept-new-executables`) by default? +3. **`userConfig` / sensitive options.** Plugins can declare config (some `sensitive`). Does ToolHive stay entirely hands-off (client keychain owns it), or offer to wire values from ToolHive's secrets manager at install time? +4. **Managed MCP servers from plugins.** Out of scope for v1 core, but is it the headline v2 feature — running a plugin's bundled MCP servers through `thv` (network isolation, permission profiles, audit) instead of the client spawning them unsandboxed? +5. **Dependency resolution.** v1 records `requires`; when do we auto-install transitive plugin/skill dependencies, and how do we avoid cycles? +6. **Multi-manifest packaging.** Do we ever emit sibling `.cursor-plugin/` / `.codex-plugin/` manifests at build time for cross-client install, or leave that to authors? + +## References + +- [RFC-0030: Skills Lifecycle Management in ToolHive CLI](./THV-0030-skills-lifecycle-management.md) +- [RFC-0029: Skills Support in Registry Server](./THV-0029-skills-support-registry-server.md) +- [RFC-0041: SQLite State Management](./THV-0041-sqlite-state-management.md) +- [RFC-0032: toolhive-core Shared Library](./THV-0032-toolhive-core-shared-library.md) +- `toolhive/docs/arch/12-skills-system.md` — the architecture this RFC mirrors +- `toolhive-core/oci/skills` — the OCI packaging this RFC parallels +- [Claude Code plugins reference](https://code.claude.com/docs/en/plugins-reference) and [marketplaces](https://code.claude.com/docs/en/plugin-marketplaces) +- [Claude Code hooks reference](https://code.claude.com/docs/en/hooks) +- [MCPB bundle spec](https://github.com/modelcontextprotocol/mcpb) — single-server packaging contrast +- [OCI image-spec 1.1 `artifactType` + `subject`](https://github.com/opencontainers/image-spec/blob/main/manifest.md) and [Referrers API (distribution-spec)](https://github.com/opencontainers/distribution-spec/blob/main/spec.md) +- [ORAS — OCI Registry As Storage](https://oras.land/docs/) — multi-file artifact packaging +- [Helm OCI media types](https://helm.sh/blog/helm-oci-mediatypes/) — single-tarball-layer precedent +- [Agent Skills specification](https://agentskills.io/specification) — the cross-vendor `SKILL.md` standard +- Cross-client bundle manifests (convergent, non-interoperable): [Cursor plugins](https://cursor.com/docs/reference/plugins), [Gemini CLI extensions](https://github.com/google-gemini/gemini-cli/blob/main/docs/extensions/index.md), [AGENTS.md convention](https://agents.md) + +--- + +## RFC Lifecycle + +### Review History + +| Date | Reviewer | Decision | Notes | +|------|----------|----------|-------| +| 2026-06-15 | - | Draft | Initial submission | + +### Implementation Tracking + +| Repository | PR | Status | +|------------|-----|--------| +| toolhive | - | Pending | +| toolhive-core | - | Pending | +| toolhive-registry-server | - | Pending | From fd78e0c9d8dbe382418ccaaa931fe1ff6a3afbe7 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 15 Jun 2026 08:05:14 +0300 Subject: [PATCH 2/8] RFC-0077: rename to match PR number and update title Co-Authored-By: Claude Opus 4.8 --- ...e-management.md => THV-0077-plugins-lifecycle-management.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename rfcs/{THV-XXXX-plugins-lifecycle-management.md => THV-0077-plugins-lifecycle-management.md} (99%) diff --git a/rfcs/THV-XXXX-plugins-lifecycle-management.md b/rfcs/THV-0077-plugins-lifecycle-management.md similarity index 99% rename from rfcs/THV-XXXX-plugins-lifecycle-management.md rename to rfcs/THV-0077-plugins-lifecycle-management.md index 49ef6f3..266d552 100644 --- a/rfcs/THV-XXXX-plugins-lifecycle-management.md +++ b/rfcs/THV-0077-plugins-lifecycle-management.md @@ -1,4 +1,4 @@ -# RFC-XXXX: Plugin Lifecycle Management in ToolHive +# RFC-0077: Plugin Lifecycle Management in ToolHive - **Status**: Draft - **Author(s)**: Juan Antonio Osorio (@JAORMX) From 15d04bbdb80d5611e9fafea031064d4b688af591 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 15 Jun 2026 08:11:45 +0300 Subject: [PATCH 3/8] RFC-0077: fix Mermaid diagram rendering errors Dotted-edge label collided with the .-> arrow syntax; angle brackets and special characters in flowchart labels broke the lexer. Use pipe-label form and quote labels containing special characters. Co-Authored-By: Claude Opus 4.8 --- rfcs/THV-0077-plugins-lifecycle-management.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/rfcs/THV-0077-plugins-lifecycle-management.md b/rfcs/THV-0077-plugins-lifecycle-management.md index 266d552..335a406 100644 --- a/rfcs/THV-0077-plugins-lifecycle-management.md +++ b/rfcs/THV-0077-plugins-lifecycle-management.md @@ -89,7 +89,7 @@ graph TB SVC --> Lookup & GitRes & OCIClient & Packager & Installer & Store & MKT Installer --> UserPlug & ProjPlug - MKT -.generates marketplace.json.-> OCI + MKT -.->|generates marketplace.json| OCI style SVC fill:#90caf9,stroke:#1565c0,stroke-width:2px style Store fill:#e3f2fd @@ -386,10 +386,10 @@ These are the skills flows with a plugin manifest in place of `SKILL.md`. The in ```mermaid flowchart TD - A[thv plugin install ] --> B{Reference type?} - B -->|git://| C[Git resolver: clone + extract subdir] - B -->|has / : or @| D[OCI pull] - B -->|plain name| E[Local store, then registry lookup] + A["thv plugin install (source)"] --> B{Reference type?} + B -->|"git:// scheme"| C["Git resolver: clone + extract subdir"] + B -->|"slash, colon or at"| D[OCI pull] + B -->|"plain name"| E[Local store, then registry lookup] E --> D C --> V[Validate plugin.json + components + fs safety] D --> P[Pull → verify digest → decompress → extract tar.gz] @@ -397,8 +397,8 @@ flowchart TD SIG -->|yes| SV[Verify cosign signature via Referrers API] SIG -->|no| V SV --> V - V --> SC[Supply-chain check: manifest name == repo last component] - SC --> W[Write to client plugin dir, sanitize perms, no symlinks/traversal] + V --> SC["Supply-chain check: manifest name equals repo last component"] + SC --> W["Write to client plugin dir, sanitize perms, no symlinks/traversal"] W --> R[Create DB record] R --> G{--group?} G -->|yes| GA[Add to group] From c05ae050406f1647292cc25eeeae0b39cae5f05f Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 15 Jun 2026 08:20:30 +0300 Subject: [PATCH 4/8] RFC-0077: clarify skills-directory plugin loads full component set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make explicit (verified against current Claude Code docs) that a skills-directory plugin activates all components — commands, agents, hooks, MCP, LSP, skills — not just skills. Add project-scope trust caveats (MCP per-server approval, LSP trust, monitors don't load, no repo-root walk-up) and note the session-only --plugin-dir/--plugin-url alternatives. Co-Authored-By: Claude Opus 4.8 --- rfcs/THV-0077-plugins-lifecycle-management.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/rfcs/THV-0077-plugins-lifecycle-management.md b/rfcs/THV-0077-plugins-lifecycle-management.md index 335a406..b418eb8 100644 --- a/rfcs/THV-0077-plugins-lifecycle-management.md +++ b/rfcs/THV-0077-plugins-lifecycle-management.md @@ -144,12 +144,18 @@ A `PathResolver` maps `(client, plugin-name, scope, project-root)` → install p Skills have a trivial install target: drop `/SKILL.md` into `~/.claude/skills/`. Plugins are harder, because Claude Code's *native* plugin install path involves a marketplace cache (`~/.claude/plugins/cache/`) plus an `enabledPlugins` entry in `settings.json` keyed `name@marketplace` — state ToolHive would have to mutate and own. -There is, however, a much cleaner mechanism that Claude Code already supports: a **skills-directory plugin**. *Any* folder under a skills directory that contains `.claude-plugin/plugin.json` is loaded in-place as a plugin (reported as `@skills-dir`) with **no marketplace registration and no settings mutation** — this is exactly what `claude plugin init` produces. This is the install target ToolHive should use for v1: +There is, however, a much cleaner mechanism that Claude Code already supports: a **skills-directory plugin**. *Any* folder under a skills directory that contains `.claude-plugin/plugin.json` is loaded in-place as a plugin (reported as `@skills-dir`) with **no marketplace registration and no settings mutation** — this is exactly what `claude plugin init` produces. -- **v1 (recommended): in-place skills-directory plugin.** ToolHive extracts the plugin tree to `//` (e.g. `~/.claude/skills//` for user scope). Claude Code auto-loads it. No `settings.json` mutation, the install/uninstall lifecycle is pure filesystem (mirroring skills exactly), and `${CLAUDE_PLUGIN_ROOT}` resolves correctly because the client computes it from the load location. +**Critically, this loads the *full* plugin, not just its skills.** The skills directory is a genuine plugin auto-discovery location: a folder there with only `SKILL.md` is a plain skill, but a folder containing `.claude-plugin/plugin.json` activates the complete component set — commands, agents, hooks, MCP servers, LSP servers, *and* skills. The Claude Code docs (§ Skills-directory plugins) describe it as "a plugin `@skills-dir`, which can bundle its own skills, agents, hooks, and more," and `claude plugin init --with agents|hooks|mcp|lsp` scaffolds precisely this layout. So a ToolHive-installed plugin's commands/agents/hooks/MCP all go live — the skills directory is just the *discovery path*, not a reduction to skill-only. This is the install target ToolHive should use for v1: + +- **v1 (recommended): in-place skills-directory plugin.** ToolHive extracts the plugin tree to `//` (e.g. `~/.claude/skills//` for user scope). Claude Code auto-loads **all components** next session. No `settings.json` mutation, the install/uninstall lifecycle is pure filesystem (mirroring skills exactly), and `${CLAUDE_PLUGIN_ROOT}` resolves correctly because the client computes it from the load location. - **Alternative / future: marketplace-cache install.** Extract to `~/.claude/plugins/cache////` and add an `enabledPlugins` entry. More faithful to native plugins (versioned cache, enable/disable scoping) but requires owning `settings.json` state and the `name@marketplace` keying. Deferred. -The `PathResolver` abstracts this per client, so a client whose only viable target is a marketplace cache can opt into the second strategy without changing the service layer. **This is flagged as an open question** — see Open Questions #1. +**Scope caveat (Claude Code, verified against current docs).** Full-component loading is clean in **user (personal) scope**. In **project scope** (`.claude/skills/`) it degrades: MCP servers require per-server approval, LSP servers require workspace trust, and *monitors do not load at all*. Project-scope `@skills-dir` plugins also do **not** walk up to the repository root — they load only from the `.claude/skills/` of the directory where the client was started. ToolHive should document this and, for project scope, surface which components will be gated. (These trust gates are arguably a feature, not a bug, for the executable surface — see Security Considerations.) + +For completeness, Claude Code also supports two no-settings, **session-only** mechanisms that load all components — `claude --plugin-dir ./path` and `claude --plugin-url ` — and a persistent local-marketplace path (`/plugin marketplace add ./path`) that loads everything but *does* mutate settings (`extraKnownMarketplaces`). These are useful for `thv plugin` dev/test workflows but are not the managed-install target. + +The `PathResolver` abstracts the install target per client, so a client whose only viable target is a marketplace cache can opt into the second strategy without changing the service layer. **The per-client target choice is flagged as an open question** — see Open Questions #1. ### OCI Artifact Format From f838ea7540aadc670c54b8c7fa70490be42f3f58 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 15 Jun 2026 08:27:27 +0300 Subject: [PATCH 5/8] RFC-0077: split into client-agnostic distribution + per-client materialization Plugin bundle formats never converged (unlike skills' SKILL.md standard), so a single tree does not install across clients. Reframe the design into two layers: a fully client-agnostic distribution layer (build/OCI/push/catalog/ pull/verify/inventory) and a per-client materialization layer behind a MaterializationAdapter seam. Scope v1 materialization to the .claude-plugin family (Claude Code + Codex), with Cursor/Copilot/Gemini as future adapters. Add the "plugin formats did not converge" design constraint, the adapter interface, a multi-manifest alternative, and corrected goals/non-goals/ open-questions/forward-compatibility. Co-Authored-By: Claude Opus 4.8 --- rfcs/THV-0077-plugins-lifecycle-management.md | 89 ++++++++++++++----- 1 file changed, 69 insertions(+), 20 deletions(-) diff --git a/rfcs/THV-0077-plugins-lifecycle-management.md b/rfcs/THV-0077-plugins-lifecycle-management.md index b418eb8..95ce952 100644 --- a/rfcs/THV-0077-plugins-lifecycle-management.md +++ b/rfcs/THV-0077-plugins-lifecycle-management.md @@ -9,13 +9,15 @@ ## Summary -This RFC proposes adding **plugin** lifecycle management to ToolHive, mirroring the existing skills system. A *plugin* is the cross-vendor "bundle of primitives" unit pioneered by Claude Code — a directory declared by a `.claude-plugin/plugin.json` manifest that bundles any combination of slash commands, subagents, Agent Skills, hooks, MCP server configurations, and LSP servers. ToolHive will let users **build** a plugin directory into a reproducible OCI artifact, **push** it to any OCI registry, **install** it to one or more AI clients (from a registry name, OCI reference, or `git://` URL), and **list/info/uninstall** it — using the same registry, OCI, groups, and storage infrastructure that already serves skills and MCP servers. As a bridge to native client tooling, ToolHive can also generate a `marketplace.json` from a set of OCI-distributed plugins. +This RFC proposes adding **plugin** lifecycle management to ToolHive. A *plugin* is the "bundle of primitives" unit pioneered by Claude Code — a directory declared by a `.claude-plugin/plugin.json` manifest that bundles any combination of slash commands, subagents, Agent Skills, hooks, MCP server configurations, and LSP servers. ToolHive will let users **build** a plugin directory into a reproducible OCI artifact, **push** it to any OCI registry, **install** it (from a registry name, OCI reference, or `git://` URL), and **list/info/uninstall** it — using the same registry, OCI, groups, and storage infrastructure that already serves skills and MCP servers. + +**A key difference from skills shapes the entire design.** Skills converged on one open standard (`SKILL.md` is read by ~30 clients), so "install to N clients" was just "drop one file in N directories." **Plugin bundle formats never converged** — every client has its own manifest *and* its own discovery rules. The design therefore splits cleanly into two layers: a **client-agnostic distribution layer** (build → OCI → push → catalog → pull → verify → inventory — the bulk of ToolHive's value, identical for every client) and a **per-client materialization layer** (turning a pulled bundle into something a specific client loads). v1 makes the distribution layer fully client-neutral and implements materialization for the `.claude-plugin/plugin.json` family that consumes it natively — **Claude Code and Codex** (which reads `.claude-plugin/plugin.json` as a fallback). Other clients are served by future materialization adapters behind a stable seam. As a bridge to native client tooling, ToolHive can also generate a `marketplace.json` from a set of OCI-distributed plugins. ## Problem Statement ToolHive already manages two of the three artifact classes an AI coding workflow consumes: **MCP servers** (as containers) and **skills** (as OCI artifacts, see [RFC-0030](./THV-0030-skills-lifecycle-management.md) and `docs/arch/12-skills-system.md`). The third class — **plugins** — is now the de-facto packaging unit across the ecosystem, and ToolHive has no story for it. -- **Plugins are the industry-convergent bundle unit.** Within ~6 months of Claude Code's October 2025 launch, near-identical bundle systems shipped in Cursor (`.cursor-plugin/plugin.json`), OpenAI Codex CLI (`.codex-plugin/plugin.json` — which even falls back to reading `.claude-plugin/plugin.json`), GitHub Copilot (`.github/plugin.json`), Gemini CLI (`gemini-extension.json`), and AWS Kiro ("Powers"). The component vocabulary (`skills`, `commands`, `agents`, `hooks`, `mcpServers`) has converged even though the manifests are not interoperable. +- **Plugins are the bundle unit every client adopted — but the format did not converge.** Within ~6 months of Claude Code's October 2025 launch, near-identical bundle systems shipped in Cursor (`.cursor-plugin/plugin.json`), OpenAI Codex CLI (`.codex-plugin/plugin.json` — which also reads `.claude-plugin/plugin.json` as a fallback), GitHub Copilot (`.github/plugin.json`), Gemini CLI (`gemini-extension.json`), and AWS Kiro ("Powers"). The *component vocabulary* (`skills`, `commands`, `agents`, `hooks`, `mcpServers`) converged, but the **manifests are not interoperable** and discovery rules differ (e.g. Gemini's slash commands are `commands/*.toml`, not `*.md`). This is the central constraint of this RFC: unlike skills, a single plugin tree does **not** install unmodified across clients. See "Design constraint: plugin formats did not converge." - **There is no OCI-based distribution for multi-primitive plugin bundles anywhere.** Plugins are distributed exclusively through Git/GitHub marketplaces (`marketplace.json`). Docker `cagent` packages single agents as OCI; ToolHive packages single skills as OCI; the official MCP Registry stays a metadata layer over container images. **No one packages a full multi-primitive plugin bundle as an immutable, content-addressable, signable OCI artifact.** This is an open niche ToolHive is uniquely positioned to fill. - **Teams cannot govern or distribute plugins.** Without ToolHive, plugin distribution means hand-curated GitHub repos with no digest pinning, no signing, no central catalog, no scoped install, and no inventory of what is installed across a fleet of developer machines. - **Plugins carry executable code, which raises the stakes.** Unlike skills (pure Markdown instructions), a plugin bundles **hooks** (shell commands fired on lifecycle events) and **MCP server configs** (executable processes). Installing a plugin is a code-trust decision today made with zero supply-chain tooling. ToolHive's content-addressable + signed-artifact model is exactly the missing control plane. @@ -25,27 +27,46 @@ Skills proved the pattern. Plugins are the natural next artifact class, and the ## Goals - Enable lifecycle management of plugins through dedicated `thv plugin` commands: `build`, `push`, `install`, `uninstall`, `list`, `info`, `validate`, and `builds`. -- Package a plugin directory tree (manifest + all component directories) into a **reproducible, content-addressable OCI artifact**, distributable through any OCI-compliant registry. -- Support installation from three sources, exactly as skills do: registry name (registry lookup), OCI reference, and `git://` URL (including `@ref` and `#subdir`). -- Reuse — not duplicate — the skills foundation: shared OCI primitives in `toolhive-core`, the SQLite `entries` table, the registry provider seam, the groups system, the git resolver, scopes, and multi-client `PathResolver` abstraction. +- Make the **distribution layer fully client-agnostic**: package a plugin tree (manifest + all component directories) into a **reproducible, content-addressable OCI artifact** distributable through any OCI registry, with catalog/discovery, digest pinning, and signature verification — none of which depends on the consuming client. +- Support the same three install sources as skills: registry name (registry lookup), OCI reference, and `git://` URL (including `@ref` and `#subdir`). +- Implement the **materialization layer for the `.claude-plugin/plugin.json` family in v1** — Claude Code and Codex (via its `.claude-plugin` fallback) — behind a per-client adapter seam that lets Cursor/Copilot/Gemini be added later without changing the service or artifact format. +- Reuse — not duplicate — the skills foundation: shared OCI primitives in `toolhive-core`, the SQLite `entries` table, the registry provider seam, the groups system, the git resolver, and scopes. Extend the `pkg/client` abstraction with a per-client **materialization adapter** (the plugin analog of the skills `PathResolver`, but doing more than path mapping). - Validate a plugin bundle before packaging or installing: `plugin.json` schema, each bundled component (reusing the skills validator for bundled skills), and filesystem safety. -- Surface the **executable surface** of a plugin (hooks, MCP server commands) to the user before install, and support digest pinning and signature verification. -- Bridge OCI distribution to native client tooling by generating a `marketplace.json` from a set of OCI-distributed plugins. +- Surface the **executable surface** of a plugin (hooks, MCP server commands) to the user before install. +- Bridge OCI distribution to native client tooling by generating a `marketplace.json` from a set of OCI-distributed plugins (so even clients without a ToolHive materialization adapter can consume ToolHive-distributed plugins). ## Non-Goals - **Authoring plugins.** ToolHive packages and distributes plugins; it does not scaffold or generate plugin content. `claude plugin init` and equivalents own authoring. - **Running a plugin's runtime behavior.** ToolHive does not execute hooks, dispatch slash commands, or interpret the manifest at the client's runtime — the AI client does that. ToolHive's job ends at placing a validated bundle in the right location (and, optionally, managing the lifecycle of bundled MCP servers — see "Managed MCP servers from plugins" as a *forward-looking* extension, explicitly out of scope for v1 core). -- **A cross-client plugin format.** ToolHive distributes the existing `plugin.json` bundle format; it does not define a new manifest schema. Multi-manifest fan-out (emitting `.cursor-plugin/`, `.codex-plugin/`, etc.) is a packaging-time concern that can be layered on later. +- **A new cross-client plugin format.** ToolHive distributes the existing `plugin.json` bundle format; it does not define a new manifest schema or attempt to make one tree install everywhere. +- **Materialization adapters for Cursor/Copilot/Gemini in v1.** These clients need their own manifest (and, for Gemini, command-format translation). They are explicitly future work behind the adapter seam. Multi-manifest fan-out (emitting `.cursor-plugin/`, `gemini-extension.json`, etc. at build time) is one way to implement them later — see Alternatives Considered and Open Questions #6. - **Auto-updates / daemon-driven reconciliation.** Same posture as skills; post-MVP. - **Kubernetes operator integration.** Plugins are client-side developer artifacts; CLI/API first. A `MCPPlugin` CRD, if ever warranted, is a separate RFC. - **Hosting a marketplace.** ToolHive *generates* `marketplace.json` and serves plugin metadata via its registry API; it does not run a hosted public marketplace service. ## Proposed Solution +### Design constraint: plugin formats did not converge + +This is the fact that distinguishes plugins from skills and dictates the architecture. + +| | Skills | Plugins | +|---|--------|---------| +| Portable format? | **Yes** — one `SKILL.md` standard, read by ~30 clients | **No** — each client has its own manifest | +| Install to N clients | drop the same file in N dirs | a per-client problem (different manifest, sometimes different component formats) | +| ToolHive client coupling | trivial (`PathResolver` = path mapping) | real (materialization adapter = manifest selection/translation + placement) | + +Concretely, the same `.claude-plugin/` tree is consumed natively by **Claude Code** and **Codex** (which falls back to reading `.claude-plugin/plugin.json`), is *close* for **Cursor** (near-identical JSON, different manifest path/var), needs a different manifest for **Copilot** (`.github/plugin.json`), and needs genuine translation for **Gemini** (`gemini-extension.json` schema + `commands/*.toml` instead of `*.md`). + +The design responds by splitting into two layers: + +- **Distribution layer — client-agnostic.** Build, OCI packaging, push, registry catalog, pull, digest pinning, signature verification, and the installed-inventory database. None of it depends on the consuming client. This is where most of ToolHive's value (supply chain, governance, a single catalog alongside servers and skills) lives, and it works for *every* client equally. +- **Materialization layer — per-client.** Taking a verified, pulled bundle and making it active in a specific client: choosing/writing the right manifest, placing the component tree where that client discovers it, and respecting that client's trust gates. v1 ships one adapter — the `.claude-plugin` family (Claude Code + Codex). The adapter is a clean interface so Cursor/Copilot/Gemini land later without touching the service or the artifact format. + ### High-Level Design -The design is intentionally isomorphic to the skills system. Where skills have `pkg/skills` + `pkg/skills/skillsvc` + `toolhive-core/oci/skills`, plugins gain `pkg/plugins` + `pkg/plugins/pluginsvc` + `toolhive-core/oci/plugins`, sharing the same lower-level tar/gzip/extraction primitives, the same `entries` storage parent, the same registry provider seam, and the same git resolver. +The distribution layer is intentionally isomorphic to the skills system. Where skills have `pkg/skills` + `pkg/skills/skillsvc` + `toolhive-core/oci/skills`, plugins gain `pkg/plugins` + `pkg/plugins/pluginsvc` + `toolhive-core/oci/plugins`, sharing the same lower-level tar/gzip/extraction primitives, the same `entries` storage parent, the same registry provider seam, and the same git resolver. The materialization layer is the new, plugin-specific part — a per-client `MaterializationAdapter` (see Component Changes) in place of the skills `PathResolver`. ```mermaid graph TB @@ -133,16 +154,16 @@ ToolHive treats the manifest as **opaque-but-validated**: it parses the fields i #### Installation scopes and clients -Identical model to skills: +Scope model is the same as skills: - **User scope** (default): available across all of the user's projects. - **Project scope** (`--scope project --project-root .` or auto-detected git root): available only within a project. -A `PathResolver` maps `(client, plugin-name, scope, project-root)` → install path per client, reusing the skills `PathResolver` pattern. When no `--clients` flag is passed, all plugin-supporting clients detected on the host are targeted (matching skills behavior). +Client targeting is **not** the same as skills. Because materialization is per-client, `--clients` selects from the clients that have a v1 **materialization adapter** — Claude Code and Codex. When `--clients` is omitted, ToolHive targets the adapter-supported clients detected on the host (v1: the `.claude-plugin` family). Requesting a client without an adapter (e.g. `--clients cursor`) fails with a clear message pointing at `thv plugin marketplace generate` as the interim path. This is deliberately narrower than skills' "all skill-supporting clients" default — see the design constraint above. -#### The install-target decision (the one genuinely new problem) +#### The materialization decision for the `.claude-plugin` family (v1 adapter) -Skills have a trivial install target: drop `/SKILL.md` into `~/.claude/skills/`. Plugins are harder, because Claude Code's *native* plugin install path involves a marketplace cache (`~/.claude/plugins/cache/`) plus an `enabledPlugins` entry in `settings.json` keyed `name@marketplace` — state ToolHive would have to mutate and own. +Skills have a trivial materialization target: drop `/SKILL.md` into `~/.claude/skills/`. The `.claude-plugin` family is harder, because the *native* plugin install path involves a marketplace cache (`~/.claude/plugins/cache/`) plus an `enabledPlugins` entry in `settings.json` keyed `name@marketplace` — state ToolHive would have to mutate and own. There is, however, a much cleaner mechanism that Claude Code already supports: a **skills-directory plugin**. *Any* folder under a skills directory that contains `.claude-plugin/plugin.json` is loaded in-place as a plugin (reported as `@skills-dir`) with **no marketplace registration and no settings mutation** — this is exactly what `claude plugin init` produces. @@ -155,7 +176,27 @@ There is, however, a much cleaner mechanism that Claude Code already supports: a For completeness, Claude Code also supports two no-settings, **session-only** mechanisms that load all components — `claude --plugin-dir ./path` and `claude --plugin-url ` — and a persistent local-marketplace path (`/plugin marketplace add ./path`) that loads everything but *does* mutate settings (`extraKnownMarketplaces`). These are useful for `thv plugin` dev/test workflows but are not the managed-install target. -The `PathResolver` abstracts the install target per client, so a client whose only viable target is a marketplace cache can opt into the second strategy without changing the service layer. **The per-client target choice is flagged as an open question** — see Open Questions #1. +**Codex within the v1 adapter.** Codex consumes the *same packaged `.claude-plugin/` tree* — its plugin loader includes `.claude-plugin/plugin.json` in its discoverable manifest paths, so no separate manifest is needed. The exact placement for Codex (its own plugin cache vs. an in-place location) differs from Claude Code's skills-directory mechanism and is an implementation detail of the adapter; the artifact ToolHive distributes is identical. (If Codex's placement turns out to require settings/registration state, the adapter handles it without affecting the distribution layer.) + +#### The `MaterializationAdapter` seam + +The per-client behavior lives behind one interface, so adding Cursor/Copilot/Gemini later is additive: + +```go +// pkg/plugins — implemented per client in pkg/client adapters +type MaterializationAdapter interface { + // Whether this client can materialize a plugin at all (v1: claude-code, codex). + Supports(client MCPClient) bool + // Place a verified, extracted bundle so the client loads it. May translate or + // emit a client-specific manifest, choose the discovery location, and report + // which components will be trust-gated. + Materialize(ctx context.Context, bundle ExtractedBundle, scope Scope, projectRoot string) (MaterializeResult, error) + // Reverse of Materialize, for uninstall. + Dematerialize(ctx context.Context, name string, scope Scope, projectRoot string) error +} +``` + +The v1 `.claude-plugin`-family adapter writes the tree to the skills-directory location (Claude Code) / Codex's location, performs no manifest translation (the packaged `.claude-plugin/plugin.json` is already native to both), and reports trust-gated components for project scope. A future Gemini adapter would, in `Materialize`, emit `gemini-extension.json` and transform `commands/*.md` → `commands/*.toml`. **The per-client target details remain an open question** — see Open Questions #1. ### OCI Artifact Format @@ -202,7 +243,7 @@ OCI Image Index (application/vnd.oci.image.index.v1+json, artifactType: dev.too **Refactor: extract shared OCI primitives.** The tar/gzip/extraction/validation helpers in `oci/skills` (`CreateTar`, `Compress`, `DecompressWithLimit`, `ExtractTarWithLimit`, the `validatingTarget` pull-hardening, the local-build marker `dev.stacklok.toolhive.local-build`) are already skill-agnostic and were explicitly designed to generalize. Lift them into a shared `toolhive-core/oci/internal` (or `oci/artifact`) package consumed by both `oci/skills` and `oci/plugins`. This is convergence, not duplication. -**New: `pkg/plugins`** — types (`PluginManifest`, `Scope`, `PathResolver`, `InstalledPlugin`), `PluginService` interface, parser (`plugin.json`), validator, installer (extraction), options/DTOs. +**New: `pkg/plugins`** — types (`PluginManifest`, `Scope`, `MaterializationAdapter`, `InstalledPlugin`), `PluginService` interface, parser (`plugin.json`), validator, installer (extraction), options/DTOs. The `MaterializationAdapter` (see "The `MaterializationAdapter` seam") is the per-client extension point; v1 ships one implementation for the `.claude-plugin` family. **New: `pkg/plugins/pluginsvc`** — the service implementation, structurally mirroring `pkg/skills/skillsvc`: `build.go`, `push` (in build.go), `install.go`, `install_oci.go`, `install_git.go`, `install_registry`, `uninstall.go`, `list.go`, `info`, `content.go`, `oci.go`, `local_build_marker.go`, `marketplace.go` (new — generator). @@ -231,7 +272,8 @@ Selected flags (consistent with `thv skill`): ``` thv plugin install [flags] - --clients strings Target client(s) (default: all plugin-supporting clients) + --clients strings Target client(s) with a materialization adapter + (v1: claude-code, codex; default: adapter-supported clients on host) --scope string user | project (default "user") --project-root string Project root for project scope --group string Add plugin to a group @@ -512,7 +554,7 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid - **Description**: Install exactly as native plugins do (cache dir + `enabledPlugins`). - **Pros**: Faithful to native lifecycle (versioned cache, enable/disable). - **Cons**: ToolHive must own and mutate client `settings.json` across scopes and the `name@marketplace` keying; brittle and client-specific. -- **Why not chosen for v1**: The in-place skills-directory-plugin mechanism gives a pure-filesystem lifecycle identical to skills. Marketplace-cache install is kept as a per-client `PathResolver` strategy for later. +- **Why not chosen for v1**: The in-place skills-directory-plugin mechanism gives a pure-filesystem lifecycle identical to skills. Marketplace-cache install is kept as a per-client `MaterializationAdapter` strategy for later. ### Alternative 5: JSON-file state instead of SQLite @@ -521,6 +563,13 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid - **Cons**: The ecosystem has since standardized on SQLite (RFC-0041, the `entries` table). Diverging would fragment storage. - **Why not chosen**: Reuse the `entries`/`entry_type` foundation that was built for this. +### Alternative 6: Multi-manifest materialization for all clients in v1 + +- **Description**: At build (or install) time, generate sibling manifests (`.cursor-plugin/plugin.json`, `.codex-plugin/plugin.json`, `.github/plugin.json`, `gemini-extension.json`) from one canonical manifest, with shared component dirs, so `thv plugin install --clients cursor,gemini,...` works on day one. +- **Pros**: True multi-client install immediately; closest to skills' "works everywhere." +- **Cons**: Large, ongoing surface — each client's schema and discovery rules must be tracked and kept current; Gemini needs real component translation (`commands/*.md` → `commands/*.toml`), not just a manifest copy; per-client trust/enable mechanics still differ. High maintenance for a fast-moving, non-standard space. +- **Why not chosen for v1**: The two-layer split lets us ship the full client-agnostic value (distribution, catalog, signing, inventory) plus working install for the `.claude-plugin` family *now*, and add clients incrementally through the `MaterializationAdapter` seam. Multi-manifest generation becomes the *implementation* of those later adapters, scoped per client rather than all at once. This is the option chosen for the eventual Cursor/Copilot/Gemini adapters (Open Questions #6). + ## Compatibility ### Backward Compatibility @@ -534,7 +583,7 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid - **Versioned artifact type** (`dev.toolhive.plugins.v1`) leaves room for multi-layer v2. - **`entry_type` discriminator** already accommodates further artifact classes. -- **`PathResolver` per client** lets the marketplace-cache install strategy land later without service changes. +- **`MaterializationAdapter` per client** is the primary extensibility point: Cursor/Copilot/Gemini adapters (and the marketplace-cache strategy) land later without touching the service or the artifact format. The distribution layer never changes when a client is added. - **Provider no-op defaults** let registries adopt plugin catalogs incrementally. - **Referrers-based signing** composes with future SBOM/provenance attachments on the same digest. - **Managed-MCP-from-plugin** can be added as an opt-in install behavior (run bundled `.mcp.json` servers through `thv` with isolation) without changing the artifact format. @@ -553,7 +602,7 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid ### Phase 3: Install / list / info / uninstall - OCI install (`install_oci.go`), git install (reuse resolver), registry-name install. - `List`, `Info` (with executable-surface inventory), `Uninstall`; groups integration (`Plugins []string` + helpers). -- Multi-client `PathResolver`: implement the in-place skills-directory-plugin strategy. +- `MaterializationAdapter` interface + the v1 `.claude-plugin`-family adapter (Claude Code skills-directory placement; Codex placement). ### Phase 4: API + CLI + content preview - REST endpoints under `/api/v1beta/plugins`; HTTP client (`pkg/plugins/client`). @@ -588,7 +637,7 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid ## Open Questions -1. **Install target per client.** v1 recommends the in-place skills-directory-plugin strategy (no `settings.json` mutation). Should any v1 client instead use the marketplace-cache + `enabledPlugins` strategy, and do we need a dedicated plugins directory distinct from the skills directory to avoid `thv skill list` / `thv plugin list` overlap? +1. **Materialization target within the v1 adapter.** v1 uses the in-place skills-directory-plugin strategy for Claude Code (no `settings.json` mutation). Two sub-questions: (a) does the skills-directory placement create confusing overlap between `thv skill list` and `thv plugin list` (both scan `~/.claude/skills/`, distinguished only by the presence of `.claude-plugin/plugin.json`), and should plugins use a dedicated directory instead? (b) What is Codex's correct placement — does it auto-discover in-place, or does it need its plugin-cache/registration, and if so does that pull `settings`-like state into the adapter? 2. **Component-inventory diff on upgrade.** Should an upgrade that *adds* a hook or MCP server require explicit re-approval (`--accept-new-executables`) by default? 3. **`userConfig` / sensitive options.** Plugins can declare config (some `sensitive`). Does ToolHive stay entirely hands-off (client keychain owns it), or offer to wire values from ToolHive's secrets manager at install time? 4. **Managed MCP servers from plugins.** Out of scope for v1 core, but is it the headline v2 feature — running a plugin's bundled MCP servers through `thv` (network isolation, permission profiles, audit) instead of the client spawning them unsandboxed? From 8180fd322367ceeb80b15e5813894c5d564812ac Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 15 Jun 2026 08:43:10 +0300 Subject: [PATCH 6/8] RFC-0077: correct Codex materialization (verified against openai/codex source) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex does NOT auto-discover in-place: loading requires a cache install plus a [plugins.*] enabled entry in ~/.codex/config.toml, and Codex activates only a subset of components (skills/MCP/apps/hooks — no commands or subagents). Model Codex as a distinct v1 adapter: cache-install + surgical config.toml round-trip edit, SupportedComponents + install-time warning on dropped commands/agents, revert-on-uninstall. Add an adapter comparison table, a client-config-mutation security note, and update goals/summary/open-questions. Co-Authored-By: Claude Opus 4.8 --- rfcs/THV-0077-plugins-lifecycle-management.md | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/rfcs/THV-0077-plugins-lifecycle-management.md b/rfcs/THV-0077-plugins-lifecycle-management.md index 95ce952..573395e 100644 --- a/rfcs/THV-0077-plugins-lifecycle-management.md +++ b/rfcs/THV-0077-plugins-lifecycle-management.md @@ -11,7 +11,7 @@ This RFC proposes adding **plugin** lifecycle management to ToolHive. A *plugin* is the "bundle of primitives" unit pioneered by Claude Code — a directory declared by a `.claude-plugin/plugin.json` manifest that bundles any combination of slash commands, subagents, Agent Skills, hooks, MCP server configurations, and LSP servers. ToolHive will let users **build** a plugin directory into a reproducible OCI artifact, **push** it to any OCI registry, **install** it (from a registry name, OCI reference, or `git://` URL), and **list/info/uninstall** it — using the same registry, OCI, groups, and storage infrastructure that already serves skills and MCP servers. -**A key difference from skills shapes the entire design.** Skills converged on one open standard (`SKILL.md` is read by ~30 clients), so "install to N clients" was just "drop one file in N directories." **Plugin bundle formats never converged** — every client has its own manifest *and* its own discovery rules. The design therefore splits cleanly into two layers: a **client-agnostic distribution layer** (build → OCI → push → catalog → pull → verify → inventory — the bulk of ToolHive's value, identical for every client) and a **per-client materialization layer** (turning a pulled bundle into something a specific client loads). v1 makes the distribution layer fully client-neutral and implements materialization for the `.claude-plugin/plugin.json` family that consumes it natively — **Claude Code and Codex** (which reads `.claude-plugin/plugin.json` as a fallback). Other clients are served by future materialization adapters behind a stable seam. As a bridge to native client tooling, ToolHive can also generate a `marketplace.json` from a set of OCI-distributed plugins. +**A key difference from skills shapes the entire design.** Skills converged on one open standard (`SKILL.md` is read by ~30 clients), so "install to N clients" was just "drop one file in N directories." **Plugin bundle formats never converged** — every client has its own manifest *and* its own discovery rules. The design therefore splits cleanly into two layers: a **client-agnostic distribution layer** (build → OCI → push → catalog → pull → verify → inventory — the bulk of ToolHive's value, identical for every client) and a **per-client materialization layer** (turning a pulled bundle into something a specific client loads). v1 makes the distribution layer fully client-neutral and implements materialization for the two clients that consume the `.claude-plugin/plugin.json` format: **Claude Code** (clean in-place install, full component set) and **Codex** (which reads `.claude-plugin/plugin.json` as a fallback, but requires a cache install + `config.toml` mutation and activates only a subset of components — skills/MCP/hooks, not commands or subagents — so the install warns about dropped components). Other clients are served by future materialization adapters behind a stable seam. As a bridge to native client tooling, ToolHive can also generate a `marketplace.json` from a set of OCI-distributed plugins. ## Problem Statement @@ -57,7 +57,7 @@ This is the fact that distinguishes plugins from skills and dictates the archite | Install to N clients | drop the same file in N dirs | a per-client problem (different manifest, sometimes different component formats) | | ToolHive client coupling | trivial (`PathResolver` = path mapping) | real (materialization adapter = manifest selection/translation + placement) | -Concretely, the same `.claude-plugin/` tree is consumed natively by **Claude Code** and **Codex** (which falls back to reading `.claude-plugin/plugin.json`), is *close* for **Cursor** (near-identical JSON, different manifest path/var), needs a different manifest for **Copilot** (`.github/plugin.json`), and needs genuine translation for **Gemini** (`gemini-extension.json` schema + `commands/*.toml` instead of `*.md`). +Concretely, the same `.claude-plugin/` tree is read by **Claude Code** (in-place, full component set) and **Codex** (via its `.claude-plugin` fallback — but with a heavier install path and only a subset of components; see below), is *close* for **Cursor** (near-identical JSON, different manifest path/var), needs a different manifest for **Copilot** (`.github/plugin.json`), and needs genuine translation for **Gemini** (`gemini-extension.json` schema + `commands/*.toml` instead of `*.md`). Even the two "natively compatible" clients diverge sharply at the materialization layer — which is exactly why materialization is modeled as per-client adapters rather than one shared path. The design responds by splitting into two layers: @@ -176,27 +176,47 @@ There is, however, a much cleaner mechanism that Claude Code already supports: a For completeness, Claude Code also supports two no-settings, **session-only** mechanisms that load all components — `claude --plugin-dir ./path` and `claude --plugin-url ` — and a persistent local-marketplace path (`/plugin marketplace add ./path`) that loads everything but *does* mutate settings (`extraKnownMarketplaces`). These are useful for `thv plugin` dev/test workflows but are not the managed-install target. -**Codex within the v1 adapter.** Codex consumes the *same packaged `.claude-plugin/` tree* — its plugin loader includes `.claude-plugin/plugin.json` in its discoverable manifest paths, so no separate manifest is needed. The exact placement for Codex (its own plugin cache vs. an in-place location) differs from Claude Code's skills-directory mechanism and is an implementation detail of the adapter; the artifact ToolHive distributes is identical. (If Codex's placement turns out to require settings/registration state, the adapter handles it without affecting the distribution layer.) +#### Codex: a second v1 adapter, with a heavier mechanism and reduced fidelity + +The same packaged `.claude-plugin/` tree is *readable* by Codex — its loader supports `.claude-plugin/plugin.json` as a fallback manifest (`ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH`), so no separate manifest is required. But — **verified against `openai/codex` source** — Codex's materialization is fundamentally different from Claude Code's, in two ways that the adapter must handle and that `thv plugin install` must surface to the user: + +1. **No in-place discovery — Codex requires cache install + config state.** Loading a plugin needs *both* the plugin materialized under `~/.codex/plugins/cache////` *and* a `[plugins.] enabled = true` entry in `~/.codex/config.toml`. A config entry without a cached install errors with `"plugin is not installed"`. There is no "drop a folder and it loads" path; even a *local* marketplace source (referenced in place, not copied) still records `[marketplaces.]` in `config.toml`. So the Codex adapter implements the **cache-install + config-mutation** strategy (the one deferred for Claude Code), and ToolHive must own a slice of the user's `~/.codex/config.toml`. +2. **Codex loads only a subset of components.** Codex's plugin manifest model supports **skills, MCP servers (`.mcp.json`), apps (`.app.json`), and hooks** — it has **no `commands` and no `agents`/subagents.** A plugin that bundles slash commands or subagents will have those **silently ignored on Codex**. The adapter therefore inspects the bundle's component inventory and **warns at install time** which components will not be active on Codex (and `thv plugin info --clients codex` reflects the reduced set). + +Additional verified Codex specifics the adapter encodes: single home scope (`~/.codex`, no project/repo plugin scope); CLI shape `codex plugin add|list|remove` and `codex plugin marketplace add|list|...` (marketplace nested under `plugin`); state in `config.toml` (`[marketplaces.*]` + `[plugins.*]`), not a JSON registry. (Codex's *public* plugin directory/publishing is still pre-GA, but the local install/load machinery is shipped and not feature-flag-gated.) #### The `MaterializationAdapter` seam -The per-client behavior lives behind one interface, so adding Cursor/Copilot/Gemini later is additive: +The per-client behavior lives behind one interface, so the two v1 adapters (Claude Code, Codex) coexist and Cursor/Copilot/Gemini are additive later: ```go // pkg/plugins — implemented per client in pkg/client adapters type MaterializationAdapter interface { // Whether this client can materialize a plugin at all (v1: claude-code, codex). Supports(client MCPClient) bool - // Place a verified, extracted bundle so the client loads it. May translate or - // emit a client-specific manifest, choose the discovery location, and report - // which components will be trust-gated. + // Components this client will actually activate from a bundle, so the service can + // warn about silently-dropped ones (e.g. Codex ignores commands/agents). + SupportedComponents(client MCPClient) ComponentSet + // Place a verified, extracted bundle so the client loads it. May write/translate a + // client-specific manifest, mutate client config (e.g. Codex config.toml), choose + // the discovery location, and report trust-gated or dropped components. Materialize(ctx context.Context, bundle ExtractedBundle, scope Scope, projectRoot string) (MaterializeResult, error) - // Reverse of Materialize, for uninstall. + // Reverse of Materialize, for uninstall (incl. reverting any config mutation). Dematerialize(ctx context.Context, name string, scope Scope, projectRoot string) error } ``` -The v1 `.claude-plugin`-family adapter writes the tree to the skills-directory location (Claude Code) / Codex's location, performs no manifest translation (the packaged `.claude-plugin/plugin.json` is already native to both), and reports trust-gated components for project scope. A future Gemini adapter would, in `Materialize`, emit `gemini-extension.json` and transform `commands/*.md` → `commands/*.toml`. **The per-client target details remain an open question** — see Open Questions #1. +The two v1 adapters differ exactly where the seam expects them to: + +| | Claude Code adapter | Codex adapter | +|---|---|---| +| Placement | in-place under skills dir (`~/.claude/skills//`) | cache (`~/.codex/plugins/cache/...`) | +| Client state mutation | none | `~/.codex/config.toml` (`[plugins.*]`, `[marketplaces.*]`) | +| Manifest translation | none (native `.claude-plugin/`) | none (reads `.claude-plugin/` fallback) | +| Components activated | all (commands, agents, skills, hooks, MCP, LSP) | subset (skills, MCP, apps, hooks) — **warns** on dropped commands/agents | +| Scope | user + project (project gated) | user/home only | + +A future Gemini adapter would additionally, in `Materialize`, emit `gemini-extension.json` and transform `commands/*.md` → `commands/*.toml`. **Remaining per-client target details are tracked in Open Questions #1.** ### OCI Artifact Format @@ -490,6 +510,7 @@ flowchart TD - Plugin artifacts and metadata are public content; no secrets are stored by the plugin manager. - `userConfig`-style secrets (a plugin manifest can declare config options, some `sensitive`) are **not** populated by ToolHive at install; they remain the client's keychain concern. ToolHive must never persist values for `sensitive` config options. (Open Question #3.) +- **Client config mutation (Codex adapter).** The Codex adapter edits the user's `~/.codex/config.toml` (`[plugins.*]`/`[marketplaces.*]`). This is user-owned configuration, not a secret store, but ToolHive must edit it surgically: round-trip-preserve unrelated entries, only add/remove ToolHive-managed keys, write atomically (temp + rename), and fully revert its own additions on uninstall. A corrupted `config.toml` would break the user's Codex setup, so this is treated as a correctness-and-trust concern (Open Question #1b). The Claude Code adapter mutates no client config (pure filesystem). ### Input Validation @@ -602,7 +623,7 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid ### Phase 3: Install / list / info / uninstall - OCI install (`install_oci.go`), git install (reuse resolver), registry-name install. - `List`, `Info` (with executable-surface inventory), `Uninstall`; groups integration (`Plugins []string` + helpers). -- `MaterializationAdapter` interface + the v1 `.claude-plugin`-family adapter (Claude Code skills-directory placement; Codex placement). +- `MaterializationAdapter` interface + two v1 adapters: **Claude Code** (in-place skills-directory, full component set) and **Codex** (cache install + surgical `~/.codex/config.toml` round-trip edit, subset of components, install-time warning on dropped commands/agents, revert-on-uninstall). Implement `SupportedComponents` and the dropped-component warning in the service. ### Phase 4: API + CLI + content preview - REST endpoints under `/api/v1beta/plugins`; HTTP client (`pkg/plugins/client`). @@ -637,7 +658,7 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid ## Open Questions -1. **Materialization target within the v1 adapter.** v1 uses the in-place skills-directory-plugin strategy for Claude Code (no `settings.json` mutation). Two sub-questions: (a) does the skills-directory placement create confusing overlap between `thv skill list` and `thv plugin list` (both scan `~/.claude/skills/`, distinguished only by the presence of `.claude-plugin/plugin.json`), and should plugins use a dedicated directory instead? (b) What is Codex's correct placement — does it auto-discover in-place, or does it need its plugin-cache/registration, and if so does that pull `settings`-like state into the adapter? +1. **Materialization details within the v1 adapters.** (a) Claude Code: does the skills-directory placement create confusing overlap between `thv skill list` and `thv plugin list` (both scan `~/.claude/skills/`, distinguished only by the presence of `.claude-plugin/plugin.json`), and should plugins use a dedicated directory instead? (b) Codex (now resolved on mechanism — it requires cache install + `config.toml` mutation): how surgically can ToolHive edit the user's `~/.codex/config.toml` `[plugins.*]`/`[marketplaces.*]` tables without clobbering hand-maintained entries (round-trip TOML preservation), and what is the cleanest `Dematerialize` that reverts exactly ToolHive's own additions? Should the Codex adapter register a ToolHive-owned local marketplace, or write `[plugins.*]` entries directly? 2. **Component-inventory diff on upgrade.** Should an upgrade that *adds* a hook or MCP server require explicit re-approval (`--accept-new-executables`) by default? 3. **`userConfig` / sensitive options.** Plugins can declare config (some `sensitive`). Does ToolHive stay entirely hands-off (client keychain owns it), or offer to wire values from ToolHive's secrets manager at install time? 4. **Managed MCP servers from plugins.** Out of scope for v1 core, but is it the headline v2 feature — running a plugin's bundled MCP servers through `thv` (network isolation, permission profiles, audit) instead of the client spawning them unsandboxed? From 6a83501ece9c396a50fc0fd3f76376738c786463 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 15 Jun 2026 08:48:52 +0300 Subject: [PATCH 7/8] RFC-0077: Codex config.toml editing reuses existing ToolHive precedent ToolHive already round-trip-edits ~/.codex/config.toml to register MCP servers (pkg/client TOMLMapConfigUpdater + pkg/fileutils AtomicWriteFile/WithFileLock, with a test proving hand-maintained fields survive). Reframe the Codex adapter's config mutation from "riskiest piece" to reuse of well-trodden code; narrow Open Question #1b to the local-marketplace-vs-direct-[plugins] choice. Co-Authored-By: Claude Opus 4.8 --- rfcs/THV-0077-plugins-lifecycle-management.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rfcs/THV-0077-plugins-lifecycle-management.md b/rfcs/THV-0077-plugins-lifecycle-management.md index 573395e..30b0797 100644 --- a/rfcs/THV-0077-plugins-lifecycle-management.md +++ b/rfcs/THV-0077-plugins-lifecycle-management.md @@ -180,7 +180,7 @@ For completeness, Claude Code also supports two no-settings, **session-only** me The same packaged `.claude-plugin/` tree is *readable* by Codex — its loader supports `.claude-plugin/plugin.json` as a fallback manifest (`ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH`), so no separate manifest is required. But — **verified against `openai/codex` source** — Codex's materialization is fundamentally different from Claude Code's, in two ways that the adapter must handle and that `thv plugin install` must surface to the user: -1. **No in-place discovery — Codex requires cache install + config state.** Loading a plugin needs *both* the plugin materialized under `~/.codex/plugins/cache////` *and* a `[plugins.] enabled = true` entry in `~/.codex/config.toml`. A config entry without a cached install errors with `"plugin is not installed"`. There is no "drop a folder and it loads" path; even a *local* marketplace source (referenced in place, not copied) still records `[marketplaces.]` in `config.toml`. So the Codex adapter implements the **cache-install + config-mutation** strategy (the one deferred for Claude Code), and ToolHive must own a slice of the user's `~/.codex/config.toml`. +1. **No in-place discovery — Codex requires cache install + config state.** Loading a plugin needs *both* the plugin materialized under `~/.codex/plugins/cache////` *and* a `[plugins.] enabled = true` entry in `~/.codex/config.toml`. A config entry without a cached install errors with `"plugin is not installed"`. There is no "drop a folder and it loads" path; even a *local* marketplace source (referenced in place, not copied) still records `[marketplaces.]` in `config.toml`. So the Codex adapter implements the **cache-install + config-mutation** strategy (the one deferred for Claude Code), and ToolHive must own a slice of the user's `~/.codex/config.toml`. **This is not new ground:** ToolHive already round-trip-edits `~/.codex/config.toml` to register MCP servers (`pkg/client` knows Codex — `config.go` defines it with `Extension: TOML`, `TOMLStorageTypeMap`, `SettingsFile: "config.toml"`, `RelPath: [".codex"]`), so the adapter reuses the existing `TOMLMapConfigUpdater` + `fileutils` atomic/locked write path (see Security › Data Security). 2. **Codex loads only a subset of components.** Codex's plugin manifest model supports **skills, MCP servers (`.mcp.json`), apps (`.app.json`), and hooks** — it has **no `commands` and no `agents`/subagents.** A plugin that bundles slash commands or subagents will have those **silently ignored on Codex**. The adapter therefore inspects the bundle's component inventory and **warns at install time** which components will not be active on Codex (and `thv plugin info --clients codex` reflects the reduced set). Additional verified Codex specifics the adapter encodes: single home scope (`~/.codex`, no project/repo plugin scope); CLI shape `codex plugin add|list|remove` and `codex plugin marketplace add|list|...` (marketplace nested under `plugin`); state in `config.toml` (`[marketplaces.*]` + `[plugins.*]`), not a JSON registry. (Codex's *public* plugin directory/publishing is still pre-GA, but the local install/load machinery is shipped and not feature-flag-gated.) @@ -267,7 +267,7 @@ OCI Image Index (application/vnd.oci.image.index.v1+json, artifactType: dev.too **New: `pkg/plugins/pluginsvc`** — the service implementation, structurally mirroring `pkg/skills/skillsvc`: `build.go`, `push` (in build.go), `install.go`, `install_oci.go`, `install_git.go`, `install_registry`, `uninstall.go`, `list.go`, `info`, `content.go`, `oci.go`, `local_build_marker.go`, `marketplace.go` (new — generator). -**Reuse as-is:** `pkg/skills/gitresolver` (git `git://` resolution, SSRF + host-scoped auth) — generalize its name to `pkg/vcs/gitresolver` or consume it directly; `pkg/groups` (add a `Plugins []string` field + `AddPluginToGroup`/`RemovePluginFromAllGroups`, mirroring skills); `pkg/client` (extend the client metadata with plugin-path fields and a `SupportsPlugins` flag). +**Reuse as-is:** `pkg/skills/gitresolver` (git `git://` resolution, SSRF + host-scoped auth) — generalize its name to `pkg/vcs/gitresolver` or consume it directly; `pkg/groups` (add a `Plugins []string` field + `AddPluginToGroup`/`RemovePluginFromAllGroups`, mirroring skills); `pkg/client` (extend the client metadata with plugin-path fields and a `SupportsPlugins` flag — and reuse its existing config-editing machinery: `TOMLMapConfigUpdater`/`config_editor.go` already round-trip-edits `~/.codex/config.toml`, and `pkg/fileutils` provides `AtomicWriteFile` + `WithFileLock` — the Codex adapter builds directly on these). #### CLI Commands @@ -510,7 +510,7 @@ flowchart TD - Plugin artifacts and metadata are public content; no secrets are stored by the plugin manager. - `userConfig`-style secrets (a plugin manifest can declare config options, some `sensitive`) are **not** populated by ToolHive at install; they remain the client's keychain concern. ToolHive must never persist values for `sensitive` config options. (Open Question #3.) -- **Client config mutation (Codex adapter).** The Codex adapter edits the user's `~/.codex/config.toml` (`[plugins.*]`/`[marketplaces.*]`). This is user-owned configuration, not a secret store, but ToolHive must edit it surgically: round-trip-preserve unrelated entries, only add/remove ToolHive-managed keys, write atomically (temp + rename), and fully revert its own additions on uninstall. A corrupted `config.toml` would break the user's Codex setup, so this is treated as a correctness-and-trust concern (Open Question #1b). The Claude Code adapter mutates no client config (pure filesystem). +- **Client config mutation (Codex adapter) — reuses established precedent.** The Codex adapter edits the user's `~/.codex/config.toml` (`[plugins.*]`/`[marketplaces.*]`). This is user-owned configuration, not a secret store, and ToolHive **already does exactly this kind of edit today**: `pkg/client` registers MCP servers into `~/.codex/config.toml` via `TOMLMapConfigUpdater` (`pkg/client/config_editor.go`), which parses the whole file to `map[string]any` with `pelletier/go-toml/v2`, modifies only the target table, marshals back (preserving all other tables — there is a test asserting `model`/`[settings]`/`debug` survive an upsert), and writes through `fileutils.AtomicWriteFile` (temp + rename) under `fileutils.WithFileLock` (in-process mutex + OS advisory lock). The plugin adapter reuses this machinery for the `[plugins.*]`/`[marketplaces.*]` tables and reverts exactly its own additions on uninstall. So this is **low-risk, well-trodden ground**, not a novel hazard. The Claude Code adapter mutates no client config (pure filesystem). ### Input Validation @@ -658,7 +658,7 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid ## Open Questions -1. **Materialization details within the v1 adapters.** (a) Claude Code: does the skills-directory placement create confusing overlap between `thv skill list` and `thv plugin list` (both scan `~/.claude/skills/`, distinguished only by the presence of `.claude-plugin/plugin.json`), and should plugins use a dedicated directory instead? (b) Codex (now resolved on mechanism — it requires cache install + `config.toml` mutation): how surgically can ToolHive edit the user's `~/.codex/config.toml` `[plugins.*]`/`[marketplaces.*]` tables without clobbering hand-maintained entries (round-trip TOML preservation), and what is the cleanest `Dematerialize` that reverts exactly ToolHive's own additions? Should the Codex adapter register a ToolHive-owned local marketplace, or write `[plugins.*]` entries directly? +1. **Materialization details within the v1 adapters.** (a) Claude Code: does the skills-directory placement create confusing overlap between `thv skill list` and `thv plugin list` (both scan `~/.claude/skills/`, distinguished only by the presence of `.claude-plugin/plugin.json`), and should plugins use a dedicated directory instead? (b) Codex (mechanism + safe-edit both resolved — it requires cache install + `config.toml` mutation, and ToolHive already has the round-trip TOML editor + atomic/locked write for `~/.codex/config.toml`): the one remaining choice is whether the Codex adapter registers a ToolHive-owned **local marketplace** (`[marketplaces.*]` pointing at a ToolHive-managed dir) or writes `[plugins.*]` entries **directly**. Local-marketplace is closer to Codex's intended model and may simplify `Dematerialize`; direct entries are fewer moving parts. 2. **Component-inventory diff on upgrade.** Should an upgrade that *adds* a hook or MCP server require explicit re-approval (`--accept-new-executables`) by default? 3. **`userConfig` / sensitive options.** Plugins can declare config (some `sensitive`). Does ToolHive stay entirely hands-off (client keychain owns it), or offer to wire values from ToolHive's secrets manager at install time? 4. **Managed MCP servers from plugins.** Out of scope for v1 core, but is it the headline v2 feature — running a plugin's bundled MCP servers through `thv` (network isolation, permission profiles, audit) instead of the client spawning them unsandboxed? From 69d8e014bcf9868767f6d06e5f777e665aa46c12 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 15 Jun 2026 09:02:37 +0300 Subject: [PATCH 8/8] RFC-0077: defer bundled MCP to a dependency-reference model Per review: v1 does not run/proxy/rewrite a plugin's bundled MCP servers. The artifact carries .mcp.json verbatim but ToolHive ignores it; the intended managed model references first-class ToolHive servers via the `requires` mechanism rather than executing servers bundled in the plugin. Add a dedicated "MCP servers in a plugin" section, Alternative 7 (run/repackage bundled MCP, considered+deferred), reframe Open Question #4 and Forward Compatibility, update Goals/Non-Goals/Summary/Security/mitigations, and add a deferred-work note. Managed MCP via references = follow-up RFC. Co-Authored-By: Claude Opus 4.8 --- rfcs/THV-0077-plugins-lifecycle-management.md | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/rfcs/THV-0077-plugins-lifecycle-management.md b/rfcs/THV-0077-plugins-lifecycle-management.md index 30b0797..4cafe9b 100644 --- a/rfcs/THV-0077-plugins-lifecycle-management.md +++ b/rfcs/THV-0077-plugins-lifecycle-management.md @@ -11,7 +11,9 @@ This RFC proposes adding **plugin** lifecycle management to ToolHive. A *plugin* is the "bundle of primitives" unit pioneered by Claude Code — a directory declared by a `.claude-plugin/plugin.json` manifest that bundles any combination of slash commands, subagents, Agent Skills, hooks, MCP server configurations, and LSP servers. ToolHive will let users **build** a plugin directory into a reproducible OCI artifact, **push** it to any OCI registry, **install** it (from a registry name, OCI reference, or `git://` URL), and **list/info/uninstall** it — using the same registry, OCI, groups, and storage infrastructure that already serves skills and MCP servers. -**A key difference from skills shapes the entire design.** Skills converged on one open standard (`SKILL.md` is read by ~30 clients), so "install to N clients" was just "drop one file in N directories." **Plugin bundle formats never converged** — every client has its own manifest *and* its own discovery rules. The design therefore splits cleanly into two layers: a **client-agnostic distribution layer** (build → OCI → push → catalog → pull → verify → inventory — the bulk of ToolHive's value, identical for every client) and a **per-client materialization layer** (turning a pulled bundle into something a specific client loads). v1 makes the distribution layer fully client-neutral and implements materialization for the two clients that consume the `.claude-plugin/plugin.json` format: **Claude Code** (clean in-place install, full component set) and **Codex** (which reads `.claude-plugin/plugin.json` as a fallback, but requires a cache install + `config.toml` mutation and activates only a subset of components — skills/MCP/hooks, not commands or subagents — so the install warns about dropped components). Other clients are served by future materialization adapters behind a stable seam. As a bridge to native client tooling, ToolHive can also generate a `marketplace.json` from a set of OCI-distributed plugins. +**A key difference from skills shapes the entire design.** Skills converged on one open standard (`SKILL.md` is read by ~30 clients), so "install to N clients" was just "drop one file in N directories." **Plugin bundle formats never converged** — every client has its own manifest *and* its own discovery rules. The design therefore splits cleanly into two layers: a **client-agnostic distribution layer** (build → OCI → push → catalog → pull → verify → inventory — the bulk of ToolHive's value, identical for every client) and a **per-client materialization layer** (turning a pulled bundle into something a specific client loads). v1 makes the distribution layer fully client-neutral and implements materialization for the two clients that consume the `.claude-plugin/plugin.json` format: **Claude Code** (clean in-place install, full component set) and **Codex** (which reads `.claude-plugin/plugin.json` as a fallback, but requires a cache install + `config.toml` mutation and activates only a subset of components — no commands or subagents — so the install warns about dropped components). Other clients are served by future materialization adapters behind a stable seam. + +**Bundled MCP servers are out of scope for v1.** A plugin's `.mcp.json` is packaged verbatim but ToolHive does not run, proxy, or rewrite it in v1; the intended end state is to *reference* first-class ToolHive-managed servers via the `requires` dependency mechanism rather than execute servers bundled inside a plugin (see "MCP servers in a plugin," deferred to a follow-up RFC). As a bridge to native client tooling, ToolHive can also generate a `marketplace.json` from a set of OCI-distributed plugins. ## Problem Statement @@ -32,13 +34,15 @@ Skills proved the pattern. Plugins are the natural next artifact class, and the - Implement the **materialization layer for the `.claude-plugin/plugin.json` family in v1** — Claude Code and Codex (via its `.claude-plugin` fallback) — behind a per-client adapter seam that lets Cursor/Copilot/Gemini be added later without changing the service or artifact format. - Reuse — not duplicate — the skills foundation: shared OCI primitives in `toolhive-core`, the SQLite `entries` table, the registry provider seam, the groups system, the git resolver, and scopes. Extend the `pkg/client` abstraction with a per-client **materialization adapter** (the plugin analog of the skills `PathResolver`, but doing more than path mapping). - Validate a plugin bundle before packaging or installing: `plugin.json` schema, each bundled component (reusing the skills validator for bundled skills), and filesystem safety. -- Surface the **executable surface** of a plugin (hooks, MCP server commands) to the user before install. +- Surface the **executable surface** of a plugin (hooks, and any declared-but-unmanaged MCP servers) to the user before install. +- **Defer bundled-MCP execution:** package `.mcp.json` verbatim but neither run nor proxy it in v1; design the `requires`-based dependency-reference seam as the path to *managed* MCP, and defer its implementation to a follow-up RFC. - Bridge OCI distribution to native client tooling by generating a `marketplace.json` from a set of OCI-distributed plugins (so even clients without a ToolHive materialization adapter can consume ToolHive-distributed plugins). ## Non-Goals - **Authoring plugins.** ToolHive packages and distributes plugins; it does not scaffold or generate plugin content. `claude plugin init` and equivalents own authoring. -- **Running a plugin's runtime behavior.** ToolHive does not execute hooks, dispatch slash commands, or interpret the manifest at the client's runtime — the AI client does that. ToolHive's job ends at placing a validated bundle in the right location (and, optionally, managing the lifecycle of bundled MCP servers — see "Managed MCP servers from plugins" as a *forward-looking* extension, explicitly out of scope for v1 core). +- **Running a plugin's runtime behavior.** ToolHive does not execute hooks, dispatch slash commands, or interpret the manifest at the client's runtime — the AI client does that. ToolHive's job ends at placing a validated bundle in the right location. +- **Managing a plugin's bundled MCP servers (v1).** ToolHive does not run, proxy, isolate, or rewrite a plugin's `.mcp.json` servers in v1 — they are packaged verbatim and left to the client, exactly as a manual install would. The intended managed model uses dependency *references* (`requires`), not bundled execution, and is deferred to a follow-up RFC. See "MCP servers in a plugin." - **A new cross-client plugin format.** ToolHive distributes the existing `plugin.json` bundle format; it does not define a new manifest schema or attempt to make one tree install everywhere. - **Materialization adapters for Cursor/Copilot/Gemini in v1.** These clients need their own manifest (and, for Gemini, command-format translation). They are explicitly future work behind the adapter seam. Multi-manifest fan-out (emitting `.cursor-plugin/`, `gemini-extension.json`, etc. at build time) is one way to implement them later — see Alternatives Considered and Open Questions #6. - **Auto-updates / daemon-driven reconciliation.** Same posture as skills; post-MVP. @@ -218,6 +222,19 @@ The two v1 adapters differ exactly where the seam expects them to: A future Gemini adapter would additionally, in `Materialize`, emit `gemini-extension.json` and transform `commands/*.md` → `commands/*.toml`. **Remaining per-client target details are tracked in Open Questions #1.** +> **MCP note.** The adapter tables above describe what each *client* can load. ToolHive itself does **not** wire or run a plugin's bundled MCP servers in v1 (see the next section) — `.mcp.json` is carried verbatim and, where a client would load it, runs via that client unmanaged. The materialized inert set ToolHive is responsible for is commands/agents/skills/hooks/LSP (Claude Code) and skills/apps/hooks (Codex). + +### MCP servers in a plugin: deferred to a dependency-reference model + +A plugin's `.mcp.json` is categorically different from its other components. Commands, agents, skills, hooks, and LSP configs are **inert** — files the client reads. MCP servers are **executable workloads**, and running MCP servers (proxied, network-isolated, audited, in containers) is precisely what ToolHive exists to do. Two facts make bundled MCP servers a poor fit for v1: + +1. **Treating them as inert files defeats the purpose.** If ToolHive simply drops `.mcp.json` into the client's load path, the *client* spawns those servers directly — unsandboxed, unproxied, unaudited — which is the exact opposite of ToolHive's value proposition. ToolHive already solves this for standalone servers by running them as workloads and rewriting client config to point at its proxy (`pkg/client/converter.go`, `config.go` `buildMCPServer`). +2. **Bringing them under management is genuinely hard.** Plugins declare servers as **local stdio `{command, args}`** (e.g. `node ${CLAUDE_PLUGIN_ROOT}/mcp/index.js`), but ToolHive runs **containerized or remote** workloads — it has no host-process runtime (verified: the only runtimes are Docker and Kubernetes; protocol schemes `uvx://`/`npx://`/`go://` build a container on the fly). Closing that gap — repackaging the bundled command into a container, or adding a local-process runtime — is a substantial effort with its own security trade-offs. + +**v1 therefore does not manage bundled MCP servers at all.** The OCI artifact carries `.mcp.json` verbatim (fidelity preserved — the plugin behaves exactly as a manual install), and ToolHive neither runs, proxies, nor isolates those servers. `thv plugin info` and the install step clearly report *"this plugin declares N MCP servers — not managed by ToolHive; they run via the client unsandboxed. Run them under ToolHive separately with `thv run`."* This is a deliberate, documented gap, not an oversight. + +**The intended end state is dependency *references*, not bundled execution.** Rather than burying a server's `command`/`args` inside a plugin and later teaching ToolHive to execute arbitrary local commands, a plugin should *reference* first-class MCP servers that ToolHive already knows how to distribute and run. This reuses the `requires`/dependencies seam already planned for the artifact (`dev.toolhive.plugins.requires`, mirroring `dev.toolhive.skills.requires`): a plugin declares e.g. `requires: ["ghcr.io/org/servers/foo:v1", "io.github.org/bar"]`, and a future ToolHive version resolves those to managed, proxied, isolated workloads through its existing MCP machinery and wires the client config to the proxy URLs. Servers stay independently-distributed, signable, isolated artifacts instead of opaque commands inside a bundle — strictly better than repackaging. Designing that resolution flow (what `requires` carries, how referenced servers map to workloads and permission profiles, client-config rewrite) is deferred to a follow-up RFC — see Open Questions #4 and Alternative 7. + ### OCI Artifact Format Plugins reuse the skills OCI machinery with a distinct artifact type. A new `toolhive-core/oci/plugins` package mirrors its sibling `oci/skills`, and the **shared, artifact-agnostic primitives are lifted into a common subpackage** (see Component Changes) rather than copied. @@ -253,7 +270,7 @@ OCI Image Index (application/vnd.oci.image.index.v1+json, artifactType: dev.too **Reproducible packaging** (identical discipline to skills): deterministic tar (sorted entries, normalized mode/`ModTime` via `SOURCE_DATE_EPOCH`, UID/GID 0), deterministic gzip (`BestCompression`, OS byte 255, empty name/comment), so identical content always yields an identical digest — the precondition for digest pinning and signature verification. -**Dependencies.** Plugins may declare dependencies (the manifest has a `dependencies` array). These are recorded as a `dev.toolhive.plugins.requires` annotation (JSON array of OCI references), mirroring `dev.toolhive.skills.requires`. Resolution of transitive plugin dependencies is **post-MVP** (Non-Goal-adjacent); v1 records but does not auto-install them. +**Dependencies.** Plugins may declare dependencies (the manifest has a `dependencies` array). These are recorded as a `dev.toolhive.plugins.requires` annotation (JSON array of OCI references), mirroring `dev.toolhive.skills.requires`. The **same `requires` mechanism is the planned carrier for MCP server references** — the deferred path to managed MCP (see "MCP servers in a plugin"). v1 records `requires` but resolves nothing automatically. ### Detailed Design @@ -321,7 +338,7 @@ SIGNATURE verified (cosign, ghcr.io/org/.github) # or: none COMPONENTS commands=3 agents=1 skills=2 hooks=4 mcpServers=1 HOOKS PreToolUse → scripts/scan.sh PostToolUse → scripts/log.sh -MCP SERVERS my-server → node ./mcp/index.js +MCP SERVERS my-server → node ./mcp/index.js (declared; NOT managed by ToolHive in v1) ``` #### API Changes @@ -381,7 +398,7 @@ type Provider interface { #### Configuration Changes -A bundled MCP server config inside a plugin (`.mcp.json`) is packaged verbatim; ToolHive does not rewrite it in v1. Example of what travels inside the artifact (unchanged Claude Code format): +A bundled MCP server config inside a plugin (`.mcp.json`) is packaged verbatim and, in v1, **left untouched** — ToolHive does not rewrite, proxy, or run it (see "MCP servers in a plugin"; the future managed model rewrites such config to ToolHive proxy URLs via the `requires` reference flow). Example of what travels inside the artifact (unchanged Claude Code format): ```json { @@ -488,6 +505,8 @@ flowchart TD **This is the section where plugins genuinely differ from skills.** A skill is inert Markdown read by the model. A plugin bundles **hooks** (shell commands executed deterministically by the client on lifecycle events) and **MCP server configs** (executable processes). **Installing a plugin is granting code-execution trust.** The whole point of putting plugins on ToolHive's content-addressable + signed OCI rail is to bring supply-chain controls to a decision currently made with none. +**v1 scope note for MCP.** ToolHive does not run a plugin's bundled MCP servers in v1 (see "MCP servers in a plugin"); they are carried verbatim and run via the client unmanaged — the same exposure as a manual `git clone` install, neither better nor worse. This is a known v1 gap, closed later by the dependency-reference model (which brings referenced servers under ToolHive's isolation/audit). The executable surface ToolHive actively *places* in v1 is hooks and their scripts; the mitigations below focus there, and `thv plugin info` flags declared MCP servers so the trust decision is explicit. + ### Threat Model | Threat | Description | Likelihood | Impact | @@ -544,7 +563,7 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid | Registry compromise / tag mutation | content-addressable digests; `--require-signature` verifies cosign signatures via the **OCI Referrers API** (`subject` + `GET /v2//referrers/`) | signature required by default; org trust roots | | Path traversal / symlink | shared extraction hardening (reused from skills) | — | | Typosquatting | registry catalog is curated; supply-chain name==repo check | namespace ownership verification | -| Managed MCP isolation | (forward-looking) running bundled MCP servers *via thv* gains network isolation + permission profiles instead of the client spawning them unsandboxed | first-class "managed MCP from plugin" | +| Bundled MCP server runs unmanaged (v1 gap) | v1 does not run bundled servers; `thv plugin info` flags declared MCP servers so users know they run via the client unsandboxed, and can instead run them under `thv run` | dependency-reference model (`requires`) resolves servers to managed, proxied, network-isolated `thv` workloads | | Inventory blind spot | SQLite inventory + `thv plugin list --format json` across fleet | dashboards / cloud UI | ## Alternatives Considered @@ -591,6 +610,13 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid - **Cons**: Large, ongoing surface — each client's schema and discovery rules must be tracked and kept current; Gemini needs real component translation (`commands/*.md` → `commands/*.toml`), not just a manifest copy; per-client trust/enable mechanics still differ. High maintenance for a fast-moving, non-standard space. - **Why not chosen for v1**: The two-layer split lets us ship the full client-agnostic value (distribution, catalog, signing, inventory) plus working install for the `.claude-plugin` family *now*, and add clients incrementally through the `MaterializationAdapter` seam. Multi-manifest generation becomes the *implementation* of those later adapters, scoped per client rather than all at once. This is the option chosen for the eventual Cursor/Copilot/Gemini adapters (Open Questions #6). +### Alternative 7: Run a plugin's bundled MCP servers under ToolHive in v1 + +- **Description**: Make "managed MCP from plugin" part of v1 — when a plugin declares `.mcp.json` servers (local `command`/`args`), bring them under ToolHive by either (a) repackaging the command into a container built from the bundle, or (b) adding a host-process runtime, then run them proxied/isolated and rewrite the client config to the proxy URL. +- **Pros**: Closes the loop — the one thing a plain `git clone` install can't do; full isolation/audit for the plugin's servers. +- **Cons**: (a) requires building containers from arbitrary `command`/`args` + bundle files (runtime detection, `${CLAUDE_PLUGIN_ROOT}` rewriting, exotic commands don't map) or (b) a brand-new host-process runtime that runs untrusted bundled code on the host with weaker isolation — against ToolHive's container-first model. Either is a large effort that would dominate v1 and entangle the otherwise-clean distribution + inert-materialization story. +- **Why not chosen for v1**: Deferred. Bundled `command`/`args` execution is the wrong long-term primitive anyway — the **dependency-reference model** (`requires` → first-class, independently-distributed, isolated servers) is cleaner than repackaging opaque commands. v1 ships distribution + inert materialization and explicitly leaves bundled MCP unmanaged (documented gap); the reference model is a focused follow-up RFC. See "MCP servers in a plugin" and Open Questions #4. + ## Compatibility ### Backward Compatibility @@ -607,7 +633,7 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid - **`MaterializationAdapter` per client** is the primary extensibility point: Cursor/Copilot/Gemini adapters (and the marketplace-cache strategy) land later without touching the service or the artifact format. The distribution layer never changes when a client is added. - **Provider no-op defaults** let registries adopt plugin catalogs incrementally. - **Referrers-based signing** composes with future SBOM/provenance attachments on the same digest. -- **Managed-MCP-from-plugin** can be added as an opt-in install behavior (run bundled `.mcp.json` servers through `thv` with isolation) without changing the artifact format. +- **Managed MCP via `requires` references** is the planned evolution: a plugin references first-class ToolHive servers (OCI/registry) that resolve to proxied, isolated workloads with client config rewritten to the proxy — additive to the artifact (`dev.toolhive.plugins.requires`) and the registry, no format break. ## Implementation Plan @@ -639,11 +665,14 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid - Existing `pkg/skills/gitresolver`, `pkg/groups`, `pkg/client`, `pkg/storage/sqlite`. - cosign/sigstore libraries for Phase 5 signature verification. +### Deferred to a follow-up RFC +- **Managed MCP via `requires` references**: resolving referenced servers to ToolHive workloads, rewriting client config to the proxy URL (reuse `pkg/client/converter.go` + `pkg/workloads`), default permission profiles for plugin-referenced servers, and `userConfig` `sensitive` → secrets wiring. v1 only records `requires` and reports declared-but-unmanaged MCP servers. + ## Testing Strategy - **Unit**: `plugin.json` parser/validator (incl. wrong-type rejection), packager determinism (identical digest for identical input), extraction safety, component-inventory extraction, marketplace generator. - **Integration**: each CLI command against a mock OCI registry; build→push→install→list→uninstall round-trip; git install with subdir/ref; group membership. -- **E2E**: full workflow against a real registry (GHCR) and verification that an installed plugin loads in Claude Code (skills-directory-plugin path). +- **E2E**: full workflow against a real registry (GHCR) and verification that an installed plugin loads in Claude Code (skills-directory-plugin path); verify a plugin that declares `.mcp.json` servers installs and reports them as *declared, not managed* (no ToolHive workload created in v1). - **Compatibility**: byte-identical round-trip (native plugin → build → install → compare tree). - **Security**: path-traversal/symlink/oversized-archive fixtures; supply-chain name-mismatch rejection; signature-required failure paths; injection/SSRF in git refs. - **Performance**: large-bundle packaging within size caps; reproducibility under `SOURCE_DATE_EPOCH`. @@ -661,7 +690,7 @@ Like skills, plugins are client-side constructs that bypass ToolHive's proxy mid 1. **Materialization details within the v1 adapters.** (a) Claude Code: does the skills-directory placement create confusing overlap between `thv skill list` and `thv plugin list` (both scan `~/.claude/skills/`, distinguished only by the presence of `.claude-plugin/plugin.json`), and should plugins use a dedicated directory instead? (b) Codex (mechanism + safe-edit both resolved — it requires cache install + `config.toml` mutation, and ToolHive already has the round-trip TOML editor + atomic/locked write for `~/.codex/config.toml`): the one remaining choice is whether the Codex adapter registers a ToolHive-owned **local marketplace** (`[marketplaces.*]` pointing at a ToolHive-managed dir) or writes `[plugins.*]` entries **directly**. Local-marketplace is closer to Codex's intended model and may simplify `Dematerialize`; direct entries are fewer moving parts. 2. **Component-inventory diff on upgrade.** Should an upgrade that *adds* a hook or MCP server require explicit re-approval (`--accept-new-executables`) by default? 3. **`userConfig` / sensitive options.** Plugins can declare config (some `sensitive`). Does ToolHive stay entirely hands-off (client keychain owns it), or offer to wire values from ToolHive's secrets manager at install time? -4. **Managed MCP servers from plugins.** Out of scope for v1 core, but is it the headline v2 feature — running a plugin's bundled MCP servers through `thv` (network isolation, permission profiles, audit) instead of the client spawning them unsandboxed? +4. **Managed MCP via dependency references (deferred — follow-up RFC).** The intended model is for a plugin to *reference* first-class ToolHive-managed MCP servers via `requires` rather than bundle executable `command`/`args`. Open: does `requires` carry OCI image references, registry names, or both? How are referenced servers resolved to workloads and wired into the client config at the proxy URL? What default permission profile applies to a server pulled in by a plugin? (The alternative — repackaging a bundle's local `command`/`args` into a container, or adding a host-process runtime — was considered and set aside in favor of references; see Alternative 7.) 5. **Dependency resolution.** v1 records `requires`; when do we auto-install transitive plugin/skill dependencies, and how do we avoid cycles? 6. **Multi-manifest packaging.** Do we ever emit sibling `.cursor-plugin/` / `.codex-plugin/` manifests at build time for cross-client install, or leave that to authors?