Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
84 changes: 84 additions & 0 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): boolean {
const sensitivePatterns = /key|token|secret|password|credential|api_key/i
for (const server of Object.values(mcpServers)) {
const env = (server as { env?: Record<string, string> }).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
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -14,6 +15,7 @@ const main = defineCommand({
convert: () => convert,
install: () => install,
list: () => listCommand,
sync: () => sync,
},
})

Expand Down
65 changes: 65 additions & 0 deletions src/parsers/claude-home.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
92 changes: 92 additions & 0 deletions src/sync/codex.ts
Original file line number Diff line number Diff line change
@@ -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<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)
}

// 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, ClaudeMcpServer>): 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"
}
75 changes: 75 additions & 0 deletions src/sync/opencode.ts
Original file line number Diff line number Diff line change
@@ -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<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
}
Loading