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:
Terry Li
2026-02-09 07:00:48 +08:00
committed by GitHub
parent f7cab16b06
commit 1bdd1030f5
8 changed files with 381 additions and 2 deletions

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

@@ -0,0 +1,84 @@
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"
function isValidTarget(value: string): value is "opencode" | "codex" {
return value === "opencode" || value === "codex"
}
/** Check if any MCP servers have env vars that might contain secrets */
function hasPotentialSecrets(mcpServers: Record<string, unknown>): boolean {
const sensitivePatterns = /key|token|secret|password|credential|api_key/i
for (const server of Object.values(mcpServers)) {
const env = (server as { env?: Record<string, string> }).env
if (env) {
for (const key of Object.keys(env)) {
if (sensitivePatterns.test(key)) return true
}
}
}
return false
}
export default defineCommand({
meta: {
name: "sync",
description: "Sync Claude Code config (~/.claude/) to OpenCode or Codex",
},
args: {
target: {
type: "string",
required: true,
description: "Target: opencode | codex",
},
claudeHome: {
type: "string",
alias: "claude-home",
description: "Path to Claude home (default: ~/.claude)",
},
},
async run({ args }) {
if (!isValidTarget(args.target)) {
throw new Error(`Unknown target: ${args.target}. Use 'opencode' or 'codex'.`)
}
const claudeHome = expandHome(args.claudeHome ?? path.join(os.homedir(), ".claude"))
const config = await loadClaudeHome(claudeHome)
// Warn about potential secrets in MCP env vars
if (hasPotentialSecrets(config.mcpServers)) {
console.warn(
"⚠️ Warning: MCP servers contain env vars that may include secrets (API keys, tokens).\n" +
" These will be copied to the target config. Review before sharing the config file.",
)
}
console.log(
`Syncing ${config.skills.length} skills, ${Object.keys(config.mcpServers).length} MCP servers...`,
)
const outputRoot =
args.target === "opencode"
? path.join(os.homedir(), ".config", "opencode")
: path.join(os.homedir(), ".codex")
if (args.target === "opencode") {
await syncToOpenCode(config, outputRoot)
} else {
await syncToCodex(config, outputRoot)
}
console.log(`✓ Synced to ${args.target}: ${outputRoot}`)
},
})
function expandHome(value: string): string {
if (value === "~") return os.homedir()
if (value.startsWith(`~${path.sep}`)) {
return path.join(os.homedir(), value.slice(2))
}
return value
}

View File

@@ -3,6 +3,7 @@ import { defineCommand, runMain } from "citty"
import convert from "./commands/convert"
import install from "./commands/install"
import listCommand from "./commands/list"
import sync from "./commands/sync"
const main = defineCommand({
meta: {
@@ -14,6 +15,7 @@ const main = defineCommand({
convert: () => convert,
install: () => install,
list: () => listCommand,
sync: () => sync,
},
})

View File

@@ -0,0 +1,65 @@
import path from "path"
import os from "os"
import fs from "fs/promises"
import type { ClaudeSkill, ClaudeMcpServer } from "../types/claude"
export interface ClaudeHomeConfig {
skills: ClaudeSkill[]
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([
loadPersonalSkills(path.join(home, "skills")),
loadSettingsMcp(path.join(home, "settings.json")),
])
return { skills, mcpServers }
}
async function loadPersonalSkills(skillsDir: string): Promise<ClaudeSkill[]> {
try {
const entries = await fs.readdir(skillsDir, { withFileTypes: true })
const skills: ClaudeSkill[] = []
for (const entry of entries) {
// Check if directory or symlink (symlinks are common for skills)
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const entryPath = path.join(skillsDir, entry.name)
const skillPath = path.join(entryPath, "SKILL.md")
try {
await fs.access(skillPath)
// Resolve symlink to get the actual source directory
const sourceDir = entry.isSymbolicLink()
? await fs.realpath(entryPath)
: entryPath
skills.push({
name: entry.name,
sourceDir,
skillPath,
})
} catch {
// No SKILL.md, skip
}
}
return skills
} catch {
return [] // Directory doesn't exist
}
}
async function loadSettingsMcp(
settingsPath: string,
): Promise<Record<string, ClaudeMcpServer>> {
try {
const content = await fs.readFile(settingsPath, "utf-8")
const settings = JSON.parse(content) as { mcpServers?: Record<string, ClaudeMcpServer> }
return settings.mcpServers ?? {}
} catch {
return {} // File doesn't exist or invalid JSON
}
}

92
src/sync/codex.ts Normal file
View 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
View 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
}

43
src/utils/symlink.ts Normal file
View File

@@ -0,0 +1,43 @@
import fs from "fs/promises"
/**
* Create a symlink, safely replacing any existing symlink at target.
* Only removes existing symlinks - refuses to delete real directories.
*/
export async function forceSymlink(source: string, target: string): Promise<void> {
try {
const stat = await fs.lstat(target)
if (stat.isSymbolicLink()) {
// 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.`
)
} else {
// Regular file - remove it
await fs.unlink(target)
}
} catch (err) {
// ENOENT means target doesn't exist, which is fine
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err
}
}
await fs.symlink(source, target)
}
/**
* Validate a skill name to prevent path traversal attacks.
* Returns true if safe, false if potentially malicious.
*/
export function isValidSkillName(name: string): boolean {
if (!name || name.length === 0) return false
if (name.includes("/") || name.includes("\\")) return false
if (name.includes("..")) return false
if (name.includes("\0")) return false
if (name === "." || name === "..") return false
return true
}