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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user