diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 17a37f5..851e416 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,13 +6,12 @@ }, "metadata": { "description": "Connect Cursor to LogRocket to query session replays, metrics, issues, and user behavior.", - "version": "0.1.0", - "pluginRoot": "plugins" + "version": "0.1.0" }, "plugins": [ { "name": "logrocket", - "source": "plugins/logrocket", + "source": "logrocket", "description": "LogRocket MCP integration with a skill for querying sessions, metrics, and issues" } ] diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml new file mode 100644 index 0000000..9403164 --- /dev/null +++ b/.github/workflows/validate-plugins.yml @@ -0,0 +1,27 @@ +name: Validate plugins + +on: + pull_request: + paths: + - ".cursor-plugin/marketplace.json" + - "**/plugin.json" + - "schemas/**" + push: + branches: + - main + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install --no-save ajv ajv-formats + + - name: Validate plugin definitions + run: node scripts/validate-plugins.mjs diff --git a/.gitignore b/.gitignore index 01ef2f7..cf3ecb0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ *.log tmp/ temp/ + +# Node +node_modules/ +package-lock.json diff --git a/README.md b/README.md index 295e446..850da0b 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ Connect Cursor to [LogRocket](https://logrocket.com) to query session replays, m ## Setup 1. Install the plugin from the Cursor Marketplace. -2. Find your **App ID** under **Settings > Project Settings** in the [LogRocket dashboard](https://app.logrocket.com), or from the URL: `https://app.logrocket.com//`. -3. Set the environment variables `LOGROCKET_ORG_ID` and `LOGROCKET_PROJECT_ID`, or replace them directly in the plugin's `mcp.json`. -4. Connect to the MCP server when prompted by Cursor to authenticate. +2. Connect to the LogRocket MCP server when prompted by Cursor, and authenticate via OAuth. + +That's it — the MCP server connects to the root `https://mcp.logrocket.com/mcp` endpoint and gives the agent access to the same LogRocket organizations and projects you can access in your browser. No environment variables required. ## Example Prompts @@ -29,10 +29,30 @@ Connect Cursor to [LogRocket](https://logrocket.com) to query session replays, m - [LogRocket MCP Server Docs](https://docs.logrocket.com/docs/mcp) - [Ask Galileo Docs](https://docs.logrocket.com/docs/ask-galileo) +## Repository structure + +This is a multi-plugin marketplace repository, mirroring the layout of the official [cursor/plugins](https://github.com/cursor/plugins) repo. The root `.cursor-plugin/marketplace.json` lists all plugins, and each plugin lives in its own top-level directory: + +``` +.cursor-plugin/ +└── marketplace.json # Marketplace manifest (lists all plugins) +logrocket/ +├── .cursor-plugin/ +│ └── plugin.json # Per-plugin manifest +├── skills/ # Agent skills (SKILL.md with frontmatter) +├── mcp.json # MCP server definition +├── README.md +├── CHANGELOG.md +└── LICENSE +schemas/ # JSON schemas for validation +scripts/validate-plugins.mjs +``` + ## Validation -Run the template validation script to verify the plugin structure: +Validate the plugin structure against the Cursor plugin schemas: ```sh -node scripts/validate-template.mjs +npm install --no-save ajv ajv-formats +node scripts/validate-plugins.mjs ``` diff --git a/plugins/logrocket/.cursor-plugin/plugin.json b/logrocket/.cursor-plugin/plugin.json similarity index 51% rename from plugins/logrocket/.cursor-plugin/plugin.json rename to logrocket/.cursor-plugin/plugin.json index 535d2eb..fe5166b 100644 --- a/plugins/logrocket/.cursor-plugin/plugin.json +++ b/logrocket/.cursor-plugin/plugin.json @@ -7,7 +7,13 @@ "name": "LogRocket", "email": "support@logrocket.com" }, + "homepage": "https://github.com/AppHubPlatform/logrocket-cursor-plugin/tree/main/logrocket", + "repository": "https://github.com/AppHubPlatform/logrocket-cursor-plugin", "license": "MIT", + "logo": "assets/logo.png", "keywords": ["logrocket", "session-replay", "analytics", "mcp", "galileo"], - "logo": "assets/logo.png" + "category": "developer-tools", + "tags": ["logrocket", "session-replay", "analytics", "observability", "debugging"], + "skills": "./skills/", + "mcpServers": "./mcp.json" } diff --git a/logrocket/CHANGELOG.md b/logrocket/CHANGELOG.md new file mode 100644 index 0000000..16542cb --- /dev/null +++ b/logrocket/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 0.1.0 + +- Initial release of the LogRocket plugin. +- Added the LogRocket MCP server connection (all toolsets enabled via `?toolsets=all`). +- Added the `use-logrocket` skill for querying sessions, metrics, issues, and user behavior. diff --git a/logrocket/LICENSE b/logrocket/LICENSE new file mode 100644 index 0000000..795df04 --- /dev/null +++ b/logrocket/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LogRocket + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/logrocket/README.md b/logrocket/README.md similarity index 51% rename from plugins/logrocket/README.md rename to logrocket/README.md index c3d73ee..2249602 100644 --- a/plugins/logrocket/README.md +++ b/logrocket/README.md @@ -6,14 +6,15 @@ Powered by the [LogRocket MCP Server](https://docs.logrocket.com/docs/mcp) and [ ## What's Included -- **MCP Server** — Connects Cursor to the LogRocket API so your AI agent can query sessions, metrics, issues, and more. +- **MCP Server** — Connects Cursor to the LogRocket API so your AI agent can query sessions, metrics, issues, and more. All toolsets are enabled (`?toolsets=all`), exposing `use_logrocket`, `find_sessions`, `watch_sessions`, `build_metric`, `list_organizations`, and `list_projects`. - **Use LogRocket Skill** — A skill that teaches your agent how to invoke LogRocket queries on your behalf. ## Setup -1. Find your **App ID** under **Settings > Project Settings** in the LogRocket dashboard, or in the URL: `https://app.logrocket.com//`. -2. Set the environment variables `LOGROCKET_ORG_ID` and `LOGROCKET_PROJECT_ID`, or replace them directly in `mcp.json`. -3. Connect to the MCP server when prompted by Cursor to authenticate. +1. Install the plugin. +2. Connect to the MCP server when prompted by Cursor, and authenticate via OAuth. + +The server connects to the root `https://mcp.logrocket.com/mcp` endpoint, so your agent can access the same LogRocket organizations and projects you can access in your browser. No environment variables required — use the `list_organizations` / `list_projects` tools to pick a specific org or project. ## Learn More diff --git a/logrocket/assets/logo.png b/logrocket/assets/logo.png new file mode 100644 index 0000000..5d2656f Binary files /dev/null and b/logrocket/assets/logo.png differ diff --git a/logrocket/mcp.json b/logrocket/mcp.json new file mode 100644 index 0000000..587daac --- /dev/null +++ b/logrocket/mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "logrocket": { + "url": "https://mcp.logrocket.com/mcp?toolsets=all" + } + } +} diff --git a/plugins/logrocket/skills/use-logrocket/SKILL.md b/logrocket/skills/use-logrocket/SKILL.md similarity index 57% rename from plugins/logrocket/skills/use-logrocket/SKILL.md rename to logrocket/skills/use-logrocket/SKILL.md index b033f53..cac968d 100644 --- a/plugins/logrocket/skills/use-logrocket/SKILL.md +++ b/logrocket/skills/use-logrocket/SKILL.md @@ -16,13 +16,27 @@ description: Query LogRocket for session replays, metrics, issues, and user beha - Analyzing post-launch behavior for a new feature - Researching a specific user or account's sessions +## Available tools + +This plugin connects to the LogRocket MCP server with all toolsets enabled (`?toolsets=all`), so you have access to both the high-level natural-language tool and specialized tools: + +- `use_logrocket` — Run a natural-language `query` against LogRocket. Powered by Ask Galileo, which chains the underlying tools internally. Best default for open-ended investigation ("figure out the root cause", "how is this feature used"). +- `find_sessions` — Filter LogRocket sessions by criteria (URL, user email, events, time range, etc.). Use when you need a precise, structured list of sessions. +- `watch_sessions` — Analyze and/or extract detailed information from one or more specific sessions. Use after `find_sessions` (or when you already have session IDs) to get qualitative, step-by-step insight into what a user did. +- `build_metric` — Query LogRocket analytics data to build/return a metric. Use for quantitative questions (counts, trends, conversion, frustration signals). +- `list_organizations` — List the LogRocket organizations you can access. +- `list_projects` — List the LogRocket projects within an organization. + ## Instructions -1. Call the `use_logrocket` MCP tool with a natural language `query` describing what you want to know. -2. To continue the same conversation (e.g. follow-up questions, drilling deeper), pass the `chatID` from the previous response. -3. Be specific about what you want analyzed — mention URLs, click targets, user emails, time ranges, or custom events when possible. -4. Ask LogRocket to watch sessions when you need detailed, qualitative insights about user behavior. -5. Present results clearly to the user, including any session URLs, metrics, charts, or actionable insights. +1. Pick the right tool for the job: + - For open-ended investigation, start with `use_logrocket` and let Ask Galileo orchestrate. + - For precise filtering or quantitative analysis, use `find_sessions` + `watch_sessions` or `build_metric` directly. +2. This MCP server is connected to the root LogRocket URL (not scoped to a specific org/project). When the target org/project isn't already known from context, call `list_organizations` and `list_projects` first to discover and confirm the right one before querying. +3. For `use_logrocket`, pass a natural language `query` describing what you want to know. To continue the same conversation (e.g. follow-up questions, drilling deeper), pass the `chatID` from the previous response. +4. Be specific about what you want analyzed — mention URLs, click targets, user emails, time ranges, or custom events when possible. +5. Use `watch_sessions` when you need detailed, qualitative insights about user behavior. +6. Present results clearly to the user, including any session URLs, metrics, charts, or actionable insights. ## Example Prompts diff --git a/plugins/logrocket/assets/logo.png b/plugins/logrocket/assets/logo.png deleted file mode 100644 index 05b07ae..0000000 Binary files a/plugins/logrocket/assets/logo.png and /dev/null differ diff --git a/plugins/logrocket/mcp.json b/plugins/logrocket/mcp.json deleted file mode 100644 index f274904..0000000 --- a/plugins/logrocket/mcp.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "mcpServers": { - "logrocket": { - "url": "https://mcp.logrocket.com/mcp/${LOGROCKET_ORG_ID}/${LOGROCKET_PROJECT_ID}" - } - } -} diff --git a/schemas/marketplace.schema.json b/schemas/marketplace.schema.json new file mode 100644 index 0000000..70eba0f --- /dev/null +++ b/schemas/marketplace.schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/marketplace.json", + "title": "Cursor Plugin Marketplace", + "description": "Schema for .cursor-plugin/marketplace.json — defines a marketplace that indexes one or more Cursor plugins.", + "type": "object", + "required": ["name", "plugins"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Unique identifier for the marketplace." + }, + "owner": { + "$ref": "#/$defs/owner", + "description": "The marketplace owner or organisation." + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": "string", + "description": "Short description of the marketplace." + } + }, + "description": "Arbitrary metadata about the marketplace." + }, + "plugins": { + "type": "array", + "items": { "$ref": "#/$defs/pluginEntry" }, + "description": "List of plugins available in the marketplace." + } + }, + "$defs": { + "owner": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Owner or organisation name." + }, + "email": { + "type": "string", + "format": "email", + "description": "Contact email address." + } + } + }, + "pluginEntry": { + "type": "object", + "required": ["name", "source"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", + "description": "Plugin identifier matching the plugin's name in its plugin.json." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Path to the plugin directory (relative to the marketplace root) or a remote URL." + }, + "description": { + "type": "string", + "description": "Short description of the plugin." + } + } + } + } +} diff --git a/schemas/plugin.schema.json b/schemas/plugin.schema.json new file mode 100644 index 0000000..d4c539e --- /dev/null +++ b/schemas/plugin.schema.json @@ -0,0 +1,140 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/plugin.json", + "title": "Cursor Plugin Manifest", + "description": "Schema for .cursor-plugin/plugin.json — defines a single Cursor plugin's metadata, components, and configuration.", + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", + "description": "Unique plugin identifier in kebab-case (lowercase alphanumeric with hyphens and periods)." + }, + "displayName": { + "type": "string", + "description": "Human-readable display name for the plugin." + }, + "description": { + "type": "string", + "description": "Short description of what the plugin does." + }, + "version": { + "type": "string", + "description": "Semantic version of the plugin (e.g. \"1.2.3\")." + }, + "author": { + "$ref": "#/$defs/author", + "description": "The plugin author." + }, + "publisher": { + "type": "string", + "minLength": 1, + "description": "Publisher or organisation name." + }, + "homepage": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's homepage." + }, + "repository": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's source code repository." + }, + "license": { + "type": "string", + "description": "SPDX license identifier (e.g. \"MIT\", \"Apache-2.0\")." + }, + "logo": { + "type": "string", + "description": "Path to a logo image (relative to the plugin root) or an absolute URL." + }, + "keywords": { + "type": "array", + "items": { "type": "string" }, + "description": "Keywords for discovery and search." + }, + "category": { + "type": "string", + "description": "Plugin category for marketplace classification." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Tags for filtering and discovery." + }, + "commands": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to command files." + }, + "agents": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to agent definition files." + }, + "skills": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to skill files." + }, + "rules": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to rule files." + }, + "hooks": { + "oneOf": [ + { "type": "string" }, + { "type": "object" } + ], + "description": "Path to a hooks configuration file, or an inline hooks object." + }, + "mcpServers": { + "$ref": "#/$defs/mcpServers", + "description": "MCP server configuration — a path, an inline config object, or an array of either." + } + }, + "$defs": { + "author": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Author name." + }, + "email": { + "type": "string", + "format": "email", + "description": "Author email address." + } + } + }, + "stringOrStringArray": { + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + }, + "mcpServers": { + "oneOf": [ + { "type": "string" }, + { "type": "object" }, + { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { "type": "object" } + ] + } + } + ] + } + } +} diff --git a/scripts/validate-plugins.mjs b/scripts/validate-plugins.mjs new file mode 100644 index 0000000..6a78708 --- /dev/null +++ b/scripts/validate-plugins.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import { readFileSync, existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +function loadJSON(path) { + return JSON.parse(readFileSync(path, "utf-8")); +} + +const marketplaceSchema = loadJSON( + resolve(root, "schemas/marketplace.schema.json") +); +const pluginSchema = loadJSON(resolve(root, "schemas/plugin.schema.json")); + +const ajv = new Ajv({ allErrors: true }); +addFormats(ajv); + +const validateMarketplace = ajv.compile(marketplaceSchema); +const validatePlugin = ajv.compile(pluginSchema); + +let errors = 0; + +function fail(message) { + console.error(`ERROR: ${message}`); + errors++; +} + +// 1. Validate marketplace.json +const marketplacePath = resolve(root, ".cursor-plugin/marketplace.json"); + +if (!existsSync(marketplacePath)) { + fail(".cursor-plugin/marketplace.json not found"); + process.exit(1); +} + +const marketplace = loadJSON(marketplacePath); + +if (!validateMarketplace(marketplace)) { + fail("marketplace.json schema validation failed:"); + for (const err of validateMarketplace.errors) { + console.error(` ${err.instancePath || "/"}: ${err.message}`); + } +} + +// 2. Validate each plugin +for (const entry of marketplace.plugins ?? []) { + const pluginDir = resolve(root, entry.source); + const pluginJsonPath = resolve(pluginDir, ".cursor-plugin/plugin.json"); + + // Check source directory exists + if (!existsSync(pluginDir)) { + fail( + `Plugin "${entry.name}": source directory "${entry.source}" does not exist` + ); + continue; + } + + // Check plugin.json exists + if (!existsSync(pluginJsonPath)) { + fail( + `Plugin "${entry.name}": missing .cursor-plugin/plugin.json in "${entry.source}"` + ); + continue; + } + + const pluginJson = loadJSON(pluginJsonPath); + + if (!validatePlugin(pluginJson)) { + fail( + `Plugin "${entry.name}": plugin.json schema validation failed (${entry.source}/.cursor-plugin/plugin.json):` + ); + for (const err of validatePlugin.errors) { + const detail = + err.keyword === "additionalProperties" + ? `${err.message}: "${err.params.additionalProperty}"` + : err.message; + console.error(` ${err.instancePath || "/"}: ${detail}`); + } + } + + // Check that marketplace name matches plugin name + if (pluginJson.name && pluginJson.name !== entry.name) { + fail( + `Plugin "${entry.name}": marketplace name does not match plugin.json name "${pluginJson.name}"` + ); + } +} + +// 3. Report results +if (errors > 0) { + console.error(`\nValidation failed with ${errors} error(s).`); + process.exit(1); +} else { + console.log("All plugins validated successfully."); + process.exit(0); +} diff --git a/scripts/validate-template.mjs b/scripts/validate-template.mjs deleted file mode 100644 index 5310b9e..0000000 --- a/scripts/validate-template.mjs +++ /dev/null @@ -1,381 +0,0 @@ -#!/usr/bin/env node - -import { promises as fs } from "node:fs"; -import path from "node:path"; -import process from "node:process"; - -const repoRoot = process.cwd(); -const errors = []; -const warnings = []; - -const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; -const marketplaceNamePattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; - -function addError(message) { - errors.push(message); -} - -function addWarning(message) { - warnings.push(message); -} - -async function pathExists(targetPath) { - try { - await fs.access(targetPath); - return true; - } catch { - return false; - } -} - -async function ensureDirectory(targetPath, context) { - try { - const stat = await fs.stat(targetPath); - if (!stat.isDirectory()) { - addError(`${context} exists but is not a directory: ${targetPath}`); - return false; - } - return true; - } catch { - addError(`${context} directory is missing: ${targetPath}`); - return false; - } -} - -async function readJsonFile(filePath, context) { - let raw; - try { - raw = await fs.readFile(filePath, "utf8"); - } catch { - addError(`${context} is missing: ${filePath}`); - return null; - } - - try { - return JSON.parse(raw); - } catch (error) { - addError(`${context} contains invalid JSON (${filePath}): ${error.message}`); - return null; - } -} - -function normalizeNewlines(content) { - return content.replace(/\r\n/g, "\n"); -} - -function parseFrontmatter(content) { - const normalized = normalizeNewlines(content); - if (!normalized.startsWith("---\n")) { - return null; - } - - const closingIndex = normalized.indexOf("\n---\n", 4); - if (closingIndex === -1) { - return null; - } - - const frontmatterBlock = normalized.slice(4, closingIndex); - const fields = {}; - - for (const line of frontmatterBlock.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) { - continue; - } - const separator = line.indexOf(":"); - if (separator === -1) { - continue; - } - const key = line.slice(0, separator).trim(); - const value = line.slice(separator + 1).trim(); - fields[key] = value; - } - - return fields; -} - -async function walkFiles(dirPath) { - const files = []; - const stack = [dirPath]; - - while (stack.length > 0) { - const current = stack.pop(); - const entries = await fs.readdir(current, { withFileTypes: true }); - for (const entry of entries) { - const entryPath = path.join(current, entry.name); - if (entry.isDirectory()) { - stack.push(entryPath); - } else if (entry.isFile()) { - files.push(entryPath); - } - } - } - - return files; -} - -function isSafeRelativePath(value) { - if (typeof value !== "string" || value.length === 0) { - return false; - } - if (value.startsWith("http://") || value.startsWith("https://")) { - return true; - } - if (path.isAbsolute(value)) { - return false; - } - const normalized = path.posix.normalize(value.replace(/\\/g, "/")); - return !normalized.startsWith("../") && normalized !== ".."; -} - -function extractPathValues(value) { - if (typeof value === "string") { - return [value]; - } - - if (Array.isArray(value)) { - return value.flatMap((entry) => extractPathValues(entry)); - } - - if (value && typeof value === "object") { - const candidates = []; - if (typeof value.path === "string") { - candidates.push(value.path); - } - if (typeof value.file === "string") { - candidates.push(value.file); - } - return candidates; - } - - return []; -} - -async function validateReferencedPath(pluginDir, fieldName, pathValue, pluginName) { - if (pathValue.startsWith("http://") || pathValue.startsWith("https://")) { - return; - } - - if (!isSafeRelativePath(pathValue)) { - addError( - `${pluginName}: field "${fieldName}" has invalid path "${pathValue}". Use a relative path without ".." or absolute prefixes.` - ); - return; - } - - const resolved = path.resolve(pluginDir, pathValue); - const exists = await pathExists(resolved); - if (!exists) { - addError(`${pluginName}: field "${fieldName}" references missing path "${pathValue}".`); - } -} - -async function validateFrontmatterFile(filePath, componentName, requiredKeys, pluginName) { - const content = await fs.readFile(filePath, "utf8"); - const parsed = parseFrontmatter(content); - const relativeFile = path.relative(repoRoot, filePath); - - if (!parsed) { - addError(`${pluginName}: ${componentName} file missing YAML frontmatter: ${relativeFile}`); - return; - } - - for (const key of requiredKeys) { - if (!parsed[key] || parsed[key].length === 0) { - addError(`${pluginName}: ${componentName} file missing "${key}" in frontmatter: ${relativeFile}`); - } - } -} - -async function validateComponentFrontmatter(pluginDir, pluginName) { - const rulesDir = path.join(pluginDir, "rules"); - if (await pathExists(rulesDir)) { - const files = await walkFiles(rulesDir); - for (const file of files) { - const ext = path.extname(file).toLowerCase(); - if (ext === ".md" || ext === ".mdc" || ext === ".markdown") { - await validateFrontmatterFile(file, "rule", ["description"], pluginName); - } - } - } - - const skillsDir = path.join(pluginDir, "skills"); - if (await pathExists(skillsDir)) { - const files = await walkFiles(skillsDir); - for (const file of files) { - if (path.basename(file) === "SKILL.md") { - await validateFrontmatterFile(file, "skill", ["name", "description"], pluginName); - } - } - } - - const agentsDir = path.join(pluginDir, "agents"); - if (await pathExists(agentsDir)) { - const files = await walkFiles(agentsDir); - for (const file of files) { - const ext = path.extname(file).toLowerCase(); - if (ext === ".md" || ext === ".mdc" || ext === ".markdown") { - await validateFrontmatterFile(file, "agent", ["name", "description"], pluginName); - } - } - } - - const commandsDir = path.join(pluginDir, "commands"); - if (await pathExists(commandsDir)) { - const files = await walkFiles(commandsDir); - for (const file of files) { - const ext = path.extname(file).toLowerCase(); - if (ext === ".md" || ext === ".mdc" || ext === ".markdown" || ext === ".txt") { - await validateFrontmatterFile(file, "command", ["name", "description"], pluginName); - } - } - } -} - -function resolveMarketplaceSource(source, pluginRoot) { - if (typeof source !== "string" || source.length === 0) { - return null; - } - if (!pluginRoot) { - return source; - } - const normalizedRoot = pluginRoot.replace(/\\/g, "/").replace(/\/+$/, ""); - const normalizedSource = source.replace(/\\/g, "/"); - if (normalizedSource === normalizedRoot || normalizedSource.startsWith(`${normalizedRoot}/`)) { - return normalizedSource; - } - return `${normalizedRoot}/${normalizedSource}`; -} - -async function main() { - const marketplacePath = path.join(repoRoot, ".cursor-plugin", "marketplace.json"); - const marketplace = await readJsonFile(marketplacePath, "Marketplace manifest"); - if (!marketplace) { - summarizeAndExit(); - return; - } - - if (typeof marketplace.name !== "string" || !marketplaceNamePattern.test(marketplace.name)) { - addError( - 'Marketplace "name" must be lowercase kebab-case and start/end with an alphanumeric character.' - ); - } - - if (!marketplace.owner || typeof marketplace.owner.name !== "string" || marketplace.owner.name.length === 0) { - addError('Marketplace "owner.name" is required.'); - } - - if (!Array.isArray(marketplace.plugins) || marketplace.plugins.length === 0) { - addError('Marketplace "plugins" must be a non-empty array.'); - summarizeAndExit(); - return; - } - - const pluginRoot = marketplace.metadata?.pluginRoot; - if (pluginRoot !== undefined) { - if (typeof pluginRoot !== "string" || !isSafeRelativePath(pluginRoot)) { - addError('Marketplace "metadata.pluginRoot" must be a safe relative path.'); - } else { - const pluginRootAbs = path.join(repoRoot, pluginRoot); - await ensureDirectory(pluginRootAbs, 'Marketplace "metadata.pluginRoot"'); - } - } - - const seenNames = new Set(); - for (const [index, entry] of marketplace.plugins.entries()) { - const label = `plugins[${index}]`; - - if (!entry || typeof entry !== "object") { - addError(`${label} must be an object.`); - continue; - } - - if (typeof entry.name !== "string" || !pluginNamePattern.test(entry.name)) { - addError(`${label}.name must be lowercase and use only alphanumerics, hyphens, and periods.`); - continue; - } - - if (seenNames.has(entry.name)) { - addError(`Duplicate plugin name in marketplace manifest: "${entry.name}"`); - } - seenNames.add(entry.name); - - const sourcePath = resolveMarketplaceSource(entry.source, pluginRoot ?? ""); - if (!sourcePath) { - addError(`${label}.source must be a string path.`); - continue; - } - if (!isSafeRelativePath(sourcePath)) { - addError(`${label}.source is not a safe relative path: "${sourcePath}"`); - continue; - } - - const pluginDir = path.join(repoRoot, sourcePath); - const pluginDirExists = await ensureDirectory(pluginDir, `${label}.source`); - if (!pluginDirExists) { - continue; - } - - const manifestPath = path.join(pluginDir, ".cursor-plugin", "plugin.json"); - const pluginManifest = await readJsonFile(manifestPath, `${entry.name} plugin manifest`); - if (!pluginManifest) { - continue; - } - - if (typeof pluginManifest.name !== "string" || !pluginNamePattern.test(pluginManifest.name)) { - addError( - `${entry.name}: "name" in plugin.json must be lowercase and use only alphanumerics, hyphens, and periods.` - ); - } - - if (pluginManifest.name && pluginManifest.name !== entry.name) { - addError( - `${entry.name}: marketplace entry name does not match plugin.json name ("${pluginManifest.name}").` - ); - } - - const manifestFields = ["logo", "rules", "skills", "agents", "commands", "hooks", "mcpServers"]; - for (const field of manifestFields) { - const values = extractPathValues(pluginManifest[field]); - for (const value of values) { - await validateReferencedPath(pluginDir, field, value, entry.name); - } - } - - await validateComponentFrontmatter(pluginDir, entry.name); - - const hooksPath = path.join(pluginDir, "hooks", "hooks.json"); - if (!(await pathExists(hooksPath))) { - addWarning(`${entry.name}: no hooks/hooks.json file found (only needed when using hooks).`); - } - - const mcpPath = path.join(pluginDir, "mcp.json"); - if (!(await pathExists(mcpPath))) { - addWarning(`${entry.name}: no mcp.json file found (only needed when using MCP servers).`); - } - } - - summarizeAndExit(); -} - -function summarizeAndExit() { - if (warnings.length > 0) { - console.log("Warnings:"); - for (const warning of warnings) { - console.log(`- ${warning}`); - } - console.log(""); - } - - if (errors.length > 0) { - console.error("Validation failed:"); - for (const error of errors) { - console.error(`- ${error}`); - } - process.exit(1); - } - - console.log("Validation passed."); -} - -await main();