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