Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/validate-plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,19 @@ jobs:
steps:
- uses: actions/checkout@v6

- name: Validate JSON manifests
- name: Validate manifests, catalogs, MCP configs and skills
run: node scripts/validate-plugins.mjs

- name: Install Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is too much bash scripting for my taste here. If we really need to perform all these validations, we can move them to a js script so

  • Readable & maintainable: real JS with editor support, formatting, lintable; no triple-escaped node -e "..." strings.
  • Locally reproducible: node scripts/validate-plugins.mjs reproduces any CI failure; can also be wired into the husky pre-commit.
  • Removed the injection smell — globbing and file reads happen in JS via fs/path instead of interpolating $f into JS source.
  • Same coverage, less duplication — the per-step failed=0 … exit $failed pattern is gone; dropped the redundant standalone "Validate JSON manifests" step (folded in + covered by claude plugin validate).
steps:
      - uses: actions/checkout@v6

      - name: Validate manifests, catalogs, MCP configs and skills
        run: node scripts/validate-plugins.mjs

      - name: Install Claude Code
        run: npm install -g @anthropic-ai/claude-code

      - name: Validate Claude Code plugins
        run: |
          shopt -s nullglob
          for dir in plugins/*/; do
            [[ -f "${dir}.claude-plugin/plugin.json" ]] || continue
            echo "--- validating $dir ---"
            claude plugin validate "$dir"
          done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked Claude to extract it:

#!/usr/bin/env node
//
// Validate plugin manifests, catalogs, MCP configs and skill files.
//
// Run locally with `node scripts/validate-plugins.mjs` to reproduce CI failures.
// The Claude plugin contract itself is checked separately by `claude plugin
// validate`; this script covers the shape and cross-file rules that tool does
// not know about (cursor manifests, catalog consistency, logos, frontmatter).
//
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";

const KEBAB_CASE = /^[a-z0-9]+(-[a-z0-9]+)*$/;

const errors = [];

/** Record a failure against a file, printed in GitHub Actions error format. */
function fail(file, message) {
  errors.push({ file, message });
  console.error(`::error file=${file}::${message}`);
}

/** Parse JSON, recording a failure (and returning null) on syntax errors. */
function readJson(file) {
  try {
    return JSON.parse(readFileSync(file, "utf8"));
  } catch (e) {
    fail(file, `Invalid JSON: ${e.message}`);
    return null;
  }
}

/** Directories under plugins/, e.g. ["plugins/contentsquare", ...]. */
function pluginDirs() {
  return readdirSync("plugins", { withFileTypes: true })
    .filter((d) => d.isDirectory())
    .map((d) => join("plugins", d.name));
}

/** SKILL.md paths under plugins/<plugin>/skills/<skill>/SKILL.md. */
function skillFiles() {
  const files = [];
  for (const dir of pluginDirs()) {
    const skillsDir = join(dir, "skills");
    if (!existsSync(skillsDir)) continue;
    for (const skill of readdirSync(skillsDir, { withFileTypes: true })) {
      if (!skill.isDirectory()) continue;
      const file = join(skillsDir, skill.name, "SKILL.md");
      if (existsSync(file)) files.push(file);
    }
  }
  return files;
}

/** Every plugin manifest that exists, across both plugin systems. */
function manifestFiles() {
  const files = [];
  for (const dir of pluginDirs()) {
    for (const rel of [".claude-plugin/plugin.json", ".cursor-plugin/plugin.json"]) {
      const file = join(dir, rel);
      if (existsSync(file)) files.push(file);
    }
  }
  return files;
}

// --- Checks -----------------------------------------------------------------

/** Manifests and catalogs are valid JSON with a kebab-case `name`. */
function validateManifestNames() {
  const catalogs = [".claude-plugin/marketplace.json", ".cursor-plugin/marketplace.json"];
  for (const file of [...catalogs, ...manifestFiles()]) {
    const json = readJson(file);
    if (!json) continue;
    if (!json.name) fail(file, "missing name");
    else if (!KEBAB_CASE.test(json.name)) fail(file, `name must be kebab-case: "${json.name}"`);
  }
}

