diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..e9adfaa --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,25 @@ +{ + "name": "compound-engineering", + "owner": { + "name": "Kieran Klaassen", + "email": "kieran@every.to", + "url": "https://github.com/kieranklaassen" + }, + "metadata": { + "description": "Cursor plugin marketplace for Every Inc plugins", + "version": "1.0.0", + "pluginRoot": "plugins" + }, + "plugins": [ + { + "name": "compound-engineering", + "source": "compound-engineering", + "description": "AI-powered development tools that get smarter with every use. Includes specialized agents, commands, skills, and Context7 MCP." + }, + { + "name": "coding-tutor", + "source": "coding-tutor", + "description": "Personalized coding tutorials with spaced repetition quizzes using your real codebase." + } + ] +} diff --git a/README.md b/README.md index 11bfe93..7e867ab 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,15 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin** /plugin install compound-engineering ``` -## OpenCode, Codex, Droid, Cursor & Pi (experimental) Install +## Cursor Install -This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Cursor, and Pi. +```text +/add-plugin compound-engineering +``` + +## OpenCode, Codex, Droid & Pi (experimental) Install + +This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid and Pi. ```bash # convert the compound-engineering plugin into OpenCode format @@ -26,9 +32,6 @@ 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 - # convert to Pi format bunx @every-env/compound-plugin install compound-engineering --to pi ``` @@ -42,7 +45,6 @@ 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. Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability. All provider targets are experimental and may change as the formats evolve. @@ -63,9 +65,6 @@ bunx @every-env/compound-plugin sync --target pi # Sync to Droid (skills only) bunx @every-env/compound-plugin sync --target droid - -# Sync to Cursor (skills + MCP servers) -bunx @every-env/compound-plugin sync --target cursor ``` This syncs: diff --git a/plugins/coding-tutor/.cursor-plugin/plugin.json b/plugins/coding-tutor/.cursor-plugin/plugin.json new file mode 100644 index 0000000..dc5e6c0 --- /dev/null +++ b/plugins/coding-tutor/.cursor-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "coding-tutor", + "displayName": "Coding Tutor", + "version": "1.2.1", + "description": "Personalized coding tutorials that use your actual codebase for examples with spaced repetition quizzes", + "author": { + "name": "Nityesh Agarwal" + }, + "homepage": "https://github.com/EveryInc/compound-engineering-plugin", + "repository": "https://github.com/EveryInc/compound-engineering-plugin", + "license": "MIT", + "keywords": [ + "cursor", + "plugin", + "coding", + "programming", + "tutorial", + "learning", + "spaced-repetition" + ] +} diff --git a/plugins/compound-engineering/.cursor-plugin/plugin.json b/plugins/compound-engineering/.cursor-plugin/plugin.json new file mode 100644 index 0000000..e8bcb63 --- /dev/null +++ b/plugins/compound-engineering/.cursor-plugin/plugin.json @@ -0,0 +1,31 @@ +{ + "name": "compound-engineering", + "displayName": "Compound Engineering", + "version": "2.33.0", + "description": "AI-powered development tools. 29 agents, 22 commands, 19 skills, 1 MCP server for code review, research, design, and workflow automation.", + "author": { + "name": "Kieran Klaassen", + "email": "kieran@every.to", + "url": "https://github.com/kieranklaassen" + }, + "homepage": "https://every.to/source-code/my-ai-had-already-fixed-the-code-before-i-saw-it", + "repository": "https://github.com/EveryInc/compound-engineering-plugin", + "license": "MIT", + "keywords": [ + "cursor", + "plugin", + "ai-powered", + "compound-engineering", + "workflow-automation", + "code-review", + "rails", + "ruby", + "python", + "typescript", + "knowledge-management", + "image-generation", + "agent-browser", + "browser-automation" + ], + "mcpServers": ".mcp.json" +} diff --git a/plugins/compound-engineering/.mcp.json b/plugins/compound-engineering/.mcp.json new file mode 100644 index 0000000..c5280c5 --- /dev/null +++ b/plugins/compound-engineering/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "context7": { + "type": "http", + "url": "https://mcp.context7.com/mcp" + } + } +} diff --git a/src/converters/claude-to-cursor.ts b/src/converters/claude-to-cursor.ts deleted file mode 100644 index d6100d8..0000000 --- a/src/converters/claude-to-cursor.ts +++ /dev/null @@ -1,166 +0,0 @@ -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/sync/cursor.ts b/src/sync/cursor.ts deleted file mode 100644 index 32f3aa4..0000000 --- a/src/sync/cursor.ts +++ /dev/null @@ -1,78 +0,0 @@ -import fs from "fs/promises" -import path from "path" -import type { ClaudeHomeConfig } from "../parsers/claude-home" -import type { ClaudeMcpServer } from "../types/claude" -import { forceSymlink, isValidSkillName } from "../utils/symlink" - -type CursorMcpServer = { - command?: string - args?: string[] - url?: string - env?: Record - headers?: Record -} - -type CursorMcpConfig = { - mcpServers: Record -} - -export async function syncToCursor( - config: ClaudeHomeConfig, - outputRoot: string, -): Promise { - const skillsDir = path.join(outputRoot, "skills") - await fs.mkdir(skillsDir, { recursive: true }) - - for (const skill of config.skills) { - if (!isValidSkillName(skill.name)) { - console.warn(`Skipping skill with invalid name: ${skill.name}`) - continue - } - const target = path.join(skillsDir, skill.name) - await forceSymlink(skill.sourceDir, target) - } - - if (Object.keys(config.mcpServers).length > 0) { - const mcpPath = path.join(outputRoot, "mcp.json") - const existing = await readJsonSafe(mcpPath) - const converted = convertMcpForCursor(config.mcpServers) - const merged: CursorMcpConfig = { - mcpServers: { - ...(existing.mcpServers ?? {}), - ...converted, - }, - } - await fs.writeFile(mcpPath, JSON.stringify(merged, null, 2), { mode: 0o600 }) - } -} - -async function readJsonSafe(filePath: string): Promise> { - try { - const content = await fs.readFile(filePath, "utf-8") - return JSON.parse(content) as Partial - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return {} - } - throw err - } -} - -function convertMcpForCursor( - servers: Record, -): Record { - 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 -} diff --git a/src/targets/cursor.ts b/src/targets/cursor.ts deleted file mode 100644 index dd9c123..0000000 --- a/src/targets/cursor.ts +++ /dev/null @@ -1,48 +0,0 @@ -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/types/cursor.ts b/src/types/cursor.ts deleted file mode 100644 index fc88828..0000000 --- a/src/types/cursor.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 9e3adaf..0000000 --- a/tests/cursor-converter.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -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 deleted file mode 100644 index 111af02..0000000 --- a/tests/cursor-writer.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -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) - }) -}) diff --git a/tests/sync-cursor.test.ts b/tests/sync-cursor.test.ts deleted file mode 100644 index e314d28..0000000 --- a/tests/sync-cursor.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { promises as fs } from "fs" -import path from "path" -import os from "os" -import { syncToCursor } from "../src/sync/cursor" -import type { ClaudeHomeConfig } from "../src/parsers/claude-home" - -describe("syncToCursor", () => { - test("symlinks skills and writes mcp.json", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-cursor-")) - const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") - - const config: ClaudeHomeConfig = { - skills: [ - { - name: "skill-one", - sourceDir: fixtureSkillDir, - skillPath: path.join(fixtureSkillDir, "SKILL.md"), - }, - ], - mcpServers: { - context7: { url: "https://mcp.context7.com/mcp" }, - local: { command: "echo", args: ["hello"], env: { FOO: "bar" } }, - }, - } - - await syncToCursor(config, tempRoot) - - // Check skill symlink - const linkedSkillPath = path.join(tempRoot, "skills", "skill-one") - const linkedStat = await fs.lstat(linkedSkillPath) - expect(linkedStat.isSymbolicLink()).toBe(true) - - // Check mcp.json - const mcpPath = path.join(tempRoot, "mcp.json") - const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as { - mcpServers: Record }> - } - - expect(mcpConfig.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp") - expect(mcpConfig.mcpServers.local?.command).toBe("echo") - expect(mcpConfig.mcpServers.local?.args).toEqual(["hello"]) - expect(mcpConfig.mcpServers.local?.env).toEqual({ FOO: "bar" }) - }) - - test("merges existing mcp.json", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-cursor-merge-")) - const mcpPath = path.join(tempRoot, "mcp.json") - - await fs.writeFile( - mcpPath, - JSON.stringify({ mcpServers: { existing: { command: "node", args: ["server.js"] } } }, null, 2), - ) - - const config: ClaudeHomeConfig = { - skills: [], - mcpServers: { - context7: { url: "https://mcp.context7.com/mcp" }, - }, - } - - await syncToCursor(config, tempRoot) - - const merged = JSON.parse(await fs.readFile(mcpPath, "utf8")) as { - mcpServers: Record - } - - expect(merged.mcpServers.existing?.command).toBe("node") - expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp") - }) - - test("does not write mcp.json when no MCP servers", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-cursor-nomcp-")) - const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") - - const config: ClaudeHomeConfig = { - skills: [ - { - name: "skill-one", - sourceDir: fixtureSkillDir, - skillPath: path.join(fixtureSkillDir, "SKILL.md"), - }, - ], - mcpServers: {}, - } - - await syncToCursor(config, tempRoot) - - const mcpExists = await fs.access(path.join(tempRoot, "mcp.json")).then(() => true).catch(() => false) - expect(mcpExists).toBe(false) - }) -})