From 718415a4675f5bbc10b5b3420d975c9d7b73cabc Mon Sep 17 00:00:00 2001 From: terry-li-hm <12233004+terry-li-hm@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:36:16 +0800 Subject: [PATCH 1/3] feat: Add sync command for Claude Code personal config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 20 ++++++++++- src/commands/sync.ts | 63 +++++++++++++++++++++++++++++++++ src/index.ts | 4 ++- src/parsers/claude-home.ts | 66 ++++++++++++++++++++++++++++++++++ src/sync/codex.ts | 70 ++++++++++++++++++++++++++++++++++++ src/sync/opencode.ts | 72 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 src/commands/sync.ts create mode 100644 src/parsers/claude-home.ts create mode 100644 src/sync/codex.ts create mode 100644 src/sync/opencode.ts diff --git a/README.md b/README.md index fe0df98..f7b64fd 100644 --- a/README.md +++ b/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 ``` diff --git a/src/commands/sync.ts b/src/commands/sync.ts new file mode 100644 index 0000000..9787380 --- /dev/null +++ b/src/commands/sync.ts @@ -0,0 +1,63 @@ +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" +} + +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)) { + console.error(`Unknown target: ${args.target}. Use 'opencode' or 'codex'.`) + process.exit(1) + } + + const claudeHome = expandHome(args.claudeHome ?? path.join(os.homedir(), ".claude")) + const config = await loadClaudeHome(claudeHome) + + 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 +} diff --git a/src/index.ts b/src/index.ts index 49c5774..c680ea6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,17 +3,19 @@ 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: { name: "compound-plugin", - version: "0.1.0", + version: "0.1.1", description: "Convert Claude Code plugins into other agent formats", }, subCommands: { convert: () => convert, install: () => install, list: () => listCommand, + sync: () => sync, }, }) diff --git a/src/parsers/claude-home.ts b/src/parsers/claude-home.ts new file mode 100644 index 0000000..f454cdd --- /dev/null +++ b/src/parsers/claude-home.ts @@ -0,0 +1,66 @@ +import path from "path" +import os from "os" +import fs from "fs/promises" +import type { ClaudeSkill, ClaudeMcpServer } from "../types/claude" + +export interface ClaudeHomeConfig { + root: string + skills: ClaudeSkill[] + mcpServers: Record +} + +export async function loadClaudeHome(claudeHome?: string): Promise { + 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 { root: home, skills, mcpServers } +} + +async function loadPersonalSkills(skillsDir: string): Promise { + 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> { + try { + const content = await fs.readFile(settingsPath, "utf-8") + const settings = JSON.parse(content) as { mcpServers?: Record } + return settings.mcpServers ?? {} + } catch { + return {} // File doesn't exist or invalid JSON + } +} diff --git a/src/sync/codex.ts b/src/sync/codex.ts new file mode 100644 index 0000000..2c01a33 --- /dev/null +++ b/src/sync/codex.ts @@ -0,0 +1,70 @@ +import fs from "fs/promises" +import path from "path" +import type { ClaudeHomeConfig } from "../parsers/claude-home" +import type { ClaudeMcpServer } from "../types/claude" + +export async function syncToCodex( + config: ClaudeHomeConfig, + outputRoot: string, +): Promise { + // Ensure output directories exist + const skillsDir = path.join(outputRoot, "skills") + await fs.mkdir(skillsDir, { recursive: true }) + + // Symlink skills + for (const skill of config.skills) { + const target = path.join(skillsDir, skill.name) + await forceSymlink(skill.sourceDir, target) + } + + // Append 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) + + // Check if MCP servers already exist in config + try { + const existing = await fs.readFile(configPath, "utf-8") + if (!existing.includes("[mcp_servers.")) { + await fs.appendFile(configPath, "\n# MCP servers synced from Claude Code\n" + mcpToml) + } + } catch { + // File doesn't exist, create it + await fs.writeFile(configPath, "# Codex config - synced from Claude Code\n\n" + mcpToml) + } + } +} + +async function forceSymlink(source: string, target: string): Promise { + await fs.rm(target, { recursive: true, force: true }) + await fs.symlink(source, target) +} + +function convertMcpForCodex(servers: Record): 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 = "${server.command}"`) + + if (server.args && server.args.length > 0) { + const argsStr = server.args.map((arg) => `"${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} = "${value}"`) + } + } + + sections.push(lines.join("\n")) + } + + return sections.join("\n\n") + "\n" +} diff --git a/src/sync/opencode.ts b/src/sync/opencode.ts new file mode 100644 index 0000000..d4bada6 --- /dev/null +++ b/src/sync/opencode.ts @@ -0,0 +1,72 @@ +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" + +export async function syncToOpenCode( + config: ClaudeHomeConfig, + outputRoot: string, +): Promise { + // Ensure output directories exist + const skillsDir = path.join(outputRoot, "skills") + await fs.mkdir(skillsDir, { recursive: true }) + + // Symlink skills + for (const skill of config.skills) { + 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)) + } +} + +async function forceSymlink(source: string, target: string): Promise { + await fs.rm(target, { recursive: true, force: true }) + await fs.symlink(source, target) +} + +async function readJsonSafe(filePath: string): Promise> { + try { + const content = await fs.readFile(filePath, "utf-8") + return JSON.parse(content) as Record + } catch { + return {} + } +} + +function convertMcpForOpenCode( + servers: Record, +): Record { + const result: Record = {} + + 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 +} From 6d4e2daade9a1813fd9b5248a0ec82efa51a892d Mon Sep 17 00:00:00 2001 From: terry-li-hm <12233004+terry-li-hm@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:51:13 +0800 Subject: [PATCH 2/3] fix: address security and quality review issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/commands/sync.ts | 25 ++++++++++++++++-- src/parsers/claude-home.ts | 3 +-- src/sync/codex.ts | 52 +++++++++++++++++++++++++++----------- src/sync/opencode.ts | 21 ++++++++------- src/utils/symlink.ts | 43 +++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 28 deletions(-) create mode 100644 src/utils/symlink.ts diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 9787380..5678b2e 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -9,6 +9,20 @@ 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): boolean { + const sensitivePatterns = /key|token|secret|password|credential|api_key/i + for (const server of Object.values(mcpServers)) { + const env = (server as { env?: Record }).env + if (env) { + for (const key of Object.keys(env)) { + if (sensitivePatterns.test(key)) return true + } + } + } + return false +} + export default defineCommand({ meta: { name: "sync", @@ -28,13 +42,20 @@ export default defineCommand({ }, async run({ args }) { if (!isValidTarget(args.target)) { - console.error(`Unknown target: ${args.target}. Use 'opencode' or 'codex'.`) - process.exit(1) + 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...`, ) diff --git a/src/parsers/claude-home.ts b/src/parsers/claude-home.ts index f454cdd..c8f1818 100644 --- a/src/parsers/claude-home.ts +++ b/src/parsers/claude-home.ts @@ -4,7 +4,6 @@ import fs from "fs/promises" import type { ClaudeSkill, ClaudeMcpServer } from "../types/claude" export interface ClaudeHomeConfig { - root: string skills: ClaudeSkill[] mcpServers: Record } @@ -17,7 +16,7 @@ export async function loadClaudeHome(claudeHome?: string): Promise { diff --git a/src/sync/codex.ts b/src/sync/codex.ts index 2c01a33..c0414bd 100644 --- a/src/sync/codex.ts +++ b/src/sync/codex.ts @@ -2,6 +2,7 @@ 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, @@ -11,33 +12,54 @@ export async function syncToCodex( const skillsDir = path.join(outputRoot, "skills") await fs.mkdir(skillsDir, { recursive: true }) - // Symlink skills + // 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) } - // Append MCP servers to config.toml (TOML format) + // 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) - // Check if MCP servers already exist in config + // Read existing config and merge idempotently + let existingContent = "" try { - const existing = await fs.readFile(configPath, "utf-8") - if (!existing.includes("[mcp_servers.")) { - await fs.appendFile(configPath, "\n# MCP servers synced from Claude Code\n" + mcpToml) + existingContent = await fs.readFile(configPath, "utf-8") + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err } - } catch { - // File doesn't exist, create it - await fs.writeFile(configPath, "# Codex config - synced from Claude Code\n\n" + mcpToml) } + + // 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 }) } } -async function forceSymlink(source: string, target: string): Promise { - await fs.rm(target, { recursive: true, force: true }) - await fs.symlink(source, target) +/** 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 { @@ -48,10 +70,10 @@ function convertMcpForCodex(servers: Record): string { const lines: string[] = [] lines.push(`[mcp_servers.${name}]`) - lines.push(`command = "${server.command}"`) + lines.push(`command = "${escapeTomlString(server.command)}"`) if (server.args && server.args.length > 0) { - const argsStr = server.args.map((arg) => `"${arg}"`).join(", ") + const argsStr = server.args.map((arg) => `"${escapeTomlString(arg)}"`).join(", ") lines.push(`args = [${argsStr}]`) } @@ -59,7 +81,7 @@ function convertMcpForCodex(servers: Record): string { lines.push("") lines.push(`[mcp_servers.${name}.env]`) for (const [key, value] of Object.entries(server.env)) { - lines.push(`${key} = "${value}"`) + lines.push(`${key} = "${escapeTomlString(value)}"`) } } diff --git a/src/sync/opencode.ts b/src/sync/opencode.ts index d4bada6..e61e638 100644 --- a/src/sync/opencode.ts +++ b/src/sync/opencode.ts @@ -3,6 +3,7 @@ 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, @@ -12,8 +13,12 @@ export async function syncToOpenCode( const skillsDir = path.join(outputRoot, "skills") await fs.mkdir(skillsDir, { recursive: true }) - // Symlink skills + // 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) } @@ -24,21 +29,19 @@ export async function syncToOpenCode( const existing = await readJsonSafe(configPath) const mcpConfig = convertMcpForOpenCode(config.mcpServers) existing.mcp = { ...(existing.mcp ?? {}), ...mcpConfig } - await fs.writeFile(configPath, JSON.stringify(existing, null, 2)) + await fs.writeFile(configPath, JSON.stringify(existing, null, 2), { mode: 0o600 }) } } -async function forceSymlink(source: string, target: string): Promise { - await fs.rm(target, { recursive: true, force: true }) - await fs.symlink(source, target) -} - async function readJsonSafe(filePath: string): Promise> { try { const content = await fs.readFile(filePath, "utf-8") return JSON.parse(content) as Record - } catch { - return {} + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return {} + } + throw err } } diff --git a/src/utils/symlink.ts b/src/utils/symlink.ts new file mode 100644 index 0000000..8855adb --- /dev/null +++ b/src/utils/symlink.ts @@ -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 { + 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 +} From 34f3f9199f5083a4964091b03a18e9d7eff7732c Mon Sep 17 00:00:00 2001 From: terry-li-hm <12233004+terry-li-hm@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:56:01 +0800 Subject: [PATCH 3/3] fix: revert version bump (leave to maintainers) --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index c680ea6..bfd0b72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import sync from "./commands/sync" const main = defineCommand({ meta: { name: "compound-plugin", - version: "0.1.1", + version: "0.1.0", description: "Convert Claude Code plugins into other agent formats", }, subCommands: {