/** .mcp.json files declare at least one server with a url or command. */
function validateMcpConfigs() {
  for (const dir of pluginDirs()) {
    const file = join(dir, ".mcp.json");
    if (!existsSync(file)) continue;
    const json = readJson(file);
    if (!json) continue;
    if (!json.mcpServers || typeof json.mcpServers !== "object") {
      fail(file, "missing or invalid mcpServers object");
      continue;
    }
    const servers = Object.entries(json.mcpServers);
    if (servers.length === 0) {
      fail(file, "mcpServers must have at least one entry");
      continue;
    }
    for (const [name, cfg] of servers) {
      if (!cfg.url && !cfg.command) fail(file, `server "${name}" must have url or command`);
    }
  }
}

/** Each SKILL.md opens with closed YAML frontmatter containing a description. */
function validateSkillFrontmatter() {
  for (const file of skillFiles()) {
    const content = readFileSync(file, "utf8");
    if (!content.startsWith("---")) {
      fail(file, "missing YAML frontmatter (must start with ---)");
      continue;
    }
    const end = content.indexOf("---", 3);
    if (end === -1) {
      fail(file, "unclosed YAML frontmatter");
      continue;
    }
    if (!/^description\s*:/m.test(content.slice(3, end))) {
      fail(file, "missing required description field in frontmatter");
    }
  }
}

/** Catalog entries point at real plugin directories with the right manifest. */
function validateCatalogConsistency() {
  const catalogs = {
    ".claude-plugin/marketplace.json": ".claude-plugin/plugin.json",
    ".cursor-plugin/marketplace.json": ".cursor-plugin/plugin.json",
  };
  for (const [catalog, manifestRel] of Object.entries(catalogs)) {
    const json = readJson(catalog);
    if (!json) continue;
    for (const plugin of json.plugins ?? []) {
      const src = plugin.source.replace(/^\.\//, "");
      if (!existsSync(src)) {
        fail(catalog, `plugin "${plugin.name}" source path does not exist: ${src}`);
        continue;
      }
      if (!existsSync(join(src, manifestRel))) {
        fail(catalog, `plugin "${plugin.name}" missing ${manifestRel}`);
      }
    }
  }
}

/** A plugin's claude and cursor manifests agree on name and version. */
function validateCrossManifestConsistency() {
  for (const dir of pluginDirs()) {
    const claudeFile = join(dir, ".claude-plugin/plugin.json");
    const cursorFile = join(dir, ".cursor-plugin/plugin.json");
    if (!existsSync(claudeFile) || !existsSync(cursorFile)) continue;
    const claude = readJson(claudeFile);
    const cursor = readJson(cursorFile);
    if (!claude || !cursor) continue;
    if (claude.name !== cursor.name) {
      fail(claudeFile, `name mismatch: .claude-plugin "${claude.name}" vs .cursor-plugin "${cursor.name}"`);
    }
    if (claude.version && cursor.version && claude.version !== cursor.version) {
      fail(claudeFile, `version mismatch: .claude-plugin "${claude.version}" vs .cursor-plugin "${cursor.version}"`);
    }
  }
}

/** Any logo referenced by a cursor manifest exists on disk. */
function validateLogoAssets() {
  for (const dir of pluginDirs()) {
    const file = join(dir, ".cursor-plugin/plugin.json");
    if (!existsSync(file)) continue;
    const json = readJson(file);
    if (!json?.logo) continue;
    const logoPath = join(dir, json.logo);
    if (!existsSync(logoPath)) fail(file, `logo file not found: ${logoPath}`);
  }
}

// --- Run --------------------------------------------------------------------

validateManifestNames();
validateMcpConfigs();
validateSkillFrontmatter();
validateCatalogConsistency();
validateCrossManifestConsistency();
validateLogoAssets();

if (errors.length > 0) {
  console.error(`\n${errors.length} validation error(s) found.`);
  process.exit(1);
}
console.log("OK - all plugin manifests, catalogs, MCP configs and skills are valid.");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, let me change that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run: npm install -g @anthropic-ai/claude-code

- name: Validate Claude Code plugins
run: |
shopt -s nullglob
for f in .claude-plugin/marketplace.json plugins/*/.claude-plugin/plugin.json; do
echo "validating $f"
node -e "const j=require('./$f'); if(!j.name) throw new Error('$f: missing name'); if(!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(j.name)) throw new Error('$f: name must be kebab-case');"
for dir in plugins/*/; do
[[ -f "${dir}.claude-plugin/plugin.json" ]] || continue
echo "--- validating $dir ---"
claude plugin validate "$dir"
done

- name: Check generated copies are in sync
Expand Down
3 changes: 0 additions & 3 deletions plugins/contentsquare-web/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,5 @@
"tag",
"tracking",
"setup"
],
"skills": [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"skills/"
]
}
19 changes: 19 additions & 0 deletions plugins/contentsquare-web/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "contentsquare-web",
"description": "Install and verify the Contentsquare tracking tag in any web project (Next.js, React, Vue, Angular, SvelteKit, Nuxt, static HTML). Drives installation and runs browser-based verification of tag load, pageviews and CSP via npx @contentsquare/wizard.",
"version": "1.0.0",
"author": {
"name": "Contentsquare",
"url": "https://contentsquare.com"
},
"homepage": "https://docs.contentsquare.com/en/web/#ai-assisted-setup",
"repository": "https://github.com/ContentSquare/skills",
"license": "MIT",
"keywords": [
"contentsquare",
"analytics",
"tag",
"tracking",
"setup"
]
}
186 changes: 186 additions & 0 deletions scripts/validate-plugins.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#!/usr/bin/env node
//
// Validate plugin manifests, catalogs, MCP configs and skill files.
//
// Run locally with `node scripts/validate-plugins.mjs` to reproduce CI failures.
// The Claude plugin contract itself is checked separately by `claude plugin
// validate`; this script covers the shape and cross-file rules that tool does
// not know about (cursor manifests, catalog consistency, logos, frontmatter).
//
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";

