Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/validate-plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- ".cursor-plugin/marketplace.json"
- "**/plugin.json"
- "schemas/**"
- "scripts/**"

jobs:
validate:
Expand Down
85 changes: 83 additions & 2 deletions scripts/validate-plugins.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

import { readFileSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { existsSync, readFileSync } from "fs";
import { dirname, relative, resolve } from "path";
import { fileURLToPath } from "url";
import Ajv from "ajv";
import addFormats from "ajv-formats";
Expand Down Expand Up @@ -31,6 +31,82 @@ function fail(message) {
errors++;
}

// Fields whose declared value is a path (or glob) relative to the plugin
// directory. `hooks`/`mcpServers` may also be inline objects and `logo` may
// be an absolute URL per the schema — those are skipped by the caller.
const COMPONENT_PATH_FIELDS = ["skills", "agents", "commands", "rules"];
const COMPONENT_SINGLE_PATH_FIELDS = ["hooks", "mcpServers", "logo"];

function isAbsoluteUrl(value) {
return /^(?:https?:|data:)/i.test(value);
}

// Verify that a path declared in plugin.json resolves to an existing file or
// directory under the plugin directory. Schema validation only checks the
// shape of plugin.json, so a typo'd component path (e.g. "skils/" or a
// renamed hook file) currently passes CI and silently fails to load in
// Cursor. Glob patterns are checked against their static directory prefix so
// a typo in the base directory is still caught.
function checkReferencedPath(pluginName, pluginDir, field, declared) {
if (typeof declared !== "string") return;

if (declared.trim().length === 0) {
fail(`Plugin "${pluginName}": ${field} path must not be empty`);
return;
}

// The schema allows `logo` to be an absolute URL; every other component
// field is a local path, so a remote-looking value there is a mistake the
// existence check would otherwise silently skip.
if (isAbsoluteUrl(declared)) {
if (field === "logo") return;
fail(
`Plugin "${pluginName}": ${field} path "${declared}" must be a local path relative to the plugin directory`
);
return;
}

const normalized = declared.replace(/^\.\//, "").replace(/\/+$/, "");
if (!normalized || normalized.startsWith("/")) {
fail(
`Plugin "${pluginName}": ${field} path "${declared}" must be relative to the plugin directory`
);
return;
}
if (normalized.split(/[\\/]/).includes("..")) {
fail(
`Plugin "${pluginName}": ${field} path "${declared}" must not escape the plugin directory`
);
return;
}

const globIndex = normalized.search(/[*?[\]]/);
const staticPart =
globIndex >= 0
? normalized.slice(0, globIndex).replace(/\/+$/, "")
: normalized;
const fullPath = resolve(pluginDir, staticPart);
if (!existsSync(fullPath)) {
fail(
`Plugin "${pluginName}": ${field} path "${declared}" does not exist (resolved to ${relative(root, fullPath)})`
);
}
}

// Check every component path declared by a plugin.json. Strings that may also
// be inline objects (hooks/mcpServers) are skipped, not treated as paths.
function checkPluginComponentPaths(pluginName, pluginDir, pluginJson) {
for (const field of [...COMPONENT_PATH_FIELDS, ...COMPONENT_SINGLE_PATH_FIELDS]) {
const value = pluginJson[field];
if (value === undefined) continue;
const patterns = Array.isArray(value) ? value : [value];
for (const pattern of patterns) {
if (typeof pattern !== "string") continue;
checkReferencedPath(pluginName, pluginDir, field, pattern);
}
}
}

// 1. Validate marketplace.json
const marketplacePath = resolve(root, ".cursor-plugin/marketplace.json");

Expand Down Expand Up @@ -90,6 +166,11 @@ for (const entry of marketplace.plugins ?? []) {
`Plugin "${entry.name}": marketplace name does not match plugin.json name "${pluginJson.name}"`
);
}

// Check that component paths declared in plugin.json exist under the plugin
// directory. A typo'd skill/hook path passes schema validation but silently
// fails to load in Cursor, so catch it at review time.
checkPluginComponentPaths(entry.name, pluginDir, pluginJson);
}

// 3. Report results
Expand Down