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,76 +1,34 @@
import { defineCommand } from "citty"
import os from "os"
import path from "path"
import { loadClaudeHome } from "../parsers/claude-home"
import { syncToOpenCode } from "../sync/opencode"
import { syncToCodex } from "../sync/codex"
import { syncToPi } from "../sync/pi"
import { syncToDroid } from "../sync/droid"
import { syncToCopilot } from "../sync/copilot"
import { syncToGemini } from "../sync/gemini"
import {
getDefaultSyncRegistryContext,
getSyncTarget,
isSyncTargetName,
syncTargetNames,
type SyncTargetName,
} from "../sync/registry"
import { expandHome } from "../utils/resolve-home"
import { hasPotentialSecrets } from "../utils/secrets"
import { detectInstalledTools } from "../utils/detect-tools"
const validTargets = ["opencode", "codex", "pi", "droid", "copilot", "gemini", "all"] as const
type SyncTarget = (typeof validTargets)[number]
const validTargets = [...syncTargetNames, "all"] as const
type SyncTarget = SyncTargetName | "all"
function isValidTarget(value: string): value is SyncTarget {
return (validTargets as readonly string[]).includes(value)
}
function resolveOutputRoot(target: string): string {
switch (target) {
case "opencode":
return path.join(os.homedir(), ".config", "opencode")
case "codex":
return path.join(os.homedir(), ".codex")
case "pi":
return path.join(os.homedir(), ".pi", "agent")
case "droid":
return path.join(os.homedir(), ".factory")
case "copilot":
return path.join(process.cwd(), ".github")
case "gemini":
return path.join(process.cwd(), ".gemini")
default:
throw new Error(`No output root for target: ${target}`)
}
}
async function syncTarget(target: string, config: Awaited<ReturnType<typeof loadClaudeHome>>, outputRoot: string): Promise<void> {
switch (target) {
case "opencode":
await syncToOpenCode(config, outputRoot)
break
case "codex":
await syncToCodex(config, outputRoot)
break
case "pi":
await syncToPi(config, outputRoot)
break
case "droid":
await syncToDroid(config, outputRoot)
break
case "copilot":
await syncToCopilot(config, outputRoot)
break
case "gemini":
await syncToGemini(config, outputRoot)
break
}
return value === "all" || isSyncTargetName(value)
}
export default defineCommand({
meta: {
name: "sync",
description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, Copilot, or Gemini",
description: "Sync Claude Code config (~/.claude/) to supported provider configs and skills",
},
args: {
target: {
type: "string",
default: "all",
description: "Target: opencode | codex | pi | droid | copilot | gemini | all (default: all)",
description: `Target: ${syncTargetNames.join(" | ")} | all (default: all)`,
},
claudeHome: {
type: "string",
@@ -83,7 +41,8 @@ export default defineCommand({
throw new Error(`Unknown target: ${args.target}. Use one of: ${validTargets.join(", ")}`)
}
const claudeHome = expandHome(args.claudeHome ?? path.join(os.homedir(), ".claude"))
const { home, cwd } = getDefaultSyncRegistryContext()
const claudeHome = expandHome(args.claudeHome ?? path.join(home, ".claude"))
const config = await loadClaudeHome(claudeHome)
// Warn about potential secrets in MCP env vars
@@ -109,19 +68,21 @@ export default defineCommand({
}
for (const name of activeTargets) {
const outputRoot = resolveOutputRoot(name)
await syncTarget(name, config, outputRoot)
const target = getSyncTarget(name as SyncTargetName)
const outputRoot = target.resolveOutputRoot(home, cwd)
await target.sync(config, outputRoot)
console.log(`✓ Synced to ${name}: ${outputRoot}`)
}
return
}
console.log(
`Syncing ${config.skills.length} skills, ${Object.keys(config.mcpServers).length} MCP servers...`,
`Syncing ${config.skills.length} skills, ${config.commands?.length ?? 0} commands, ${Object.keys(config.mcpServers).length} MCP servers...`,
)
const outputRoot = resolveOutputRoot(args.target)
await syncTarget(args.target, config, outputRoot)
const target = getSyncTarget(args.target as SyncTargetName)
const outputRoot = target.resolveOutputRoot(home, cwd)
await target.sync(config, outputRoot)
console.log(`✓ Synced to ${args.target}: ${outputRoot}`)
},
})

View File

@@ -1,22 +1,26 @@
import path from "path"
import os from "os"
import fs from "fs/promises"
import type { ClaudeSkill, ClaudeMcpServer } from "../types/claude"
import { parseFrontmatter } from "../utils/frontmatter"
import { walkFiles } from "../utils/files"
import type { ClaudeCommand, ClaudeSkill, ClaudeMcpServer } from "../types/claude"
export interface ClaudeHomeConfig {
skills: ClaudeSkill[]
commands?: ClaudeCommand[]
mcpServers: Record<string, ClaudeMcpServer>
}
export async function loadClaudeHome(claudeHome?: string): Promise<ClaudeHomeConfig> {
const home = claudeHome ?? path.join(os.homedir(), ".claude")
const [skills, mcpServers] = await Promise.all([
const [skills, commands, mcpServers] = await Promise.all([
loadPersonalSkills(path.join(home, "skills")),
loadPersonalCommands(path.join(home, "commands")),
loadSettingsMcp(path.join(home, "settings.json")),
])
return { skills, mcpServers }
return { skills, commands, mcpServers }
}
async function loadPersonalSkills(skillsDir: string): Promise<ClaudeSkill[]> {
@@ -63,3 +67,51 @@ async function loadSettingsMcp(
return {} // File doesn't exist or invalid JSON
}
}
async function loadPersonalCommands(commandsDir: string): Promise<ClaudeCommand[]> {
try {
const files = (await walkFiles(commandsDir))
.filter((file) => file.endsWith(".md"))
.sort()
const commands: ClaudeCommand[] = []
for (const file of files) {
const raw = await fs.readFile(file, "utf8")
const { data, body } = parseFrontmatter(raw)
commands.push({
name: typeof data.name === "string" ? data.name : deriveCommandName(commandsDir, file),
description: data.description as string | undefined,
argumentHint: data["argument-hint"] as string | undefined,
model: data.model as string | undefined,
allowedTools: parseAllowedTools(data["allowed-tools"]),
disableModelInvocation: data["disable-model-invocation"] === true ? true : undefined,
body: body.trim(),
sourcePath: file,
})
}
return commands
} catch {
return []
}
}
function deriveCommandName(commandsDir: string, filePath: string): string {
const relative = path.relative(commandsDir, filePath)
const withoutExt = relative.replace(/\.md$/i, "")
return withoutExt.split(path.sep).join(":")
}
function parseAllowedTools(value: unknown): string[] | undefined {
if (!value) return undefined
if (Array.isArray(value)) {
return value.map((item) => String(item))
}
if (typeof value === "string") {
return value
.split(/,/)
.map((item) => item.trim())
.filter(Boolean)
}
return undefined
}

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
}

View File

@@ -30,9 +30,11 @@ export type KiroSteeringFile = {
}
export type KiroMcpServer = {
command: string
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
}
export type KiroBundle = {

View File

@@ -14,6 +14,9 @@ export type QwenMcpServer = {
args?: string[]
env?: Record<string, string>
cwd?: string
httpUrl?: string
url?: string
headers?: Record<string, string>
}
export type QwenSetting = {

View File

@@ -19,6 +19,7 @@ export type WindsurfMcpServerEntry = {
args?: string[]
env?: Record<string, string>
serverUrl?: string
url?: string
headers?: Record<string, string>
}

View File

@@ -1,6 +1,6 @@
import os from "os"
import path from "path"
import { pathExists } from "./files"
import { syncTargets } from "../sync/registry"
export type DetectedTool = {
name: string
@@ -12,27 +12,18 @@ export async function detectInstalledTools(
home: string = os.homedir(),
cwd: string = process.cwd(),
): Promise<DetectedTool[]> {
const checks: Array<{ name: string; paths: string[] }> = [
{ name: "opencode", paths: [path.join(home, ".config", "opencode"), path.join(cwd, ".opencode")] },
{ name: "codex", paths: [path.join(home, ".codex")] },
{ name: "droid", paths: [path.join(home, ".factory")] },
{ name: "cursor", paths: [path.join(cwd, ".cursor"), path.join(home, ".cursor")] },
{ name: "pi", paths: [path.join(home, ".pi")] },
{ name: "gemini", paths: [path.join(cwd, ".gemini"), path.join(home, ".gemini")] },
]
const results: DetectedTool[] = []
for (const check of checks) {
for (const target of syncTargets) {
let detected = false
let reason = "not found"
for (const p of check.paths) {
for (const p of target.detectPaths(home, cwd)) {
if (await pathExists(p)) {
detected = true
reason = `found ${p}`
break
}
}
results.push({ name: check.name, detected, reason })
results.push({ name: target.name, detected, reason })
}
return results
}

View File

@@ -41,6 +41,12 @@ export async function writeText(filePath: string, content: string): Promise<void
await fs.writeFile(filePath, content, "utf8")
}
export async function writeTextSecure(filePath: string, content: string): Promise<void> {
await ensureDir(path.dirname(filePath))
await fs.writeFile(filePath, content, { encoding: "utf8", mode: 0o600 })
await fs.chmod(filePath, 0o600)
}
export async function writeJson(filePath: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2)
await writeText(filePath, content + "\n")
@@ -51,6 +57,7 @@ export async function writeJsonSecure(filePath: string, data: unknown): Promise<
const content = JSON.stringify(data, null, 2)
await ensureDir(path.dirname(filePath))
await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 })
await fs.chmod(filePath, 0o600)
}
export async function walkFiles(root: string): Promise<string[]> {

View File

@@ -2,7 +2,7 @@ import fs from "fs/promises"
/**
* Create a symlink, safely replacing any existing symlink at target.
* Only removes existing symlinks - refuses to delete real directories.
* Only removes existing symlinks - skips real directories with a warning.
*/
export async function forceSymlink(source: string, target: string): Promise<void> {
try {
@@ -11,11 +11,9 @@ export async function forceSymlink(source: string, target: string): Promise<void
// Safe to remove existing symlink
await fs.unlink(target)
} else if (stat.isDirectory()) {
// Refuse to delete real directories
throw new Error(
`Cannot create symlink at ${target}: a real directory exists there. ` +
`Remove it manually if you want to replace it with a symlink.`
)
// Skip real directories rather than deleting them
console.warn(`Skipping ${target}: a real directory exists there (remove it manually to replace with a symlink).`)
return
} else {
// Regular file - remove it
await fs.unlink(target)