Skip to content

feat(marketplace): make the skill a gateway and add marketplace add - #89

Merged
unional merged 14 commits into
mainfrom
feat/marketplace-add-88
Sep 18, 2026
Merged

unional merged 14 commits into
mainfrom
feat/marketplace-add-88

Conversation

@unional

@unional unional commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Closes #88. Covers the first half of #86.

Why

marketplace init derives a catalog from the plugins a repository holds. A repository that
curates plugins living somewhere else — the way cyberuni/marketplace does — had no command at
all, so the entry had to be hand-written into a catalog, which the skill forbids precisely because
a hand-assembled catalog carries the shapes every runtime refuses.

marketplace add <spec>

One positional names where the plugin lives, disambiguated by shape:

Spec Source written
./plugins/alpha "./plugins/alpha"
cyberuni/universal-plugin { "source": "github", "repo": … }
https://…, git@… { "source": "url", "url": … }
npm:repobuddy, @cyberuni/upx { "source": "npm", "package": … }
repobuddy@cyberplace whatever that marketplace publishes

Two shapes collide and each has a flag that settles it. plugins/alpha reads as a GitHub repository
— owner/repo and a relative path are the same string, and consulting the filesystem would make it
mean different things in different working directories. A leading @ is a scope, so @cyberuni/upx
is a package while upx@cyberplace is a marketplace entry. --path, --npm, --github, --url,
and --from-marketplace force the reading.

Monorepos. Neither github nor url has a field for a directory inside the repository, so
--subdir turns the source into git-subdir and names the entry for that directory:

marketplace add cyberuni/cyber-sdd --subdir plugins/aced
# aced -> {"source":"git-subdir","url":"https://github.com/cyberuni/cyber-sdd.git","path":"plugins/aced"}

--ref and --sha pin a git source. All three apply to a git-backed source only and say so rather
than being ignored; a --sha is checked against the 40-character hash the schema requires, at input
rather than against the catalog schema at install time.

Copying from another marketplace. The schema has no "from another marketplace" source, so the
entry is resolved and copied. A source relative to that marketplace is rewritten, not refused —
it names a location inside a repository whose URL is known:

Entry there Written here
./plugins/cyberspace in cyberuni/cyberplace {"source":"git-subdir","url":"https://github.com/cyberuni/cyberplace.git","path":"plugins/cyberspace"}
./ in unional/skills {"source":"github","repo":"unional/skills"}

The origin comes from what the runtime recorded in known_marketplaces.json, falling back to the
clone's own git remote — which is also how --from <dir> resolves one. Only a marketplace with
neither stops the run.

Per-runtime source support. An entry only reaches a catalog whose runtime can resolve it. Claude
Code takes the schema's full tagged set, Codex takes npm, Copilot CLI and Cursor take local paths
only. The rest are reported skipped with the reason rather than written a source they refuse:

target   status   entry      reason
claude   added    repobuddy
codex    added    repobuddy
copilot  skipped  repobuddy  npm source is not supported by copilot
cursor   skipped  repobuddy  npm source is not supported by cursor

A prerequisite: regeneration stopped discarding entries

marketplace init rebuilt catalogs from discovery alone, so an added entry was a conflict and then a
deletion under --force. init, plugin build, and plugin init now keep an entry whose source is
not a local path, and keep a non-local source on a plugin they do discover while refreshing its
derived metadata. That is the loss in #86 — a plugin shipped through npm, whose repository path holds
gitignored build output, was rewritten to that path on every build. What discovery owns it still
owns: a local-path entry it no longer finds is dropped.

The skill is now a gateway

SKILL.md holds no procedure. It declares the routes in argument-hint, carries the intake, the
runtime table, and the shared rules, and names one reference per route: references/init.md (today's
steps, moved), references/add.md (new), and references/validate.md — which was the last step of
init and is now one home rather than two, since both writing routes end there.

One user-invocable skill with routes, rather than marketplace-init and marketplace-add skills
competing to trigger on the same phrases.

Decisions worth a reviewer's eye

  • No network. Metadata comes from a path's own plugin.json, an installed node_modules copy,
    the entry being copied, or the metadata flags. The marketplace spec's non-goals name "calling a
    marketplace API" and nothing in src/ does network I/O. A --fetch flag stays open.
  • --author is not supported. It would have meant changing the shared serializer, which would
    have added author to discovered entries too.
  • npm version/registry are not exposed. --version is already the entry's metadata version;
    overloading it would be confusing.

Verification

