feat(sync): add Claude home sync parity across providers

This commit is contained in:
Kieran Klaassen
2026-03-02 21:02:21 -08:00
parent 1a0ddb9de1
commit 168c946033
38 changed files with 2323 additions and 307 deletions

46
tests/claude-home.test.ts Normal file
View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import { loadClaudeHome } from "../src/parsers/claude-home"
describe("loadClaudeHome", () => {
test("loads personal skills, commands, and MCP servers", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "claude-home-"))
const skillDir = path.join(tempHome, "skills", "reviewer")
const commandsDir = path.join(tempHome, "commands")
await fs.mkdir(skillDir, { recursive: true })
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: reviewer\n---\nReview things.\n")
await fs.mkdir(path.join(commandsDir, "workflows"), { recursive: true })
await fs.writeFile(
path.join(commandsDir, "workflows", "plan.md"),
"---\ndescription: Planning command\nargument-hint: \"[feature]\"\n---\nPlan the work.\n",
)
await fs.writeFile(
path.join(commandsDir, "custom.md"),
"---\nname: custom-command\ndescription: Custom command\nallowed-tools: Bash, Read\n---\nDo custom work.\n",
)
await fs.writeFile(
path.join(tempHome, "settings.json"),
JSON.stringify({
mcpServers: {
context7: { url: "https://mcp.context7.com/mcp" },
},
}),
)
const config = await loadClaudeHome(tempHome)
expect(config.skills.map((skill) => skill.name)).toEqual(["reviewer"])
expect(config.commands?.map((command) => command.name)).toEqual([
"custom-command",
"workflows:plan",
])
expect(config.commands?.find((command) => command.name === "workflows:plan")?.argumentHint).toBe("[feature]")
expect(config.commands?.find((command) => command.name === "custom-command")?.allowedTools).toEqual(["Bash", "Read"])
expect(config.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
})
})

View File

