From 450394fc0faadb9db094887593101ee885939528 Mon Sep 17 00:00:00 2001 From: Jon Eide Johnsen Date: Fri, 20 Feb 2026 11:15:14 -0800 Subject: [PATCH] feat: add cursor plugin support Co-authored-by: Cursor --- .claude-plugin/marketplace.json | 2 +- .cursor-plugin/marketplace.json | 33 +++ .cursor-plugin/plugin.json | 24 ++ .github/workflows/ci.yml | 3 + AGENTS.md | 2 +- README.md | 47 ++-- assets/logo.svg | 17 ++ commands/deploy-schema.md | 1 + commands/review.md | 1 + commands/sanity.md | 1 + commands/typegen.md | 1 + package.json | 7 +- scripts/validate-cursor-plugin.mjs | 397 +++++++++++++++++++++++++++++ 13 files changed, 515 insertions(+), 21 deletions(-) create mode 100644 .cursor-plugin/marketplace.json create mode 100644 .cursor-plugin/plugin.json create mode 100644 assets/logo.svg create mode 100644 scripts/validate-cursor-plugin.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ced8640..4d8f741 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -27,7 +27,7 @@ "visual-editing" ], "author": { - "name": "Sanity.io Team", + "name": "Sanity", "email": "support@sanity.io", "url": "https://www.sanity.io" }, diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..b0edb93 --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,33 @@ +{ + "name": "sanity-agent-toolkit", + "owner": { + "name": "Sanity", + "email": "support@sanity.io" + }, + "metadata": { + "description": "Official Sanity plugin for Cursor with MCP server, agent skills, and agent rules.", + "version": "1.0.0" + }, + "plugins": [ + { + "name": "sanity-plugin", + "source": "./", + "description": "Official Sanity plugin for Cursor with MCP server, agent skills, agent rules, and commands.", + "version": "1.0.0", + "homepage": "https://github.com/sanity-io/agent-toolkit", + "repository": "https://github.com/sanity-io/agent-toolkit", + "license": "MIT", + "keywords": [ + "sanity", + "cms", + "content-lake", + "groq", + "content", + "structured-content", + "visual-editing" + ], + "category": "cms", + "tags": ["sanity", "cms", "groq", "structured-content", "visual-editing"] + } + ] +} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json new file mode 100644 index 0000000..50a95e2 --- /dev/null +++ b/.cursor-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "sanity-plugin", + "displayName": "Sanity Plugin", + "version": "1.0.0", + "description": "Official Sanity plugin for Cursor with MCP server, agent skills, agent rules, and commands.", + "author": { + "name": "Sanity", + "email": "support@sanity.io" + }, + "homepage": "https://github.com/sanity-io/agent-toolkit", + "repository": "https://github.com/sanity-io/agent-toolkit", + "license": "MIT", + "keywords": [ + "sanity", + "cms", + "content-lake", + "groq", + "content", + "structured-content", + "visual-editing" + ], + "logo": "assets/logo.svg", + "mcpServers": "./.mcp.json" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97e1000..78cb223 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,6 @@ jobs: - name: Validate skills run: npm run validate + + - name: Validate Cursor plugin + run: npm run validate:cursor-plugin diff --git a/AGENTS.md b/AGENTS.md index f3e0df3..529b35a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Sanity Project -This is a Sanity-powered project. Use the Knowledge Router below to find Sanity guidance for your task. +This is a Sanity-powered project. Use the Knowledge Router below to find Sanity guidance for your task. Available as a [Claude Code plugin](https://github.com/sanity-io/agent-toolkit#option-3-install-claude-code-plugin) and [Cursor plugin](https://github.com/sanity-io/agent-toolkit#option-4-install-cursor-plugin). ## Commands diff --git a/README.md b/README.md index c0115ff..a09530d 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,11 @@ Collection of resources to help AI agents build better with [Sanity](https://www ## Features -- **Agent rules:** 20+ portable `.mdc` files covering schema design, GROQ, Visual Editing, SEO, localization, migrations, and front-end framework integrations. -- **Agent skills:** Comprehensive best practices skills for Sanity development, content modeling, SEO/AEO, and experimentation. - **MCP server:** Direct access to your Sanity projects (content, datasets, releases, schemas) and agent rules. -- **Claude Code plugin:** Slash commands and MCP integration for Claude Code users. +- **Agent skills:** Comprehensive best practices skills for Sanity development, content modeling, SEO/AEO, and experimentation. +- **Agent rules:** 20+ portable `.mdc` files covering schema design, GROQ, Visual Editing, SEO, localization, migrations, and front-end framework integrations. +- **Claude Code plugin:** MCP server, agent skills, agent rules, and slash commands for [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) users. +- **Cursor plugin:** MCP server, agent skills, agent rules, and commands for the [Cursor Marketplace](https://cursor.com/marketplace). --- @@ -24,7 +25,7 @@ Choose your path based on how you want agents to work with Sanity: 1. **MCP server** — Give your agent always up-to-date rules and full access to your Sanity projects. No local files to maintain. Works with Cursor, VS Code, Claude Code, Lovable, v0, and other MCP-compatible clients. 2. **Agent skills** — Install best practices skills for Sanity, content modeling, SEO/AEO, and experimentation. Works with Cursor, Claude Code, and any [Agent Skills](https://agentskills.io)-compatible agent. -3. **Claude Code plugin** — Adds interactive skills and slash commands on top of MCP for guided workflows. +3. **Plugin** — Install the Sanity plugin for Cursor or Claude Code. Bundles MCP server, agent skills, agent rules, and commands. 4. **Manual installation** — Copy rules locally for offline use. You'll need to update them yourself. ### Option 1: Install MCP server (recommended) @@ -154,16 +155,13 @@ Install best practices skills that work with any [Agent Skills](https://agentski npx skills add sanity-io/agent-toolkit ``` -**Cursor:** -1. Open **Cursor Settings** (Cmd+Shift+J / Ctrl+Shift+J) -2. Navigate to **Rules** → **Add Rule** → **Remote Rule (Github)** -3. Enter: `sanity-io/agent-toolkit` +See [Option 3](#option-3-install-plugin) for plugin installation. -See [Option 3](#option-3-install-claude-code-plugin) for Claude Code plugin installation. +### Option 3: Install plugin -### Option 3: Install Claude Code plugin +Install the Sanity plugin to get MCP server, agent skills, agent rules, and commands. -For Claude Code users, install the full plugin with skills and slash commands: +#### Claude Code 1. Add the Sanity marketplace: @@ -187,9 +185,17 @@ You should see the Sanity skills listed. Or run `/sanity` to explore all capabilities. +#### Cursor + +In Cursor chat, run: + +``` +/add-plugin sanity-plugin +``` + ### Option 4: Manual installation -Install the context rules locally to teach your editor Sanity best practices: +Install the agent rules locally to teach your editor Sanity best practices: 1. Create a rules directory: `mkdir -p .cursor/rules` 2. Copy the contents of the `rules/` folder to your project's `.cursor/rules/` directory. @@ -232,9 +238,9 @@ The onboarding guide follows three phases: Just say: "Get started with Sanity" to begin. -### Context rules +### Agent rules -These files provide passive knowledge to the AI, ensuring generated code follows Sanity standards. +Portable `.mdc` files that provide Sanity best practices to AI agents.
Core fundamentals @@ -290,17 +296,24 @@ sanity-io/agent-toolkit/ ├── README.md # This file ├── .claude-plugin/ # Claude Code plugin configuration │ └── marketplace.json # Plugin metadata and marketplace config +├── .cursor-plugin/ # Cursor plugin configuration +│ ├── marketplace.json # Cursor marketplace metadata +│ └── plugin.json # Per-plugin manifest ├── .mcp.json # MCP server configuration -├── commands/ # Slash commands (Claude Code) +├── assets/ # Plugin branding +│ └── logo.svg # Sanity logo for marketplace display +├── commands/ # Agent commands │ ├── sanity.md # /sanity help │ ├── review.md # /review │ ├── typegen.md # /typegen │ └── deploy-schema.md # /deploy-schema -├── rules/ # Context rules (.mdc) +├── rules/ # Agent rules (.mdc) │ ├── sanity-schema.mdc # Schema design patterns │ ├── sanity-groq.mdc # GROQ query patterns │ ├── sanity-nextjs.mdc # Next.js integration │ └── ... # Additional framework rules +├── scripts/ # Validation and CI scripts +│ └── validate-cursor-plugin.mjs # Cursor plugin validator └── skills/ # Agent skills (agentskills.io format) ├── sanity-best-practices/ # Comprehensive Sanity skill │ ├── SKILL.md @@ -332,7 +345,7 @@ Found a better pattern? Missing a framework or best practice? 2. Update the relevant file: - **Rules:** Edit `.mdc` files in `rules/` - **Skills:** Edit rule files in `skills//rules/` -3. Run `npm run validate` to check skill validity. +3. Run `npm run validate:all` to check skill validity and Cursor plugin structure. 4. Submit a PR. --- diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..613194b --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/commands/deploy-schema.md b/commands/deploy-schema.md index db3de27..8a95167 100644 --- a/commands/deploy-schema.md +++ b/commands/deploy-schema.md @@ -1,4 +1,5 @@ --- +name: deploy-schema description: Deploy Sanity schema to the Content Lake with verification. --- diff --git a/commands/review.md b/commands/review.md index 3f1d5cd..4bb2bb8 100644 --- a/commands/review.md +++ b/commands/review.md @@ -1,4 +1,5 @@ --- +name: review description: Review code for Sanity best practices and common issues. --- diff --git a/commands/sanity.md b/commands/sanity.md index dd0ac04..f765841 100644 --- a/commands/sanity.md +++ b/commands/sanity.md @@ -1,4 +1,5 @@ --- +name: sanity description: Lists available Sanity skills and help topics. --- diff --git a/commands/typegen.md b/commands/typegen.md index deac864..6f44d3e 100644 --- a/commands/typegen.md +++ b/commands/typegen.md @@ -1,4 +1,5 @@ --- +name: typegen description: Run Sanity TypeGen and troubleshoot type generation issues. --- diff --git a/package.json b/package.json index b7c829c..ebdc41f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "validate:sanity": "skills-ref validate ./skills/sanity-best-practices", "validate:content-modeling": "skills-ref validate ./skills/content-modeling-best-practices", "validate:seo": "skills-ref validate ./skills/seo-aeo-best-practices", - "validate:experimentation": "skills-ref validate ./skills/content-experimentation-best-practices" + "validate:experimentation": "skills-ref validate ./skills/content-experimentation-best-practices", + "validate:cursor-plugin": "node scripts/validate-cursor-plugin.mjs", + "validate:all": "npm run validate && npm run validate:cursor-plugin" }, "devDependencies": { "skills-ref": "^0.1.5" @@ -18,7 +20,8 @@ "ai", "agent", "skills", - "claude" + "claude", + "cursor" ], "license": "MIT" } diff --git a/scripts/validate-cursor-plugin.mjs b/scripts/validate-cursor-plugin.mjs new file mode 100644 index 0000000..d527b80 --- /dev/null +++ b/scripts/validate-cursor-plugin.mjs @@ -0,0 +1,397 @@ +#!/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 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 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) { + if (typeof source !== "string" || source.length === 0) { + return null; + } + return source; +} + +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 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); + 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 pathExists(pluginDir); + if (!pluginDirExists) { + addError(`${label}.source directory does not exist: ${sourcePath}`); + 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); + } + + 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("Cursor plugin validation passed."); +} + +await main();