731 tests, 726 passing, and plugin build --check is clean.

End to end in a scratch repository: init → add npm:repobuddy → add <plugin>@cyberplace →
init --force keeps all three entries, and all four catalogs validate. The <plugin>@<marketplace>
path was exercised against the real installed cyberplace and unional clones, both the
subdirectory and root cases, not only against fixtures.

CI will be red on 5 tests that are already red on main — four in src/build/build.test.ts and
one in src/init/init.test.ts, all about catalog refresh. I verified this in a clean origin/main
worktree at e76bd25, where they fail identically with none of this branch's code present. They are
not diagnosed here: reading src/build/ is blocked by local permission settings.

🤖 Generated with Claude Code

unional and others added 14 commits September 17, 2026 17:04
A catalog can list plugins that live outside the repository — an npm
package, a GitHub repo. Discovery walks directories, so it produced none
of them, and a regeneration that trusted discovery alone reported every
one as a deletion, which `--force` then carried out.

Two things now survive a re-derivation. An entry whose source is not
local stays even though discovery never saw it, and a discovered plugin
whose entry names a non-local source keeps that source with only its
derived metadata refreshed. What discovery owns it still owns: a
local-path entry it no longer finds is dropped, so the catalog keeps
mirroring the repository for the part of it discovery describes.

The same rule applies at the other two places an entry is re-derived,
`plugin build`'s refresh and `plugin init`'s registration, which is the
first half of #86 — a plugin shipped through npm, whose repository path
holds only gitignored build output, was rewritten to that path on every
build.

