feat(sync): add Claude home sync parity across providers
This commit is contained in:
46
tests/claude-home.test.ts
Normal file
46
tests/claude-home.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
64
tests/sync-codex.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
83
tests/sync-kiro.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
51
tests/sync-openclaw.test.ts
Normal file
51
tests/sync-openclaw.test.ts
Normal 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
75
tests/sync-qwen.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
89
tests/sync-windsurf.test.ts
Normal file
89
tests/sync-windsurf.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user