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

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
}