Add Cursor CLI as target provider (#179)

* feat(cursor): add Cursor CLI as target provider

Add converter, writer, types, and tests for converting Claude Code
plugins to Cursor-compatible format (.mdc rules, commands, skills,
mcp.json). Agents become Agent Requested rules (alwaysApply: false),
commands are plain markdown, skills copy directly, MCP is 1:1 JSON.

* docs: add Cursor spec and update README with cursor target

* chore: bump CLI version to 0.5.0 for cursor target

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: note Cursor IDE + CLI compatibility in README

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kieran Klaassen
2026-02-12 15:16:43 -06:00
committed by GitHub
parent 56b174a056
commit 0aaca5a7a7
12 changed files with 1138 additions and 5 deletions

View File

@@ -0,0 +1,347 @@
import { describe, expect, test, spyOn } from "bun:test"
import { convertClaudeToCursor, transformContentForCursor } from "../src/converters/claude-to-cursor"
import { parseFrontmatter } from "../src/utils/frontmatter"
import type { ClaudePlugin } from "../src/types/claude"
const fixturePlugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
agents: [
{
name: "Security Reviewer",
description: "Security-focused code review agent",
capabilities: ["Threat modeling", "OWASP"],
model: "claude-sonnet-4-20250514",
body: "Focus on vulnerabilities.",
sourcePath: "/tmp/plugin/agents/security-reviewer.md",
},
],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[FOCUS]",
model: "inherit",
allowedTools: ["Read"],
body: "Plan the work.",
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
},
],
skills: [
{
name: "existing-skill",
description: "Existing skill",
sourceDir: "/tmp/plugin/skills/existing-skill",
skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
},
],
hooks: undefined,
mcpServers: undefined,
}
const defaultOptions = {
agentMode: "subagent" as const,
inferTemperature: false,
permissions: "none" as const,
}
describe("convertClaudeToCursor", () => {
test("converts agents to rules with .mdc frontmatter", () => {
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
expect(bundle.rules).toHaveLength(1)
const rule = bundle.rules[0]
expect(rule.name).toBe("security-reviewer")
const parsed = parseFrontmatter(rule.content)
expect(parsed.data.description).toBe("Security-focused code review agent")
expect(parsed.data.alwaysApply).toBe(false)
// globs is omitted (Agent Requested mode doesn't need it)
expect(parsed.body).toContain("Capabilities")
expect(parsed.body).toContain("Threat modeling")
expect(parsed.body).toContain("Focus on vulnerabilities.")
})
test("agent with empty description gets default", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "basic-agent",
body: "Do things.",
sourcePath: "/tmp/plugin/agents/basic.md",
},
],
}
const bundle = convertClaudeToCursor(plugin, defaultOptions)
const parsed = parseFrontmatter(bundle.rules[0].content)
expect(parsed.data.description).toBe("Converted from Claude agent basic-agent")
})
test("agent with empty body gets default body", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "empty-agent",
description: "Empty agent",
body: "",
sourcePath: "/tmp/plugin/agents/empty.md",
},
],
}
const bundle = convertClaudeToCursor(plugin, defaultOptions)
const parsed = parseFrontmatter(bundle.rules[0].content)
expect(parsed.body).toContain("Instructions converted from the empty-agent agent.")
})
test("agent capabilities are prepended to body", () => {
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
const parsed = parseFrontmatter(bundle.rules[0].content)
expect(parsed.body).toMatch(/## Capabilities\n- Threat modeling\n- OWASP/)
})
test("agent model field is silently dropped", () => {
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
const parsed = parseFrontmatter(bundle.rules[0].content)
expect(parsed.data.model).toBeUndefined()
})
test("flattens namespaced command names", () => {
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
expect(bundle.commands).toHaveLength(1)
const command = bundle.commands[0]
expect(command.name).toBe("plan")
})
test("commands are plain markdown without frontmatter", () => {
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
const command = bundle.commands[0]
// Should NOT start with ---
expect(command.content.startsWith("---")).toBe(false)
// Should include the description as a comment
expect(command.content).toContain("<!-- Planning command -->")
expect(command.content).toContain("Plan the work.")
})
test("command name collision after flattening is deduplicated", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
{
name: "workflows:plan",
description: "Workflow plan",
body: "Plan body.",
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
},
{
name: "plan",
description: "Top-level plan",
body: "Top plan body.",
sourcePath: "/tmp/plugin/commands/plan.md",
},
],
agents: [],
skills: [],
}
const bundle = convertClaudeToCursor(plugin, defaultOptions)
const names = bundle.commands.map((c) => c.name)
expect(names).toEqual(["plan", "plan-2"])
})
test("command with disable-model-invocation is still included", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
{
name: "setup",
description: "Setup command",
disableModelInvocation: true,
body: "Setup body.",
sourcePath: "/tmp/plugin/commands/setup.md",
},
],
agents: [],
skills: [],
}
const bundle = convertClaudeToCursor(plugin, defaultOptions)
expect(bundle.commands).toHaveLength(1)
expect(bundle.commands[0].name).toBe("setup")
})
test("command allowedTools is silently dropped", () => {
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
const command = bundle.commands[0]
expect(command.content).not.toContain("allowedTools")
expect(command.content).not.toContain("Read")
})
test("command with argument-hint gets Arguments section", () => {
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
const command = bundle.commands[0]
expect(command.content).toContain("## Arguments")
expect(command.content).toContain("[FOCUS]")
})
test("passes through skill directories", () => {
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
expect(bundle.skillDirs).toHaveLength(1)
expect(bundle.skillDirs[0].name).toBe("existing-skill")
expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill")
})
test("converts MCP servers to JSON config", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
skills: [],
mcpServers: {
playwright: {
command: "npx",
args: ["-y", "@anthropic/mcp-playwright"],
env: { DISPLAY: ":0" },
},
},
}
const bundle = convertClaudeToCursor(plugin, defaultOptions)
expect(bundle.mcpServers).toBeDefined()
expect(bundle.mcpServers!.playwright.command).toBe("npx")
expect(bundle.mcpServers!.playwright.args).toEqual(["-y", "@anthropic/mcp-playwright"])
expect(bundle.mcpServers!.playwright.env).toEqual({ DISPLAY: ":0" })
})
test("MCP headers pass through for remote servers", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
skills: [],
mcpServers: {
remote: {
url: "https://mcp.example.com/sse",
headers: { Authorization: "Bearer token" },
},
},
}
const bundle = convertClaudeToCursor(plugin, defaultOptions)
expect(bundle.mcpServers!.remote.url).toBe("https://mcp.example.com/sse")
expect(bundle.mcpServers!.remote.headers).toEqual({ Authorization: "Bearer token" })
})
test("warns when hooks are present", () => {
const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
skills: [],
hooks: {
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] }],
},
},
}
convertClaudeToCursor(plugin, defaultOptions)
expect(warnSpy).toHaveBeenCalledWith(
"Warning: Cursor does not support hooks. Hooks were skipped during conversion.",
)
warnSpy.mockRestore()
})
test("no warning when hooks are absent", () => {
const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
convertClaudeToCursor(fixturePlugin, defaultOptions)
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
test("plugin with zero agents produces empty rules array", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
}
const bundle = convertClaudeToCursor(plugin, defaultOptions)
expect(bundle.rules).toHaveLength(0)
})
test("plugin with only skills works", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
}
const bundle = convertClaudeToCursor(plugin, defaultOptions)
expect(bundle.rules).toHaveLength(0)
expect(bundle.commands).toHaveLength(0)
expect(bundle.skillDirs).toHaveLength(1)
})
})
describe("transformContentForCursor", () => {
test("rewrites .claude/ paths to .cursor/", () => {
const input = "Read `.claude/compound-engineering.local.md` for config."
const result = transformContentForCursor(input)
expect(result).toContain(".cursor/compound-engineering.local.md")
expect(result).not.toContain(".claude/")
})
test("rewrites ~/.claude/ paths to ~/.cursor/", () => {
const input = "Global config at ~/.claude/settings.json"
const result = transformContentForCursor(input)
expect(result).toContain("~/.cursor/settings.json")
expect(result).not.toContain("~/.claude/")
})
test("transforms Task agent calls to skill references", () => {
const input = `Run agents:
- Task repo-research-analyst(feature_description)
- Task learnings-researcher(feature_description)
Task best-practices-researcher(topic)`
const result = transformContentForCursor(input)
expect(result).toContain("Use the repo-research-analyst skill to: feature_description")
expect(result).toContain("Use the learnings-researcher skill to: feature_description")
expect(result).toContain("Use the best-practices-researcher skill to: topic")
expect(result).not.toContain("Task repo-research-analyst(")
})
test("flattens slash commands", () => {
const input = `1. Run /deepen-plan to enhance
2. Start /workflows:work to implement
3. File at /tmp/output.md`
const result = transformContentForCursor(input)
expect(result).toContain("/deepen-plan")
expect(result).toContain("/work")
expect(result).not.toContain("/workflows:work")
// File paths preserved
expect(result).toContain("/tmp/output.md")
})
test("transforms @agent references to rule references", () => {
const input = "Have @security-sentinel and @dhh-rails-reviewer check the code."
const result = transformContentForCursor(input)
expect(result).toContain("the security-sentinel rule")
expect(result).toContain("the dhh-rails-reviewer rule")
expect(result).not.toContain("@security-sentinel")
})
})

