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:
@@ -49,7 +49,7 @@ All provider targets are experimental and may change as the formats evolve.
|
|||||||
|
|
||||||
## Sync Personal Config
|
## 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
|
```bash
|
||||||
# Sync skills and MCP servers to OpenCode
|
# Sync skills and MCP servers to OpenCode
|
||||||
@@ -60,6 +60,12 @@ bunx @every-env/compound-plugin sync --target codex
|
|||||||
|
|
||||||
# Sync to Pi
|
# Sync to Pi
|
||||||
bunx @every-env/compound-plugin sync --target 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:
|
This syncs:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@every-env/compound-plugin",
|
"name": "@every-env/compound-plugin",
|
||||||
"version": "0.5.2",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { loadClaudePlugin } from "../parsers/claude"
|
|||||||
import { targets } from "../targets"
|
import { targets } from "../targets"
|
||||||
import type { PermissionMode } from "../converters/claude-to-opencode"
|
import type { PermissionMode } from "../converters/claude-to-opencode"
|
||||||
import { ensureCodexAgentsFile } from "../utils/codex-agents"
|
import { ensureCodexAgentsFile } from "../utils/codex-agents"
|
||||||
|
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
|
||||||
|
|
||||||
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
|
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
|
||||||
|
|
||||||
@@ -77,8 +78,8 @@ export default defineCommand({
|
|||||||
|
|
||||||
const plugin = await loadClaudePlugin(String(args.source))
|
const plugin = await loadClaudePlugin(String(args.source))
|
||||||
const outputRoot = resolveOutputRoot(args.output)
|
const outputRoot = resolveOutputRoot(args.output)
|
||||||
const codexHome = resolveCodexRoot(args.codexHome)
|
const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex"))
|
||||||
const piHome = resolvePiRoot(args.piHome)
|
const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
|
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
|
||||||
@@ -131,38 +132,6 @@ function parseExtraTargets(value: unknown): string[] {
|
|||||||
.filter(Boolean)
|
.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 {
|
function resolveOutputRoot(value: unknown): string {
|
||||||
if (value && String(value).trim()) {
|
if (value && String(value).trim()) {
|
||||||
const expanded = expandHome(String(value).trim())
|
const expanded = expandHome(String(value).trim())
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { targets } from "../targets"
|
|||||||
import { pathExists } from "../utils/files"
|
import { pathExists } from "../utils/files"
|
||||||
import type { PermissionMode } from "../converters/claude-to-opencode"
|
import type { PermissionMode } from "../converters/claude-to-opencode"
|
||||||
import { ensureCodexAgentsFile } from "../utils/codex-agents"
|
import { ensureCodexAgentsFile } from "../utils/codex-agents"
|
||||||
|
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
|
||||||
|
|
||||||
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
|
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
|
||||||
|
|
||||||
@@ -81,8 +82,8 @@ export default defineCommand({
|
|||||||
try {
|
try {
|
||||||
const plugin = await loadClaudePlugin(resolvedPlugin.path)
|
const plugin = await loadClaudePlugin(resolvedPlugin.path)
|
||||||
const outputRoot = resolveOutputRoot(args.output)
|
const outputRoot = resolveOutputRoot(args.output)
|
||||||
const codexHome = resolveCodexRoot(args.codexHome)
|
const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex"))
|
||||||
const piHome = resolvePiRoot(args.piHome)
|
const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
|
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
|
||||||
@@ -158,38 +159,6 @@ function parseExtraTargets(value: unknown): string[] {
|
|||||||
.filter(Boolean)
|
.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 {
|
function resolveOutputRoot(value: unknown): string {
|
||||||
if (value && String(value).trim()) {
|
if (value && String(value).trim()) {
|
||||||
const expanded = expandHome(String(value).trim())
|
const expanded = expandHome(String(value).trim())
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import { loadClaudeHome } from "../parsers/claude-home"
|
|||||||
import { syncToOpenCode } from "../sync/opencode"
|
import { syncToOpenCode } from "../sync/opencode"
|
||||||
import { syncToCodex } from "../sync/codex"
|
import { syncToCodex } from "../sync/codex"
|
||||||
import { syncToPi } from "../sync/pi"
|
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" {
|
const validTargets = ["opencode", "codex", "pi", "droid", "cursor"] as const
|
||||||
return value === "opencode" || value === "codex" || value === "pi"
|
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 */
|
/** 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
|
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({
|
export default defineCommand({
|
||||||
meta: {
|
meta: {
|
||||||
name: "sync",
|
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: {
|
args: {
|
||||||
target: {
|
target: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
required: true,
|
||||||
description: "Target: opencode | codex | pi",
|
description: "Target: opencode | codex | pi | droid | cursor",
|
||||||
},
|
},
|
||||||
claudeHome: {
|
claudeHome: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -43,7 +64,7 @@ export default defineCommand({
|
|||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
if (!isValidTarget(args.target)) {
|
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"))
|
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...`,
|
`Syncing ${config.skills.length} skills, ${Object.keys(config.mcpServers).length} MCP servers...`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const outputRoot =
|
const outputRoot = resolveOutputRoot(args.target)
|
||||||
args.target === "opencode"
|
|
||||||
? path.join(os.homedir(), ".config", "opencode")
|
|
||||||
: args.target === "codex"
|
|
||||||
? path.join(os.homedir(), ".codex")
|
|
||||||
: path.join(os.homedir(), ".pi", "agent")
|
|
||||||
|
|
||||||
if (args.target === "opencode") {
|
switch (args.target) {
|
||||||
await syncToOpenCode(config, outputRoot)
|
case "opencode":
|
||||||
} else if (args.target === "codex") {
|
await syncToOpenCode(config, outputRoot)
|
||||||
await syncToCodex(config, outputRoot)
|
break
|
||||||
} else {
|
case "codex":
|
||||||
await syncToPi(config, outputRoot)
|
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}`)
|
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
78
src/sync/cursor.ts
Normal 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
21
src/sync/droid.ts
Normal 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
17
src/utils/resolve-home.ts
Normal 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
92
tests/sync-cursor.test.ts
Normal 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
57
tests/sync-droid.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user