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/plugins/compound-engineering/.claude-plugin/plugin.json b/plugins/compound-engineering/.claude-plugin/plugin.json
index 97ea742..bae8f70 100644
--- a/plugins/compound-engineering/.claude-plugin/plugin.json
+++ b/plugins/compound-engineering/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "compound-engineering",
- "version": "2.28.0",
+ "version": "2.28.2",
"description": "AI-powered development tools. 28 agents, 24 commands, 15 skills, 1 MCP server for code review, research, design, and workflow automation.",
"author": {
"name": "Kieran Klaassen",
diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md
index dd1c7f9..516339d 100644
--- a/plugins/compound-engineering/CHANGELOG.md
+++ b/plugins/compound-engineering/CHANGELOG.md
@@ -5,6 +5,32 @@ All notable changes to the compound-engineering plugin will be documented in thi
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.28.2] - 2026-01-30
+
+### Fixed
+
+- **`/lfg` command** - Fixed broken command references and Ralph-Wiggum namespace typos for OpenCode users (#126)
+
+---
+
+## [2.28.1] - 2026-01-30
+
+### Fixed
+
+- **CORA Cleanup** - Removed remaining hardcoded CORA references in schemas and commands (#127)
+- **Agent Rename** - `cora-test-reviewer` renamed to `test-quality-reviewer` for consistency
+
+---
+
+## [2.28.0] - 2026-01-21
+
+### Fixed
+
+- **CORA Cleanup** - Removed remaining hardcoded CORA references in schemas and commands (#127)
+- **Agent Rename** - `cora-test-reviewer` renamed to `test-quality-reviewer` for consistency
+
+---
+
## [2.28.0] - 2026-01-21
### Added
diff --git a/plugins/compound-engineering/commands/lfg.md b/plugins/compound-engineering/commands/lfg.md
index d28fe93..5400f50 100644
--- a/plugins/compound-engineering/commands/lfg.md
+++ b/plugins/compound-engineering/commands/lfg.md
@@ -6,14 +6,13 @@ argument-hint: "[feature description]"
Run these slash commands in order. Do not do anything else.
-1. `/ralph-wiggum:ralph-loop "finish all slash commands" --completion-promise "DONE"`
-2. `/workflows:plan $ARGUMENTS`
-3. `/compound-engineering:deepen-plan`
-4. `/workflows:work`
-5. `/workflows:review`
-6. `/compound-engineering:resolve_todo_parallel`
-7. `/compound-engineering:test-browser`
-8. `/compound-engineering:feature-video`
-9. Output `DONE` when video is in PR
+1. `/workflows:plan $ARGUMENTS`
+2. `deepen-plan`
+3. `/workflows:work`
+4. `/workflows:review`
+5. `resolve_todo_parallel`
+6. `test-browser`
+7. `feature-video`
+8. Output `DONE` when video is in PR
Start with step 1 now.
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/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts
index ad8cc00..4e5c4a5 100644
--- a/src/converters/claude-to-opencode.ts
+++ b/src/converters/claude-to-opencode.ts
@@ -116,7 +116,7 @@ function convertCommands(commands: ClaudeCommand[]): Record ralph-loop.
+ */
+function transformContentForOpenCode(body: string): string {
+ let result = body
+
+ // 1. Fix legacy ralph-wiggum namespace
+ result = result.replace(/\/ralph-wiggum:ralph-loop/g, "/ralph-loop:ralph-loop")
+
+ return result
+}
+
function convertMcp(servers: Record): Record {
const result: Record = {}
for (const [name, server] of Object.entries(servers)) {
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
+}