@every-env/compound-plugin 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.claude/commands/triage-prs.md +193 -0
  2. package/.claude-plugin/marketplace.json +4 -4
  3. package/.github/workflows/ci.yml +25 -0
  4. package/README.md +25 -4
  5. package/docs/index.html +14 -14
  6. package/docs/pages/changelog.html +1 -1
  7. package/docs/pages/getting-started.html +1 -1
  8. package/docs/plans/2026-02-08-feat-pr-triage-and-merge-plan.md +128 -0
  9. package/package.json +1 -1
  10. package/plans/grow-your-own-garden-plugin-architecture.md +1 -1
  11. package/plugins/compound-engineering/.claude-plugin/plugin.json +3 -3
  12. package/plugins/compound-engineering/CHANGELOG.md +32 -0
  13. package/plugins/compound-engineering/CLAUDE.md +3 -4
  14. package/plugins/compound-engineering/README.md +20 -7
  15. package/plugins/compound-engineering/agents/research/best-practices-researcher.md +14 -3
  16. package/plugins/compound-engineering/agents/research/framework-docs-researcher.md +11 -3
  17. package/plugins/compound-engineering/agents/research/git-history-analyzer.md +2 -0
  18. package/plugins/compound-engineering/agents/research/learnings-researcher.md +243 -0
  19. package/plugins/compound-engineering/agents/research/repo-research-analyst.md +5 -4
  20. package/plugins/compound-engineering/agents/review/code-simplicity-reviewer.md +1 -0
  21. package/plugins/compound-engineering/agents/review/pattern-recognition-specialist.md +1 -1
  22. package/plugins/compound-engineering/agents/review/schema-drift-detector.md +139 -0
  23. package/plugins/compound-engineering/commands/deepen-plan.md +5 -5
  24. package/plugins/compound-engineering/commands/report-bug.md +3 -3
  25. package/plugins/compound-engineering/commands/resolve_todo_parallel.md +2 -0
  26. package/plugins/compound-engineering/commands/slfg.md +31 -0
  27. package/plugins/compound-engineering/commands/technical_review.md +7 -0
  28. package/plugins/compound-engineering/commands/workflows/brainstorm.md +124 -0
  29. package/plugins/compound-engineering/commands/workflows/compound.md +64 -27
  30. package/plugins/compound-engineering/commands/workflows/plan.md +127 -42
  31. package/plugins/compound-engineering/commands/workflows/review.md +12 -0
  32. package/plugins/compound-engineering/commands/workflows/work.md +72 -2
  33. package/plugins/compound-engineering/skills/brainstorming/SKILL.md +190 -0
  34. package/plugins/compound-engineering/skills/compound-docs/SKILL.md +9 -9
  35. package/plugins/compound-engineering/skills/compound-docs/assets/critical-pattern-template.md +1 -1
  36. package/plugins/compound-engineering/skills/compound-docs/assets/resolution-template.md +3 -3
  37. package/plugins/compound-engineering/skills/compound-docs/references/yaml-schema.md +1 -1
  38. package/plugins/compound-engineering/skills/create-agent-skills/SKILL.md +168 -192
  39. package/plugins/compound-engineering/skills/create-agent-skills/references/official-spec.md +74 -125
  40. package/plugins/compound-engineering/skills/create-agent-skills/references/skill-structure.md +109 -329
  41. package/plugins/compound-engineering/skills/document-review/SKILL.md +87 -0
  42. package/plugins/compound-engineering/skills/git-worktree/scripts/worktree-manager.sh +2 -10
  43. package/plugins/compound-engineering/skills/orchestrating-swarms/SKILL.md +1717 -0
  44. package/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md +89 -0
  45. package/plugins/compound-engineering/skills/resolve-pr-parallel/scripts/get-pr-comments +68 -0
  46. package/plugins/compound-engineering/skills/resolve-pr-parallel/scripts/resolve-pr-thread +23 -0
  47. package/src/commands/install.ts +3 -1
  48. package/src/commands/sync.ts +84 -0
  49. package/src/converters/claude-to-codex.ts +59 -2
  50. package/src/converters/claude-to-opencode.ts +7 -5
  51. package/src/index.ts +2 -0
  52. package/src/parsers/claude-home.ts +65 -0
  53. package/src/sync/codex.ts +92 -0
  54. package/src/sync/opencode.ts +75 -0
  55. package/src/targets/codex.ts +7 -2
  56. package/src/targets/opencode.ts +11 -2
  57. package/src/types/claude.ts +1 -1
  58. package/src/utils/files.ts +13 -0
  59. package/src/utils/symlink.ts +43 -0
  60. package/tests/cli.test.ts +7 -5
  61. package/tests/codex-converter.test.ts +83 -0
  62. package/tests/codex-writer.test.ts +32 -0
  63. package/tests/opencode-writer.test.ts +57 -0
  64. package/plugins/compound-engineering/commands/plan_review.md +0 -7
  65. package/plugins/compound-engineering/commands/resolve_pr_parallel.md +0 -49
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: resolve_pr_parallel
3
+ description: Resolve all PR comments using parallel processing. Use when addressing PR review feedback, resolving review threads, or batch-fixing PR comments.
4
+ argument-hint: "[optional: PR number or current PR]"
5
+ disable-model-invocation: true
6
+ allowed-tools: Bash(gh *), Bash(git *), Read
7
+ ---
8
+
9
+ # Resolve PR Comments in Parallel
10
+
11
+ Resolve all unresolved PR review comments by spawning parallel agents for each thread.
12
+
13
+ ## Context Detection
14
+
15
+ Claude Code automatically detects git context:
16
+ - Current branch and associated PR
17
+ - All PR comments and review threads
18
+ - Works with any PR by specifying the number
19
+
20
+ ## Workflow
21
+
22
+ ### 1. Analyze
23
+
24
+ Fetch unresolved review threads using the GraphQL script:
25
+
26
+ ```bash
27
+ bash ${CLAUDE_PLUGIN_ROOT}/skills/resolve-pr-parallel/scripts/get-pr-comments PR_NUMBER
28
+ ```
29
+
30
+ This returns only **unresolved, non-outdated** threads with file paths, line numbers, and comment bodies.
31
+
32
+ If the script fails, fall back to:
33
+ ```bash
34
+ gh pr view PR_NUMBER --json reviews,comments
35
+ gh api repos/{owner}/{repo}/pulls/PR_NUMBER/comments
36
+ ```
37
+
38
+ ### 2. Plan
39
+
40
+ Create a TodoWrite list of all unresolved items grouped by type:
41
+ - Code changes requested
42
+ - Questions to answer
43
+ - Style/convention fixes
44
+ - Test additions needed
45
+
46
+ ### 3. Implement (PARALLEL)
47
+
48
+ Spawn a `pr-comment-resolver` agent for each unresolved item in parallel.
49
+
50
+ If there are 3 comments, spawn 3 agents:
51
+
52
+ 1. Task pr-comment-resolver(comment1)
53
+ 2. Task pr-comment-resolver(comment2)
54
+ 3. Task pr-comment-resolver(comment3)
55
+
56
+ Always run all in parallel subagents/Tasks for each Todo item.
57
+
58
+ ### 4. Commit & Resolve
59
+
60
+ - Commit changes with a clear message referencing the PR feedback
61
+ - Resolve each thread programmatically:
62
+
63
+ ```bash
64
+ bash ${CLAUDE_PLUGIN_ROOT}/skills/resolve-pr-parallel/scripts/resolve-pr-thread THREAD_ID
65
+ ```
66
+
67
+ - Push to remote
68
+
69
+ ### 5. Verify
70
+
71
+ Re-fetch comments to confirm all threads are resolved:
72
+
73
+ ```bash
74
+ bash ${CLAUDE_PLUGIN_ROOT}/skills/resolve-pr-parallel/scripts/get-pr-comments PR_NUMBER
75
+ ```
76
+
77
+ Should return an empty array `[]`. If threads remain, repeat from step 1.
78
+
79
+ ## Scripts
80
+
81
+ - [scripts/get-pr-comments](scripts/get-pr-comments) - GraphQL query for unresolved review threads
82
+ - [scripts/resolve-pr-thread](scripts/resolve-pr-thread) - GraphQL mutation to resolve a thread by ID
83
+
84
+ ## Success Criteria
85
+
86
+ - All unresolved review threads addressed
87
+ - Changes committed and pushed
88
+ - Threads resolved via GraphQL (marked as resolved on GitHub)
89
+ - Empty result from get-pr-comments on verify
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -e
4
+
5
+ if [ $# -lt 1 ]; then
6
+ echo "Usage: get-pr-comments PR_NUMBER [OWNER/REPO]"
7
+ echo "Example: get-pr-comments 123"
8
+ echo "Example: get-pr-comments 123 EveryInc/cora"
9
+ exit 1
10
+ fi
11
+
12
+ PR_NUMBER=$1
13
+
14
+ if [ -n "$2" ]; then
15
+ OWNER=$(echo "$2" | cut -d/ -f1)
16
+ REPO=$(echo "$2" | cut -d/ -f2)
17
+ else
18
+ OWNER=$(gh repo view --json owner -q .owner.login 2>/dev/null)
19
+ REPO=$(gh repo view --json name -q .name 2>/dev/null)
20
+ fi
21
+
22
+ if [ -z "$OWNER" ] || [ -z "$REPO" ]; then
23
+ echo "Error: Could not detect repository. Pass OWNER/REPO as second argument."
24
+ exit 1
25
+ fi
26
+
27
+ gh api graphql -f owner="$OWNER" -f repo="$REPO" -F pr="$PR_NUMBER" -f query='
28
+ query FetchUnresolvedComments($owner: String!, $repo: String!, $pr: Int!) {
29
+ repository(owner: $owner, name: $repo) {
30
+ pullRequest(number: $pr) {
31
+ title
32
+ url
33
+ reviewThreads(first: 100) {
34
+ totalCount
35
+ edges {
36
+ node {
37
+ id
38
+ isResolved
39
+ isOutdated
40
+ isCollapsed
41
+ path
42
+ line
43
+ startLine
44
+ diffSide
45
+ comments(first: 100) {
46
+ totalCount
47
+ nodes {
48
+ id
49
+ author {
50
+ login
51
+ }
52
+ body
53
+ createdAt
54
+ updatedAt
55
+ url
56
+ outdated
57
+ }
58
+ }
59
+ }
60
+ }
61
+ pageInfo {
62
+ hasNextPage
63
+ endCursor
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }' | jq '.data.repository.pullRequest.reviewThreads.edges | map(select(.node.isResolved == false and .node.isOutdated == false))'
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -e
4
+
5
+ if [ $# -eq 0 ]; then
6
+ echo "Usage: resolve-pr-thread THREAD_ID"
7
+ echo "Example: resolve-pr-thread PRRT_kwDOABC123"
8
+ exit 1
9
+ fi
10
+
11
+ THREAD_ID=$1
12
+
13
+ gh api graphql -f threadId="$THREAD_ID" -f query='
14
+ mutation ResolveReviewThread($threadId: ID!) {
15
+ resolveReviewThread(input: {threadId: $threadId}) {
16
+ thread {
17
+ id
18
+ isResolved
19
+ path
20
+ line
21
+ }
22
+ }
23
+ }'
@@ -175,7 +175,9 @@ function resolveOutputRoot(value: unknown): string {
175
175
  const expanded = expandHome(String(value).trim())
176
176
  return path.resolve(expanded)
177
177
  }
178
- return path.join(os.homedir(), ".opencode")
178
+ // OpenCode global config lives at ~/.config/opencode per XDG spec
179
+ // See: https://opencode.ai/docs/config/
180
+ return path.join(os.homedir(), ".config", "opencode")
179
181
  }
180
182
 
181
183
  async function resolveGitHubPluginPath(pluginName: string): Promise<ResolvedPluginPath> {
@@ -0,0 +1,84 @@
1
+ import { defineCommand } from "citty"
2
+ import os from "os"
3
+ import path from "path"
4
+ import { loadClaudeHome } from "../parsers/claude-home"
5
+ import { syncToOpenCode } from "../sync/opencode"
6
+ import { syncToCodex } from "../sync/codex"
7
+
8
+ function isValidTarget(value: string): value is "opencode" | "codex" {
9
+ return value === "opencode" || value === "codex"
10
+ }
11
+
12
+ /** Check if any MCP servers have env vars that might contain secrets */
13
+ function hasPotentialSecrets(mcpServers: Record<string, unknown>): boolean {
14
+ const sensitivePatterns = /key|token|secret|password|credential|api_key/i
15
+ for (const server of Object.values(mcpServers)) {
16
+ const env = (server as { env?: Record<string, string> }).env
17
+ if (env) {
18
+ for (const key of Object.keys(env)) {
19
+ if (sensitivePatterns.test(key)) return true
20
+ }
21
+ }
22
+ }
23
+ return false
24
+ }
25
+
26
+ export default defineCommand({
27
+ meta: {
28
+ name: "sync",
29
+ description: "Sync Claude Code config (~/.claude/) to OpenCode or Codex",
30
+ },
31
+ args: {
32
+ target: {
33
+ type: "string",
34
+ required: true,
35
+ description: "Target: opencode | codex",
36
+ },
37
+ claudeHome: {
38
+ type: "string",
39
+ alias: "claude-home",
40
+ description: "Path to Claude home (default: ~/.claude)",
41
+ },
42
+ },
43
+ async run({ args }) {
44
+ if (!isValidTarget(args.target)) {
45
+ throw new Error(`Unknown target: ${args.target}. Use 'opencode' or 'codex'.`)
46
+ }
47
+
48
+ const claudeHome = expandHome(args.claudeHome ?? path.join(os.homedir(), ".claude"))
49
+ const config = await loadClaudeHome(claudeHome)
50
+
51
+ // Warn about potential secrets in MCP env vars
52
+ if (hasPotentialSecrets(config.mcpServers)) {
53
+ console.warn(
54
+ "⚠️ Warning: MCP servers contain env vars that may include secrets (API keys, tokens).\n" +
55
+ " These will be copied to the target config. Review before sharing the config file.",
56
+ )
57
+ }
58
+
59
+ console.log(
60
+ `Syncing ${config.skills.length} skills, ${Object.keys(config.mcpServers).length} MCP servers...`,
61
+ )
62
+
63
+ const outputRoot =
64
+ args.target === "opencode"
65
+ ? path.join(os.homedir(), ".config", "opencode")
66
+ : path.join(os.homedir(), ".codex")
67
+
68
+ if (args.target === "opencode") {
69
+ await syncToOpenCode(config, outputRoot)
70
+ } else {
71
+ await syncToCodex(config, outputRoot)
72
+ }
73
+
74
+ console.log(`✓ Synced to ${args.target}: ${outputRoot}`)
75
+ },
76
+ })
77
+
78
+ function expandHome(value: string): string {
79
+ if (value === "~") return os.homedir()
80
+ if (value.startsWith(`~${path.sep}`)) {
81
+ return path.join(os.homedir(), value.slice(2))
82
+ }
83
+ return value
84
+ }
@@ -73,19 +73,76 @@ function convertCommandSkill(command: ClaudeCommand, usedNames: Set<string>): Co
73
73
  if (command.allowedTools && command.allowedTools.length > 0) {
74
74
  sections.push(`## Allowed tools\n${command.allowedTools.map((tool) => `- ${tool}`).join("\n")}`)
75
75
  }
76
- sections.push(command.body.trim())
76
+ // Transform Task agent calls to Codex skill references
77
+ const transformedBody = transformTaskCalls(command.body.trim())
78
+ sections.push(transformedBody)
77
79
  const body = sections.filter(Boolean).join("\n\n").trim()
78
80
  const content = formatFrontmatter(frontmatter, body.length > 0 ? body : command.body)
79
81
  return { name, content }
80
82
  }
81
83
 
84
+ /**
85
+ * Transform Claude Code content to Codex-compatible content.
86
+ *
87
+ * Handles multiple syntax differences:
88
+ * 1. Task agent calls: Task agent-name(args) → Use the $agent-name skill to: args
89
+ * 2. Slash commands: /command-name → /prompts:command-name
90
+ * 3. Agent references: @agent-name → $agent-name skill
91
+ *
92
+ * This bridges the gap since Claude Code and Codex have different syntax
93
+ * for invoking commands, agents, and skills.
94
+ */
95
+ function transformContentForCodex(body: string): string {
96
+ let result = body
97
+
98
+ // 1. Transform Task agent calls
99
+ // Match: Task repo-research-analyst(feature_description)
100
+ // Match: - Task learnings-researcher(args)
101
+ const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
102
+ result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
103
+ const skillName = normalizeName(agentName)
104
+ const trimmedArgs = args.trim()
105
+ return `${prefix}Use the $${skillName} skill to: ${trimmedArgs}`
106
+ })
107
+
108
+ // 2. Transform slash command references
109
+ // Match: /command-name or /workflows:command but NOT /path/to/file or URLs
110
+ // Look for slash commands in contexts like "Run /command", "use /command", etc.
111
+ // Avoid matching file paths (contain multiple slashes) or URLs (contain ://)
112
+ const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
113
+ result = result.replace(slashCommandPattern, (match, commandName: string) => {
114
+ // Skip if it looks like a file path (contains /)
115
+ if (commandName.includes('/')) return match
116
+ // Skip common non-command patterns
117
+ if (['dev', 'tmp', 'etc', 'usr', 'var', 'bin', 'home'].includes(commandName)) return match
118
+ // Transform to Codex prompt syntax
119
+ const normalizedName = normalizeName(commandName)
120
+ return `/prompts:${normalizedName}`
121
+ })
122
+
123
+ // 3. Transform @agent-name references
124
+ // Match: @agent-name in text (not emails)
125
+ const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
126
+ result = result.replace(agentRefPattern, (_match, agentName: string) => {
127
+ const skillName = normalizeName(agentName)
128
+ return `$${skillName} skill`
129
+ })
130
+
131
+ return result
132
+ }
133
+
134
+ // Alias for backward compatibility
135
+ const transformTaskCalls = transformContentForCodex
136
+
82
137
  function renderPrompt(command: ClaudeCommand, skillName: string): string {
83
138
  const frontmatter: Record<string, unknown> = {
84
139
  description: command.description,
85
140
  "argument-hint": command.argumentHint,
86
141
  }
87
142
  const instructions = `Use the $${skillName} skill for this command and follow its instructions.`
88
- const body = [instructions, "", command.body].join("\n").trim()
143
+ // Transform Task calls in prompt body too (not just skill body)
144
+ const transformedBody = transformTaskCalls(command.body)
145
+ const body = [instructions, "", transformedBody].join("\n").trim()
89
146
  return formatFrontmatter(frontmatter, body)
90
147
  }
91
148
 
@@ -209,9 +209,11 @@ function renderHookStatements(
209
209
  ): string[] {
210
210
  if (!matcher.hooks || matcher.hooks.length === 0) return []
211
211
  const tools = matcher.matcher
212
- .split("|")
213
- .map((tool) => tool.trim().toLowerCase())
214
- .filter(Boolean)
212
+ ? matcher.matcher
213
+ .split("|")
214
+ .map((tool) => tool.trim().toLowerCase())
215
+ .filter(Boolean)
216
+ : []
215
217
 
216
218
  const useMatcher = useToolMatcher && tools.length > 0 && !tools.includes("*")
217
219
  const condition = useMatcher
@@ -232,10 +234,10 @@ function renderHookStatements(
232
234
  continue
233
235
  }
234
236
  if (hook.type === "prompt") {
235
- statements.push(`// Prompt hook for ${matcher.matcher}: ${hook.prompt.replace(/\n/g, " ")}`)
237
+ statements.push(`// Prompt hook for ${matcher.matcher ?? "*"}: ${hook.prompt.replace(/\n/g, " ")}`)
236
238
  continue
237
239
  }
238
- statements.push(`// Agent hook for ${matcher.matcher}: ${hook.agent}`)
240
+ statements.push(`// Agent hook for ${matcher.matcher ?? "*"}: ${hook.agent}`)
239
241
  }
240
242
 
241
243
  return statements
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { defineCommand, runMain } from "citty"
3
3
  import convert from "./commands/convert"
4
4
  import install from "./commands/install"
5
5
  import listCommand from "./commands/list"
6
+ import sync from "./commands/sync"
6
7
 
7
8
  const main = defineCommand({
8
9
  meta: {
@@ -14,6 +15,7 @@ const main = defineCommand({
14
15
  convert: () => convert,
15
16
  install: () => install,
16
17
  list: () => listCommand,
18
+ sync: () => sync,
17
19
  },
18
20
  })
19
21
 
@@ -0,0 +1,65 @@
1
+ import path from "path"
2
+ import os from "os"
3
+ import fs from "fs/promises"
4
+ import type { ClaudeSkill, ClaudeMcpServer } from "../types/claude"
5
+
6
+ export interface ClaudeHomeConfig {
7
+ skills: ClaudeSkill[]
8
+ mcpServers: Record<string, ClaudeMcpServer>
9
+ }
10
+
11
+ export async function loadClaudeHome(claudeHome?: string): Promise<ClaudeHomeConfig> {
12
+ const home = claudeHome ?? path.join(os.homedir(), ".claude")
13
+
14
+ const [skills, mcpServers] = await Promise.all([
15
+ loadPersonalSkills(path.join(home, "skills")),
16
+ loadSettingsMcp(path.join(home, "settings.json")),
17
+ ])
18
+
19
+ return { skills, mcpServers }
20
+ }
21
+
22
+ async function loadPersonalSkills(skillsDir: string): Promise<ClaudeSkill[]> {
23
+ try {
24
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true })
25
+ const skills: ClaudeSkill[] = []
26
+
27
+ for (const entry of entries) {
28
+ // Check if directory or symlink (symlinks are common for skills)
29
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
30
+
31
+ const entryPath = path.join(skillsDir, entry.name)
32
+ const skillPath = path.join(entryPath, "SKILL.md")
33
+
34
+ try {
35
+ await fs.access(skillPath)
36
+ // Resolve symlink to get the actual source directory
37
+ const sourceDir = entry.isSymbolicLink()
38
+ ? await fs.realpath(entryPath)
39
+ : entryPath
40
+ skills.push({
41
+ name: entry.name,
42
+ sourceDir,
43
+ skillPath,
44
+ })
45
+ } catch {
46
+ // No SKILL.md, skip
47
+ }
48
+ }
49
+ return skills
50
+ } catch {
51
+ return [] // Directory doesn't exist
52
+ }
53
+ }
54
+
55
+ async function loadSettingsMcp(
56
+ settingsPath: string,
57
+ ): Promise<Record<string, ClaudeMcpServer>> {
58
+ try {
59
+ const content = await fs.readFile(settingsPath, "utf-8")
60
+ const settings = JSON.parse(content) as { mcpServers?: Record<string, ClaudeMcpServer> }
61
+ return settings.mcpServers ?? {}
62
+ } catch {
63
+ return {} // File doesn't exist or invalid JSON
64
+ }
65
+ }
@@ -0,0 +1,92 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+ import type { ClaudeHomeConfig } from "../parsers/claude-home"
4
+ import type { ClaudeMcpServer } from "../types/claude"
5
+ import { forceSymlink, isValidSkillName } from "../utils/symlink"
6
+
7
+ export async function syncToCodex(
8
+ config: ClaudeHomeConfig,
9
+ outputRoot: string,
10
+ ): Promise<void> {
11
+ // Ensure output directories exist
12
+ const skillsDir = path.join(outputRoot, "skills")
13
+ await fs.mkdir(skillsDir, { recursive: true })
14
+
15
+ // Symlink skills (with validation)
16
+ for (const skill of config.skills) {
17
+ if (!isValidSkillName(skill.name)) {
18
+ console.warn(`Skipping skill with invalid name: ${skill.name}`)
19
+ continue
20
+ }
21
+ const target = path.join(skillsDir, skill.name)
22
+ await forceSymlink(skill.sourceDir, target)
23
+ }
24
+
25
+ // Write MCP servers to config.toml (TOML format)
26
+ if (Object.keys(config.mcpServers).length > 0) {
27
+ const configPath = path.join(outputRoot, "config.toml")
28
+ const mcpToml = convertMcpForCodex(config.mcpServers)
29
+
30
+ // Read existing config and merge idempotently
31
+ let existingContent = ""
32
+ try {
33
+ existingContent = await fs.readFile(configPath, "utf-8")
34
+ } catch (err) {
35
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
36
+ throw err
37
+ }
38
+ }
39
+
40
+ // Remove any existing Claude Code MCP section to make idempotent
41
+ const marker = "# MCP servers synced from Claude Code"
42
+ const markerIndex = existingContent.indexOf(marker)
43
+ if (markerIndex !== -1) {
44
+ existingContent = existingContent.slice(0, markerIndex).trimEnd()
45
+ }
46
+
47
+ const newContent = existingContent
48
+ ? existingContent + "\n\n" + marker + "\n" + mcpToml
49
+ : "# Codex config - synced from Claude Code\n\n" + mcpToml
50
+
51
+ await fs.writeFile(configPath, newContent, { mode: 0o600 })
52
+ }
53
+ }
54
+
55
+ /** Escape a string for TOML double-quoted strings */
56
+ function escapeTomlString(str: string): string {
57
+ return str
58
+ .replace(/\\/g, "\\\\")
59
+ .replace(/"/g, '\\"')
60
+ .replace(/\n/g, "\\n")
61
+ .replace(/\r/g, "\\r")
62
+ .replace(/\t/g, "\\t")
63
+ }
64
+
65
+ function convertMcpForCodex(servers: Record<string, ClaudeMcpServer>): string {
66
+ const sections: string[] = []
67
+
68
+ for (const [name, server] of Object.entries(servers)) {
69
+ if (!server.command) continue
70
+
71
+ const lines: string[] = []
72
+ lines.push(`[mcp_servers.${name}]`)
73
+ lines.push(`command = "${escapeTomlString(server.command)}"`)
74
+
75
+ if (server.args && server.args.length > 0) {
76
+ const argsStr = server.args.map((arg) => `"${escapeTomlString(arg)}"`).join(", ")
77
+ lines.push(`args = [${argsStr}]`)
78
+ }
79
+
80
+ if (server.env && Object.keys(server.env).length > 0) {
81
+ lines.push("")
82
+ lines.push(`[mcp_servers.${name}.env]`)
83
+ for (const [key, value] of Object.entries(server.env)) {
84
+ lines.push(`${key} = "${escapeTomlString(value)}"`)
85
+ }
86
+ }
87
+
88
+ sections.push(lines.join("\n"))
89
+ }
90
+
91
+ return sections.join("\n\n") + "\n"
92
+ }
@@ -0,0 +1,75 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+ import type { ClaudeHomeConfig } from "../parsers/claude-home"
4
+ import type { ClaudeMcpServer } from "../types/claude"
5
+ import type { OpenCodeMcpServer } from "../types/opencode"
6
+ import { forceSymlink, isValidSkillName } from "../utils/symlink"
7
+
8
+ export async function syncToOpenCode(
9
+ config: ClaudeHomeConfig,
10
+ outputRoot: string,
11
+ ): Promise<void> {
12
+ // Ensure output directories exist
13
+ const skillsDir = path.join(outputRoot, "skills")
14
+ await fs.mkdir(skillsDir, { recursive: true })
15
+
16
+ // Symlink skills (with validation)
17
+ for (const skill of config.skills) {
18
+ if (!isValidSkillName(skill.name)) {
19
+ console.warn(`Skipping skill with invalid name: ${skill.name}`)
20
+ continue
21
+ }
22
+ const target = path.join(skillsDir, skill.name)
23
+ await forceSymlink(skill.sourceDir, target)
24
+ }
25
+
26
+ // Merge MCP servers into opencode.json
27
+ if (Object.keys(config.mcpServers).length > 0) {
28
+ const configPath = path.join(outputRoot, "opencode.json")
29
+ const existing = await readJsonSafe(configPath)
30
+ const mcpConfig = convertMcpForOpenCode(config.mcpServers)
31
+ existing.mcp = { ...(existing.mcp ?? {}), ...mcpConfig }
32
+ await fs.writeFile(configPath, JSON.stringify(existing, null, 2), { mode: 0o600 })
33
+ }
34
+ }
35
+
36
+ async function readJsonSafe(filePath: string): Promise<Record<string, unknown>> {
37
+ try {
38
+ const content = await fs.readFile(filePath, "utf-8")
39
+ return JSON.parse(content) as Record<string, unknown>
40
+ } catch (err) {
41
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
42
+ return {}
43
+ }
44
+ throw err
45
+ }
46
+ }
47
+
48
+ function convertMcpForOpenCode(
49
+ servers: Record<string, ClaudeMcpServer>,
50
+ ): Record<string, OpenCodeMcpServer> {
51
+ const result: Record<string, OpenCodeMcpServer> = {}
52
+
53
+ for (const [name, server] of Object.entries(servers)) {
54
+ if (server.command) {
55
+ result[name] = {
56
+ type: "local",
57
+ command: [server.command, ...(server.args ?? [])],
58
+ environment: server.env,
59
+ enabled: true,
60
+ }
61
+ continue
62
+ }
63
+
64
+ if (server.url) {
65
+ result[name] = {
66
+ type: "remote",
67
+ url: server.url,
68
+ headers: server.headers,
69
+ enabled: true,
70
+ }
71
+ }
72
+ }
73
+
74
+ return result
75
+ }
@@ -1,5 +1,5 @@
1
1
  import path from "path"
2
- import { copyDir, ensureDir, writeText } from "../utils/files"
2
+ import { backupFile, copyDir, ensureDir, writeText } from "../utils/files"
3
3
  import type { CodexBundle } from "../types/codex"
4
4
  import type { ClaudeMcpServer } from "../types/claude"
5
5
 
@@ -30,7 +30,12 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
30
30
 
31
31
  const config = renderCodexConfig(bundle.mcpServers)
32
32
  if (config) {
33
- await writeText(path.join(codexRoot, "config.toml"), config)
33
+ const configPath = path.join(codexRoot, "config.toml")
34
+ const backupPath = await backupFile(configPath)
35
+ if (backupPath) {
36
+ console.log(`Backed up existing config to ${backupPath}`)
37
+ }
38
+ await writeText(configPath, config)
34
39
  }
35
40
  }
36
41