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

View File

@@ -1,31 +1,29 @@
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"
import { renderCodexConfig } from "../targets/codex"
import { writeTextSecure } from "../utils/files"
import { syncCodexCommands } from "./commands"
import { syncSkills } from "./skills"
const CURRENT_START_MARKER = "# BEGIN compound-plugin Claude Code MCP"
const CURRENT_END_MARKER = "# END compound-plugin Claude Code MCP"
const LEGACY_MARKER = "# MCP servers synced from Claude Code"
export async function syncToCodex(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
// Ensure output directories exist
const skillsDir = path.join(outputRoot, "skills")
await fs.mkdir(skillsDir, { recursive: true })
// Symlink skills (with validation)
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)
}
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncCodexCommands(config, outputRoot)
// Write MCP servers to config.toml (TOML format)
if (Object.keys(config.mcpServers).length > 0) {
const configPath = path.join(outputRoot, "config.toml")
const mcpToml = convertMcpForCodex(config.mcpServers)
const mcpToml = renderCodexConfig(config.mcpServers)
if (!mcpToml) {
return
}
// Read existing config and merge idempotently
let existingContent = ""
@@ -37,56 +35,34 @@ export async function syncToCodex(
}
}
// Remove any existing Claude Code MCP section to make idempotent
const marker = "# MCP servers synced from Claude Code"
const markerIndex = existingContent.indexOf(marker)
if (markerIndex !== -1) {
existingContent = existingContent.slice(0, markerIndex).trimEnd()
}
const managedBlock = [
CURRENT_START_MARKER,
mcpToml.trim(),
CURRENT_END_MARKER,
"",
].join("\n")
const newContent = existingContent
? existingContent + "\n\n" + marker + "\n" + mcpToml
: "# Codex config - synced from Claude Code\n\n" + mcpToml
const withoutCurrentBlock = existingContent.replace(
new RegExp(
`${escapeForRegex(CURRENT_START_MARKER)}[\\s\\S]*?${escapeForRegex(CURRENT_END_MARKER)}\\n?`,
"g",
),
"",
).trimEnd()
await fs.writeFile(configPath, newContent, { mode: 0o600 })
const legacyMarkerIndex = withoutCurrentBlock.indexOf(LEGACY_MARKER)
const cleaned = legacyMarkerIndex === -1
? withoutCurrentBlock
: withoutCurrentBlock.slice(0, legacyMarkerIndex).trimEnd()
const newContent = cleaned
? `${cleaned}\n\n${managedBlock}`
: `${managedBlock}`
await writeTextSecure(configPath, newContent)
}
}
/** Escape a string for TOML double-quoted strings */
function escapeTomlString(str: string): string {
return str
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t")
}
function convertMcpForCodex(servers: Record<string, ClaudeMcpServer>): string {
const sections: string[] = []
for (const [name, server] of Object.entries(servers)) {
if (!server.command) continue
const lines: string[] = []
lines.push(`[mcp_servers.${name}]`)
lines.push(`command = "${escapeTomlString(server.command)}"`)
if (server.args && server.args.length > 0) {
const argsStr = server.args.map((arg) => `"${escapeTomlString(arg)}"`).join(", ")
lines.push(`args = [${argsStr}]`)
}
if (server.env && Object.keys(server.env).length > 0) {
lines.push("")
lines.push(`[mcp_servers.${name}.env]`)
for (const [key, value] of Object.entries(server.env)) {
lines.push(`${key} = "${escapeTomlString(value)}"`)
}
}
sections.push(lines.join("\n"))
}
return sections.join("\n\n") + "\n"
function escapeForRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}

198
src/sync/commands.ts Normal file
View File