const KEBAB_CASE = /^[a-z0-9]+(-[a-z0-9]+)*$/;

const errors = [];

/** Record a failure against a file, printed in GitHub Actions error format. */
function fail(file, message) {
errors.push({ file, message });
console.error(`::error file=${file}::${message}`);
}

/** Parse JSON, recording a failure (and returning null) on syntax errors. */
function readJson(file) {
try {
return JSON.parse(readFileSync(file, "utf8"));
} catch (e) {
fail(file, `Invalid JSON: ${e.message}`);
return null;
}
}

/** Directories under plugins/, e.g. ["plugins/contentsquare", ...]. */
function pluginDirs() {
return readdirSync("plugins", { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => join("plugins", d.name));
}

/** SKILL.md paths under plugins/<plugin>/skills/<skill>/SKILL.md. */
function skillFiles() {
const files = [];
for (const dir of pluginDirs()) {
const skillsDir = join(dir, "skills");
if (!existsSync(skillsDir)) continue;
for (const skill of readdirSync(skillsDir, { withFileTypes: true })) {
if (!skill.isDirectory()) continue;
const file = join(skillsDir, skill.name, "SKILL.md");
if (existsSync(file)) files.push(file);
}
}
return files;
}

/** Every plugin manifest that exists, across both plugin systems. */
function manifestFiles() {
const files = [];
for (const dir of pluginDirs()) {
for (const rel of [".claude-plugin/plugin.json", ".cursor-plugin/plugin.json"]) {
const file = join(dir, rel);
if (existsSync(file)) files.push(file);
}
}
return files;
}

// --- Checks -----------------------------------------------------------------

/** Manifests and catalogs are valid JSON with a kebab-case `name`. */
function validateManifestNames() {
const catalogs = [".claude-plugin/marketplace.json", ".cursor-plugin/marketplace.json"];
for (const file of [...catalogs, ...manifestFiles()]) {
const json = readJson(file);
if (!json) continue;
if (!json.name) fail(file, "missing name");
else if (!KEBAB_CASE.test(json.name)) fail(file, `name must be kebab-case: "${json.name}"`);
}
}

/** .mcp.json files declare at least one server with a url or command. */
function validateMcpConfigs() {
for (const dir of pluginDirs()) {
const file = join(dir, ".mcp.json");
if (!existsSync(file)) continue;
const json = readJson(file);
if (!json) continue;
if (!json.mcpServers || typeof json.mcpServers !== "object") {
fail(file, "missing or invalid mcpServers object");
continue;
}
const servers = Object.entries(json.mcpServers);
if (servers.length === 0) {
fail(file, "mcpServers must have at least one entry");
continue;
}
for (const [name, cfg] of servers) {
if (!cfg.url && !cfg.command) fail(file, `server "${name}" must have url or command`);
}
}
}

/** Each SKILL.md opens with closed YAML frontmatter containing a description. */
function validateSkillFrontmatter() {
for (const file of skillFiles()) {
const content = readFileSync(file, "utf8");
if (!content.startsWith("---")) {
fail(file, "missing YAML frontmatter (must start with ---)");
continue;
}
const end = content.indexOf("---", 3);
if (end === -1) {
fail(file, "unclosed YAML frontmatter");
continue;
}
if (!/^description\s*:/m.test(content.slice(3, end))) {
fail(file, "missing required description field in frontmatter");
}
}
}

/** Catalog entries point at real plugin directories with the right manifest. */
function validateCatalogConsistency() {
const catalogs = {
".claude-plugin/marketplace.json": ".claude-plugin/plugin.json",
".cursor-plugin/marketplace.json": ".cursor-plugin/plugin.json",
};
for (const [catalog, manifestRel] of Object.entries(catalogs)) {
const json = readJson(catalog);
if (!json) continue;
for (const plugin of json.plugins ?? []) {
const src = plugin.source.replace(/^\.\//, "");
if (!existsSync(src)) {
fail(catalog, `plugin "${plugin.name}" source path does not exist: ${src}`);
continue;
}
if (!existsSync(join(src, manifestRel))) {
fail(catalog, `plugin "${plugin.name}" missing ${manifestRel}`);
}
}
}
}

/** A plugin's claude and cursor manifests agree on name and version. */
function validateCrossManifestConsistency() {
for (const dir of pluginDirs()) {
const claudeFile = join(dir, ".claude-plugin/plugin.json");
const cursorFile = join(dir, ".cursor-plugin/plugin.json");
if (!existsSync(claudeFile) || !existsSync(cursorFile)) continue;
const claude = readJson(claudeFile);
const cursor = readJson(cursorFile);
if (!claude || !cursor) continue;
if (claude.name !== cursor.name) {
fail(claudeFile, `name mismatch: .claude-plugin "${claude.name}" vs .cursor-plugin "${cursor.name}"`);
}
if (claude.version && cursor.version && claude.version !== cursor.version) {
fail(claudeFile, `version mismatch: .claude-plugin "${claude.version}" vs .cursor-plugin "${cursor.version}"`);
}
}
}

/** Any logo referenced by a cursor manifest exists on disk. */
function validateLogoAssets() {
for (const dir of pluginDirs()) {
const file = join(dir, ".cursor-plugin/plugin.json");
if (!existsSync(file)) continue;
const json = readJson(file);
if (!json?.logo) continue;
const logoPath = join(dir, json.logo);
if (!existsSync(logoPath)) fail(file, `logo file not found: ${logoPath}`);
}
}

// --- Run --------------------------------------------------------------------

validateManifestNames();
validateMcpConfigs();
validateSkillFrontmatter();
validateCatalogConsistency();
validateCrossManifestConsistency();
validateLogoAssets();

if (errors.length > 0) {
console.error(`\n${errors.length} validation error(s) found.`);
process.exit(1);
}
console.log("OK - all plugin manifests, catalogs, MCP configs and skills are valid.");