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:
20
README.md
20
README.md
@@ -30,10 +30,28 @@ Local dev:
|
||||
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.
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@every-env/compound-plugin",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"bin": {
|
||||
|
||||
84
src/commands/sync.ts
Normal file
84
src/commands/sync.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
65
src/parsers/claude-home.ts
Normal file
65
src/parsers/claude-home.ts
Normal 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
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
|
||||
}
|
||||
43
src/utils/symlink.ts
Normal file
43
src/utils/symlink.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user