@@ -0,0 +1,198 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudePlugin } from "../types/claude"
import { backupFile, writeText } from "../utils/files"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
import { convertClaudeToDroid } from "../converters/claude-to-droid"
import { convertClaudeToGemini } from "../converters/claude-to-gemini"
import { convertClaudeToKiro } from "../converters/claude-to-kiro"
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
import { convertClaudeToPi } from "../converters/claude-to-pi"
import { convertClaudeToQwen, type ClaudeToQwenOptions } from "../converters/claude-to-qwen"
import { convertClaudeToWindsurf } from "../converters/claude-to-windsurf"
import { writeWindsurfBundle } from "../targets/windsurf"
type WindsurfSyncScope = "global" | "workspace"
const HOME_SYNC_PLUGIN_ROOT = path.join(process.cwd(), ".compound-sync-home")
const DEFAULT_SYNC_OPTIONS: ClaudeToOpenCodeOptions = {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
}
const DEFAULT_QWEN_SYNC_OPTIONS: ClaudeToQwenOptions = {
agentMode: "subagent",
inferTemperature: false,
}
function hasCommands(config: ClaudeHomeConfig): boolean {
return (config.commands?.length ?? 0) > 0
}
function buildClaudeHomePlugin(config: ClaudeHomeConfig): ClaudePlugin {
return {
root: HOME_SYNC_PLUGIN_ROOT,
manifest: {
name: "claude-home",
version: "1.0.0",
description: "Personal Claude Code home config",
},
agents: [],
commands: config.commands ?? [],
skills: config.skills,
mcpServers: undefined,
}
}
export async function syncOpenCodeCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToOpenCode(plugin, DEFAULT_SYNC_OPTIONS)
for (const commandFile of bundle.commandFiles) {
const commandPath = path.join(outputRoot, "commands", `${commandFile.name}.md`)
const backupPath = await backupFile(commandPath)
if (backupPath) {
console.log(`Backed up existing command file to ${backupPath}`)
}
await writeText(commandPath, commandFile.content + "\n")
}
}
export async function syncCodexCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToCodex(plugin, DEFAULT_SYNC_OPTIONS)
for (const prompt of bundle.prompts) {
await writeText(path.join(outputRoot, "prompts", `${prompt.name}.md`), prompt.content + "\n")
}
for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", skill.name, "SKILL.md"), skill.content + "\n")
}
}
export async function syncPiCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToPi(plugin, DEFAULT_SYNC_OPTIONS)
for (const prompt of bundle.prompts) {
await writeText(path.join(outputRoot, "prompts", `${prompt.name}.md`), prompt.content + "\n")
}
for (const extension of bundle.extensions) {
await writeText(path.join(outputRoot, "extensions", extension.name), extension.content + "\n")
}
}
export async function syncDroidCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToDroid(plugin, DEFAULT_SYNC_OPTIONS)
for (const command of bundle.commands) {
await writeText(path.join(outputRoot, "commands", `${command.name}.md`), command.content + "\n")
}
}
export async function syncCopilotCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToCopilot(plugin, DEFAULT_SYNC_OPTIONS)
for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", skill.name, "SKILL.md"), skill.content + "\n")
}
}
export async function syncGeminiCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToGemini(plugin, DEFAULT_SYNC_OPTIONS)
for (const command of bundle.commands) {
await writeText(path.join(outputRoot, "commands", `${command.name}.toml`), command.content + "\n")
}
}
export async function syncKiroCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToKiro(plugin, DEFAULT_SYNC_OPTIONS)
for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", skill.name, "SKILL.md"), skill.content + "\n")
}
}
export async function syncWindsurfCommands(
config: ClaudeHomeConfig,
outputRoot: string,
scope: WindsurfSyncScope = "global",
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToWindsurf(plugin, DEFAULT_SYNC_OPTIONS)
await writeWindsurfBundle(outputRoot, {
agentSkills: [],
commandWorkflows: bundle.commandWorkflows,
skillDirs: [],
mcpConfig: null,
}, scope)
}
export async function syncQwenCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToQwen(plugin, DEFAULT_QWEN_SYNC_OPTIONS)
for (const commandFile of bundle.commandFiles) {
const parts = commandFile.name.split(":")
if (parts.length > 1) {
const nestedDir = path.join(outputRoot, "commands", ...parts.slice(0, -1))
await writeText(path.join(nestedDir, `${parts[parts.length - 1]}.md`), commandFile.content + "\n")
continue
}
await writeText(path.join(outputRoot, "commands", `${commandFile.name}.md`), commandFile.content + "\n")
}
}
export function warnUnsupportedOpenClawCommands(config: ClaudeHomeConfig): void {
if (!hasCommands(config)) return
console.warn(
"Warning: OpenClaw personal command sync is skipped because this sync target currently has no documented user-level command surface.",
)
}

View File

