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 <noreply@anthropic.com>
This commit is contained in:
Kieran Klaassen
2026-02-12 20:37:15 -08:00
parent 84af459c79
commit e41904a569
10 changed files with 322 additions and 95 deletions

View File

@@ -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:

View File

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

View File

@@ -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())

View File

@@ -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())

View File

@@ -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<string, unknown>): 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
}

78
src/sync/cursor.ts Normal file
View File

@@ -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<string, string>
headers?: Record<string, string>
}
type CursorMcpConfig = {
mcpServers: Record<string, CursorMcpServer>
}
export async function syncToCursor(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
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<Partial<CursorMcpConfig>> {
try {
const content = await fs.readFile(filePath, "utf-8")
return JSON.parse(content) as Partial<CursorMcpConfig>
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {}
}
throw err
}
}
function convertMcpForCursor(
servers: Record<string, ClaudeMcpServer>,
): Record<string, CursorMcpServer> {
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
}

21
src/sync/droid.ts Normal file
View File

@@ -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<void> {
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)
}
}

17
src/utils/resolve-home.ts Normal file
View File

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

92
tests/sync-cursor.test.ts Normal file
View File

@@ -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<string, { url?: string; command?: string; args?: string[]; env?: Record<string, string> }>
}
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<string, { command?: string; url?: string }>
}
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)
})
})

57
tests/sync-droid.test.ts Normal file
View File

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