From e41904a569dccf17108262fe8447ab681539e02c Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 12 Feb 2026 20:37:15 -0800 Subject: [PATCH] Add droid and cursor sync targets, extract shared path helpers - Add sync --target droid (skills to ~/.factory/skills/) - Add sync --target cursor (skills + MCP to .cursor/) - Extract expandHome/resolveTargetHome to src/utils/resolve-home.ts - Remove duplicated path helpers from convert.ts and install.ts - Bump version to 0.6.0 Co-Authored-By: Claude Opus 4.6 --- README.md | 8 +++- package.json | 2 +- src/commands/convert.ts | 37 ++-------------- src/commands/install.ts | 37 ++-------------- src/commands/sync.ts | 68 ++++++++++++++++++----------- src/sync/cursor.ts | 78 +++++++++++++++++++++++++++++++++ src/sync/droid.ts | 21 +++++++++ src/utils/resolve-home.ts | 17 ++++++++ tests/sync-cursor.test.ts | 92 +++++++++++++++++++++++++++++++++++++++ tests/sync-droid.test.ts | 57 ++++++++++++++++++++++++ 10 files changed, 322 insertions(+), 95 deletions(-) create mode 100644 src/sync/cursor.ts create mode 100644 src/sync/droid.ts create mode 100644 src/utils/resolve-home.ts create mode 100644 tests/sync-cursor.test.ts create mode 100644 tests/sync-droid.test.ts diff --git a/README.md b/README.md index 3a930b8..11bfe93 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ All provider targets are experimental and may change as the formats evolve. ## Sync Personal Config -Sync your personal Claude Code config (`~/.claude/`) to OpenCode, Codex, or Pi: +Sync your personal Claude Code config (`~/.claude/`) to other AI coding tools: ```bash # Sync skills and MCP servers to OpenCode @@ -60,6 +60,12 @@ bunx @every-env/compound-plugin sync --target codex # Sync to Pi 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/package.json b/package.json index 788b339..e56906e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@every-env/compound-plugin", - "version": "0.5.2", + "version": "0.6.0", "type": "module", "private": false, "bin": { diff --git a/src/commands/convert.ts b/src/commands/convert.ts index e5a36d9..08e885e 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -5,6 +5,7 @@ import { loadClaudePlugin } from "../parsers/claude" import { targets } from "../targets" import type { PermissionMode } from "../converters/claude-to-opencode" import { ensureCodexAgentsFile } from "../utils/codex-agents" +import { expandHome, resolveTargetHome } from "../utils/resolve-home" const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"] @@ -77,8 +78,8 @@ export default defineCommand({ const plugin = await loadClaudePlugin(String(args.source)) const outputRoot = resolveOutputRoot(args.output) - const codexHome = resolveCodexRoot(args.codexHome) - const piHome = resolvePiRoot(args.piHome) + const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex")) + const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent")) const options = { agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", @@ -131,38 +132,6 @@ function parseExtraTargets(value: unknown): string[] { .filter(Boolean) } -function resolveCodexHome(value: unknown): string | null { - if (!value) return null - const raw = String(value).trim() - if (!raw) return null - const expanded = expandHome(raw) - return path.resolve(expanded) -} - -function resolveCodexRoot(value: unknown): string { - return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex") -} - -function resolvePiHome(value: unknown): string | null { - if (!value) return null - const raw = String(value).trim() - if (!raw) return null - const expanded = expandHome(raw) - return path.resolve(expanded) -} - -function resolvePiRoot(value: unknown): string { - return resolvePiHome(value) ?? path.join(os.homedir(), ".pi", "agent") -} - -function expandHome(value: string): string { - if (value === "~") return os.homedir() - if (value.startsWith(`~${path.sep}`)) { - return path.join(os.homedir(), value.slice(2)) - } - return value -} - function resolveOutputRoot(value: unknown): string { if (value && String(value).trim()) { const expanded = expandHome(String(value).trim()) diff --git a/src/commands/install.ts b/src/commands/install.ts index b1f053f..c9a86e5 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -7,6 +7,7 @@ import { targets } from "../targets" import { pathExists } from "../utils/files" import type { PermissionMode } from "../converters/claude-to-opencode" import { ensureCodexAgentsFile } from "../utils/codex-agents" +import { expandHome, resolveTargetHome } from "../utils/resolve-home" const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"] @@ -81,8 +82,8 @@ export default defineCommand({ try { const plugin = await loadClaudePlugin(resolvedPlugin.path) const outputRoot = resolveOutputRoot(args.output) - const codexHome = resolveCodexRoot(args.codexHome) - const piHome = resolvePiRoot(args.piHome) + const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex")) + const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent")) const options = { agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", @@ -158,38 +159,6 @@ function parseExtraTargets(value: unknown): string[] { .filter(Boolean) } -function resolveCodexHome(value: unknown): string | null { - if (!value) return null - const raw = String(value).trim() - if (!raw) return null - const expanded = expandHome(raw) - return path.resolve(expanded) -} - -function resolveCodexRoot(value: unknown): string { - return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex") -} - -function resolvePiHome(value: unknown): string | null { - if (!value) return null - const raw = String(value).trim() - if (!raw) return null - const expanded = expandHome(raw) - return path.resolve(expanded) -} - -function resolvePiRoot(value: unknown): string { - return resolvePiHome(value) ?? path.join(os.homedir(), ".pi", "agent") -} - -function expandHome(value: string): string { - if (value === "~") return os.homedir() - if (value.startsWith(`~${path.sep}`)) { - return path.join(os.homedir(), value.slice(2)) - } - return value -} - function resolveOutputRoot(value: unknown): string { if (value && String(value).trim()) { const expanded = expandHome(String(value).trim()) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index aa6626b..e5b576e 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -5,9 +5,15 @@ import { loadClaudeHome } from "../parsers/claude-home" import { syncToOpenCode } from "../sync/opencode" import { syncToCodex } from "../sync/codex" import { syncToPi } from "../sync/pi" +import { syncToDroid } from "../sync/droid" +import { syncToCursor } from "../sync/cursor" +import { expandHome } from "../utils/resolve-home" -function isValidTarget(value: string): value is "opencode" | "codex" | "pi" { - return value === "opencode" || value === "codex" || value === "pi" +const validTargets = ["opencode", "codex", "pi", "droid", "cursor"] as const +type SyncTarget = (typeof validTargets)[number] + +function isValidTarget(value: string): value is SyncTarget { + return (validTargets as readonly string[]).includes(value) } /** Check if any MCP servers have env vars that might contain secrets */ @@ -24,16 +30,31 @@ function hasPotentialSecrets(mcpServers: Record): boolean { return false } +function resolveOutputRoot(target: SyncTarget): string { + switch (target) { + case "opencode": + return path.join(os.homedir(), ".config", "opencode") + case "codex": + return path.join(os.homedir(), ".codex") + case "pi": + return path.join(os.homedir(), ".pi", "agent") + case "droid": + return path.join(os.homedir(), ".factory") + case "cursor": + return path.join(process.cwd(), ".cursor") + } +} + export default defineCommand({ meta: { name: "sync", - description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, or Pi", + description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, or Cursor", }, args: { target: { type: "string", required: true, - description: "Target: opencode | codex | pi", + description: "Target: opencode | codex | pi | droid | cursor", }, claudeHome: { type: "string", @@ -43,7 +64,7 @@ export default defineCommand({ }, async run({ args }) { if (!isValidTarget(args.target)) { - throw new Error(`Unknown target: ${args.target}. Use 'opencode', 'codex', or 'pi'.`) + throw new Error(`Unknown target: ${args.target}. Use one of: ${validTargets.join(", ")}`) } const claudeHome = expandHome(args.claudeHome ?? path.join(os.homedir(), ".claude")) @@ -61,29 +82,26 @@ export default defineCommand({ `Syncing ${config.skills.length} skills, ${Object.keys(config.mcpServers).length} MCP servers...`, ) - const outputRoot = - args.target === "opencode" - ? path.join(os.homedir(), ".config", "opencode") - : args.target === "codex" - ? path.join(os.homedir(), ".codex") - : path.join(os.homedir(), ".pi", "agent") + const outputRoot = resolveOutputRoot(args.target) - if (args.target === "opencode") { - await syncToOpenCode(config, outputRoot) - } else if (args.target === "codex") { - await syncToCodex(config, outputRoot) - } else { - await syncToPi(config, outputRoot) + switch (args.target) { + case "opencode": + await syncToOpenCode(config, outputRoot) + break + case "codex": + await syncToCodex(config, outputRoot) + break + case "pi": + await syncToPi(config, outputRoot) + break + case "droid": + await syncToDroid(config, outputRoot) + break + case "cursor": + await syncToCursor(config, outputRoot) + break } console.log(`✓ Synced to ${args.target}: ${outputRoot}`) }, }) - -function expandHome(value: string): string { - if (value === "~") return os.homedir() - if (value.startsWith(`~${path.sep}`)) { - return path.join(os.homedir(), value.slice(2)) - } - return value -} diff --git a/src/sync/cursor.ts b/src/sync/cursor.ts new file mode 100644 index 0000000..32f3aa4 --- /dev/null +++ b/src/sync/cursor.ts @@ -0,0 +1,78 @@ +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/sync/droid.ts b/src/sync/droid.ts new file mode 100644 index 0000000..1f55968 --- /dev/null +++ b/src/sync/droid.ts @@ -0,0 +1,21 @@ +import fs from "fs/promises" +import path from "path" +import type { ClaudeHomeConfig } from "../parsers/claude-home" +import { forceSymlink, isValidSkillName } from "../utils/symlink" + +export async function syncToDroid( + 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) + } +} diff --git a/src/utils/resolve-home.ts b/src/utils/resolve-home.ts new file mode 100644 index 0000000..fca4fcb --- /dev/null +++ b/src/utils/resolve-home.ts @@ -0,0 +1,17 @@ +import os from "os" +import path from "path" + +export function expandHome(value: string): string { + if (value === "~") return os.homedir() + if (value.startsWith(`~${path.sep}`)) { + return path.join(os.homedir(), value.slice(2)) + } + return value +} + +export function resolveTargetHome(value: unknown, defaultPath: string): string { + if (!value) return defaultPath + const raw = String(value).trim() + if (!raw) return defaultPath + return path.resolve(expandHome(raw)) +} diff --git a/tests/sync-cursor.test.ts b/tests/sync-cursor.test.ts new file mode 100644 index 0000000..e314d28 --- /dev/null +++ b/tests/sync-cursor.test.ts @@ -0,0 +1,92 @@ +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) + }) +}) diff --git a/tests/sync-droid.test.ts b/tests/sync-droid.test.ts new file mode 100644 index 0000000..5920f51 --- /dev/null +++ b/tests/sync-droid.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { syncToDroid } from "../src/sync/droid" +import type { ClaudeHomeConfig } from "../src/parsers/claude-home" + +describe("syncToDroid", () => { + test("symlinks skills to factory skills dir", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-")) + 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" }, + }, + } + + await syncToDroid(config, tempRoot) + + const linkedSkillPath = path.join(tempRoot, "skills", "skill-one") + const linkedStat = await fs.lstat(linkedSkillPath) + expect(linkedStat.isSymbolicLink()).toBe(true) + + // Droid does not write MCP config + const mcpExists = await fs.access(path.join(tempRoot, "mcp.json")).then(() => true).catch(() => false) + expect(mcpExists).toBe(false) + }) + + test("skips skills with invalid names", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-invalid-")) + const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "../escape", + sourceDir: fixtureSkillDir, + skillPath: path.join(fixtureSkillDir, "SKILL.md"), + }, + ], + mcpServers: {}, + } + + await syncToDroid(config, tempRoot) + + const entries = await fs.readdir(path.join(tempRoot, "skills")) + expect(entries).toHaveLength(0) + }) +})