diff --git a/docs/marketplace.md b/docs/marketplace.md new file mode 100644 index 000000000..f03b16d5e --- /dev/null +++ b/docs/marketplace.md @@ -0,0 +1,207 @@ +# Marketplace plugin system + +The marketplace system lets you discover, install, and manage plugins from Git-hosted catalogs. It is compatible with the Claude Code plugin registry format. + +## Quick start + +``` +/marketplace add anthropics/claude-plugins-official +/marketplace install wordpress.com@claude-plugins-official +``` + +Or just type `/marketplace` with no arguments to open the interactive plugin browser. + +## Concepts + +A **marketplace** is a Git repository (or local directory) containing a catalog file at `.claude-plugin/marketplace.json`. The catalog lists available plugins with their sources, descriptions, and metadata. + +A **plugin** is a directory containing skills, commands, hooks, MCP servers, or LSP servers. Plugins are identified by `name@marketplace` (e.g. `code-review@claude-plugins-official`). + +**Scopes**: plugins can be installed at two scopes: + +- **user** (default) -- available in all projects, stored in `~/.omp/plugins/installed_plugins.json` +- **project** -- available only in the current project, stored in `.omp/installed_plugins.json` + +Project-scoped installs shadow user-scoped installs of the same plugin. + +## Commands + +### Interactive mode + +| Command | Effect | +|---|---| +| `/marketplace` | Open interactive plugin browser (install) | + +### Marketplace management + +| Command | Effect | +|---|---| +| `/marketplace add ` | Add a marketplace source | +| `/marketplace remove ` | Remove a marketplace | +| `/marketplace update [name]` | Re-fetch catalog(s); omit name to update all | +| `/marketplace list` | List configured marketplaces | + +### Plugin operations + +| Command | Effect | +|---|---| +| `/marketplace discover [marketplace]` | Browse available plugins | +| `/marketplace install [--force] [--scope user\|project] name@marketplace` | Install a plugin | +| `/marketplace uninstall [--scope user\|project] name@marketplace` | Uninstall a plugin | +| `/marketplace installed` | List installed marketplace plugins | +| `/marketplace upgrade [--scope user\|project] [name@marketplace]` | Upgrade one or all plugins | + +### CLI equivalents + +The same operations are available from the command line: + +``` +omp plugin marketplace add +omp plugin marketplace remove +omp plugin marketplace update [name] +omp plugin marketplace list +omp plugin discover [marketplace] +omp plugin install --scope project name@marketplace +``` + +## Marketplace sources + +When you run `/marketplace add `, the system classifies the source: + +| Source format | Type | Example | +|---|---|---| +| `owner/repo` | GitHub shorthand | `anthropics/claude-plugins-official` | +| `https://...*.json` | Direct catalog URL | `https://example.com/marketplace.json` | +| `https://...*.git` or `git@...` | Git repository | `https://github.com/org/repo.git` | +| `./path` or `~/path` or `/path` | Local directory | `./my-marketplace` | + +The system clones the repository (or reads the local directory), locates `.claude-plugin/marketplace.json`, validates it, and caches the catalog locally. + +## Catalog format (marketplace.json) + +A marketplace catalog lives at `.claude-plugin/marketplace.json` in the repository root: + +```json +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "my-marketplace", + "owner": { + "name": "Your Name", + "email": "you@example.com" + }, + "description": "A collection of plugins", + "plugins": [ + { + "name": "my-plugin", + "description": "What this plugin does", + "source": "./plugins/my-plugin", + "category": "development", + "homepage": "https://github.com/you/my-plugin" + } + ] +} +``` + +### Required fields + +| Field | Description | +|---|---| +| `name` | Marketplace name. Lowercase alphanumeric, hyphens, and dots. Must start and end with alphanumeric. Max 64 chars. | +| `owner.name` | Marketplace owner name | +| `plugins` | Array of plugin entries | + +### Plugin entry fields + +| Field | Required | Description | +|---|---|---| +| `name` | yes | Plugin name (same rules as marketplace name) | +| `source` | yes | Where to find the plugin (see below) | +| `description` | no | Short description | +| `version` | no | Version string | +| `author` | no | `{ name, email? }` | +| `homepage` | no | URL | +| `category` | no | Category string (e.g. `development`, `productivity`, `security`) | +| `tags` | no | Array of string tags | +| `strict` | no | Boolean | +| `commands` | no | Slash commands provided | +| `agents` | no | Agents provided | +| `hooks` | no | Hook definitions | +| `mcpServers` | no | MCP server definitions | +| `lspServers` | no | LSP server definitions | + +### Plugin source formats + +The `source` field supports several formats: + +**Relative path** (within the marketplace repo): +```json +"source": "./plugins/my-plugin" +``` + +**Git repository URL**: +```json +"source": { + "source": "url", + "url": "https://github.com/org/repo.git", + "sha": "abc123..." +} +``` + +**GitHub shorthand**: +```json +"source": { + "source": "github", + "repo": "org/repo", + "ref": "main", + "sha": "abc123..." +} +``` + +**Git subdirectory** (monorepo): +```json +"source": { + "source": "git-subdir", + "url": "https://github.com/org/monorepo.git", + "path": "plugins/my-plugin", + "ref": "main", + "sha": "abc123..." +} +``` + +**npm package**: +```json +"source": { + "source": "npm", + "package": "@scope/my-plugin", + "version": "1.0.0" +} +``` + +## On-disk layout + +``` +~/.omp/ + config/ + marketplaces.json # Registry of added marketplaces + plugins/ + installed_plugins.json # User-scoped installed plugins + cache/ + marketplaces/ # Cached marketplace catalogs + plugins/ # Cached plugin directories + +/.omp/ + installed_plugins.json # Project-scoped installed plugins +``` + +## Naming rules + +Marketplace and plugin names must: + +- Start and end with a lowercase letter or digit +- Contain only lowercase letters, digits, hyphens, and dots +- Be at most 64 characters + +Plugin IDs (`name@marketplace`) must be at most 128 characters total. + +Valid examples: `my-plugin`, `code-review`, `wordpress.com`, `ai-firstify` +Invalid examples: `-bad`, `bad-`, `.bad`, `Bad`, `under_score` diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cbe71c9f8..4aa9145af 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog ## [Unreleased] - ### Added +- Added `/marketplace help` command to display usage guide for all marketplace operations - Added dedicated `gh-renderer.ts` module for rich terminal rendering of GitHub Actions workflow runs with live status snapshots and job details - Added `gh_pr_checkout` tool to check out GitHub pull requests into dedicated git worktrees with contributor push metadata - Added `gh_pr_push` tool to push checked-out pull request branches back to their source branches @@ -34,12 +34,16 @@ ### Changed +- Improved marketplace catalog parsing to skip invalid plugin entries with warnings instead of failing the entire catalog load +- Enhanced `/marketplace discover` command to suggest adding the official marketplace when no plugins are available +- Improved `/marketplace` command messaging with clearer guidance for first-time setup and available commands - Enhanced `gh_run_watch` tool call rendering to display animated spinner status and target description (run ID, branch, or current HEAD) with improved visual hierarchy - Enhanced `gh_pr_view` tool to include inline review comments alongside pull request reviews for improved discussion context - Improved `gh_run_watch` tool output rendering with dedicated visual component for streaming run snapshots and job status updates ### Fixed +- Fixed marketplace error messages to display error details instead of object stringification - Fixed artifact storage for non-persistent sessions to use in-memory fallback instead of returning undefined, enabling proper spill truncation for all session types - Fixed prompt file formatting to include trailing newlines at EOF for consistency across all prompt markdown files - Fixed `gh_pr_diff` to preserve raw patch content instead of normalizing tabs and whitespace diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts index efb83ece0..1d3b6e6b9 100644 --- a/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts @@ -125,48 +125,66 @@ export function parseMarketplaceCatalog(content: string, filePath: string): Mark assertField(Array.isArray(obj.plugins), "plugins", filePath); const plugins = obj.plugins as unknown[]; + const validPlugins: unknown[] = []; for (let i = 0; i < plugins.length; i++) { - const entry = plugins[i]; - assertField(typeof entry === "object" && entry !== null && !Array.isArray(entry), `plugins[${i}]`, filePath); - const p = entry as Record; - assertField(typeof p.name === "string" && isValidNameSegment(p.name), `plugins[${i}].name`, filePath); - // source can be a string path or a typed object (github/url/git-subdir/npm) - // all typed objects carry a "source" discriminant string field - assertField( - typeof p.source === "string" || - (typeof p.source === "object" && - p.source !== null && - !Array.isArray(p.source) && - typeof (p.source as Record).source === "string"), - `plugins[${i}].source`, - filePath, - ); - // String sources must be relative paths starting with "./" - if (typeof p.source === "string") { - assertField((p.source as string).startsWith("./"), `plugins[${i}].source (must start with "./")`, filePath); - } - // Validate required fields for typed source variants - if (typeof p.source === "object" && p.source !== null) { - const src = p.source as Record; - const variant = src.source as string; - if (variant === "github") { - assertField(typeof src.repo === "string" && src.repo.length > 0, `plugins[${i}].source.repo`, filePath); - } else if (variant === "url" || variant === "git-subdir") { - assertField(typeof src.url === "string" && src.url.length > 0, `plugins[${i}].source.url`, filePath); - if (variant === "git-subdir") { - assertField(typeof src.path === "string" && src.path.length > 0, `plugins[${i}].source.path`, filePath); - } - } else if (variant === "npm") { - assertField( - typeof src.package === "string" && src.package.length > 0, - `plugins[${i}].source.package`, - filePath, - ); - } else { - assertField(false, `plugins[${i}].source.source (unknown variant: "${variant}")`, filePath); + try { + const entry = plugins[i]; + assertField(typeof entry === "object" && entry !== null && !Array.isArray(entry), `plugins[${i}]`, filePath); + const p = entry as Record; + assertField(typeof p.name === "string" && isValidNameSegment(p.name), `plugins[${i}].name`, filePath); + // source can be a string path or a typed object (github/url/git-subdir/npm) + // all typed objects carry a "source" discriminant string field + assertField( + typeof p.source === "string" || + (typeof p.source === "object" && + p.source !== null && + !Array.isArray(p.source) && + typeof (p.source as Record).source === "string"), + `plugins[${i}].source`, + filePath, + ); + // String sources must be relative paths starting with "./" + if (typeof p.source === "string") { + assertField((p.source as string).startsWith("./"), `plugins[${i}].source (must start with "./")`, filePath); } + // Validate required fields for typed source variants + if (typeof p.source === "object" && p.source !== null) { + const src = p.source as Record; + const variant = src.source as string; + if (variant === "github") { + assertField(typeof src.repo === "string" && src.repo.length > 0, `plugins[${i}].source.repo`, filePath); + } else if (variant === "url" || variant === "git-subdir") { + assertField(typeof src.url === "string" && src.url.length > 0, `plugins[${i}].source.url`, filePath); + if (variant === "git-subdir") { + assertField( + typeof src.path === "string" && src.path.length > 0, + `plugins[${i}].source.path`, + filePath, + ); + } + } else if (variant === "npm") { + assertField( + typeof src.package === "string" && src.package.length > 0, + `plugins[${i}].source.package`, + filePath, + ); + } else { + assertField(false, `plugins[${i}].source.source (unknown variant: "${variant}")`, filePath); + } + } + validPlugins.push(entry); + } catch (err) { + // Warn and skip invalid plugin entries instead of failing the entire catalog. + // This lets the rest of the marketplace load even if one entry has a bad name/source. + const name = + typeof plugins[i] === "object" && plugins[i] !== null + ? ((plugins[i] as Record).name ?? `[${i}]`) + : `[${i}]`; + logger.warn(`Skipping invalid plugin ${name}: ${(err as Error).message}`); } } + // Replace the plugins array with only valid entries + obj.plugins = validPlugins; // Extra fields are preserved — cast through unknown for type safety return obj as unknown as MarketplaceCatalog; diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 8e3619a0f..278b504f5 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -579,6 +579,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "uninstall", description: "Uninstall a plugin (selector if no args)", usage: "[name@marketplace]" }, { name: "installed", description: "List installed marketplace plugins" }, { name: "upgrade", description: "Upgrade outdated plugins", usage: "[name@marketplace]" }, + { name: "help", description: "Show usage guide" }, ], allowArgs: true, handle: async (command, runtime) => { @@ -648,7 +649,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ case "discover": { const plugins = await mgr.listAvailablePlugins(rest || undefined); if (plugins.length === 0) { - runtime.ctx.showStatus("No plugins available"); + const marketplaces = await mgr.listMarketplaces(); + if (marketplaces.length === 0) { + runtime.ctx.showStatus( + "No marketplaces configured. Try:\n /marketplace add anthropics/claude-plugins-official", + ); + } else { + runtime.ctx.showStatus("No plugins available in configured marketplaces"); + } } else { const lines = plugins.map( p => @@ -725,20 +733,46 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ } break; } + case "help": { + runtime.ctx.showStatus( + [ + "Marketplace commands:", + " /marketplace Browse and install plugins", + " /marketplace add Add a marketplace (e.g. owner/repo)", + " /marketplace remove Remove a marketplace", + " /marketplace update [name] Re-fetch catalog(s)", + " /marketplace list List configured marketplaces", + " /marketplace discover [marketplace] Browse available plugins", + " /marketplace install Install a plugin", + " /marketplace uninstall Uninstall a plugin", + " /marketplace installed List installed plugins", + " /marketplace upgrade [name@marketplace] Upgrade plugin(s)", + "", + "Quick start:", + " /marketplace add anthropics/claude-plugins-official", + " /marketplace (opens interactive browser)", + ].join("\n"), + ); + break; + } default: { - // Default to list marketplaces const marketplaces = await mgr.listMarketplaces(); if (marketplaces.length === 0) { - runtime.ctx.showStatus("No marketplaces configured. Use /marketplace add "); + runtime.ctx.showStatus( + "No marketplaces configured.\n\nGet started:\n /marketplace add anthropics/claude-plugins-official\n\nThen browse plugins with /marketplace or /marketplace discover", + ); } else { const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`); - runtime.ctx.showStatus(`Marketplaces:\n${lines.join("\n")}`); + runtime.ctx.showStatus( + `Marketplaces:\n${lines.join("\n")}\n\nUse /marketplace discover to browse plugins, or /marketplace help for all commands`, + ); } break; } } } catch (err) { - runtime.ctx.showStatus(`Marketplace error: ${err}`); + const msg = err instanceof Error ? err.message : String(err); + runtime.ctx.showStatus(`Marketplace error: ${msg}`); } }, },