feat: Add sync command for Claude Code personal config (#123)
* feat: Add sync command for Claude Code personal config Add `compound-plugin sync` command to sync ~/.claude/ personal config (skills and MCP servers) to OpenCode or Codex. Features: - Parses ~/.claude/skills/ for personal skills (supports symlinks) - Parses ~/.claude/settings.json for MCP servers - Syncs skills as symlinks (single source of truth) - Converts MCP to JSON (OpenCode) or TOML (Codex) - Dedicated sync functions bypass existing converter architecture Usage: compound-plugin sync --target opencode compound-plugin sync --target codex 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address security and quality review issues Security fixes: - Add path traversal validation with isValidSkillName() - Warn when MCP servers contain potential secrets (API keys, tokens) - Set restrictive file permissions (600) on config files - Safe forceSymlink refuses to delete real directories - Proper TOML escaping for quotes/backslashes/control chars Code quality fixes: - Extract shared symlink utils to src/utils/symlink.ts - Replace process.exit(1) with thrown error - Distinguish ENOENT from other errors in catch blocks - Remove unused `root` field from ClaudeHomeConfig - Make Codex sync idempotent (remove+rewrite managed section) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: revert version bump (leave to maintainers) * feat: bump root version to 0.2.0 for sync command --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
92
src/sync/codex.ts
Normal file
92
src/sync/codex.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Read existing config and merge idempotently
|
||||
let existingContent = ""
|
||||
try {
|
||||
existingContent = await fs.readFile(configPath, "utf-8")
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 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 newContent = existingContent
|
||||
? existingContent + "\n\n" + marker + "\n" + mcpToml
|
||||
: "# Codex config - synced from Claude Code\n\n" + mcpToml
|
||||
|
||||
await fs.writeFile(configPath, newContent, { mode: 0o600 })
|
||||
}
|
||||
}
|
||||
|
||||
/** 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"
|
||||
}
|
||||
75
src/sync/opencode.ts
Normal file
75
src/sync/opencode.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
function convertMcpForOpenCode(
|
||||
servers: Record<string, ClaudeMcpServer>,
|
||||
): Record<string, OpenCodeMcpServer> {
|
||||
const result: Record<string, OpenCodeMcpServer> = {}
|
||||
|
||||
for (const [name, server] of Object.entries(servers)) {
|
||||
if (server.command) {
|
||||
result[name] = {
|
||||
type: "local",
|
||||
command: [server.command, ...(server.args ?? [])],
|
||||
environment: server.env,
|
||||
enabled: true,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (server.url) {
|
||||
result[name] = {
|
||||
type: "remote",
|
||||
url: server.url,
|
||||
headers: server.headers,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user