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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
85
docs/specs/cursor.md
Normal 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)`.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@every-env/compound-plugin",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"bin": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
166
src/converters/claude-to-cursor.ts
Normal file
166
src/converters/claude-to-cursor.ts
Normal 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
48
src/targets/cursor.ts
Normal 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") }
|
||||
}
|
||||
@@ -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
29
src/types/cursor.ts
Normal 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>
|
||||
}
|
||||
347
tests/cursor-converter.test.ts
Normal file
347
tests/cursor-converter.test.ts
Normal 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
137
tests/cursor-writer.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user