137
tests/cursor-writer.test.ts Normal file
View File

@@ -0,0 +1,137 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { writeCursorBundle } from "../src/targets/cursor"
import type { CursorBundle } from "../src/types/cursor"
async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
describe("writeCursorBundle", () => {
test("writes rules, commands, skills, and mcp.json", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-test-"))
const bundle: CursorBundle = {
rules: [{ name: "security-reviewer", content: "---\ndescription: Security\nglobs: \"\"\nalwaysApply: false\n---\n\nReview code." }],
commands: [{ name: "plan", content: "<!-- Planning -->\n\nPlan the work." }],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
mcpServers: {
playwright: { command: "npx", args: ["-y", "@anthropic/mcp-playwright"] },
},
}
await writeCursorBundle(tempRoot, bundle)
expect(await exists(path.join(tempRoot, ".cursor", "rules", "security-reviewer.mdc"))).toBe(true)
expect(await exists(path.join(tempRoot, ".cursor", "commands", "plan.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".cursor", "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".cursor", "mcp.json"))).toBe(true)
const ruleContent = await fs.readFile(
path.join(tempRoot, ".cursor", "rules", "security-reviewer.mdc"),
"utf8",
)
expect(ruleContent).toContain("Review code.")
const commandContent = await fs.readFile(
path.join(tempRoot, ".cursor", "commands", "plan.md"),
"utf8",
)
expect(commandContent).toContain("Plan the work.")
const mcpContent = JSON.parse(
await fs.readFile(path.join(tempRoot, ".cursor", "mcp.json"), "utf8"),
)
expect(mcpContent.mcpServers.playwright.command).toBe("npx")
})
test("writes directly into a .cursor output root without double-nesting", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-home-"))
const cursorRoot = path.join(tempRoot, ".cursor")
const bundle: CursorBundle = {
rules: [{ name: "reviewer", content: "Reviewer rule content" }],
commands: [{ name: "plan", content: "Plan content" }],
skillDirs: [],
}
await writeCursorBundle(cursorRoot, bundle)
expect(await exists(path.join(cursorRoot, "rules", "reviewer.mdc"))).toBe(true)
expect(await exists(path.join(cursorRoot, "commands", "plan.md"))).toBe(true)
// Should NOT double-nest under .cursor/.cursor
expect(await exists(path.join(cursorRoot, ".cursor"))).toBe(false)
})
test("handles empty bundles gracefully", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-empty-"))
const bundle: CursorBundle = {
rules: [],
commands: [],
skillDirs: [],
}
await writeCursorBundle(tempRoot, bundle)
expect(await exists(tempRoot)).toBe(true)
})
test("writes multiple rules as separate .mdc files", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-multi-"))
const cursorRoot = path.join(tempRoot, ".cursor")
const bundle: CursorBundle = {
rules: [
{ name: "security-sentinel", content: "Security rules" },
{ name: "performance-oracle", content: "Performance rules" },
{ name: "code-simplicity-reviewer", content: "Simplicity rules" },
],
commands: [],
skillDirs: [],
}
await writeCursorBundle(cursorRoot, bundle)
expect(await exists(path.join(cursorRoot, "rules", "security-sentinel.mdc"))).toBe(true)
expect(await exists(path.join(cursorRoot, "rules", "performance-oracle.mdc"))).toBe(true)
expect(await exists(path.join(cursorRoot, "rules", "code-simplicity-reviewer.mdc"))).toBe(true)
})
test("backs up existing mcp.json before overwriting", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-backup-"))
const cursorRoot = path.join(tempRoot, ".cursor")
await fs.mkdir(cursorRoot, { recursive: true })
// Write an existing mcp.json
const mcpPath = path.join(cursorRoot, "mcp.json")
await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { command: "old-cmd" } } }))
const bundle: CursorBundle = {
rules: [],
commands: [],
skillDirs: [],
mcpServers: {
newServer: { command: "new-cmd" },
},
}
await writeCursorBundle(cursorRoot, bundle)
// New mcp.json should have the new content
const newContent = JSON.parse(await fs.readFile(mcpPath, "utf8"))
expect(newContent.mcpServers.newServer.command).toBe("new-cmd")
// A backup file should exist
const files = await fs.readdir(cursorRoot)
const backupFiles = files.filter((f) => f.startsWith("mcp.json.bak."))
expect(backupFiles.length).toBeGreaterThanOrEqual(1)
})
})