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

@@ -12,9 +12,9 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin**
/plugin install compound-engineering
```
## OpenCode, Codex & Droid (experimental) Install
## OpenCode, Codex, Droid & Cursor (experimental) Install
This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, and Factory Droid.
This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, and Cursor.
```bash
# convert the compound-engineering plugin into OpenCode format
@@ -25,6 +25,9 @@ bunx @every-env/compound-plugin install compound-engineering --to codex
# convert to Factory Droid format
bunx @every-env/compound-plugin install compound-engineering --to droid
# convert to Cursor format
bunx @every-env/compound-plugin install compound-engineering --to cursor
```
Local dev:
@@ -36,6 +39,7 @@ bun run src/index.ts install ./plugins/compound-engineering --to opencode
OpenCode output is written to `~/.config/opencode` by default, with `opencode.json` at the root and `agents/`, `skills/`, and `plugins/` alongside it.
Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit).
Droid output is written to `~/.factory/` with commands, droids (agents), and skills. Claude tool names are mapped to Factory equivalents (`Bash``Execute`, `Write``Create`, etc.) and namespace prefixes are stripped from commands.
Cursor output is written to `.cursor/` with rules (`.mdc`), commands, skills, and `mcp.json`. Agents become "Agent Requested" rules (`alwaysApply: false`) so Cursor's AI activates them on demand. Works with both the Cursor IDE and Cursor CLI (`cursor-agent`) — they share the same `.cursor/` config directory.
All provider targets are experimental and may change as the formats evolve.

View File

@@ -0,0 +1,306 @@
---
title: Add Cursor CLI as a Target Provider
type: feat
date: 2026-02-12
---
# Add Cursor CLI as a Target Provider
## Overview
Add `cursor` as a fourth target provider in the converter CLI, alongside `opencode`, `codex`, and `droid`. This enables `--to cursor` for both `convert` and `install` commands, converting Claude Code plugins into Cursor-compatible format.
Cursor CLI (`cursor-agent`) launched in August 2025 and supports rules (`.mdc`), commands (`.md`), skills (`SKILL.md` standard), and MCP servers (`.cursor/mcp.json`). The mapping from Claude Code is straightforward because Cursor adopted the open SKILL.md standard and has a similar command format.
## Component Mapping
| Claude Code | Cursor Equivalent | Notes |
|---|---|---|
| `agents/*.md` | `.cursor/rules/*.mdc` | Agents become "Agent Requested" rules (`alwaysApply: false`, `description` set) so the AI activates them on demand rather than flooding context |
| `commands/*.md` | `.cursor/commands/*.md` | Plain markdown files; Cursor commands have no frontmatter support -- description becomes a markdown heading |
| `skills/*/SKILL.md` | `.cursor/skills/*/SKILL.md` | **Identical standard** -- copy directly |
| MCP servers | `.cursor/mcp.json` | Same JSON structure (`mcpServers` key), compatible format |
| `hooks/` | No equivalent | Cursor has no hook system; emit `console.warn` and skip |
| `.claude/` paths | `.cursor/` paths | Content rewriting needed |
### Key Design Decisions
**1. Agents use `alwaysApply: false` (Agent Requested mode)**
With 29 agents, setting `alwaysApply: true` would flood every Cursor session's context. Instead, agents become "Agent Requested" rules: `alwaysApply: false` with a populated `description` field. Cursor's AI reads the description and activates the rule only when relevant -- matching how Claude Code agents are invoked on demand.
**2. Commands are plain markdown (no frontmatter)**
Cursor commands (`.cursor/commands/*.md`) are simple markdown files where the filename becomes the command name. Unlike Claude Code commands, they do not support YAML frontmatter. The converter emits the description as a leading markdown comment, then the command body.
**3. Flattened command names with deduplication**
Cursor uses flat command names (no namespaces). `workflows:plan` becomes `plan`. If two commands flatten to the same name, the `uniqueName()` pattern from the codex converter appends `-2`, `-3`, etc.
### Rules (`.mdc`) Frontmatter Format
```yaml
---
description: "What this rule does and when it applies"
globs: ""
alwaysApply: false
---
```
- `description` (string): Used by the AI to decide relevance -- maps from agent `description`
- `globs` (string): Comma-separated file patterns for auto-attachment -- leave empty for converted agents
- `alwaysApply` (boolean): Set `false` for Agent Requested mode
### MCP Servers (`.cursor/mcp.json`)
```json
{
"mcpServers": {
"server-name": {
"command": "npx",
"args": ["-y", "package-name"],
"env": { "KEY": "value" }
}
}
}
```
Supports both local (command-based) and remote (url-based) servers. Pass through `headers` for remote servers.
## Acceptance Criteria
- [x] `bun run src/index.ts convert --to cursor ./plugins/compound-engineering` produces valid Cursor config
- [x] Agents convert to `.cursor/rules/*.mdc` with `alwaysApply: false` and populated `description`
- [x] Commands convert to `.cursor/commands/*.md` as plain markdown (no frontmatter)
- [x] Flattened command names that collide are deduplicated (`plan`, `plan-2`, etc.)
- [x] Skills copied to `.cursor/skills/` (identical format)
- [x] MCP servers written to `.cursor/mcp.json` with backup of existing file
- [x] Content transformation rewrites `.claude/` and `~/.claude/` paths to `.cursor/` and `~/.cursor/`
- [x] `/workflows:plan` transformed to `/plan` (flat command names)
- [x] `Task agent-name(args)` transformed to natural-language skill reference
- [x] Plugins with hooks emit `console.warn` about unsupported hooks
- [x] Writer does not double-nest `.cursor/.cursor/` (follows droid writer pattern)
- [x] `model` and `allowedTools` fields silently dropped (no Cursor equivalent)
- [x] Converter and writer tests pass
- [x] Existing tests still pass (`bun test`)
## Implementation
### Phase 1: Types
**Create `src/types/cursor.ts`**
```typescript
export type CursorRule = {
name: string
content: string // Full .mdc file with YAML frontmatter
}
export type CursorCommand = {
name: string
content: string // Plain markdown (no frontmatter)
}
export type CursorSkillDir = {
name: string
sourceDir: string
}
export type CursorBundle = {
rules: CursorRule[]
commands: CursorCommand[]
skillDirs: CursorSkillDir[]
mcpServers?: Record<string, {
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
}>
}
```
### Phase 2: Converter
**Create `src/converters/claude-to-cursor.ts`**
Core functions:
1. **`convertClaudeToCursor(plugin, options)`** -- main entry point
- Convert each agent to a `.mdc` rule via `convertAgentToRule()`
- Convert each command (including `disable-model-invocation` ones) via `convertCommand()`
- Pass skills through as directory references
- Convert MCP servers to JSON-compatible object
- Emit `console.warn` if `plugin.hooks` has entries
2. **`convertAgentToRule(agent, usedNames)`** -- agent -> `.mdc` rule
- Frontmatter fields: `description` (from agent description), `globs: ""`, `alwaysApply: false`
- Body: agent body with content transformations applied
- Prepend capabilities section if present
- Deduplicate names via `uniqueName()`
- Silently drop `model` field (no Cursor equivalent)
3. **`convertCommand(command, usedNames)`** -- command -> plain `.md`
- Flatten namespace: `workflows:plan` -> `plan`
- Deduplicate flattened names via `uniqueName()`
- Emit as plain markdown: description as `<!-- description -->` comment, then body
- Include `argument-hint` as a `## Arguments` section if present
- Body: apply `transformContentForCursor()` transformations
- Silently drop `allowedTools` (no Cursor equivalent)
4. **`transformContentForCursor(body)`** -- content rewriting
- `.claude/` -> `.cursor/` and `~/.claude/` -> `~/.cursor/`
- `Task agent-name(args)` -> `Use the agent-name skill to: args` (same as codex)
- `/workflows:command` -> `/command` (flatten slash commands)
- `@agent-name` references -> `the agent-name rule` (use codex's suffix-matching pattern)
- Skip file paths (containing `/`) and common non-command patterns
5. **`convertMcpServers(servers)`** -- MCP config
- Map each `ClaudeMcpServer` entry to Cursor-compatible JSON
- Pass through: `command`, `args`, `env`, `url`, `headers`
- Drop `type` field (Cursor infers transport from `command` vs `url`)
### Phase 3: Writer
**Create `src/targets/cursor.ts`**
Output structure:
```
.cursor/
├── rules/
│ ├── agent-name-1.mdc
│ └── agent-name-2.mdc
├── commands/
│ ├── command-1.md
│ └── command-2.md
├── skills/
│ └── skill-name/
│ └── SKILL.md
└── mcp.json
```
Core function: `writeCursorBundle(outputRoot, bundle)`
- `resolveCursorPaths(outputRoot)` -- detect if path already ends in `.cursor` to avoid double-nesting (follow droid writer pattern at `src/targets/droid.ts:31-50`)
- Write rules to `rules/` as `.mdc` files
- Write commands to `commands/` as `.md` files
- Copy skill directories to `skills/` via `copyDir()`
- Write `mcp.json` via `writeJson()` with `backupFile()` for existing files
### Phase 4: Wire into CLI
**Modify `src/targets/index.ts`**
```typescript
import { convertClaudeToCursor } from "../converters/claude-to-cursor"
import { writeCursorBundle } from "./cursor"
import type { CursorBundle } from "../types/cursor"
// Add to targets:
cursor: {
name: "cursor",
implemented: true,
convert: convertClaudeToCursor as TargetHandler<CursorBundle>["convert"],
write: writeCursorBundle as TargetHandler<CursorBundle>["write"],
},
```
**Modify `src/commands/convert.ts`**
- Update `--to` description: `"Target format (opencode | codex | droid | cursor)"`
- Add to `resolveTargetOutputRoot`: `if (targetName === "cursor") return path.join(outputRoot, ".cursor")`
**Modify `src/commands/install.ts`**
- Same two changes as convert.ts
### Phase 5: Tests
**Create `tests/cursor-converter.test.ts`**
Test cases (use inline `ClaudePlugin` fixtures, following codex converter test pattern):
- Agent converts to rule with `.mdc` frontmatter (`alwaysApply: false`, `description` populated)
- Agent with empty description gets default description text
- Agent with capabilities prepended to body
- Agent `model` field silently dropped
- Agent with empty body gets default body text
- Command converts with flattened name (`workflows:plan` -> `plan`)
- Command name collision after flattening is deduplicated (`plan`, `plan-2`)
- Command with `disable-model-invocation` is still included
- Command `allowedTools` silently dropped
- Command with `argument-hint` gets Arguments section
- Skills pass through as directory references
- MCP servers convert to JSON config (local and remote)
- MCP `headers` pass through for remote servers
- Content transformation: `.claude/` paths -> `.cursor/`
- Content transformation: `~/.claude/` paths -> `~/.cursor/`
- Content transformation: `Task agent(args)` -> natural language
- Content transformation: slash commands flattened
- Hooks present -> `console.warn` emitted
- Plugin with zero agents produces empty rules array
- Plugin with only skills works correctly
**Create `tests/cursor-writer.test.ts`**
Test cases (use temp directories, following droid writer test pattern):
- Full bundle writes rules, commands, skills, mcp.json
- Rules written as `.mdc` files in `rules/` directory
- Commands written as `.md` files in `commands/` directory
- Skills copied to `skills/` directory
- MCP config written as valid JSON `mcp.json`
- Existing `mcp.json` is backed up before overwrite
- Output root already ending in `.cursor` does NOT double-nest
- Empty bundle (no rules, commands, skills, or MCP) produces no output
### Phase 6: Documentation
**Create `docs/specs/cursor.md`**
Document the Cursor CLI spec as a reference, following `docs/specs/codex.md` pattern:
- Rules format (`.mdc` with `description`, `globs`, `alwaysApply` frontmatter)
- Commands format (plain markdown, no frontmatter)
- Skills format (identical SKILL.md standard)
- MCP server configuration (`.cursor/mcp.json`)
- CLI permissions (`.cursor/cli.json` -- for reference, not converted)
- Config file locations (project-level vs global)
**Update `README.md`**
Add `cursor` to the supported targets in the CLI usage section.
## What We're NOT Doing
- Not converting hooks (Cursor has no hook system -- warn and skip)
- Not generating `.cursor/cli.json` permissions (user-specific, not plugin-scoped)
- Not creating `AGENTS.md` (Cursor reads it natively, but not part of plugin conversion)
- Not using `globs` field intelligently (would require analyzing agent content to guess file patterns)
- Not adding sync support (follow-up task)
- Not transforming content inside copied SKILL.md files (known limitation -- skills may reference `.claude/` paths internally)
- Not clearing old output before writing (matches existing target behavior -- re-runs accumulate)
## Complexity Assessment
This is a **medium change**. The converter architecture is well-established with three existing targets, so this is mostly pattern-following. The key novelties are:
1. The `.mdc` frontmatter format (different from all other targets)
2. Agents map to "rules" rather than a direct equivalent
3. Commands are plain markdown (no frontmatter) unlike other targets
4. Name deduplication needed for flattened command namespaces
Skills being identical across platforms simplifies things significantly. MCP config is nearly 1:1.
## References
- Cursor Rules: `.cursor/rules/*.mdc` with `description`, `globs`, `alwaysApply` frontmatter
- Cursor Commands: `.cursor/commands/*.md` (plain markdown, no frontmatter)
- Cursor Skills: `.cursor/skills/*/SKILL.md` (open standard, identical to Claude Code)
- Cursor MCP: `.cursor/mcp.json` with `mcpServers` key
- Cursor CLI: `cursor-agent` command (launched August 2025)
- Existing codex converter: `src/converters/claude-to-codex.ts` (has `uniqueName()` deduplication pattern)
- Existing droid writer: `src/targets/droid.ts` (has double-nesting guard pattern)
- Existing codex plan: `docs/plans/2026-02-08-feat-convert-local-md-settings-for-opencode-codex-plan.md`
- Target provider checklist: `AGENTS.md` section "Adding a New Target Provider"

85
docs/specs/cursor.md Normal file
View File

@@ -0,0 +1,85 @@
# Cursor Spec (Rules, Commands, Skills, MCP)
Last verified: 2026-02-12
## Primary sources
```
https://docs.cursor.com/context/rules
https://docs.cursor.com/context/rules-for-ai
https://docs.cursor.com/customize/model-context-protocol
```
## Config locations
| Scope | Path |
|-------|------|
| Project rules | `.cursor/rules/*.mdc` |
| Project commands | `.cursor/commands/*.md` |
| Project skills | `.cursor/skills/*/SKILL.md` |
| Project MCP | `.cursor/mcp.json` |
| Project CLI permissions | `.cursor/cli.json` |
| Global MCP | `~/.cursor/mcp.json` |
| Global CLI config | `~/.cursor/cli-config.json` |
| Legacy rules | `.cursorrules` (deprecated) |
## Rules (.mdc files)
- Rules are Markdown files with the `.mdc` extension stored in `.cursor/rules/`.
- Each rule has YAML frontmatter with three fields: `description`, `globs`, `alwaysApply`.
- Rules have four activation types based on frontmatter configuration:
| Type | `alwaysApply` | `globs` | `description` | Behavior |
|------|:---:|:---:|:---:|---|
| Always | `true` | ignored | optional | Included in every conversation |
| Auto Attached | `false` | set | optional | Included when matching files are in context |
| Agent Requested | `false` | empty | set | AI decides based on description relevance |
| Manual | `false` | empty | empty | Only included via `@rule-name` mention |
- Precedence: Team Rules > Project Rules > User Rules > Legacy `.cursorrules` > `AGENTS.md`.
## Commands (slash commands)
- Custom commands are Markdown files stored in `.cursor/commands/`.
- Commands are plain markdown with no YAML frontmatter support.
- The filename (without `.md`) becomes the command name.
- Commands are invoked by typing `/` in the chat UI.
- Commands support parameterized arguments via `$1`, `$2`, etc.
## Skills (Agent Skills)
- Skills follow the open SKILL.md standard, identical to Claude Code and Codex.
- A skill is a folder containing `SKILL.md` plus optional `scripts/`, `references/`, and `assets/`.
- `SKILL.md` uses YAML frontmatter with required `name` and `description` fields.
- Skills can be repo-scoped in `.cursor/skills/` or user-scoped in `~/.cursor/skills/`.
- At startup, only each skill's name/description is loaded; full content is injected on invocation.
## MCP (Model Context Protocol)
- MCP configuration lives in `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global).
- Each server is configured under the `mcpServers` key.
- STDIO servers support `command` (required), `args`, and `env`.
- Remote servers support `url` (required) and optional `headers`.
- Cursor infers transport type from whether `command` or `url` is present.
Example:
```json
{
"mcpServers": {
"server-name": {
"command": "npx",
"args": ["-y", "package-name"],
"env": { "KEY": "value" }
}
}
}
```
## CLI (cursor-agent)
- Cursor CLI launched August 2025 as `cursor-agent`.
- Supports interactive mode, headless mode (`-p`), and cloud agents.
- Reads `.cursor/rules/`, `.cursorrules`, and `AGENTS.md` for instructions.
- CLI permissions controlled via `.cursor/cli.json` with allow/deny lists.
- Permission tokens: `Shell(command)`, `Read(path)`, `Write(path)`, `Delete(path)`, `Grep(path)`, `LS(path)`.