@@ -1,11 +1,13 @@
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"
import { syncCopilotCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { hasExplicitSseTransport } from "./mcp-transports"
import { syncSkills } from "./skills"
type CopilotMcpServer = {
type: string
type: "local" | "http" | "sse"
command?: string
args?: string[]
url?: string
@@ -22,41 +24,17 @@ export async function syncToCopilot(
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)
}
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncCopilotCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
const mcpPath = path.join(outputRoot, "copilot-mcp-config.json")
const existing = await readJsonSafe(mcpPath)
const mcpPath = path.join(outputRoot, "mcp-config.json")
const converted = convertMcpForCopilot(config.mcpServers)
const merged: CopilotMcpConfig = {
mcpServers: {
...(existing.mcpServers ?? {}),
...converted,
},
}
await fs.writeFile(mcpPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
}
}
async function readJsonSafe(filePath: string): Promise<Partial<CopilotMcpConfig>> {
try {
const content = await fs.readFile(filePath, "utf-8")
return JSON.parse(content) as Partial<CopilotMcpConfig>
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {}
}
throw err
await mergeJsonConfigAtKey({
configPath: mcpPath,
key: "mcpServers",
incoming: converted,
})
}
}
@@ -66,7 +44,7 @@ function convertMcpForCopilot(
const result: Record<string, CopilotMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
const entry: CopilotMcpServer = {
type: server.command ? "local" : "sse",
type: server.command ? "local" : hasExplicitSseTransport(server) ? "sse" : "http",
tools: ["*"],
}

View File

@@ -1,21 +1,62 @@
import fs from "fs/promises"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import { forceSymlink, isValidSkillName } from "../utils/symlink"
import type { ClaudeMcpServer } from "../types/claude"
import { syncDroidCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
type DroidMcpServer = {
type: "stdio" | "http"
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
disabled: boolean
}
export async function syncToDroid(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
const skillsDir = path.join(outputRoot, "skills")
await fs.mkdir(skillsDir, { recursive: true })
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncDroidCommands(config, outputRoot)
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) {
await mergeJsonConfigAtKey({
configPath: path.join(outputRoot, "mcp.json"),
key: "mcpServers",
incoming: convertMcpForDroid(config.mcpServers),
})
}
}
function convertMcpForDroid(
servers: Record<string, ClaudeMcpServer>,
): Record<string, DroidMcpServer> {
const result: Record<string, DroidMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env,
disabled: false,
}
continue
}
if (server.url) {
result[name] = {
type: "http",
url: server.url,
headers: server.headers,
disabled: false,
}
}
}
return result
}

View File

