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
42 changes: 42 additions & 0 deletions .github/workflows/script-packages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Script packages

# The two skills that ship TypeScript scripts carry their own tests and
# typecheck configs. Run them on every PR that touches those packages so a
# change to a script cannot land with a failing test. Each package is checked
# on its own; a failure in one does not hide the other.

on:
pull_request:
paths:
- "orchestrate/skills/orchestrate/scripts/**"
- "pstack/skills/poteto-mode/scripts/**"
- ".github/workflows/script-packages.yml"

jobs:
check:
name: ${{ matrix.package }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package:
- orchestrate/skills/orchestrate/scripts
- pstack/skills/poteto-mode/scripts
defaults:
run:
working-directory: ${{ matrix.package }}
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Typecheck
run: bun run typecheck

- name: Test
run: bun run test
9 changes: 7 additions & 2 deletions .github/workflows/validate-plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
- ".cursor-plugin/marketplace.json"
- "**/plugin.json"
- "schemas/**"
- "scripts/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/validate-plugins.yml"

jobs:
validate:
Expand All @@ -16,9 +20,10 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm install --no-save ajv ajv-formats
run: npm ci

- name: Validate plugin definitions
run: node scripts/validate-plugins.mjs
run: npm run validate
90 changes: 90 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "cursor-plugins-repo",
"private": true,
"description": "Repo-level tooling for cursor/plugins. Plugins themselves have no runtime dependencies; this only pins what scripts/validate-plugins.mjs needs.",
"scripts": {
"validate": "node scripts/validate-plugins.mjs"
},
"devDependencies": {
"ajv": "8.20.0",
"ajv-formats": "3.0.1"
}
}
124 changes: 121 additions & 3 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 { readFileSync, existsSync, readdirSync, statSync } from "fs";
import { resolve, dirname, join } from "path";
import { fileURLToPath } from "url";
import Ajv from "ajv";
import addFormats from "ajv-formats";
Expand All @@ -25,12 +25,96 @@ const validateMarketplace = ajv.compile(marketplaceSchema);
const validatePlugin = ajv.compile(pluginSchema);

let errors = 0;
let warnings = 0;

function fail(message) {
console.error(`ERROR: ${message}`);
errors++;
}

// Reported but not fatal. Used for rules that main does not satisfy yet; flip
// a rule to fail() once the existing offenders are fixed.
function warn(message) {
console.error(`WARN: ${message}`);
warnings++;
}

// Skill frontmatter rules, from cursor.com/docs/context/skills: `name` and
// `description` are required; `name` is lowercase letters, digits and hyphens
// and must match the parent folder; descriptions are capped at 1024 characters.
const SKILL_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const SKILL_DESCRIPTION_MAX = 1024;

// Minimal YAML-subset frontmatter reader: top-level `key: value` pairs, quoted
// scalars, and `>` / `|` block scalars (with optional `-`/`+` chomping) whose
// continuation lines are indented. Returns null when there is no frontmatter.
function parseFrontmatter(text) {
if (!text.startsWith("---")) return null;
const end = text.indexOf("\n---", 3);
if (end === -1) return null;
const lines = text.slice(text.indexOf("\n") + 1, end).split("\n");
const fields = {};
for (let i = 0; i < lines.length; i++) {
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]);
if (!match) continue;
const key = match[1];
let value = match[2].trim();
if (/^[>|][-+]?$/.test(value)) {
const folded = value.startsWith(">");
const block = [];
while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === "")) {
block.push(lines[++i].trim());
}
value = folded ? block.join(" ").trim() : block.join("\n").trim();
} else if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
fields[key] = value;
}
return fields;
}

function checkSkillFrontmatter(pluginName, skillDir) {
const skillFile = join(skillDir, "SKILL.md");
if (!existsSync(skillFile)) return;
const dirName = skillDir.split(/[\\/]/).pop();
const label = `Plugin "${pluginName}": skill "${dirName}"`;
const fields = parseFrontmatter(readFileSync(skillFile, "utf-8"));
if (!fields) {
fail(`${label} has no YAML frontmatter in SKILL.md`);
return;
}
if (!fields.name) {
fail(`${label} is missing "name" in SKILL.md frontmatter`);
} else if (!SKILL_NAME_PATTERN.test(fields.name)) {
warn(`${label} has name "${fields.name}"; names are lowercase letters, digits and hyphens`);
} else if (fields.name !== dirName) {
warn(`${label} has name "${fields.name}" but lives in "${dirName}"; name must match the parent folder`);
}
if (!fields.description) {
fail(`${label} is missing "description" in SKILL.md frontmatter`);
} else if (fields.description.length > SKILL_DESCRIPTION_MAX) {
warn(`${label} description is ${fields.description.length} characters; the limit is ${SKILL_DESCRIPTION_MAX}`);
}
}

// Skills live in the plugin's declared `skills` directory, or `skills/` by default.
function checkPluginSkills(pluginName, pluginDir, declared) {
const dirs = declared === undefined ? ["skills/"] : Array.isArray(declared) ? declared : [declared];
for (const raw of dirs) {
if (typeof raw !== "string" || /[*?{}[\]]/.test(raw)) continue;
const dir = resolve(pluginDir, raw);
if (!existsSync(dir) || !statSync(dir).isDirectory()) continue;
for (const child of readdirSync(dir)) {
const skillDir = join(dir, child);
if (statSync(skillDir).isDirectory()) checkSkillFrontmatter(pluginName, skillDir);
}
}
}

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

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

// Check SKILL.md frontmatter for every skill the plugin ships
checkPluginSkills(entry.name, pluginDir, pluginJson.skills);
}

// 3. Every plugin directory in the repo must be listed in marketplace.json.
// A directory with a .cursor-plugin/plugin.json that marketplace.json does not
// reference is invisible to the marketplace and to the checks above, so a
// forgotten listing (or a typo in `source`) would otherwise pass silently.
const listedDirs = new Set(
(marketplace.plugins ?? []).map((entry) => resolve(root, entry.source))
);

function pluginDirsUnder(dir) {
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((name) => !name.startsWith("."))
.map((name) => resolve(dir, name))
.filter(
(path) =>
statSync(path).isDirectory() &&
existsSync(resolve(path, ".cursor-plugin/plugin.json"))
);
}

// 3. Report results
for (const dir of [...pluginDirsUnder(root), ...pluginDirsUnder(resolve(root, "third_party"))]) {
if (!listedDirs.has(dir)) {
fail(
`Unlisted plugin: "${dir.slice(root.length + 1)}" has a .cursor-plugin/plugin.json but no entry in marketplace.json`
);
}
}

// 4. Report results
if (warnings > 0) {
console.error(`\n${warnings} warning(s).`);
}
if (errors > 0) {
console.error(`\nValidation failed with ${errors} error(s).`);
process.exit(1);
Expand Down