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

View File

@@ -30,10 +30,28 @@ Local dev:
bun run src/index.ts install ./plugins/compound-engineering --to opencode bun run src/index.ts install ./plugins/compound-engineering --to opencode
``` ```
OpenCode output is written to `~/.opencode` by default, with `opencode.json` at the root and `agents/`, `skills/`, and `plugins/` alongside it. OpenCode output is written to `~/.config/opencode` by default, with `opencode.json` at the root and `agents/`, `skills/`, and `plugins/` alongside it.
Both provider targets are experimental and may change as the formats evolve. Both provider targets are experimental and may change as the formats evolve.
Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit). Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit).
## Sync Personal Config
Sync your personal Claude Code config (`~/.claude/`) to OpenCode or Codex:
```bash
# Sync skills and MCP servers to OpenCode
bunx @every-env/compound-plugin sync --target opencode
# Sync to Codex
bunx @every-env/compound-plugin sync --target codex
```
This syncs:
- Personal skills from `~/.claude/skills/` (as symlinks)
- MCP servers from `~/.claude/settings.json`
Skills are symlinked (not copied) so changes in Claude Code are reflected immediately.
## Workflow ## Workflow
``` ```

View File

@@ -1,6 +1,6 @@
{ {
"name": "@every-env/compound-plugin", "name": "@every-env/compound-plugin",
"version": "0.1.1", "version": "0.2.0",
"type": "module", "type": "module",
"private": false, "private": false,
"bin": { "bin": {

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 convert from "./commands/convert"
import install from "./commands/install" import install from "./commands/install"
import listCommand from "./commands/list" import listCommand from "./commands/list"
import sync from "./commands/sync"
const main = defineCommand({ const main = defineCommand({
meta: { meta: {
@@ -14,6 +15,7 @@ const main = defineCommand({
convert: () => convert, convert: () => convert,
install: () => install, install: () => install,
list: () => listCommand, 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
}