@@ -2,7 +2,9 @@ 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"
import { syncGeminiCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
type GeminiMcpServer = {
command?: string
@@ -16,43 +18,100 @@ export async function syncToGemini(
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)
}
await syncGeminiSkills(config.skills, outputRoot)
await syncGeminiCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
const settingsPath = path.join(outputRoot, "settings.json")
const existing = await readJsonSafe(settingsPath)
const converted = convertMcpForGemini(config.mcpServers)
const existingMcp =
existing.mcpServers && typeof existing.mcpServers === "object"
? (existing.mcpServers as Record<string, unknown>)
: {}
const merged = {
...existing,
mcpServers: { ...existingMcp, ...converted },
}
await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
await mergeJsonConfigAtKey({
configPath: settingsPath,
key: "mcpServers",
incoming: converted,
})
}
}
async function readJsonSafe(filePath: string): Promise<Record<string, unknown>> {
try {
const content = await fs.readFile(filePath, "utf-8")
return JSON.parse(content) as Record<string, unknown>
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {}
async function syncGeminiSkills(
skills: ClaudeHomeConfig["skills"],
outputRoot: string,
): Promise<void> {
const skillsDir = path.join(outputRoot, "skills")
const sharedSkillsDir = getGeminiSharedSkillsDir(outputRoot)
if (!sharedSkillsDir) {
await syncSkills(skills, skillsDir)
return
}
const canonicalSharedSkillsDir = await canonicalizePath(sharedSkillsDir)
const mirroredSkills: ClaudeHomeConfig["skills"] = []
const directSkills: ClaudeHomeConfig["skills"] = []
for (const skill of skills) {
if (await isWithinDir(skill.sourceDir, canonicalSharedSkillsDir)) {
mirroredSkills.push(skill)
} else {
directSkills.push(skill)
}
}
await removeGeminiMirrorConflicts(mirroredSkills, skillsDir, canonicalSharedSkillsDir)
await syncSkills(directSkills, skillsDir)
}
function getGeminiSharedSkillsDir(outputRoot: string): string | null {
if (path.basename(outputRoot) !== ".gemini") return null
return path.join(path.dirname(outputRoot), ".agents", "skills")
}
async function canonicalizePath(targetPath: string): Promise<string> {
try {
return await fs.realpath(targetPath)
} catch {
return path.resolve(targetPath)
}
}
async function isWithinDir(candidate: string, canonicalParentDir: string): Promise<boolean> {
const resolvedCandidate = await canonicalizePath(candidate)
return resolvedCandidate === canonicalParentDir
|| resolvedCandidate.startsWith(`${canonicalParentDir}${path.sep}`)
}
async function removeGeminiMirrorConflicts(
skills: ClaudeHomeConfig["skills"],
skillsDir: string,
sharedSkillsDir: string,
): Promise<void> {
for (const skill of skills) {
const duplicatePath = path.join(skillsDir, skill.name)
let stat
try {
stat = await fs.lstat(duplicatePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
continue
}
throw error
}
if (!stat.isSymbolicLink()) {
continue
}
let resolvedTarget: string
try {
resolvedTarget = await canonicalizePath(duplicatePath)
} catch {
continue
}
if (resolvedTarget === await canonicalizePath(skill.sourceDir)
|| await isWithinDir(resolvedTarget, sharedSkillsDir)) {
await fs.unlink(duplicatePath)
}
throw err
}
}

47
src/sync/json-config.ts Normal file
View File

@@ -0,0 +1,47 @@
import path from "path"
import { pathExists, readJson, writeJsonSecure } from "../utils/files"
type JsonObject = Record<string, unknown>
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export async function mergeJsonConfigAtKey(options: {
configPath: string
key: string
incoming: Record<string, unknown>
}): Promise<void> {
const { configPath, key, incoming } = options
const existing = await readJsonObjectSafe(configPath)
const existingEntries = isJsonObject(existing[key]) ? existing[key] : {}
const merged = {
...existing,
[key]: {
...existingEntries,
...incoming,
},
}
await writeJsonSecure(configPath, merged)
}
async function readJsonObjectSafe(configPath: string): Promise<JsonObject> {
if (!(await pathExists(configPath))) {
return {}
}
try {
const parsed = await readJson<unknown>(configPath)
if (isJsonObject(parsed)) {
return parsed
}
} catch {
// Fall through to warning and replacement.
}
console.warn(
`Warning: existing ${path.basename(configPath)} could not be parsed and will be replaced.`,
)
return {}
}

49
src/sync/kiro.ts Normal file
View File

@@ -0,0 +1,49 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import type { KiroMcpServer } from "../types/kiro"
import { syncKiroCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
export async function syncToKiro(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncKiroCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
await mergeJsonConfigAtKey({
configPath: path.join(outputRoot, "settings", "mcp.json"),
key: "mcpServers",
incoming: convertMcpForKiro(config.mcpServers),
})
}
}
function convertMcpForKiro(
servers: Record<string, ClaudeMcpServer>,
): Record<string, KiroMcpServer> {
const result: Record<string, KiroMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
command: server.command,
args: server.args,
env: server.env,
}
continue
}
if (server.url) {
result[name] = {
url: server.url,
headers: server.headers,
}
}
}
return result
}

View File

@@ -0,0 +1,19 @@
import type { ClaudeMcpServer } from "../types/claude"
function getTransportType(server: ClaudeMcpServer): string {
return server.type?.toLowerCase().trim() ?? ""
}
export function hasExplicitSseTransport(server: ClaudeMcpServer): boolean {
const type = getTransportType(server)
return type.includes("sse")
}
export function hasExplicitHttpTransport(server: ClaudeMcpServer): boolean {
const type = getTransportType(server)
return type.includes("http") || type.includes("streamable")
}
export function hasExplicitRemoteTransport(server: ClaudeMcpServer): boolean {
return hasExplicitSseTransport(server) || hasExplicitHttpTransport(server)
}

18
src/sync/openclaw.ts Normal file
View File

@@ -0,0 +1,18 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import { warnUnsupportedOpenClawCommands } from "./commands"
import { syncSkills } from "./skills"
export async function syncToOpenClaw(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
warnUnsupportedOpenClawCommands(config)
if (Object.keys(config.mcpServers).length > 0) {
console.warn(
"Warning: OpenClaw MCP sync is skipped because the current official OpenClaw docs do not clearly document an MCP server config contract.",
)
}
}

View File