@@ -504,4 +504,106 @@ describe("CLI", () => {
expect(json).toHaveProperty("permission")
expect(json.permission).not.toBeNull()
})
test("sync --target all detects new sync targets and ignores stale cursor directories", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "cli-sync-home-"))
const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "cli-sync-cwd-"))
const repoRoot = path.join(import.meta.dir, "..")
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const claudeSkillsDir = path.join(tempHome, ".claude", "skills", "skill-one")
const claudeCommandsDir = path.join(tempHome, ".claude", "commands", "workflows")
await fs.mkdir(path.dirname(claudeSkillsDir), { recursive: true })
await fs.cp(fixtureSkillDir, claudeSkillsDir, { recursive: true })
await fs.mkdir(claudeCommandsDir, { recursive: true })
await fs.writeFile(
path.join(claudeCommandsDir, "plan.md"),
[
"---",
"name: workflows:plan",
"description: Plan work",
"argument-hint: \"[goal]\"",
"---",
"",
"Plan the work.",
].join("\n"),
)
await fs.writeFile(
path.join(tempHome, ".claude", "settings.json"),
JSON.stringify({
mcpServers: {
local: { command: "echo", args: ["hello"] },
remote: { url: "https://example.com/mcp" },
legacy: { type: "sse", url: "https://example.com/sse" },
},
}, null, 2),
)
await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".factory"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".copilot"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".codeium", "windsurf"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".kiro"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".qwen"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".openclaw"), { recursive: true })
await fs.mkdir(path.join(tempCwd, ".cursor"), { recursive: true })
const proc = Bun.spawn([
"bun",
"run",
path.join(repoRoot, "src", "index.ts"),
"sync",
"--target",
"all",
], {
cwd: tempCwd,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
HOME: tempHome,
},
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
}
expect(stdout).toContain("Synced to codex")
expect(stdout).toContain("Synced to opencode")
expect(stdout).toContain("Synced to pi")
expect(stdout).toContain("Synced to droid")
expect(stdout).toContain("Synced to windsurf")
expect(stdout).toContain("Synced to kiro")
expect(stdout).toContain("Synced to qwen")
expect(stdout).toContain("Synced to openclaw")
expect(stdout).toContain("Synced to copilot")
expect(stdout).toContain("Synced to gemini")
expect(stdout).not.toContain("cursor")
expect(await exists(path.join(tempHome, ".config", "opencode", "commands", "workflows:plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".codex", "config.toml"))).toBe(true)
expect(await exists(path.join(tempHome, ".codex", "prompts", "workflows-plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".codex", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".pi", "agent", "prompts", "workflows-plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".factory", "commands", "plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".codeium", "windsurf", "mcp_config.json"))).toBe(true)
expect(await exists(path.join(tempHome, ".codeium", "windsurf", "global_workflows", "workflows-plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".kiro", "settings", "mcp.json"))).toBe(true)
expect(await exists(path.join(tempHome, ".kiro", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".qwen", "settings.json"))).toBe(true)
expect(await exists(path.join(tempHome, ".qwen", "commands", "workflows", "plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".copilot", "mcp-config.json"))).toBe(true)
expect(await exists(path.join(tempHome, ".copilot", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".gemini", "settings.json"))).toBe(true)
expect(await exists(path.join(tempHome, ".gemini", "commands", "workflows", "plan.toml"))).toBe(true)
expect(await exists(path.join(tempHome, ".openclaw", "skills", "skill-one"))).toBe(true)
})
})

View File

@@ -11,8 +11,9 @@ describe("detectInstalledTools", () => {
// Create directories for some tools
await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
await fs.mkdir(path.join(tempCwd, ".cursor"), { recursive: true })
await fs.mkdir(path.join(tempCwd, ".gemini"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".codeium", "windsurf"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".copilot"), { recursive: true })
const results = await detectInstalledTools(tempHome, tempCwd)
@@ -20,14 +21,18 @@ describe("detectInstalledTools", () => {
expect(codex?.detected).toBe(true)
expect(codex?.reason).toContain(".codex")
const cursor = results.find((t) => t.name === "cursor")
expect(cursor?.detected).toBe(true)
expect(cursor?.reason).toContain(".cursor")
const windsurf = results.find((t) => t.name === "windsurf")
expect(windsurf?.detected).toBe(true)
expect(windsurf?.reason).toContain(".codeium/windsurf")
const gemini = results.find((t) => t.name === "gemini")
expect(gemini?.detected).toBe(true)
expect(gemini?.reason).toContain(".gemini")
const copilot = results.find((t) => t.name === "copilot")
expect(copilot?.detected).toBe(true)
expect(copilot?.reason).toContain(".copilot")
// Tools without directories should not be detected
const opencode = results.find((t) => t.name === "opencode")
expect(opencode?.detected).toBe(false)
@@ -45,7 +50,7 @@ describe("detectInstalledTools", () => {
const results = await detectInstalledTools(tempHome, tempCwd)
expect(results.length).toBe(6)
expect(results.length).toBe(10)
for (const tool of results) {
expect(tool.detected).toBe(false)
expect(tool.reason).toBe("not found")
@@ -59,12 +64,30 @@ describe("detectInstalledTools", () => {
await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".factory"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".openclaw"), { recursive: true })
const results = await detectInstalledTools(tempHome, tempCwd)
expect(results.find((t) => t.name === "opencode")?.detected).toBe(true)
expect(results.find((t) => t.name === "droid")?.detected).toBe(true)
expect(results.find((t) => t.name === "pi")?.detected).toBe(true)
expect(results.find((t) => t.name === "openclaw")?.detected).toBe(true)
})
test("detects copilot from project-specific skills without generic .github false positives", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-copilot-home-"))
const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-copilot-cwd-"))
await fs.mkdir(path.join(tempCwd, ".github"), { recursive: true })
let results = await detectInstalledTools(tempHome, tempCwd)
expect(results.find((t) => t.name === "copilot")?.detected).toBe(false)
await fs.mkdir(path.join(tempCwd, ".github", "skills"), { recursive: true })
results = await detectInstalledTools(tempHome, tempCwd)
expect(results.find((t) => t.name === "copilot")?.detected).toBe(true)
expect(results.find((t) => t.name === "copilot")?.reason).toContain(".github/skills")
})
})
@@ -74,7 +97,7 @@ describe("getDetectedTargetNames", () => {
const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-names-cwd-"))
await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
await fs.mkdir(path.join(tempCwd, ".gemini"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
const names = await getDetectedTargetNames(tempHome, tempCwd)

64
tests/sync-codex.test.ts Normal file
View File

@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToCodex } from "../src/sync/codex"
describe("syncToCodex", () => {
test("writes stdio and remote MCP servers into a managed block without clobbering user config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-codex-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const configPath = path.join(tempRoot, "config.toml")
await fs.writeFile(
configPath,
[
"[custom]",
"enabled = true",
"",
"# BEGIN compound-plugin Claude Code MCP",
"[mcp_servers.old]",
"command = \"old\"",
"# END compound-plugin Claude Code MCP",
"",
"[post]",
"value = 2",
"",
].join("\n"),
)
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {
local: { command: "echo", args: ["hello"], env: { KEY: "VALUE" } },
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
},
}
await syncToCodex(config, tempRoot)
const skillPath = path.join(tempRoot, "skills", "skill-one")
expect((await fs.lstat(skillPath)).isSymbolicLink()).toBe(true)
const content = await fs.readFile(configPath, "utf8")
expect(content).toContain("[custom]")
expect(content).toContain("[post]")
expect(content).not.toContain("[mcp_servers.old]")
expect(content).toContain("[mcp_servers.local]")
expect(content).toContain("command = \"echo\"")
expect(content).toContain("[mcp_servers.remote]")
expect(content).toContain("url = \"https://example.com/mcp\"")
expect(content).toContain("http_headers")
expect(content.match(/# BEGIN compound-plugin Claude Code MCP/g)?.length).toBe(1)
const perms = (await fs.stat(configPath)).mode & 0o777
expect(perms).toBe(0o600)
})
})

View File

@@ -28,6 +28,34 @@ describe("syncToCopilot", () => {
expect(linkedStat.isSymbolicLink()).toBe(true)
})
test("converts personal commands into Copilot skills", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-cmd-"))
const config: ClaudeHomeConfig = {
skills: [],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[goal]",
body: "Plan the work carefully.",
sourcePath: "/tmp/workflows/plan.md",
},
],
mcpServers: {},
}
await syncToCopilot(config, tempRoot)
const skillContent = await fs.readFile(
path.join(tempRoot, "skills", "workflows-plan", "SKILL.md"),
"utf8",
)
expect(skillContent).toContain("name: workflows-plan")
expect(skillContent).toContain("Planning command")
expect(skillContent).toContain("## Arguments")
})
test("skips skills with invalid names", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-invalid-"))
@@ -51,7 +79,7 @@ describe("syncToCopilot", () => {
test("merges MCP config with existing file", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-merge-"))
const mcpPath = path.join(tempRoot, "copilot-mcp-config.json")
const mcpPath = path.join(tempRoot, "mcp-config.json")
await fs.writeFile(
mcpPath,
@@ -77,6 +105,7 @@ describe("syncToCopilot", () => {
expect(merged.mcpServers.existing?.command).toBe("node")
expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
expect(merged.mcpServers.context7?.type).toBe("http")
})
test("transforms MCP env var names to COPILOT_MCP_ prefix", async () => {
@@ -95,7 +124,7 @@ describe("syncToCopilot", () => {
await syncToCopilot(config, tempRoot)
const mcpPath = path.join(tempRoot, "copilot-mcp-config.json")
const mcpPath = path.join(tempRoot, "mcp-config.json")
const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
mcpServers: Record<string, { env?: Record<string, string> }>
}
@@ -118,7 +147,7 @@ describe("syncToCopilot", () => {
await syncToCopilot(config, tempRoot)
const mcpPath = path.join(tempRoot, "copilot-mcp-config.json")
const mcpPath = path.join(tempRoot, "mcp-config.json")
const stat = await fs.stat(mcpPath)
// Check owner read+write permission (0o600 = 33216 in decimal, masked to file perms)
const perms = stat.mode & 0o777
@@ -142,7 +171,34 @@ describe("syncToCopilot", () => {
await syncToCopilot(config, tempRoot)
const mcpExists = await fs.access(path.join(tempRoot, "copilot-mcp-config.json")).then(() => true).catch(() => false)
const mcpExists = await fs.access(path.join(tempRoot, "mcp-config.json")).then(() => true).catch(() => false)
expect(mcpExists).toBe(false)
})
test("preserves explicit SSE transport for legacy remote servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-sse-"))
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
legacy: {
type: "sse",
url: "https://example.com/sse",
},
},
}
await syncToCopilot(config, tempRoot)
const mcpPath = path.join(tempRoot, "mcp-config.json")
const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
mcpServers: Record<string, { type?: string; url?: string }>
}
expect(mcpConfig.mcpServers.legacy).toEqual({
type: "sse",
tools: ["*"],
url: "https://example.com/sse",
})
})
})

View File

@@ -6,7 +6,7 @@ import { syncToDroid } from "../src/sync/droid"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
describe("syncToDroid", () => {
test("symlinks skills to factory skills dir", async () => {
test("symlinks skills to factory skills dir and writes mcp.json", 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")
@@ -29,9 +29,49 @@ describe("syncToDroid", () => {
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)
const mcpConfig = JSON.parse(
await fs.readFile(path.join(tempRoot, "mcp.json"), "utf8"),
) as {
mcpServers: Record<string, { type: string; url?: string; disabled: boolean }>
}
expect(mcpConfig.mcpServers.context7?.type).toBe("http")
expect(mcpConfig.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
expect(mcpConfig.mcpServers.context7?.disabled).toBe(false)
})
test("merges existing mcp.json and overwrites same-named servers from Claude", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-merge-"))
await fs.writeFile(
path.join(tempRoot, "mcp.json"),
JSON.stringify({
theme: "dark",
mcpServers: {
shared: { type: "http", url: "https://old.example.com", disabled: true },
existing: { type: "stdio", command: "node", disabled: false },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
shared: { url: "https://new.example.com" },
},
}
await syncToDroid(config, tempRoot)
const mcpConfig = JSON.parse(
await fs.readFile(path.join(tempRoot, "mcp.json"), "utf8"),
) as {
theme: string
mcpServers: Record<string, { type: string; url?: string; command?: string; disabled: boolean }>
}
expect(mcpConfig.theme).toBe("dark")
expect(mcpConfig.mcpServers.existing?.command).toBe("node")
expect(mcpConfig.mcpServers.shared?.url).toBe("https://new.example.com")
expect(mcpConfig.mcpServers.shared?.disabled).toBe(false)
})
test("skips skills with invalid names", async () => {

View File

@@ -77,6 +77,33 @@ describe("syncToGemini", () => {
expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
})
test("writes personal commands as Gemini TOML prompts", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-cmd-"))
const config: ClaudeHomeConfig = {
skills: [],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[goal]",
body: "Plan the work carefully.",
sourcePath: "/tmp/workflows/plan.md",
},
],
mcpServers: {},
}
await syncToGemini(config, tempRoot)
const content = await fs.readFile(
path.join(tempRoot, "commands", "workflows", "plan.toml"),
"utf8",
)
expect(content).toContain("Planning command")
expect(content).toContain("User request: {{args}}")
})
test("does not write settings.json when no MCP servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-nomcp-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
@@ -103,4 +130,31 @@ describe("syncToGemini", () => {
const settingsExists = await fs.access(path.join(tempRoot, "settings.json")).then(() => true).catch(() => false)
expect(settingsExists).toBe(false)
})
test("skips mirrored ~/.agents skills when syncing to ~/.gemini and removes stale duplicate symlinks", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-home-"))
const geminiRoot = path.join(tempHome, ".gemini")
const agentsSkillDir = path.join(tempHome, ".agents", "skills", "skill-one")
await fs.mkdir(path.join(agentsSkillDir), { recursive: true })
await fs.writeFile(path.join(agentsSkillDir, "SKILL.md"), "# Skill One\n", "utf8")
await fs.mkdir(path.join(geminiRoot, "skills"), { recursive: true })
await fs.symlink(agentsSkillDir, path.join(geminiRoot, "skills", "skill-one"))
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: agentsSkillDir,
skillPath: path.join(agentsSkillDir, "SKILL.md"),
},
],
mcpServers: {},
}
await syncToGemini(config, geminiRoot)
const duplicateExists = await fs.access(path.join(geminiRoot, "skills", "skill-one")).then(() => true).catch(() => false)
expect(duplicateExists).toBe(false)
})
})

83
tests/sync-kiro.test.ts Normal file
View File

@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToKiro } from "../src/sync/kiro"
describe("syncToKiro", () => {
test("writes user-scope settings/mcp.json with local and remote servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-kiro-"))
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: {
local: { command: "echo", args: ["hello"], env: { TOKEN: "secret" } },
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
},
}
await syncToKiro(config, tempRoot)
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings", "mcp.json"), "utf8"),
) as {
mcpServers: Record<string, {
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
}>
}
expect(content.mcpServers.local?.command).toBe("echo")
expect(content.mcpServers.local?.args).toEqual(["hello"])
expect(content.mcpServers.local?.env).toEqual({ TOKEN: "secret" })
expect(content.mcpServers.remote?.url).toBe("https://example.com/mcp")
expect(content.mcpServers.remote?.headers).toEqual({ Authorization: "Bearer token" })
})
test("merges existing settings/mcp.json", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-kiro-merge-"))
await fs.mkdir(path.join(tempRoot, "settings"), { recursive: true })
await fs.writeFile(
path.join(tempRoot, "settings", "mcp.json"),
JSON.stringify({
note: "preserve",
mcpServers: {
existing: { command: "node" },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
remote: { url: "https://example.com/mcp" },
},
}
await syncToKiro(config, tempRoot)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings", "mcp.json"), "utf8"),
) as {
note: string
mcpServers: Record<string, { command?: string; url?: string }>
}
expect(content.note).toBe("preserve")
expect(content.mcpServers.existing?.command).toBe("node")
expect(content.mcpServers.remote?.url).toBe("https://example.com/mcp")
})
})

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToOpenClaw } from "../src/sync/openclaw"
describe("syncToOpenClaw", () => {
test("symlinks skills and warns instead of writing unvalidated MCP config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-openclaw-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (message?: unknown) => {
warnings.push(String(message))
}
try {
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
commands: [
{
name: "workflows:plan",
description: "Planning command",
body: "Plan the work.",
sourcePath: "/tmp/workflows/plan.md",
},
],
mcpServers: {
remote: { url: "https://example.com/mcp" },
},
}
await syncToOpenClaw(config, tempRoot)
} finally {
console.warn = originalWarn
}
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
const openclawConfigExists = await fs.access(path.join(tempRoot, "openclaw.json")).then(() => true).catch(() => false)
expect(openclawConfigExists).toBe(false)
expect(warnings.some((warning) => warning.includes("OpenClaw personal command sync is skipped"))).toBe(true)
expect(warnings.some((warning) => warning.includes("OpenClaw MCP sync is skipped"))).toBe(true)
})
})

75
tests/sync-qwen.test.ts Normal file
View File

@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToQwen } from "../src/sync/qwen"
describe("syncToQwen", () => {
test("defaults ambiguous remote URLs to httpUrl and warns", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-qwen-"))
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (message?: unknown) => {
warnings.push(String(message))
}
try {
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
},
}
await syncToQwen(config, tempRoot)
} finally {
console.warn = originalWarn
}
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings.json"), "utf8"),
) as {
mcpServers: Record<string, { httpUrl?: string; url?: string; headers?: Record<string, string> }>
}
expect(content.mcpServers.remote?.httpUrl).toBe("https://example.com/mcp")
expect(content.mcpServers.remote?.url).toBeUndefined()
expect(content.mcpServers.remote?.headers).toEqual({ Authorization: "Bearer token" })
expect(warnings.some((warning) => warning.includes("ambiguous remote transport"))).toBe(true)
})
test("uses legacy url only for explicit SSE servers and preserves existing settings", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-qwen-sse-"))
await fs.writeFile(
path.join(tempRoot, "settings.json"),
JSON.stringify({
theme: "dark",
mcpServers: {
existing: { command: "node" },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
legacy: { type: "sse", url: "https://example.com/sse" },
},
}
await syncToQwen(config, tempRoot)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings.json"), "utf8"),
) as {
theme: string
mcpServers: Record<string, { command?: string; httpUrl?: string; url?: string }>
}
expect(content.theme).toBe("dark")
expect(content.mcpServers.existing?.command).toBe("node")
expect(content.mcpServers.legacy?.url).toBe("https://example.com/sse")
expect(content.mcpServers.legacy?.httpUrl).toBeUndefined()
})
})

