* 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>
76 lines
2.2 KiB
TypeScript
76 lines
2.2 KiB
TypeScript
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
|
|
}
|