@@ -1,47 +1,27 @@
import fs from "fs/promises"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import type { OpenCodeMcpServer } from "../types/opencode"
import { forceSymlink, isValidSkillName } from "../utils/symlink"
import { syncOpenCodeCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
export async function syncToOpenCode(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
// Ensure output directories exist
const skillsDir = path.join(outputRoot, "skills")
await fs.mkdir(skillsDir, { recursive: true })
// Symlink skills (with validation)
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)
}
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncOpenCodeCommands(config, outputRoot)
// Merge MCP servers into opencode.json
if (Object.keys(config.mcpServers).length > 0) {
const configPath = path.join(outputRoot, "opencode.json")
const existing = await readJsonSafe(configPath)
const mcpConfig = convertMcpForOpenCode(config.mcpServers)
existing.mcp = { ...(existing.mcp ?? {}), ...mcpConfig }
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), { mode: 0o600 })
}
}
async function readJsonSafe(filePath: string): Promise<Record<string, unknown>> {
try {
const content = await fs.readFile(filePath, "utf-8")
return JSON.parse(content) as Record<string, unknown>
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {}
}
throw err
await mergeJsonConfigAtKey({
configPath,
key: "mcp",
incoming: mcpConfig,
})
}
}

View File

@@ -1,8 +1,10 @@
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"
import { ensureDir } from "../utils/files"
import { syncPiCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
type McporterServer = {
baseUrl?: string
@@ -20,45 +22,19 @@ export async function syncToPi(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
const skillsDir = path.join(outputRoot, "skills")
const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
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)
}
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncPiCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
await fs.mkdir(path.dirname(mcporterPath), { recursive: true })
const existing = await readJsonSafe(mcporterPath)
await ensureDir(path.dirname(mcporterPath))
const converted = convertMcpToMcporter(config.mcpServers)
const merged: McporterConfig = {
mcpServers: {
...(existing.mcpServers ?? {}),
...converted.mcpServers,
},
}
await fs.writeFile(mcporterPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
}
}
async function readJsonSafe(filePath: string): Promise<Partial<McporterConfig>> {
try {
const content = await fs.readFile(filePath, "utf-8")
return JSON.parse(content) as Partial<McporterConfig>
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {}
}
throw err
await mergeJsonConfigAtKey({
configPath: mcporterPath,
key: "mcpServers",
incoming: converted.mcpServers,
})
}
}

66
src/sync/qwen.ts Normal file
View File

@@ -0,0 +1,66 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import type { QwenMcpServer } from "../types/qwen"
import { syncQwenCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { hasExplicitRemoteTransport, hasExplicitSseTransport } from "./mcp-transports"
import { syncSkills } from "./skills"
export async function syncToQwen(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncQwenCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
await mergeJsonConfigAtKey({
configPath: path.join(outputRoot, "settings.json"),
key: "mcpServers",
incoming: convertMcpForQwen(config.mcpServers),
})
}
}
function convertMcpForQwen(
servers: Record<string, ClaudeMcpServer>,
): Record<string, QwenMcpServer> {
const result: Record<string, QwenMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
command: server.command,
args: server.args,
env: server.env,
}
continue
}
if (!server.url) {
continue
}
if (hasExplicitSseTransport(server)) {
result[name] = {
url: server.url,
headers: server.headers,
}
continue
}
if (!hasExplicitRemoteTransport(server)) {
console.warn(
`Warning: Qwen MCP server "${name}" has an ambiguous remote transport; defaulting to Streamable HTTP.`,
)
}
result[name] = {
httpUrl: server.url,
headers: server.headers,
}
}
return result
}

141
src/sync/registry.ts Normal file
View File

