feat: add cursor plugin support

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jon Eide Johnsen
2026-02-20 11:15:14 -08:00
parent a3ad5e1a20
commit 450394fc0f
13 changed files with 515 additions and 21 deletions
+1 -1
View File
@@ -27,7 +27,7 @@
"visual-editing"
],
"author": {
"name": "Sanity.io Team",
"name": "Sanity",
"email": "support@sanity.io",
"url": "https://www.sanity.io"
},
+33
View File
@@ -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"]
}
]
}
+24
View File
@@ -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"
}
+3
View File
@@ -22,3 +22,6 @@ jobs:
- name: Validate skills
run: npm run validate
- name: Validate Cursor plugin
run: npm run validate:cursor-plugin
+1 -1
View File
@@ -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
+30 -17
View File
@@ -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.
<details>
<summary><strong>Core fundamentals</strong></summary>
@@ -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/<skill-name>/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.
---
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 192 192" height="192" width="192">
<g clip-path="url(#clip0_2_1549)">
<rect fill="#0B0B0B" height="192" width="192"></rect>
<rect fill="#0B0B0B" height="192" width="192"></rect>
<g clip-path="url(#clip1_2_1549)">
<path fill="white" d="M180.012 117.855L173.386 106.285L141.338 125.872L176.933 80.3953L182.315 77.2267L180.983 75.2195L183.428 72.0847L172.196 62.7029L167.055 69.277L63.2806 130.202L101.649 83.9022L173.113 44.5934L166.323 31.4228L127.399 52.825L146.566 29.7088L135.585 20L92.447 72.0509L49.6034 95.6294L82.4047 52.1146L102.959 41.3797L96.4312 28.0625L36.5484 59.3426L52.8781 37.6585L41.4713 28.491L7 74.2723L7.53486 74.7008L13.975 87.8601L52.1904 67.89L17.3589 114.089L23.0677 118.678L26.4624 125.263L66.6972 103.128L22.3909 156.6L33.372 166.309L35.5769 163.647L142.462 100.692L106.976 146.045L107.554 146.53L107.5 146.564L114.857 159.408L162.055 130.552L143.881 159.983L156.063 168L185 121.147L180.012 117.855Z"></path>
</g>
</g>
<defs>
<clipPath id="clip0_2_1549">
<rect fill="white" height="192" width="192"></rect>
</clipPath>
<clipPath id="clip1_2_1549">
<rect transform="translate(7 20)" fill="white" height="148" width="178"></rect>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -1,4 +1,5 @@
---
name: deploy-schema
description: Deploy Sanity schema to the Content Lake with verification.
---
+1
View File
@@ -1,4 +1,5 @@
---
name: review
description: Review code for Sanity best practices and common issues.
---
+1
View File
@@ -1,4 +1,5 @@
---
name: sanity
description: Lists available Sanity skills and help topics.
---
+1
View File
@@ -1,4 +1,5 @@
---
name: typegen
description: Run Sanity TypeGen and troubleshoot type generation issues.
---
+5 -2
View File
@@ -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"
}
+397
View File
@@ -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();