feat: marketplace.json as central manifest with code generation#2
Conversation
Agent-Logs-Url: https://github.com/knitli/toolshed/sessions/878abfcd-d012-4d55-acc6-0f63b5be9c81 Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>
…I permissions Agent-Logs-Url: https://github.com/knitli/toolshed/sessions/878abfcd-d012-4d55-acc6-0f63b5be9c81 Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>
|
@copilot did you validate the new json? I can't read schema very well with the naked eye, but I suspect the changes don't comply: {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://catalog.lintel.tools/schemas/claude-code/marketplace/latest.json",
"title": "Claude Code Marketplace",
"description": "Schema for [Claude Code marketplace](https://code.claude.com/docs/en/plugin-marketplaces) catalog (`.claude-plugin/marketplace.json`). Marketplaces distribute plugins to teams and communities, providing centralized discovery, version tracking, and automatic updates. Each marketplace lists plugins with their sources (local paths, GitHub repos, git URLs, npm packages, or pip packages). Users add marketplaces via `/plugin marketplace add` and install individual plugins with `/plugin install plugin-name@marketplace-name`.",
"x-lintel": {
"source": "https://raw.githubusercontent.com/lintel-rs/catalog/master/schemas/claude-code/marketplace.json",
"sourceSha256": "e384a8b15aea368420746f6f677c735c071650a85e61d941ffa2b3f1bfa4fe06",
"fileMatch": [
"**/.claude-plugin/marketplace.json",
".claude-plugin/marketplace.json"
],
"parsers": [
"json"
],
"catalogDescription": "Claude Code plugin marketplace catalog."
},
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Marketplace identifier (kebab-case, no spaces). This is public-facing — users see it when installing plugins (e.g. `/plugin install my-tool@your-marketplace`). **Reserved names** (cannot be used): `claude-code-marketplace`, `claude-code-plugins`, `claude-plugins-official`, `anthropic-marketplace`, `anthropic-plugins`, `agent-skills`, `life-sciences`. Names that impersonate official marketplaces are also blocked.",
"pattern": "^[a-z0-9][a-z0-9-]*$",
"examples": [
"acme-tools",
"company-tools",
"my-plugins"
]
},
"owner": {
"$ref": "#/$defs/Owner",
"description": "Marketplace maintainer information."
},
"plugins": {
"type": "array",
"items": {
"$ref": "#/$defs/PluginEntry"
},
"description": "List of available plugins in this marketplace. Each entry needs at minimum a `name` and `source`. Plugin entries can also include any field from the [plugin manifest schema](https://code.claude.com/docs/en/plugins-reference#plugin-manifest-schema) (`description`, `version`, `author`, `commands`, `hooks`, etc.)."
},
"metadata": {
"$ref": "#/$defs/Metadata",
"description": "Optional marketplace metadata."
}
},
"$defs": {
"Owner": {
"type": "object",
"description": "Marketplace maintainer information.",
"properties": {
"name": {
"type": "string",
"description": "Name of the maintainer or team.",
"examples": [
"DevTools Team",
"Jane Doe"
]
},
"email": {
"type": "string",
"description": "Contact email for the maintainer.",
"examples": [
"devtools@example.com"
]
}
},
"required": [
"name"
],
"additionalProperties": false
},
"Metadata": {
"type": "object",
"description": "Optional marketplace-level metadata.",
"properties": {
"description": {
"type": "string",
"description": "Brief marketplace description.",
"examples": [
"Internal tools for the DevOps team"
]
},
"version": {
"type": "string",
"description": "Marketplace catalog version.",
"examples": [
"1.0.0"
]
},
"pluginRoot": {
"type": "string",
"description": "Base directory prepended to relative plugin source paths. For example, setting this to `./plugins` lets you write `\"source\": \"formatter\"` instead of `\"source\": \"./plugins/formatter\"`.",
"examples": [
"./plugins"
]
}
},
"additionalProperties": false
},
"PluginEntry": {
"type": "object",
"description": "A plugin listed in the marketplace. Includes marketplace-specific fields (`source`, `category`, `tags`, `strict`) plus any field from the [plugin manifest schema](https://code.claude.com/docs/en/plugins-reference#plugin-manifest-schema) (`description`, `version`, `author`, `commands`, `hooks`, etc.).",
"properties": {
"name": {
"type": "string",
"description": "Plugin identifier (kebab-case, no spaces). This is public-facing — users see it when installing (e.g. `/plugin install my-plugin@marketplace`).",
"pattern": "^[a-z0-9][a-z0-9-]*$",
"examples": [
"code-formatter",
"deployment-tools",
"review-plugin"
]
},
"source": {
"$ref": "#/$defs/PluginSource",
"description": "Where to fetch the plugin from. Can be a relative path within the marketplace repo (starting with `./`) or an object specifying a remote source (`github`, `url`, `npm`, or `pip`). **Note**: Relative paths only work when users add the marketplace via Git — they won't resolve for URL-based marketplaces. See [plugin sources](https://code.claude.com/docs/en/plugin-marketplaces#plugin-sources)."
},
"description": {
"type": "string",
"description": "Brief plugin description shown when browsing the marketplace.",
"examples": [
"Automatic code formatting on save"
]
},
"version": {
"type": "string",
"description": "Plugin version. If the plugin also has a version in its `plugin.json`, the `plugin.json` version **takes priority**. For relative-path plugins, set the version here. For remote plugins, set it in `plugin.json`. See [version resolution](https://code.claude.com/docs/en/plugin-marketplaces#version-resolution-and-release-channels).",
"examples": [
"1.0.0",
"2.1.0"
]
},
"author": {
"$ref": "https://catalog.lintel.tools/schemas/claude-code/author/latest.json",
"description": "Plugin author information."
},
"homepage": {
"type": "string",
"description": "Plugin homepage or documentation URL.",
"examples": [
"https://docs.example.com/plugins/my-tool"
]
},
"repository": {
"type": "string",
"description": "Source code repository URL.",
"examples": [
"https://github.com/company/my-plugin"
]
},
"license": {
"type": "string",
"description": "[SPDX license identifier](https://spdx.org/licenses/).",
"examples": [
"MIT",
"Apache-2.0"
]
},
"keywords": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags for plugin discovery and categorization.",
"examples": [
[
"enterprise",
"workflow",
"automation"
]
]
},
"category": {
"type": "string",
"description": "Plugin category for organization.",
"examples": [
"productivity",
"code-quality",
"deployment"
]
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags for searchability.",
"examples": [
[
"lint",
"format",
"style"
]
]
},
"strict": {
"type": "boolean",
"description": "Controls whether `plugin.json` is the authority for component definitions. When `true` (default), `plugin.json` is authoritative and the marketplace entry can supplement it with additional components (both are merged). When `false`, the marketplace entry is the **entire definition** — if the plugin also has a `plugin.json` that declares components, that's a conflict and the plugin fails to load. See [strict mode](https://code.claude.com/docs/en/plugin-marketplaces#strict-mode).",
"default": true
},
"commands": {
"$ref": "https://catalog.lintel.tools/schemas/claude-code/path-or-paths/latest.json",
"description": "Custom paths to command files or directories, relative to the plugin root."
},
"agents": {
"$ref": "https://catalog.lintel.tools/schemas/claude-code/path-or-paths/latest.json",
"description": "Custom paths to agent files, relative to the plugin root."
},
"skills": {
"$ref": "https://catalog.lintel.tools/schemas/claude-code/path-or-paths/latest.json",
"description": "Custom paths to skill directories, relative to the plugin root."
},
"hooks": {
"description": "[Hook](https://code.claude.com/docs/en/hooks) configuration — paths to JSON config files or inline definitions.",
"oneOf": [
{
"type": "string",
"description": "Path to a hooks JSON config file."
},
{
"type": "array",
"items": {
"type": "string"
},
"description": "Array of paths."
},
{
"$ref": "https://catalog.lintel.tools/schemas/claude-code/hooks/latest.json",
"description": "Inline hook definitions."
}
]
},
"mcpServers": {
"description": "[MCP server](https://code.claude.com/docs/en/mcp) configuration — paths to JSON config files or inline definitions.",
"oneOf": [
{
"type": "string",
"description": "Path to an MCP config JSON file."
},
{
"type": "array",
"items": {
"type": "string"
},
"description": "Array of paths."
},
{
"type": "object",
"description": "Inline MCP server definitions.",
"additionalProperties": {
"oneOf": [
{
"type": "string",
"description": "Reference to an already-configured server."
},
{
"$ref": "https://catalog.lintel.tools/schemas/claude-code/mcp-server-config/latest.json",
"description": "Inline definition."
}
]
}
}
]
},
"outputStyles": {
"$ref": "https://catalog.lintel.tools/schemas/claude-code/path-or-paths/latest.json",
"description": "Custom paths to output style files or directories."
},
"lspServers": {
"description": "[LSP server](https://code.claude.com/docs/en/plugins-reference#lsp-servers) configuration — paths to JSON config files or inline definitions.",
"oneOf": [
{
"type": "string",
"description": "Path to an LSP config JSON file."
},
{
"type": "array",
"items": {
"type": "string"
},
"description": "Array of paths."
},
{
"type": "object",
"description": "Inline LSP server definitions.",
"additionalProperties": {
"$ref": "https://catalog.lintel.tools/schemas/claude-code/lsp-server-config/latest.json"
}
}
]
}
},
"required": [
"name",
"source"
],
"additionalProperties": false
},
"PluginSource": {
"oneOf": [
{
"type": "string",
"description": "Relative path within the marketplace repo. Must start with `./`. Only works when users add the marketplace via Git (not URL-based).",
"examples": [
"./plugins/my-plugin"
]
},
{
"$ref": "#/$defs/GitHubSource"
},
{
"$ref": "#/$defs/UrlSource"
},
{
"$ref": "#/$defs/NpmSource"
},
{
"$ref": "#/$defs/PipSource"
}
]
},
"GitHubSource": {
"type": "object",
"description": "Fetch the plugin from a [GitHub](https://github.com) repository. Supports pinning to a branch, tag, or exact commit SHA.",
"properties": {
"source": {
"const": "github"
},
"repo": {
"type": "string",
"description": "GitHub repository in `owner/repo` format.",
"pattern": "^[^/]+/[^/]+$",
"examples": [
"owner/plugin-repo",
"company/enterprise-plugin"
]
},
"ref": {
"type": "string",
"description": "Git branch or tag. Defaults to the repository's default branch.",
"examples": [
"main",
"v2.0.0",
"stable"
]
},
"sha": {
"type": "string",
"description": "Full 40-character git commit SHA to pin to an exact version.",
"pattern": "^[0-9a-f]{40}$",
"examples": [
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
]
}
},
"required": [
"source",
"repo"
],
"additionalProperties": false
},
"UrlSource": {
"type": "object",
"description": "Fetch the plugin from a git URL (GitHub, GitLab, Bitbucket, self-hosted, etc.).",
"properties": {
"source": {
"const": "url"
},
"url": {
"type": "string",
"description": "Full git repository URL (must end with `.git`).",
"pattern": "\\.git$",
"examples": [
"https://gitlab.com/team/plugin.git"
]
},
"ref": {
"type": "string",
"description": "Git branch or tag. Defaults to the repository's default branch.",
"examples": [
"main",
"v1.0.0"
]
},
"sha": {
"type": "string",
"description": "Full 40-character git commit SHA to pin to an exact version.",
"pattern": "^[0-9a-f]{40}$"
}
},
"required": [
"source",
"url"
],
"additionalProperties": false
},
"NpmSource": {
"type": "object",
"description": "Install the plugin from an [npm](https://www.npmjs.com/) registry via `npm install`.",
"properties": {
"source": {
"const": "npm"
},
"package": {
"type": "string",
"description": "npm package name.",
"examples": [
"@company/mcp-server",
"my-claude-plugin"
]
},
"version": {
"type": "string",
"description": "Version range or exact version.",
"examples": [
"1.0.0",
"^2.0.0"
]
},
"registry": {
"type": "string",
"description": "Custom npm registry URL.",
"examples": [
"https://npm.company.com"
]
}
},
"required": [
"source",
"package"
],
"additionalProperties": false
},
"PipSource": {
"type": "object",
"description": "Install the plugin via [pip](https://pip.pypa.io/).",
"properties": {
"source": {
"const": "pip"
},
"package": {
"type": "string",
"description": "Python package name.",
"examples": [
"my-claude-plugin"
]
},
"version": {
"type": "string",
"description": "Version specifier.",
"examples": [
"1.0.0",
">=2.0"
]
},
"registry": {
"type": "string",
"description": "Custom PyPI registry URL.",
"examples": [
"https://pypi.company.com/simple"
]
}
},
"required": [
"source",
"package"
],
"additionalProperties": false
}
},
"required": [
"name",
"owner",
"plugins"
]
} |
… on PluginEntry) Agent-Logs-Url: https://github.com/knitli/toolshed/sessions/709d6e9b-431b-482a-b1cd-dff958712cb9 Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>
... Good catch — it wasn't compliant. The
Also added the |
There was a problem hiding this comment.
Pull request overview
This PR centralizes plugin metadata in .claude-plugin/marketplace.json and introduces a generator to keep derived repo files (commitlint config, release workflow matrix, per-plugin plugin.json and package.json) automatically in sync, reducing manual edits and drift.
Changes:
- Expand
.claude-plugin/marketplace.jsonto act as the source-of-truth (shared metadata + per-plugin fields + extensions). - Add
scripts/generate.mjsto generate/verify derived files (--check,--new). - Wire drift checking into
npm run validateand GitHub Actions validation.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
scripts/generate.mjs |
Adds the central manifest generator and drift-check/new-plugin scaffolding logic. |
.claude-plugin/marketplace.json |
Becomes the central manifest with shared metadata and richer plugin entries. |
.commitlintrc.json |
Generated commitlint scope enum derived from plugin names. |
.github/workflows/release.yml |
Plugin matrix updated to reflect manifest ordering. |
.github/workflows/validate.yml |
Adds a job to enforce generated-file drift checks in CI. |
package.json |
Adds generate script and updates validate to include generate --check. |
plugins/ctx/.claude-plugin/plugin.json |
Updates author metadata in the plugin manifest. |
plugins/strip-ansi/.claude-plugin/plugin.json |
Updates author metadata in the plugin manifest. |
plugins/codeweaver/.claude-plugin/plugin.json |
Updates keywords/formatting and preserves userConfig via extensions. |
plugins/ctx/package.json |
Adds shared package metadata fields to the plugin package. |
plugins/strip-ansi/package.json |
Adds shared package metadata fields to the plugin package. |
plugins/codeweaver/package.json |
Adds shared package metadata fields to the plugin package. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for (const plugin of plugins) { | ||
| const pluginDir = join(ROOT, plugin.source.replace(/^\.\//, '')); | ||
| const pluginJsonPath = join(pluginDir, '.claude-plugin', 'plugin.json'); | ||
|
|
There was a problem hiding this comment.
--new <name> scaffolding runs after the main generation loops, but the generation step writes plugins/<name>/.claude-plugin/plugin.json and plugins/<name>/package.json without ensuring the directories exist. If the new plugin directory (or .claude-plugin/) isn’t already present, --new will fail before it reaches the mkdir scaffolding section. Consider running scaffolding (mkdirs) before generating for the target plugin, or ensure parent dirs exist before writing generated files.
| const extensions = pluginManifestExtensions[plugin.name] ?? {}; | ||
|
|
||
| const pluginJson = { | ||
| name: plugin.name, | ||
| version: plugin.version, | ||
| description: plugin.description, | ||
| author: shared.author, | ||
| homepage: plugin.homepage ?? shared.homepage, | ||
| repository: shared.repository, | ||
| license: plugin.license ?? shared.license, | ||
| keywords: plugin.keywords, | ||
| ...(plugin.mcpServers !== undefined && { mcpServers: plugin.mcpServers }), | ||
| ...extensions, | ||
| }; |
There was a problem hiding this comment.
pluginManifestExtensions is spread last into the generated plugin.json, which means it can override core fields like name, version, repository, etc. That makes the “central manifest is source of truth” invariant easy to accidentally break. Consider either filtering extension keys to an allowlist (e.g., userConfig) or merging extensions first so the canonical fields always win.
| const NEW_PLUGIN = newIdx !== -1 ? args[newIdx + 1] : null; | ||
|
|
There was a problem hiding this comment.
--new argument parsing doesn’t validate that a name was actually provided (e.g., --new as the last arg). In that case NEW_PLUGIN becomes undefined and the flag is silently ignored. Consider erroring out when --new is present but no plugin name is provided (or when the next token starts with --).
| const NEW_PLUGIN = newIdx !== -1 ? args[newIdx + 1] : null; | |
| let NEW_PLUGIN = null; | |
| if (newIdx !== -1) { | |
| const candidate = args[newIdx + 1]; | |
| if (!candidate || candidate.startsWith('--')) { | |
| console.error('ERROR: --new requires a plugin name.'); | |
| process.exit(1); | |
| } | |
| NEW_PLUGIN = candidate; | |
| } |
| "name": "Knitli Inc.", | ||
| "email": "hello@knitli.com" | ||
| }, | ||
| "homepage": "https://knitli.com", |
There was a problem hiding this comment.
marketplace.json sets an explicit homepage for ctx (https://knitli.com/ctx), and the generator uses plugin.homepage ?? shared.homepage for plugin.json. This generated plugin.json still has the shared homepage (https://knitli.com), so npm run generate --check will report drift. Please regenerate (or update the file) so the plugin.json homepage matches the manifest override.
| "homepage": "https://knitli.com", | |
| "homepage": "https://knitli.com/ctx", |
| - name: Fail if any generated file was modified | ||
| run: git diff --exit-code | ||
|
|
There was a problem hiding this comment.
This job runs node scripts/generate.mjs --check, which never writes to disk, so the subsequent git diff --exit-code step will always be a no-op. If you want a second layer of enforcement, run the generator in write mode and then git diff --exit-code, otherwise consider dropping the diff step to reduce CI noise/time.
| - name: Fail if any generated file was modified | |
| run: git diff --exit-code |
| runs-on: ubuntu-latest | ||
| strategy: | ||
| matrix: | ||
| plugin: [ctx, codeweaver, strip-ansi] | ||
| plugin: [ctx, strip-ansi, codeweaver] | ||
| steps: |
There was a problem hiding this comment.
With the new “marketplace.json is the source of truth + generated plugin.json/package.json” model, this workflow’s version-sync step (updating plugins/<plugin>/.claude-plugin/plugin.json based on package.json) will create drift against the generator output (which sets versions from marketplace.json). Consider updating the release process to sync versions back into .claude-plugin/marketplace.json and re-run the generator, or stop directly editing generated files in the workflow.
|
@copilot apply changes based on the comments in this thread |
…se.yml Agent-Logs-Url: https://github.com/knitli/toolshed/sessions/717af85c-94cd-450a-b8c8-cd515b4e1c93 Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>
…ogic Agent-Logs-Url: https://github.com/knitli/toolshed/sessions/717af85c-94cd-450a-b8c8-cd515b4e1c93 Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>
Applied all changes from that review thread (commit 894b7ea, polish in f8b6d80):
|
|
🎉 This PR is included in version @knitli/ctx-v1.2.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
|
🎉 This PR is included in version @knitli/codeweaver-v1.1.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
|
🎉 This PR is included in version @knitli/strip-ansi-v1.1.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Adding a plugin required manual edits to four separate files (
marketplace.json,.commitlintrc.json,release.yml,plugin.json,package.json) with no enforcement that they stayed in sync.Changes
Expanded
.claude-plugin/marketplace.json— added$schemapointer,sharedblock (author,npmScope,repository,homepage,license,private), and per-plugin fields (version,description, per-pluginhomepageoverrides, mergedkeywords,mcpServers). Plugin-manifest-only fields not permitted inPluginEntry(e.g.userConfig) live in a root-levelpluginManifestExtensionsblock keyed by plugin name — the root schema has noadditionalProperties: false, so this is valid.scripts/generate.mjs(Node ESM, zero new deps) — reads the manifest and writes all derived files:.commitlintrc.json—scope-enumfrom plugin names.github/workflows/release.yml—matrix.pluginfrom plugin names (surgical line replacement, rest of file untouched)plugins/<name>/.claude-plugin/plugin.json— mergespluginManifestExtensions[name](extensions spread first so canonical fields always win) so fields likeuserConfigstill land in the generated outputplugins/<name>/package.json— adds shared metadata fields;releaseblock is preserved verbatim; parent directories are created automatically if absentFlags:
--check(exits 1 on drift, used bynpm run validate) and--new <name>(scaffolds a new plugin directory after its entry is added to the manifest; errors clearly if name is missing or invalid)npm run validatenow chainsvalidate-marketplace.sh && generate --checkvalidate.ymlgains agenerated-files-in-syncjob — runs the generator in write mode then assertsgit diff --exit-code(making the drift check meaningful rather than a no-op)release.ymlversion-sync step updated — aftersemantic-releasebumpspackage.json, the new version is synced back intomarketplace.jsonand the generator is re-run to regenerate all derived files (marketplace.json+plugin.json+package.jsonare committed together), preventing drift between the central manifest and generated outputsSchema compliance
The
PluginEntrydefinition in the official marketplace schema has"additionalProperties": false. The manifest uses only schema-valid fields inside thepluginsarray. Any plugin-manifest-only data that has noPluginEntrycounterpart (e.g.userConfig) is stored in the root-levelpluginManifestExtensionsobject and merged into the generatedplugin.jsonby the generator.Adding a plugin going forward
That's it — no manual edits to
commitlintrc,release.yml, or the JSON manifests.