@@ -0,0 +1,141 @@
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import { syncToCodex } from "./codex"
import { syncToCopilot } from "./copilot"
import { syncToDroid } from "./droid"
import { syncToGemini } from "./gemini"
import { syncToKiro } from "./kiro"
import { syncToOpenClaw } from "./openclaw"
import { syncToOpenCode } from "./opencode"
import { syncToPi } from "./pi"
import { syncToQwen } from "./qwen"
import { syncToWindsurf } from "./windsurf"
function getCopilotHomeRoot(home: string): string {
return path.join(home, ".copilot")
}
function getGeminiHomeRoot(home: string): string {
return path.join(home, ".gemini")
}
export type SyncTargetName =
| "opencode"
| "codex"
| "pi"
| "droid"
| "copilot"
| "gemini"
| "windsurf"
| "kiro"
| "qwen"
| "openclaw"
export type SyncTargetDefinition = {
name: SyncTargetName
detectPaths: (home: string, cwd: string) => string[]
resolveOutputRoot: (home: string, cwd: string) => string
sync: (config: ClaudeHomeConfig, outputRoot: string) => Promise<void>
}
export const syncTargets: SyncTargetDefinition[] = [
{
name: "opencode",
detectPaths: (home, cwd) => [
path.join(home, ".config", "opencode"),
path.join(cwd, ".opencode"),
],
resolveOutputRoot: (home) => path.join(home, ".config", "opencode"),
sync: syncToOpenCode,
},
{
name: "codex",
detectPaths: (home) => [path.join(home, ".codex")],
resolveOutputRoot: (home) => path.join(home, ".codex"),
sync: syncToCodex,
},
{
name: "pi",
detectPaths: (home) => [path.join(home, ".pi")],
resolveOutputRoot: (home) => path.join(home, ".pi", "agent"),
sync: syncToPi,
},
{
name: "droid",
detectPaths: (home) => [path.join(home, ".factory")],
resolveOutputRoot: (home) => path.join(home, ".factory"),
sync: syncToDroid,
},
{
name: "copilot",
detectPaths: (home, cwd) => [
getCopilotHomeRoot(home),
path.join(cwd, ".github", "skills"),
path.join(cwd, ".github", "agents"),
path.join(cwd, ".github", "copilot-instructions.md"),
],
resolveOutputRoot: (home) => getCopilotHomeRoot(home),
sync: syncToCopilot,
},
{
name: "gemini",
detectPaths: (home, cwd) => [
path.join(cwd, ".gemini"),
getGeminiHomeRoot(home),
],
resolveOutputRoot: (home) => getGeminiHomeRoot(home),
sync: syncToGemini,
},
{
name: "windsurf",
detectPaths: (home, cwd) => [
path.join(home, ".codeium", "windsurf"),
path.join(cwd, ".windsurf"),
],
resolveOutputRoot: (home) => path.join(home, ".codeium", "windsurf"),
sync: syncToWindsurf,
},
{
name: "kiro",
detectPaths: (home, cwd) => [
path.join(home, ".kiro"),
path.join(cwd, ".kiro"),
],
resolveOutputRoot: (home) => path.join(home, ".kiro"),
sync: syncToKiro,
},
{
name: "qwen",
detectPaths: (home, cwd) => [
path.join(home, ".qwen"),
path.join(cwd, ".qwen"),
],
resolveOutputRoot: (home) => path.join(home, ".qwen"),
sync: syncToQwen,
},
{
name: "openclaw",
detectPaths: (home) => [path.join(home, ".openclaw")],
resolveOutputRoot: (home) => path.join(home, ".openclaw"),
sync: syncToOpenClaw,
},
]
export const syncTargetNames = syncTargets.map((target) => target.name)
export function isSyncTargetName(value: string): value is SyncTargetName {
return syncTargetNames.includes(value as SyncTargetName)
}
export function getSyncTarget(name: SyncTargetName): SyncTargetDefinition {
const target = syncTargets.find((entry) => entry.name === name)
if (!target) {
throw new Error(`Unknown sync target: ${name}`)
}
return target
}
export function getDefaultSyncRegistryContext(): { home: string; cwd: string } {
return { home: os.homedir(), cwd: process.cwd() }
}

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

@@ -0,0 +1,21 @@
import path from "path"
import type { ClaudeSkill } from "../types/claude"
import { ensureDir } from "../utils/files"
import { forceSymlink, isValidSkillName } from "../utils/symlink"
export async function syncSkills(
skills: ClaudeSkill[],
skillsDir: string,
): Promise<void> {
await ensureDir(skillsDir)
for (const skill of 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)
}
}

59
src/sync/windsurf.ts Normal file
View File

@@ -0,0 +1,59 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import type { WindsurfMcpServerEntry } from "../types/windsurf"
import { syncWindsurfCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { hasExplicitSseTransport } from "./mcp-transports"
import { syncSkills } from "./skills"
export async function syncToWindsurf(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncWindsurfCommands(config, outputRoot, "global")
if (Object.keys(config.mcpServers).length > 0) {
await mergeJsonConfigAtKey({
configPath: path.join(outputRoot, "mcp_config.json"),
key: "mcpServers",
incoming: convertMcpForWindsurf(config.mcpServers),
})
}
}
function convertMcpForWindsurf(
servers: Record<string, ClaudeMcpServer>,
): Record<string, WindsurfMcpServerEntry> {
const result: Record<string, WindsurfMcpServerEntry> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
command: server.command,
args: server.args,
env: server.env,
}
continue
}
if (!server.url) {
continue
}
const entry: WindsurfMcpServerEntry = {
headers: server.headers,
}
if (hasExplicitSseTransport(server)) {
entry.url = server.url
} else {
entry.serverUrl = server.url
}
result[name] = entry
}
return result
}