feat(sync): add Claude home sync parity across providers
This commit is contained in:
@@ -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
198
src/sync/commands.ts
Normal 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.",
|
||||
)
|
||||
}
|
||||
@@ -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: ["*"],
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
47
src/sync/json-config.ts
Normal 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
49
src/sync/kiro.ts
Normal 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
|
||||
}
|
||||
19
src/sync/mcp-transports.ts
Normal file
19
src/sync/mcp-transports.ts
Normal 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
18
src/sync/openclaw.ts
Normal 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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
66
src/sync/qwen.ts
Normal 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
141
src/sync/registry.ts
Normal 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
21
src/sync/skills.ts
Normal 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
59
src/sync/windsurf.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user