marketplace

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 15, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package marketplace models the Claude marketplace.json + plugin.json schemas, the Fetcher interface for resolving plugin sources, and the projection layer that decomposes plugin manifests into canonical source model entries.

Package marketplace models the Claude marketplace plugin format and provides the projection layer that decomposes plugin manifests into canonical source model entries. Skills, commands, and subagents are fully loaded from their on-disk markdown files (frontmatter + body + clean Name) via the injected readFile function so that adapters downstream receive complete, render-ready entries.

Index

Constants

View Source
const MaxMetadataBytes = 16 * 1024 * 1024

MaxMetadataBytes caps the npm registry metadata JSON response. A 10 GB malicious response would otherwise be buffered by json.Decoder.

View Source
const MaxTarballBytes = 512 * 1024 * 1024

MaxTarballBytes caps the total decompressed bytes from a single npm tarball. A 10KB zip-bomb expands to many GB; without a ceiling the extraction fills the user's disk and OOMs the process. 512 MiB is well above any legitimate plugin size and small enough to fail quickly on a malicious upload. Override via AGENTSYNC_MAX_TARBALL_MB.

Variables

This section is empty.

Functions

func LoadProjected

func LoadProjected(fs afero.Fs, home, pluginCacheRoot string) (source.Canonical, error)

LoadProjected loads the canonical model and expands each installed plugin's cached manifest into it, so downstream adapters see plugin components transparently. It is the single projecting load: source.Load stays plugin- unaware, and every command that needs the full canonical (apply, status, diff, reconcile, import, explain, update) calls this.

This is the ONLY plugin projector — it delegates to Project, which honours plugin.json hooks AND marketplace-entry inline overrides. Previously the loader carried a separate, leaner reimplementation that silently dropped both; collapsing to one function is what keeps the two from drifting again.

pluginCacheRoot is <home>/.state/cache/plugins; an empty root skips projection (behaving like source.Load).

func LoadProjectedExcluding

func LoadProjectedExcluding(fs afero.Fs, home, pluginCacheRoot string, disabled []string) (source.Canonical, error)

LoadProjectedExcluding is LoadProjected with an additional set of plugin IDs to skip projecting. At project scope the CLI collects these from the project tree's plugins/<id>.toml entries marked `disabled = true` (the dir-model successor to the M5 marker's `[plugins] disabled`) and passes them when projecting BOTH the user and project homes, so a plugin disabled by the project never renders its components in that repo.

disabled is matched against pl.ID (the plugins/<id>.toml filename stem), so it composes with the per-plugin `disabled = true` flag below: either path skips the same projection.

func LoadProjectedLenient

func LoadProjectedLenient(fs afero.Fs, home, pluginCacheRoot string, disabled []string) (source.Canonical, error)

LoadProjectedLenient is LoadProjected for read-only/diagnostic commands (status, diff, explain): a strict same-name plugin.json/entry conflict is resolved entry-wins with a logged warning instead of a hard error, so those commands still show state rather than refusing to run on a conflict they exist to surface. Mutating commands (apply, reconcile, import, update) use the fatal LoadProjected/LoadProjectedExcluding so they never act on ambiguity.

func PluginEntryHash

func PluginEntryHash(entry PluginEntry) (string, error)

PluginEntryHash pins an entry-only plugin — one with no cached plugin.json or component bodies — over its marketplace entry definition. The entry-prefix tag tells verify to skip recomputation (the entry isn't available there).

func PluginTreeHash

func PluginTreeHash(fs afero.Fs, cacheDir string) (string, error)

PluginTreeHash computes a deterministic content hash over every file in a plugin's cache dir, EXCLUDING .git/ (which projection never reads and which would otherwise make a git-sourced plugin's pin non-deterministic across clones). It covers the component bodies projection actually ships — convention-discovered skills, command/subagent markdown — not just plugin.json, so a body tamper with an unchanged plugin.json is detected.

The hash is sha256 over the sorted "<slash-relpath>\x00<sha256(content)>" lines, so it captures file additions/removals as well as edits, and is independent of walk order and OS path separators. A symlink is hashed by its TARGET PATH (not followed): the fetcher already guarantees a cached symlink stays in-tree, and hashing the link string — rather than skipping it — keeps a swapped target from hiding from the pin.

Types

type Author

