From 5903c383d984cf59433a3a9e4a546a48928010dd Mon Sep 17 00:00:00 2001 From: Guillaume Lebedel Date: Mon, 23 Feb 2026 18:52:18 +0000 Subject: [PATCH 1/2] feat: validate marketplace.json using Zod schema mirroring Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-rolled custom validator with a Zod schema that mirrors the exact validation Claude Code runs internally (reverse-engineered from @anthropic-ai/claude-code cli.js). - scripts/validate-marketplace.js: Zod schema matching Claude Code's e76/jP5 schemas — validates the marketplace name, plugin entries, and source union (local "./path", github, npm, pip, url) - package.json: adds zod as the only dev dependency, npm run validate script - .github/workflows/validate.yml: runs on push to any branch and PRs to main - .gitignore: excludes node_modules The source union now precisely matches what Claude Code accepts, so any invalid source (like the bare "." that triggered this) is caught with the same error message Claude Code would produce. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/validate.yml | 26 ++++++ .gitignore | 1 + package-lock.json | 23 ++++++ package.json | 11 +++ scripts/validate-marketplace.js | 140 ++++++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+) create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/validate-marketplace.js diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..b6fa0b7 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,26 @@ +name: Validate Plugin Manifest + +on: + push: + branches: + - "**" + pull_request: + branches: + - main + +jobs: + validate: + name: Validate marketplace.json + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22" + + - name: Install dependencies + run: npm install + + - name: Validate plugin manifests + run: npm run validate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dc9b443 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "agent-plugins-marketplace", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agent-plugins-marketplace", + "devDependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7761a74 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "agent-plugins-marketplace", + "private": true, + "type": "module", + "scripts": { + "validate": "node scripts/validate-marketplace.js" + }, + "devDependencies": { + "zod": "^3.24.2" + } +} diff --git a/scripts/validate-marketplace.js b/scripts/validate-marketplace.js new file mode 100644 index 0000000..903b23b --- /dev/null +++ b/scripts/validate-marketplace.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * Validates .claude-plugin/marketplace.json against the exact Zod schema + * used by Claude Code internally (reverse-engineered from cli.js). + */ + +import { z } from "zod"; +import { readFileSync, existsSync } from "fs"; +import { resolve } from "path"; + +const ROOT = new URL("..", import.meta.url).pathname; + +// ─── Schemas (mirroring Claude Code's internal Zod definitions) ────────────── + +// yQ: local path must start with "./" +const localPath = z.string().startsWith("./"); + +// l97: full 40-char git commit SHA +const gitSha = z + .string() + .length(40) + .regex(/^[a-f0-9]{40}$/, "Must be a full 40-character lowercase git commit SHA"); + +// jP5: plugin source union +const pluginSource = z.union([ + localPath.describe("Path to the plugin root, relative to the marketplace directory"), + z + .object({ + source: z.literal("npm"), + package: z.string().describe("Package name"), + version: z.string().optional().describe("Version or version range (e.g., ^1.0.0)"), + registry: z.string().url().optional().describe("Custom NPM registry URL"), + }) + .describe("NPM package as plugin source"), + z + .object({ + source: z.literal("pip"), + package: z.string().describe("Python package name on PyPI"), + version: z.string().optional().describe("Version specifier (e.g., ==1.0.0)"), + registry: z.string().url().optional().describe("Custom PyPI registry URL"), + }) + .describe("Python package as plugin source"), + z + .object({ + source: z.literal("url"), + url: z.string().endsWith(".git").describe("Full git repository URL (https:// or git@)"), + ref: z.string().optional().describe('Git branch or tag (e.g., "main", "v1.0.0")'), + sha: gitSha.optional().describe("Specific commit SHA"), + }) + .describe("Git URL as plugin source"), + z + .object({ + source: z.literal("github"), + repo: z.string().describe("GitHub repository in owner/repo format"), + ref: z.string().optional().describe('Git branch or tag (e.g., "main", "v1.0.0")'), + sha: gitSha.optional().describe("Specific commit SHA"), + }) + .describe("GitHub repository as plugin source"), +]); + +// n97: owner/author +const owner = z.object({ + name: z.string().min(1, "Author name cannot be empty"), + email: z.string().optional(), + url: z.string().optional(), +}); + +// DP5: marketplace plugin entry +const marketplacePlugin = z + .object({ + name: z + .string() + .min(1, "Plugin name cannot be empty") + .refine((n) => !n.includes(" "), { + message: 'Plugin name cannot contain spaces. Use kebab-case (e.g., "my-plugin")', + }), + source: pluginSource, + description: z.string().optional(), + category: z.string().optional(), + tags: z.array(z.string()).optional(), + version: z.string().optional(), + author: owner.optional(), + homepage: z.string().optional(), + repository: z.string().optional(), + license: z.string().optional(), + strict: z.boolean().optional().default(true), + }) + .strict(); + +// e76: marketplace schema +const marketplaceSchema = z.object({ + $schema: z.string().optional(), + name: z + .string() + .min(1, "Marketplace must have a name") + .refine((n) => !n.includes(" "), { + message: 'Marketplace name cannot contain spaces. Use kebab-case (e.g., "my-marketplace")', + }), + owner: owner.optional(), + plugins: z.array(marketplacePlugin).min(1, "Marketplace must have at least one plugin"), + forceRemoveDeletedPlugins: z.boolean().optional(), + metadata: z + .object({ + pluginRoot: z.string().optional(), + version: z.string().optional(), + description: z.string().optional(), + }) + .optional(), +}); + +// ─── Validate ───────────────────────────────────────────────────────────────── + +const filePath = resolve(ROOT, ".claude-plugin/marketplace.json"); + +if (!existsSync(filePath)) { + console.error("❌ .claude-plugin/marketplace.json not found"); + process.exit(1); +} + +let raw; +try { + raw = JSON.parse(readFileSync(filePath, "utf8")); +} catch (e) { + console.error(`❌ .claude-plugin/marketplace.json: invalid JSON — ${e.message}`); + process.exit(1); +} + +const result = marketplaceSchema.safeParse(raw); + +if (!result.success) { + const issues = result.error.issues + .map((i) => ` • ${i.path.join(".")}: ${i.message}`) + .join("\n"); + console.error(`❌ .claude-plugin/marketplace.json validation failed:\n${issues}`); + process.exit(1); +} + +console.log( + `✅ .claude-plugin/marketplace.json — valid (${result.data.plugins.length} plugin(s))` +); From 2361c23cc2a908025e03a0f578be5e26314685d9 Mon Sep 17 00:00:00 2001 From: Guillaume Lebedel Date: Mon, 23 Feb 2026 22:57:59 +0000 Subject: [PATCH 2/2] fix: use claude plugin validate instead of custom Zod script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claude plugin validate .` is a built-in CLI command that runs the exact same internal Zod schema. No custom script or extra dependencies needed — just npx @anthropic-ai/claude-code in CI. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/validate.yml | 9 +- package-lock.json | 23 ------ package.json | 11 --- scripts/validate-marketplace.js | 140 -------------------------------- 4 files changed, 1 insertion(+), 182 deletions(-) delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 scripts/validate-marketplace.js diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b6fa0b7..078b17e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -15,12 +15,5 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "22" - - - name: Install dependencies - run: npm install - - name: Validate plugin manifests - run: npm run validate + run: npx --yes @anthropic-ai/claude-code plugin validate . diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index dc9b443..0000000 --- a/package-lock.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "agent-plugins-marketplace", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "agent-plugins-marketplace", - "devDependencies": { - "zod": "^3.24.2" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 7761a74..0000000 --- a/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "agent-plugins-marketplace", - "private": true, - "type": "module", - "scripts": { - "validate": "node scripts/validate-marketplace.js" - }, - "devDependencies": { - "zod": "^3.24.2" - } -} diff --git a/scripts/validate-marketplace.js b/scripts/validate-marketplace.js deleted file mode 100644 index 903b23b..0000000 --- a/scripts/validate-marketplace.js +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env node -/** - * Validates .claude-plugin/marketplace.json against the exact Zod schema - * used by Claude Code internally (reverse-engineered from cli.js). - */ - -import { z } from "zod"; -import { readFileSync, existsSync } from "fs"; -import { resolve } from "path"; - -const ROOT = new URL("..", import.meta.url).pathname; - -// ─── Schemas (mirroring Claude Code's internal Zod definitions) ────────────── - -// yQ: local path must start with "./" -const localPath = z.string().startsWith("./"); - -// l97: full 40-char git commit SHA -const gitSha = z - .string() - .length(40) - .regex(/^[a-f0-9]{40}$/, "Must be a full 40-character lowercase git commit SHA"); - -// jP5: plugin source union -const pluginSource = z.union([ - localPath.describe("Path to the plugin root, relative to the marketplace directory"), - z - .object({ - source: z.literal("npm"), - package: z.string().describe("Package name"), - version: z.string().optional().describe("Version or version range (e.g., ^1.0.0)"), - registry: z.string().url().optional().describe("Custom NPM registry URL"), - }) - .describe("NPM package as plugin source"), - z - .object({ - source: z.literal("pip"), - package: z.string().describe("Python package name on PyPI"), - version: z.string().optional().describe("Version specifier (e.g., ==1.0.0)"), - registry: z.string().url().optional().describe("Custom PyPI registry URL"), - }) - .describe("Python package as plugin source"), - z - .object({ - source: z.literal("url"), - url: z.string().endsWith(".git").describe("Full git repository URL (https:// or git@)"), - ref: z.string().optional().describe('Git branch or tag (e.g., "main", "v1.0.0")'), - sha: gitSha.optional().describe("Specific commit SHA"), - }) - .describe("Git URL as plugin source"), - z - .object({ - source: z.literal("github"), - repo: z.string().describe("GitHub repository in owner/repo format"), - ref: z.string().optional().describe('Git branch or tag (e.g., "main", "v1.0.0")'), - sha: gitSha.optional().describe("Specific commit SHA"), - }) - .describe("GitHub repository as plugin source"), -]); - -// n97: owner/author -const owner = z.object({ - name: z.string().min(1, "Author name cannot be empty"), - email: z.string().optional(), - url: z.string().optional(), -}); - -// DP5: marketplace plugin entry -const marketplacePlugin = z - .object({ - name: z - .string() - .min(1, "Plugin name cannot be empty") - .refine((n) => !n.includes(" "), { - message: 'Plugin name cannot contain spaces. Use kebab-case (e.g., "my-plugin")', - }), - source: pluginSource, - description: z.string().optional(), - category: z.string().optional(), - tags: z.array(z.string()).optional(), - version: z.string().optional(), - author: owner.optional(), - homepage: z.string().optional(), - repository: z.string().optional(), - license: z.string().optional(), - strict: z.boolean().optional().default(true), - }) - .strict(); - -// e76: marketplace schema -const marketplaceSchema = z.object({ - $schema: z.string().optional(), - name: z - .string() - .min(1, "Marketplace must have a name") - .refine((n) => !n.includes(" "), { - message: 'Marketplace name cannot contain spaces. Use kebab-case (e.g., "my-marketplace")', - }), - owner: owner.optional(), - plugins: z.array(marketplacePlugin).min(1, "Marketplace must have at least one plugin"), - forceRemoveDeletedPlugins: z.boolean().optional(), - metadata: z - .object({ - pluginRoot: z.string().optional(), - version: z.string().optional(), - description: z.string().optional(), - }) - .optional(), -}); - -// ─── Validate ───────────────────────────────────────────────────────────────── - -const filePath = resolve(ROOT, ".claude-plugin/marketplace.json"); - -if (!existsSync(filePath)) { - console.error("❌ .claude-plugin/marketplace.json not found"); - process.exit(1); -} - -let raw; -try { - raw = JSON.parse(readFileSync(filePath, "utf8")); -} catch (e) { - console.error(`❌ .claude-plugin/marketplace.json: invalid JSON — ${e.message}`); - process.exit(1); -} - -const result = marketplaceSchema.safeParse(raw); - -if (!result.success) { - const issues = result.error.issues - .map((i) => ` • ${i.path.join(".")}: ${i.message}`) - .join("\n"); - console.error(`❌ .claude-plugin/marketplace.json validation failed:\n${issues}`); - process.exit(1); -} - -console.log( - `✅ .claude-plugin/marketplace.json — valid (${result.data.plugins.length} plugin(s))` -);