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..5678b2e --- /dev/null +++ b/src/commands/sync.ts @@ -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): 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", + 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 +} diff --git a/src/index.ts b/src/index.ts index 49c5774..bfd0b72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ 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: { @@ -14,6 +15,7 @@ const main = defineCommand({ 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..c8f1818 --- /dev/null +++ b/src/parsers/claude-home.ts @@ -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 +} + +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 { 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..c0414bd --- /dev/null +++ b/src/sync/codex.ts @@ -0,0 +1,92 @@ +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, + outputRoot: string, +): Promise { + // 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) + } + + // 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) + + // Read existing config and merge idempotently + let existingContent = "" + try { + existingContent = await fs.readFile(configPath, "utf-8") + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err + } + } + + // 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 }) + } +} + +/** 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 { + 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 = "${escapeTomlString(server.command)}"`) + + if (server.args && server.args.length > 0) { + const argsStr = server.args.map((arg) => `"${escapeTomlString(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} = "${escapeTomlString(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..e61e638 --- /dev/null +++ b/src/sync/opencode.ts @@ -0,0 +1,75 @@ +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 { + // 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> { + try { + const content = await fs.readFile(filePath, "utf-8") + return JSON.parse(content) as Record + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return {} + } + throw err + } +} + +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 +} 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 +}