View File

@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToWindsurf } from "../src/sync/windsurf"
describe("syncToWindsurf", () => {
test("writes stdio, http, and sse MCP servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-windsurf-"))
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: {
local: { command: "npx", args: ["serve"], env: { FOO: "bar" } },
remoteHttp: { url: "https://example.com/mcp", headers: { Authorization: "Bearer a" } },
remoteSse: { type: "sse", url: "https://example.com/sse" },
},
}
await syncToWindsurf(config, tempRoot)
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "mcp_config.json"), "utf8"),
) as {
mcpServers: Record<string, {
command?: string
args?: string[]
env?: Record<string, string>
serverUrl?: string
url?: string
}>
}
expect(content.mcpServers.local).toEqual({
command: "npx",
args: ["serve"],
env: { FOO: "bar" },
})
expect(content.mcpServers.remoteHttp?.serverUrl).toBe("https://example.com/mcp")
expect(content.mcpServers.remoteSse?.url).toBe("https://example.com/sse")
const perms = (await fs.stat(path.join(tempRoot, "mcp_config.json"))).mode & 0o777
expect(perms).toBe(0o600)
})
test("merges existing config and overwrites same-named servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-windsurf-merge-"))
await fs.writeFile(
path.join(tempRoot, "mcp_config.json"),
JSON.stringify({
theme: "dark",
mcpServers: {
existing: { command: "node" },
shared: { serverUrl: "https://old.example.com" },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
shared: { url: "https://new.example.com" },
},
}
await syncToWindsurf(config, tempRoot)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "mcp_config.json"), "utf8"),
) as {
theme: string
mcpServers: Record<string, { command?: string; serverUrl?: string }>
}
expect(content.theme).toBe("dark")
expect(content.mcpServers.existing?.command).toBe("node")
expect(content.mcpServers.shared?.serverUrl).toBe("https://new.example.com")
})
})