From 31a9ff3c476a9fdb9fadd533447acb112dba61d8 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Tue, 13 Oct 2026 19:37:36 +0300 Subject: [PATCH 1/4] ci: pin the validator's ajv dependencies in a root package.json and install with npm ci (cherry picked from commit b2aed9d2aa79b2d15f9396a530dc556914879481) --- .github/workflows/validate-plugins.yml | 9 ++- package-lock.json | 90 ++++++++++++++++++++++++++ package.json | 12 ++++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml index 03e8e1928..ca886aee2 100644 --- a/.github/workflows/validate-plugins.yml +++ b/.github/workflows/validate-plugins.yml @@ -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: @@ -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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..90bb28892 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,90 @@ +{ + "name": "cursor-plugins-repo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cursor-plugins-repo", + "devDependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..fc3777acb --- /dev/null +++ b/package.json @@ -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" + } +} From 7f8cf2d1d658388fb491cf8638e3626217f2c75b Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Tue, 13 Oct 2026 14:09:23 +0300 Subject: [PATCH 2/4] validate: check SKILL.md frontmatter (name, description, folder match, length) (cherry picked from commit 94f15a7d615861f05b25abf1d69645fc7fd87e94) --- scripts/validate-plugins.mjs | 94 +++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/scripts/validate-plugins.mjs b/scripts/validate-plugins.mjs index 6a7870854..b1c3f0ed6 100644 --- a/scripts/validate-plugins.mjs +++ b/scripts/validate-plugins.mjs @@ -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"; @@ -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"); @@ -90,9 +174,15 @@ 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. 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); From 41ae3280896b90548a3835a0907183691d7f4bef Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Tue, 13 Oct 2026 13:15:41 +0300 Subject: [PATCH 3/4] ci: run bun typecheck and tests for the two script packages on PRs (cherry picked from commit 1e976773d0f72d6c912ebd10dd23905c2cde846e) --- .github/workflows/script-packages.yml | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/script-packages.yml diff --git a/.github/workflows/script-packages.yml b/.github/workflows/script-packages.yml new file mode 100644 index 000000000..b582333f4 --- /dev/null +++ b/.github/workflows/script-packages.yml @@ -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 From e8253aeea609a06be7c0f0ac2b2161b104a356b2 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Wed, 14 Oct 2026 16:10:54 +0300 Subject: [PATCH 4/4] validate: fail when a plugin directory is not listed in marketplace.json The validator walked marketplace.json entries only, so a directory with a .cursor-plugin/plugin.json that no entry references (forgotten listing, or a typo in source) passed CI and never reached the marketplace. Walk the repo root and third_party/ for plugin directories and fail for each one that is not a listed source. main has 102 listed and 0 unlisted today, so this is an error from the start. (cherry picked from commit e8ea0316c4dba7b2d0c65b6f421543729e74d20d) --- scripts/validate-plugins.mjs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/validate-plugins.mjs b/scripts/validate-plugins.mjs index b1c3f0ed6..add3f8e48 100644 --- a/scripts/validate-plugins.mjs +++ b/scripts/validate-plugins.mjs @@ -179,7 +179,35 @@ for (const entry of marketplace.plugins ?? []) { checkPluginSkills(entry.name, pluginDir, pluginJson.skills); } -// 3. Report results +// 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")) + ); +} + +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).`); }