View File

@@ -1,6 +1,6 @@
{
"name": "@every-env/compound-plugin",
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"private": false,
"bin": {

View File

@@ -22,7 +22,7 @@ export default defineCommand({
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex | droid)",
description: "Target format (opencode | codex | droid | cursor)",
},
output: {
type: "string",
@@ -156,5 +156,6 @@ function resolveOutputRoot(value: unknown): string {
function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string): string {
if (targetName === "codex") return codexHome
if (targetName === "droid") return path.join(os.homedir(), ".factory")
if (targetName === "cursor") return path.join(outputRoot, ".cursor")
return outputRoot
}

View File

@@ -24,7 +24,7 @@ export default defineCommand({
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex | droid)",
description: "Target format (opencode | codex | droid | cursor)",
},
output: {
type: "string",
@@ -181,6 +181,7 @@ function resolveOutputRoot(value: unknown): string {
function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string): string {
if (targetName === "codex") return codexHome
if (targetName === "droid") return path.join(os.homedir(), ".factory")
if (targetName === "cursor") return path.join(outputRoot, ".cursor")
return outputRoot
}

View File

@@ -0,0 +1,166 @@
import { formatFrontmatter } from "../utils/frontmatter"
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
import type { CursorBundle, CursorCommand, CursorMcpServer, CursorRule } from "../types/cursor"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
export type ClaudeToCursorOptions = ClaudeToOpenCodeOptions
export function convertClaudeToCursor(
plugin: ClaudePlugin,
_options: ClaudeToCursorOptions,
): CursorBundle {
const usedRuleNames = new Set<string>()
const usedCommandNames = new Set<string>()
const rules = plugin.agents.map((agent) => convertAgentToRule(agent, usedRuleNames))
const commands = plugin.commands.map((command) => convertCommand(command, usedCommandNames))
const skillDirs = plugin.skills.map((skill) => ({
name: skill.name,
sourceDir: skill.sourceDir,
}))
const mcpServers = convertMcpServers(plugin.mcpServers)
if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
console.warn("Warning: Cursor does not support hooks. Hooks were skipped during conversion.")
}
return { rules, commands, skillDirs, mcpServers }
}
function convertAgentToRule(agent: ClaudeAgent, usedNames: Set<string>): CursorRule {
const name = uniqueName(normalizeName(agent.name), usedNames)
const description = agent.description ?? `Converted from Claude agent ${agent.name}`
const frontmatter: Record<string, unknown> = {
description,
alwaysApply: false,
}
let body = transformContentForCursor(agent.body.trim())
if (agent.capabilities && agent.capabilities.length > 0) {
const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
}
if (body.length === 0) {
body = `Instructions converted from the ${agent.name} agent.`
}
const content = formatFrontmatter(frontmatter, body)
return { name, content }
}
function convertCommand(command: ClaudeCommand, usedNames: Set<string>): CursorCommand {
const name = uniqueName(flattenCommandName(command.name), usedNames)
const sections: string[] = []
if (command.description) {
sections.push(`<!-- ${command.description} -->`)
}
if (command.argumentHint) {
sections.push(`## Arguments\n${command.argumentHint}`)
}
const transformedBody = transformContentForCursor(command.body.trim())
sections.push(transformedBody)
const content = sections.filter(Boolean).join("\n\n").trim()
return { name, content }
}
/**
* Transform Claude Code content to Cursor-compatible content.
*
* 1. Task agent calls: Task agent-name(args) -> Use the agent-name skill to: args
* 2. Slash commands: /workflows:plan -> /plan (flatten namespace)
* 3. Path rewriting: .claude/ -> .cursor/
* 4. Agent references: @agent-name -> the agent-name rule
*/
export function transformContentForCursor(body: string): string {
let result = body
// 1. Transform Task agent calls
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
const skillName = normalizeName(agentName)
return `${prefix}Use the ${skillName} skill to: ${args.trim()}`
})
// 2. Transform slash command references (flatten namespaces)
const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
result = result.replace(slashCommandPattern, (match, commandName: string) => {
if (commandName.includes("/")) return match
if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match
const flattened = flattenCommandName(commandName)
return `/${flattened}`
})
// 3. Rewrite .claude/ paths to .cursor/
result = result
.replace(/~\/\.claude\//g, "~/.cursor/")
.replace(/\.claude\//g, ".cursor/")
// 4. Transform @agent-name references
const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
result = result.replace(agentRefPattern, (_match, agentName: string) => {
return `the ${normalizeName(agentName)} rule`
})
return result
}
function convertMcpServers(
servers?: Record<string, ClaudeMcpServer>,
): Record<string, CursorMcpServer> | undefined {
if (!servers || Object.keys(servers).length === 0) return undefined
const result: Record<string, CursorMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
const entry: CursorMcpServer = {}
if (server.command) {
entry.command = server.command
if (server.args && server.args.length > 0) entry.args = server.args
if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
} else if (server.url) {
entry.url = server.url
if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
}
result[name] = entry
}
return result
}
function flattenCommandName(name: string): string {
const colonIndex = name.lastIndexOf(":")
const base = colonIndex >= 0 ? name.slice(colonIndex + 1) : name
return normalizeName(base)
}
function normalizeName(value: string): string {
const trimmed = value.trim()
if (!trimmed) return "item"
const normalized = trimmed
.toLowerCase()
.replace(/[\\/]+/g, "-")
.replace(/[:\s]+/g, "-")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
return normalized || "item"
}
function uniqueName(base: string, used: Set<string>): string {
if (!used.has(base)) {
used.add(base)
return base
}
let index = 2
while (used.has(`${base}-${index}`)) {
index += 1
}
const name = `${base}-${index}`
used.add(name)
return name
}

48
src/targets/cursor.ts Normal file
View File

@@ -0,0 +1,48 @@
import path from "path"
import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files"
import type { CursorBundle } from "../types/cursor"
export async function writeCursorBundle(outputRoot: string, bundle: CursorBundle): Promise<void> {
const paths = resolveCursorPaths(outputRoot)
await ensureDir(paths.cursorDir)
if (bundle.rules.length > 0) {
const rulesDir = path.join(paths.cursorDir, "rules")
for (const rule of bundle.rules) {
await writeText(path.join(rulesDir, `${rule.name}.mdc`), rule.content + "\n")
}
}
if (bundle.commands.length > 0) {
const commandsDir = path.join(paths.cursorDir, "commands")
for (const command of bundle.commands) {
await writeText(path.join(commandsDir, `${command.name}.md`), command.content + "\n")
}
}
if (bundle.skillDirs.length > 0) {
const skillsDir = path.join(paths.cursorDir, "skills")
for (const skill of bundle.skillDirs) {
await copyDir(skill.sourceDir, path.join(skillsDir, skill.name))
}
}
if (bundle.mcpServers && Object.keys(bundle.mcpServers).length > 0) {
const mcpPath = path.join(paths.cursorDir, "mcp.json")
const backupPath = await backupFile(mcpPath)
if (backupPath) {
console.log(`Backed up existing mcp.json to ${backupPath}`)
}
await writeJson(mcpPath, { mcpServers: bundle.mcpServers })
}
}
function resolveCursorPaths(outputRoot: string) {
const base = path.basename(outputRoot)
// If already pointing at .cursor, write directly into it
if (base === ".cursor") {
return { cursorDir: outputRoot }
}
// Otherwise nest under .cursor
return { cursorDir: path.join(outputRoot, ".cursor") }
}

View File

@@ -2,12 +2,15 @@ import type { ClaudePlugin } from "../types/claude"
import type { OpenCodeBundle } from "../types/opencode"
import type { CodexBundle } from "../types/codex"
import type { DroidBundle } from "../types/droid"
import type { CursorBundle } from "../types/cursor"
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { convertClaudeToDroid } from "../converters/claude-to-droid"
import { convertClaudeToCursor } from "../converters/claude-to-cursor"
import { writeOpenCodeBundle } from "./opencode"
import { writeCodexBundle } from "./codex"
import { writeDroidBundle } from "./droid"
import { writeCursorBundle } from "./cursor"
export type TargetHandler<TBundle = unknown> = {
name: string
@@ -35,4 +38,10 @@ export const targets: Record<string, TargetHandler> = {
convert: convertClaudeToDroid as TargetHandler<DroidBundle>["convert"],
write: writeDroidBundle as TargetHandler<DroidBundle>["write"],
},
cursor: {
name: "cursor",
implemented: true,
convert: convertClaudeToCursor as TargetHandler<CursorBundle>["convert"],
write: writeCursorBundle as TargetHandler<CursorBundle>["write"],
},
}

29
src/types/cursor.ts Normal file
View File

@@ -0,0 +1,29 @@
export type CursorRule = {
name: string
content: string
}
export type CursorCommand = {
name: string
content: string
}
export type CursorSkillDir = {
name: string
sourceDir: string
}
export type CursorMcpServer = {
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
}
export type CursorBundle = {
rules: CursorRule[]
commands: CursorCommand[]
skillDirs: CursorSkillDir[]
mcpServers?: Record<string, CursorMcpServer>
}

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)
})
})