This is what lets `marketplace add` (#88) coexist with `marketplace
init` on one repository.

Refs #88, #86

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…where

`marketplace init` derives a catalog from the plugins a repository
holds. A repository that curates plugins living somewhere else had no
command at all, so the entry had to be hand-written into a catalog —
which the marketplace skill forbids, because a catalog assembled by hand
carries the shapes every runtime refuses.

`marketplace add <spec>` writes that entry. One positional names where
the plugin lives, disambiguated by shape with a flag to override every
guess:

  ./plugins/alpha         a path in this repository
  owner/repo              a GitHub repository
  https://... , git@...   a git URL
  npm:pkg , @scope/pkg    an npm package
  plugin@marketplace      an entry another marketplace already publishes

The last form has no source type in the schema, so it is resolved and
copied: the marketplaces the runtime has already added are read from
`~/.claude/plugins`, with `--from` for one that is not installed.

Nothing is fetched. Metadata comes from the plugin manifest at a local
path, from an installed `node_modules` copy for a package, from the
entry being copied, and otherwise from the metadata flags.

An entry only reaches a catalog whose runtime can resolve it: npm goes
to Claude Code and Codex, and Copilot CLI and Cursor are reported
skipped with the reason rather than written a source they refuse.
Replacing an entry already listed differently needs `--force`.

Closes #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skill documented one job: derive a catalog from the plugins this
repository holds. `marketplace add` is a second job with different
inputs, a different failure mode, and almost no steps in common — a
single document would have had to hedge every instruction with "unless
the plugin is not in this repository".

SKILL.md now classifies and routes, holding no procedure of its own. It
declares the routes in `argument-hint`, carries the intake, the runtime
table, and the rules that apply to every route, and names one reference
per route:

  init      references/init.md      (today's steps, moved)
  add       references/add.md       (new)
  validate  references/validate.md  (was the last step of init; both
                                     writing routes end there, so it
                                     has one home rather than two)

One user-invocable skill with routes, rather than splitting into
marketplace-init and marketplace-add skills that would compete to
trigger on the same phrases.

scripts/add.mjs runs the new command from the CLI shipped beside the
skill, matching the other three wrappers.

Refs #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the `add/` behavioral node — spec reading, the per-runtime source
support that decides what is skipped, metadata with no fetch, resolving
`<plugin>@<marketplace>`, and the write guards — plus its frozen
scenario suite and the group index entry.

Records the preservation rule in `init/`: which entries a regeneration
keeps, why folding happens after the containment check, and that a
result row names what the catalog ends up listing rather than only what
discovery contributed.

Refs #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The local-marketplace page described two commands that write catalogs.
`marketplace add` is a third, and the page had no account of listing a
plugin the repository does not hold.

Adds its section — the spec forms and the two collisions that need a
flag, the per-runtime source support and what a skip means, where
metadata comes from now that nothing is fetched, and how
`<plugin>@<marketplace>` resolves — and notes on the `init` and build
sections that a non-local source survives a regeneration.

Refs #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
knip flagged both as dead: claudePluginsHome has one caller in its own
file, and SOURCE_KINDS was never read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…igin

Copying an entry out of another marketplace refused a `./` source. The
reasoning was sound — that path resolves against a root this repository
does not share — but the conclusion was wrong. It still names a location
inside a repository whose URL is known, which is what `git-subdir`
states, so the path can be rewritten instead of rejected.

  ./plugins/aced in cyberuni/cyberplace
    -> git-subdir https://github.com/cyberuni/cyberplace.git plugins/aced

  ./ in unional/skills
    -> github unional/skills

A plugin at the marketplace root needs no subdirectory, so it takes the
plainer `github` form, or `url` where the origin names no owner/repo.

The origin is what the runtime recorded for that marketplace — a github
repo slug or a git clone URL — falling back to the clone's own origin
remote, which is also how a `--from` directory supplies one. Only a
marketplace with neither still stops the run, and it now says why.

Both rewritten forms are Claude Code source types, so a subdirectory
plugin reports Codex, Copilot CLI, and Cursor as skipped.

Refs #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the local-source refusal with the rewrite: which form a
subdirectory and a root take, where the origin comes from, and the one
case that still stops the command.

Refs #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skill's add reference and the CLI page both described the refusal.
Both now carry the rewrite table and note that a subdirectory plugin
reaches Claude Code only.

Refs #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`owner/repo` and a git URL name a whole repository, and neither the
`github` nor the `url` source form has a field for a directory inside
it. A monorepo publishing several plugins could therefore only be listed
at its root, which installs the wrong thing rather than failing.

`--subdir` states it, turning the source into `git-subdir` and naming
the entry for the directory rather than the repository:

  add cyberuni/cyber-sdd --subdir plugins/aced
    -> aced  git-subdir  .../cyber-sdd.git  plugins/aced

`--ref` and `--sha` pin a git source to a branch, tag, or commit. All
three apply to a git-backed source only — an npm package is pinned by
version and a path is whatever is on disk — and each says so rather than
being ignored. A `--sha` is checked against the 40-character hash the
schema requires, at input rather than against the catalog schema later.

Refs #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skill's add reference gains a monorepo section, including when to
ask the user for --subdir: a repository that publishes more than one
plugin installs the wrong one without it.

Refs #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 49ffa79

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
universal-plugin Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@unional
unional merged commit 77395d0 into main Sep 18, 2026
6 checks passed
@unional
unional deleted the feat/marketplace-add-88 branch September 18, 2026 04:36
unional pushed a commit that referenced this pull request Sep 18, 2026
The package readme and the web docs overview page listed `marketplace
init`/`validate` but never `marketplace add`, added in #89.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWLkFTgecKnDFyJjkiCBHJ
unional added a commit that referenced this pull request Sep 21, 2026
Closes #86.

## What #86 asked, and where it already stands

- **Keep an npm source across a rebuild.** Fixed in 5811bd6: `plugin
build`'s refresh, `plugin init`, and `marketplace init` all keep an
entry whose source is not local and re-derive only its fields.
- **Generate an npm source.** `marketplace add npm:<pkg> --force` (#89)
writes `{ "source": "npm", "package": … }` to the Claude and Codex
catalogs and skips Copilot CLI and Cursor, which document only local
paths.

I checked this against a fixture shaped like repobuddy: a monorepo with
`packages/buddy`, npm entries in the Claude and Codex catalogs, and a
local entry in the Copilot catalog. `plugin build` bumped each entry's
version and kept the npm sources and the Copilot path. `marketplace add
npm:repobuddy --force` turned a local Claude entry into an npm one, and
the next build kept it. repobuddy's `sync-plugin-manifests.mjs`
workaround is no longer needed.

## What this PR adds

The build spec never stated this rule, and no build-level test held it.
This PR adds:
- a decision in `spec/plugin/build/README.md`: a refresh keeps where the
plugin is distributed from, and never chooses a non-local source itself
- a `build.feature` scenario
- a `buildPlugin` test covering both the Claude and Codex catalogs

## Not done: an automatic npm source whenever `packagePath` is set

This repository has `packagePath: "."`, is published, and keeps a
local-path catalog on purpose so it can be developed locally. A default
that switched to npm whenever `packagePath` is set would break that
catalog. So the npm source stays opt-in, done once with `marketplace add
npm:<pkg> --force`, and every later build and regeneration keeps it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(marketplace): make the skill a gateway and add marketplace add

1 participant