Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/plugin-marketplace-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Only the URL/git source forms are covered (phase 1). Codex and Cursor require a

Plugins that aren't `Ready` with a resolved source pin, or that resolved to an OCI source (no representation in this schema), are silently skipped — the document never contains a partial or broken entry.

**Composed plugins are also skipped** (logged at debug level). A plugin with composition refs (`spec.skills` / `spec.mcpServers` / `spec.commands` / `spec.instructions`) has no single upstream git URL, and serving just its base source would silently drop the overlays. Composed plugins are consumed via deploy-time materialization or `arctl plugin pull`; serving them to unmodified Claude Code is gated on git-backed marketplace hosting (tracked upstream).

## Pointing a client at it

```
Expand Down
11 changes: 10 additions & 1 deletion internal/registry/api/handlers/pluginmarketplace/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,16 @@ func getMarketplace(cfg Config) func(context.Context, *struct{}) (*types.Respons
if err != nil {
// Not-yet-resolved / unsupported-source plugins are silently
// skipped: the marketplace.json document never contains a
// partial/broken entry.
// partial/broken entry. Composed plugins are a durable,
// by-design omission (no single upstream URL until git-backed
// marketplace hosting lands) — log those so operators can see
// why a plugin is absent from the catalogue.
if errors.Is(err, pluginmarketplace.ErrComposed) {
logger.Debug("plugin marketplace: skipping composed plugin (no single-source representation; consume via deploy or arctl)",
"namespace", p.Metadata.NamespaceOrDefault(),
"plugin_name", p.Metadata.Name,
"tag", p.Metadata.Tag)
}
continue
}
if seenNames[entry.Name] {
Expand Down
224 changes: 224 additions & 0 deletions internal/registry/controller/plugin_components.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package controller

import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"

"github.com/agentregistry-dev/agentregistry/internal/registry/plugins/bundle"
"github.com/agentregistry-dev/agentregistry/internal/registry/plugins/compose"
"github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1"
pkgdb "github.com/agentregistry-dev/agentregistry/pkg/registry/database"
"github.com/agentregistry-dev/agentregistry/pkg/registry/v1alpha1store"
)

// Component-resolution sentinels. Missing/invalid are TERMINAL (the user must
// change the spec); pending is RETRYABLE (the referenced object's own
// controller will pin it, so rate-limited backoff converges).
var (
errComponentMissing = errors.New("plugin component missing")
errComponentInvalid = errors.New("plugin component invalid")
errComponentsPending = errors.New("plugin components pending")
)

// componentGetter is the read-only, per-kind store access component
// resolution needs. *v1alpha1store.Store satisfies it.
type componentGetter interface {
Get(ctx context.Context, namespace, name, tag string) (*v1alpha1.RawObject, error)
}

// treeFetcher fetches a git tree pinned at a commit (a referenced Skill's
// repo). source.FetchGitTree in production; a fake in tests.
type treeFetcher func(ctx context.Context, repo *v1alpha1.Repository, commit string) (*bundle.CanonicalBundle, error)

// resolveComponents resolves every composition ref on p to (a) the compose
// inputs carrying the actual content and (b) the pin set recorded in status.
// Pins are returned in spec order: skills, mcpServers, commands, instructions.
func (c *PluginController) resolveComponents(ctx context.Context, p *v1alpha1.Plugin) (compose.Inputs, []v1alpha1.PluginResolvedComponent, error) {
in := compose.Inputs{
PluginName: p.Metadata.Name,
Title: p.Spec.Title,
Description: p.Spec.Description,
}
var pins []v1alpha1.PluginResolvedComponent
ns := p.Metadata.NamespaceOrDefault()

// Skill destinations are keyed by the SKILL.md-DECLARED name (the Agent
// Skills spec requires directory == declared name), so collisions between
// declared names can only be caught here, after content is fetched —
// admission sees only ref names.
declaredNames := map[string]v1alpha1.ComponentRef{}
for _, ref := range p.Spec.Skills {
skill, pin, err := c.resolveSkill(ctx, ns, ref)
if err != nil {
return in, nil, err
}
if prev, ok := declaredNames[skill.Name]; ok {
return in, nil, fmt.Errorf("%w: skills %s and %s both declare SKILL.md name %q (directory would collide)",
errComponentInvalid, componentID(v1alpha1.KindSkill, ns, prev), componentID(v1alpha1.KindSkill, ns, ref), skill.Name)
}
declaredNames[skill.Name] = ref
in.Skills = append(in.Skills, skill)
pins = append(pins, pin)
}
for _, ref := range p.Spec.MCPServers {
server, pin, err := c.resolveMCPServer(ctx, ns, ref)
if err != nil {
return in, nil, err
}
in.MCPServers = append(in.MCPServers, server)
pins = append(pins, pin)
}
for _, ref := range p.Spec.Commands {
body, pin, err := c.resolvePrompt(ctx, ns, ref)
if err != nil {
return in, nil, err
}
in.Commands = append(in.Commands, compose.Command{Name: ref.Name, Body: body})
pins = append(pins, pin)
}
if ref := p.Spec.Instructions; ref != nil {
body, pin, err := c.resolvePrompt(ctx, ns, *ref)
if err != nil {
return in, nil, err
}
in.Instructions = &compose.Instructions{Name: ref.Name, Body: body}
pins = append(pins, pin)
}
return in, pins, nil
}

// resolveSkill gates on the referenced Skill's own resolve-and-pin (its
// controller writes status.resolvedSource.commit) and fetches the pinned tree.
func (c *PluginController) resolveSkill(ctx context.Context, ns string, ref v1alpha1.ComponentRef) (compose.Skill, v1alpha1.PluginResolvedComponent, error) {
raw, err := c.getComponent(ctx, v1alpha1.KindSkill, ns, ref)
if err != nil {
return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, err
}
skill, err := v1alpha1.EnvelopeFromRaw(func() *v1alpha1.Skill { return &v1alpha1.Skill{} }, raw, v1alpha1.KindSkill)
if err != nil {
return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: decode skill %s: %v", errComponentInvalid, componentID(v1alpha1.KindSkill, ns, ref), err)
}
if skill.Spec.Source == nil || skill.Spec.Source.Repository == nil {
return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: skill %s has no git source", errComponentInvalid, componentID(v1alpha1.KindSkill, ns, ref))
}
if skill.Status.ResolvedSource == nil || skill.Status.ResolvedSource.Commit == "" {
return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: skill %s not yet resolved", errComponentsPending, componentID(v1alpha1.KindSkill, ns, ref))
}
commit := skill.Status.ResolvedSource.Commit
tree, err := c.fetchTree(ctx, skill.Spec.Source.Repository, commit)
if err != nil {
return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("fetch skill %s@%s: %w", componentID(v1alpha1.KindSkill, ns, ref), commit, err)
}
// The on-disk directory name is the SKILL.md-declared name (Agent Skills
// spec: directory MUST match the declared name), not the registry ref
// name. Missing/invalid declared names are terminal.
declared, err := bundle.DeclaredSkillName(tree.Files)
if err != nil {
return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: skill %s@%s: %v", errComponentInvalid, componentID(v1alpha1.KindSkill, ns, ref), commit, err)
}
return compose.Skill{Name: declared, Files: tree.Files},
componentPin(v1alpha1.KindSkill, ns, ref, commit, ""), nil
}

// resolveMCPServer maps the referenced spec to its .mcp.json entry; shapes
// with no faithful desktop form are terminal.
func (c *PluginController) resolveMCPServer(ctx context.Context, ns string, ref v1alpha1.ComponentRef) (compose.MCPServer, v1alpha1.PluginResolvedComponent, error) {
raw, err := c.getComponent(ctx, v1alpha1.KindMCPServer, ns, ref)
if err != nil {
return compose.MCPServer{}, v1alpha1.PluginResolvedComponent{}, err
}
server, err := v1alpha1.EnvelopeFromRaw(func() *v1alpha1.MCPServer { return &v1alpha1.MCPServer{} }, raw, v1alpha1.KindMCPServer)
if err != nil {
return compose.MCPServer{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: decode mcp server %s: %v", errComponentInvalid, componentID(v1alpha1.KindMCPServer, ns, ref), err)
}
entry, err := compose.MCPEntryFromSpec(&server.Spec)
if err != nil {
return compose.MCPServer{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: mcp server %s: %v", errComponentInvalid, componentID(v1alpha1.KindMCPServer, ns, ref), err)
}
return compose.MCPServer{Name: ref.Name, Entry: entry},
componentPin(v1alpha1.KindMCPServer, ns, ref, "", specHash(raw)), nil
}

// resolvePrompt loads an inline Prompt body (commands and instructions).
func (c *PluginController) resolvePrompt(ctx context.Context, ns string, ref v1alpha1.ComponentRef) (string, v1alpha1.PluginResolvedComponent, error) {
raw, err := c.getComponent(ctx, v1alpha1.KindPrompt, ns, ref)
if err != nil {
return "", v1alpha1.PluginResolvedComponent{}, err
}
prompt, err := v1alpha1.EnvelopeFromRaw(func() *v1alpha1.Prompt { return &v1alpha1.Prompt{} }, raw, v1alpha1.KindPrompt)
if err != nil {
return "", v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: decode prompt %s: %v", errComponentInvalid, componentID(v1alpha1.KindPrompt, ns, ref), err)
}
return prompt.Spec.Content, componentPin(v1alpha1.KindPrompt, ns, ref, "", specHash(raw)), nil
}

// getComponent reads one referenced object, defaulting namespace to the
// plugin's and a blank tag to the literal latest tag.
func (c *PluginController) getComponent(ctx context.Context, kind, pluginNS string, ref v1alpha1.ComponentRef) (*v1alpha1.RawObject, error) {
getter := c.Components[kind]
if getter == nil {
return nil, fmt.Errorf("plugin controller: no store for component kind %s", kind)
}
ns, tag := componentNS(pluginNS, ref), componentTag(ref)
raw, err := getter.Get(ctx, ns, ref.Name, tag)
if errors.Is(err, pkgdb.ErrNotFound) {
return nil, fmt.Errorf("%w: %s %s/%s:%s not found", errComponentMissing, kind, ns, ref.Name, tag)
}
if err != nil {
return nil, fmt.Errorf("plugin controller: load %s %s/%s:%s: %w", kind, ns, ref.Name, tag, err) // retryable
}
return raw, nil
}

func componentNS(pluginNS string, ref v1alpha1.ComponentRef) string {
if ref.Namespace != "" {
return ref.Namespace
}
return pluginNS
}

func componentTag(ref v1alpha1.ComponentRef) string {
if ref.Tag != "" {
return ref.Tag
}
return v1alpha1store.DefaultTag()
}

func componentID(kind, pluginNS string, ref v1alpha1.ComponentRef) string {
return fmt.Sprintf("%s %s/%s:%s", kind, componentNS(pluginNS, ref), ref.Name, componentTag(ref))
}

func componentPin(kind, pluginNS string, ref v1alpha1.ComponentRef, commit, contentHash string) v1alpha1.PluginResolvedComponent {
return v1alpha1.PluginResolvedComponent{
Kind: kind,
Namespace: componentNS(pluginNS, ref),
Name: ref.Name,
Tag: componentTag(ref),
Commit: commit,
ContentHash: contentHash,
}
}

// specHash pins an inline content kind: sha256 of the stored spec bytes.
func specHash(raw *v1alpha1.RawObject) string {
sum := sha256.Sum256(raw.Spec)
return hex.EncodeToString(sum[:])
}

// classifyComponentErr maps a component-resolution error to a status reason
// and terminality; falls back to the source classifier for fetch errors.
func classifyComponentErr(err error) (reason string, terminal bool) {
switch {
case errors.Is(err, errComponentMissing):
return "ComponentMissing", true
case errors.Is(err, errComponentInvalid):
return "ComponentInvalid", true
case errors.Is(err, errComponentsPending):
return "ComponentsPending", false
default:
return classifyResolveErr(err)
}
}
Loading
Loading