type Author struct {
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

Author holds the name and optional email of a plugin author.

type Bump

type Bump struct {
	// ID is the plugin's filesystem ID (e.g. "demo").
	ID string
	// From is the currently pinned version (from plugins/<id>.toml).
	From string
	// To is the latest version available in the fetched marketplace.
	To string
	// UpdateMode is the plugin's configured update mode ("pinned", "track", "manual").
	UpdateMode string
}

Bump describes a pending plugin version change discovered by the update scan.

func ComputePendingBumps

func ComputePendingBumps(
	_ *state.Targets,
	_ []source.Marketplace,
	plugins []source.Plugin,
	fetched map[string]map[string]PluginEntry,
	defaultMode string,
) []Bump

ComputePendingBumps compares each installed plugin's current version against the freshly-fetched marketplace data and returns the list of plugins that have a newer version available and whose update mode allows automatic upgrade.

Only plugins whose Update mode is "" (default → "track") or "track" are included as pending bumps. Plugins with Update = "pinned" or "manual" are skipped (they need explicit user action).

fetched maps marketplace name → fetched Marketplace index (keyed by plugin name).

type FetchResult

type FetchResult struct {
	HeadSHA string // for git sources
	Version string // for npm
}

FetchResult carries metadata about a completed fetch.

type Fetcher

type Fetcher interface {
	// Fetch resolves src into the directory at into, creating it if necessary.
	// Returns a FetchResult with available metadata (HeadSHA for git sources,
	// Version for npm).
	Fetch(src Source, into string) (FetchResult, error)
}

Fetcher fetches one plugin source into a local directory.

func Dispatch

func Dispatch(src Source) Fetcher

Dispatch returns the appropriate Fetcher for the given Source.

type GitFetcher

type GitFetcher struct{}

GitFetcher handles "github", "url", and "git-subdir" source kinds. All three are git repositories; the difference is:

  • "github": repo = "owner/repo", clone from https://github.com/<repo>
  • "url": repo or url = full git URL
  • "git-subdir": same as github/url but also limits to a sub-directory path

func (*GitFetcher) Fetch

func (f *GitFetcher) Fetch(src Source, into string) (FetchResult, error)

Fetch clones (or re-uses an already-cloned) repository into into, optionally checking out a specific ref/sha and, for git-subdir, extracting only the configured subdirectory.

type Marketplace

type Marketplace struct {
	Schema                              string               `json:"$schema,omitempty"`
	Name                                string               `json:"name"`
	Owner                               Owner                `json:"owner"`
	Description                         string               `json:"description,omitempty"`
	Version                             string               `json:"version,omitempty"`
	Metadata                            *MarketplaceMetadata `json:"metadata,omitempty"`
	Plugins                             []PluginEntry        `json:"plugins"`
	AllowCrossMarketplaceDependenciesOn []string             `json:"allowCrossMarketplaceDependenciesOn,omitempty"`
}

Marketplace is the .claude-plugin/marketplace.json document.

type MarketplaceMetadata

type MarketplaceMetadata struct {
	PluginRoot string `json:"pluginRoot,omitempty"`
}

MarketplaceMetadata holds optional extra metadata for a marketplace.

type NPMFetcher

type NPMFetcher struct {
	// HTTPClient overrides the default http.DefaultClient. Used in tests to
	// inject a fake httptest.Server.
	HTTPClient *http.Client
}

NPMFetcher fetches npm packages by downloading the tarball directly from the registry HTTP API — no `npm` CLI required at runtime.

func (*NPMFetcher) Fetch

func (f *NPMFetcher) Fetch(src Source, into string) (FetchResult, error)

Fetch downloads the tarball for src.Package at src.Version into into. If src.Version is empty or "latest", the latest dist-tag is used.

type Owner

type Owner struct {
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

Owner holds the name and optional email of a marketplace or plugin owner.

type PluginEntry

type PluginEntry struct {
	Name        string   `json:"name"`
	Source      Source   `json:"source"`
	Description string   `json:"description,omitempty"`
	Version     string   `json:"version,omitempty"`
	Author      *Author  `json:"author,omitempty"`
	Homepage    string   `json:"homepage,omitempty"`
	Repository  string   `json:"repository,omitempty"`
	License     string   `json:"license,omitempty"`
	Keywords    []string `json:"keywords,omitempty"`
	Category    string   `json:"category,omitempty"`
	Tags        []string `json:"tags,omitempty"`
	Strict      *bool    `json:"strict,omitempty"` // conflict policy on the plugin.json+entry union (default true): strict errors on a same-name conflict, non-strict lets the entry override. See marketplace.resolveConflicts.

	// Component config can be inlined here (overlaid on plugin.json):
	Skills     any            `json:"skills,omitempty"`   // string | []string
	Commands   any            `json:"commands,omitempty"` // string | []string
	Agents     any            `json:"agents,omitempty"`   // string | []string
	Hooks      any            `json:"hooks,omitempty"`    // string | object
	MCPServers map[string]any `json:"mcpServers,omitempty"`
	LSPServers map[string]any `json:"lspServers,omitempty"`
}

PluginEntry is one plugin listed in a marketplace.

type PluginManifest

type PluginManifest struct {
	Name        string         `json:"name"`
	Description string         `json:"description,omitempty"`
	Version     string         `json:"version,omitempty"`
	MCPServers  map[string]any `json:"mcpServers,omitempty"`
	Skills      any            `json:"skills,omitempty"`
	Commands    any            `json:"commands,omitempty"`
	Agents      any            `json:"agents,omitempty"`
	Hooks       any            `json:"hooks,omitempty"`
	LSPServers  map[string]any `json:"lspServers,omitempty"`
}

PluginManifest is .claude-plugin/plugin.json for a strict-mode plugin.

type ProjectionResult

type ProjectionResult struct {
	MCPServers []source.MCPServer
	Skills     []source.Skill
	Subagents  []source.Subagent
	Commands   []source.Command
	Hooks      []source.Hook
	LSPServers []source.LSPServer
}

ProjectionResult collects the canonical model entries derived from a plugin's components.

func Project

func Project(entry PluginEntry, cacheDir string) (ProjectionResult, error)

Project loads the plugin's components and returns them as canonical entries:

  • Reads cacheDir/.claude-plugin/plugin.json (when present) for the primary component list; a missing plugin.json is fine (a curator-defined plugin may declare everything in the entry).
  • If the manifest lists no skills, falls back to convention-based discovery: scans cacheDir/skills/*/ for SKILL.md files.
  • Then overlays any component fields on the PluginEntry itself.

This is a UNION: plugin.json PLUS entry additions. The entry.Strict flag governs how a same-name CONFLICT between the two is resolved (see resolveConflicts): strict (the default) errors so a packaging disagreement is never silently guessed; non-strict lets the entry override. Union semantics never silently drop a plugin's declared components — the pre-Strict-policy non-strict path used to IGNORE plugin.json entirely, dropping a plugin's own components after an upstream strict-flip.

${CLAUDE_PLUGIN_ROOT} in command/url strings is replaced with cacheDir so non-Claude adapters can resolve binary paths.

func ProjectWithReader

func ProjectWithReader(entry PluginEntry, cacheDir string, readFile func(string) ([]byte, error)) (ProjectionResult, error)

ProjectWithReader is like Project but uses a caller-supplied readFile function for loading plugin.json and component markdown files. This enables in-memory filesystem use in tests. Convention-based discovery (skills/ directory scan) is disabled when using ProjectWithReader; use Project for full behavior.

type RelativeFetcher

type RelativeFetcher struct{}

RelativeFetcher copies a local directory tree into the destination. The src.Relative field is treated as an absolute path or a path relative to the caller's working directory; callers should resolve it to absolute before invoking Fetch.

If src.RootDir is non-empty, the resolved Relative path is required to be contained within RootDir — this prevents a malicious marketplace entry from setting `"source": "../../../../etc"` and copying arbitrary host files into the plugin cache.

func (*RelativeFetcher) Fetch

func (f *RelativeFetcher) Fetch(src Source, into string) (FetchResult, error)

Fetch copies src.Relative (a local directory) into into.

type SHAWarning

type SHAWarning struct {
	// ID is the plugin's filesystem ID.
	ID string
	// Version is the version that was re-uploaded.
	Version string
	// RecordedSHA is what was stored in plugins/<id>.toml at install time.
	RecordedSHA string
	// FetchedSHA is the SHA of the freshly-fetched plugin.json.
	FetchedSHA string
}

SHAWarning is emitted when a plugin's recorded manifest_sha differs from the freshly-fetched SHA for the same version — a signal that the marketplace publisher re-uploaded the same version with different content.

func DetectSHADrift

func DetectSHADrift(plugins []source.Plugin, freshSHAs map[string]string) []SHAWarning

DetectSHADrift checks installed plugins against freshly-fetched manifest SHAs (provided by the caller after re-fetching each plugin's cache). For each plugin whose version hasn't changed but whose SHA has, a SHAWarning is returned.

freshSHAs maps plugin ID → sha256 hex string of the freshly-fetched plugin.json.

type Source

type Source struct {
	Kind     string `json:"source,omitempty"` // "github" | "url" | "git-subdir" | "npm"
	Repo     string `json:"repo,omitempty"`
	URL      string `json:"url,omitempty"`
	Path     string `json:"path,omitempty"`
	Ref      string `json:"ref,omitempty"`
	SHA      string `json:"sha,omitempty"`
	Package  string `json:"package,omitempty"`
	Version  string `json:"version,omitempty"`
	Registry string `json:"registry,omitempty"`
	// Relative is the relative-path string when Source was a JSON string.
	Relative string `json:"-"`
	// RootDir, if non-empty, constrains where the RelativeFetcher will copy
	// from — Relative must resolve inside RootDir or the fetch is rejected.
	// Callers that resolve a marketplace-supplied relative path against the
	// marketplace cache directory should set this to the cache root so a
	// hostile marketplace.json entry like `"source": "../../../etc"` cannot
	// copy host files into the plugin cache.
	RootDir string `json:"-"`
}

Source is the polymorphic plugin source. Tag-based dispatch: - A JSON string → Relative path - A JSON object → Kind-based (github, url, git-subdir, npm)

func (Source) MarshalJSON

func (s Source) MarshalJSON() ([]byte, error)

MarshalJSON serialises a Source back to either a JSON string (relative) or a JSON object (all other kinds).

func (*Source) UnmarshalJSON

func (s *Source) UnmarshalJSON(data []byte) error

UnmarshalJSON handles the polymorphic shape: string → Relative; object → Kind etc.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL