From 0aaca5a7a71be8579971ee258b3f35990ea66599 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 12 Feb 2026 15:16:43 -0600 Subject: [PATCH] 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 * docs: note Cursor IDE + CLI compatibility in README --------- Co-authored-by: Claude Opus 4.6 --- README.md | 8 +- ...eat-add-cursor-cli-target-provider-plan.md | 306 +++++++++++++++ docs/specs/cursor.md | 85 +++++ package.json | 2 +- src/commands/convert.ts | 3 +- src/commands/install.ts | 3 +- src/converters/claude-to-cursor.ts | 166 +++++++++ src/targets/cursor.ts | 48 +++ src/targets/index.ts | 9 + src/types/cursor.ts | 29 ++ tests/cursor-converter.test.ts | 347 ++++++++++++++++++ tests/cursor-writer.test.ts | 137 +++++++ 12 files changed, 1138 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-02-12-feat-add-cursor-cli-target-provider-plan.md create mode 100644 docs/specs/cursor.md create mode 100644 src/converters/claude-to-cursor.ts create mode 100644 src/targets/cursor.ts create mode 100644 src/types/cursor.ts create mode 100644 tests/cursor-converter.test.ts create mode 100644 tests/cursor-writer.test.ts diff --git a/README.md b/README.md index 063416c..7badfd2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/plans/2026-02-12-feat-add-cursor-cli-target-provider-plan.md b/docs/plans/2026-02-12-feat-add-cursor-cli-target-provider-plan.md new file mode 100644 index 0000000..b5c7287 --- /dev/null +++ b/docs/plans/2026-02-12-feat-add-cursor-cli-target-provider-plan.md @@ -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 + url?: string + headers?: Record + }> +} +``` + +### 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 `` 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["convert"], + write: writeCursorBundle as TargetHandler["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" diff --git a/docs/specs/cursor.md b/docs/specs/cursor.md new file mode 100644 index 0000000..0f26e4e --- /dev/null +++ b/docs/specs/cursor.md @@ -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)`. diff --git a/package.json b/package.json index eb3293b..1e3fbed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@every-env/compound-plugin", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "private": false, "bin": { diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 9d42570..2830a98 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -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 } diff --git a/src/commands/install.ts b/src/commands/install.ts index 9323937..cdaa34f 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -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 } diff --git a/src/converters/claude-to-cursor.ts b/src/converters/claude-to-cursor.ts new file mode 100644 index 0000000..d6100d8 --- /dev/null +++ b/src/converters/claude-to-cursor.ts @@ -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() + const usedCommandNames = new Set() + + 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): CursorRule { + const name = uniqueName(normalizeName(agent.name), usedNames) + const description = agent.description ?? `Converted from Claude agent ${agent.name}` + + const frontmatter: Record = { + 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): CursorCommand { + const name = uniqueName(flattenCommandName(command.name), usedNames) + + const sections: string[] = [] + + if (command.description) { + sections.push(``) + } + + 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 = /(? { + 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, +): Record | undefined { + if (!servers || Object.keys(servers).length === 0) return undefined + + const result: Record = {} + 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 { + 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 +} diff --git a/src/targets/cursor.ts b/src/targets/cursor.ts new file mode 100644 index 0000000..dd9c123 --- /dev/null +++ b/src/targets/cursor.ts @@ -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 { + 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") } +} diff --git a/src/targets/index.ts b/src/targets/index.ts index 7e5436a..21372b9 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -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 = { name: string @@ -35,4 +38,10 @@ export const targets: Record = { convert: convertClaudeToDroid as TargetHandler["convert"], write: writeDroidBundle as TargetHandler["write"], }, + cursor: { + name: "cursor", + implemented: true, + convert: convertClaudeToCursor as TargetHandler["convert"], + write: writeCursorBundle as TargetHandler["write"], + }, } diff --git a/src/types/cursor.ts b/src/types/cursor.ts new file mode 100644 index 0000000..fc88828 --- /dev/null +++ b/src/types/cursor.ts @@ -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 + url?: string + headers?: Record +} + +export type CursorBundle = { + rules: CursorRule[] + commands: CursorCommand[] + skillDirs: CursorSkillDir[] + mcpServers?: Record +} diff --git a/tests/cursor-converter.test.ts b/tests/cursor-converter.test.ts new file mode 100644 index 0000000..9e3adaf --- /dev/null +++ b/tests/cursor-converter.test.ts @@ -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("") + 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") + }) +}) diff --git a/tests/cursor-writer.test.ts b/tests/cursor-writer.test.ts new file mode 100644 index 0000000..111af02 --- /dev/null +++ b/tests/cursor-writer.test.ts @@ -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 { + 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: "\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) + }) +})