import fs from "fs/promises" import path from "path" import { backupFile, copyDir, copySkillDir, ensureDir, sanitizePathName, writeText, writeTextSecure } from "../utils/files" import type { CodexBundle } from "../types/codex" import type { ClaudeMcpServer } from "../types/claude" import { transformContentForCodex } from "../utils/codex-content" const MANAGED_START_MARKER = "# BEGIN Compound Engineering plugin MCP -- do not edit this block" const MANAGED_END_MARKER = "# END Compound Engineering plugin MCP" const PREV_START_MARKER = "# BEGIN compound-plugin Claude Code MCP" const PREV_END_MARKER = "# END compound-plugin Claude Code MCP" const LEGACY_MARKER = "# MCP servers synced from Claude Code" const UNMARKED_LEGACY_MARKER = "# Generated by compound-plugin" export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle): Promise { const codexRoot = resolveCodexRoot(outputRoot) await ensureDir(codexRoot) if (bundle.prompts.length > 0) { const promptsDir = path.join(codexRoot, "prompts") for (const prompt of bundle.prompts) { await writeText(path.join(promptsDir, `${prompt.name}.md`), prompt.content + "\n") } } if (bundle.skillDirs.length > 0) { const skillsRoot = path.join(codexRoot, "skills") for (const skill of bundle.skillDirs) { await copySkillDir( skill.sourceDir, path.join(skillsRoot, sanitizePathName(skill.name)), (content) => transformContentForCodex(content, bundle.invocationTargets, { unknownSlashBehavior: "preserve", }), ) } } if (bundle.generatedSkills.length > 0) { const skillsRoot = path.join(codexRoot, "skills") for (const skill of bundle.generatedSkills) { const skillDir = path.join(skillsRoot, sanitizePathName(skill.name)) await writeText(path.join(skillDir, "SKILL.md"), skill.content + "\n") for (const sidecar of skill.sidecarDirs ?? []) { await copyDir(sidecar.sourceDir, path.join(skillDir, sidecar.targetName)) } } } const configPath = path.join(codexRoot, "config.toml") const existingConfig = await readFileSafe(configPath) const mcpToml = renderCodexConfig(bundle.mcpServers) const merged = mergeCodexConfig(existingConfig, mcpToml) if (merged !== null) { const backupPath = await backupFile(configPath) if (backupPath) { console.log(`Backed up existing config to ${backupPath}`) } await writeTextSecure(configPath, merged) } } function resolveCodexRoot(outputRoot: string): string { return path.basename(outputRoot) === ".codex" ? outputRoot : path.join(outputRoot, ".codex") } export function renderCodexConfig(mcpServers?: Record): string | null { if (!mcpServers || Object.keys(mcpServers).length === 0) return null const lines: string[] = [] for (const [name, server] of Object.entries(mcpServers)) { if (!server.command && !server.url) continue const key = formatTomlKey(name) lines.push(`[mcp_servers.${key}]`) if (server.command) { lines.push(`command = ${formatTomlString(server.command)}`) if (server.args && server.args.length > 0) { const args = server.args.map((arg) => formatTomlString(arg)).join(", ") lines.push(`args = [${args}]`) } if (server.env && Object.keys(server.env).length > 0) { lines.push("") lines.push(`[mcp_servers.${key}.env]`) for (const [envKey, value] of Object.entries(server.env)) { lines.push(`${formatTomlKey(envKey)} = ${formatTomlString(value)}`) } } } else if (server.url) { lines.push(`url = ${formatTomlString(server.url)}`) if (server.headers && Object.keys(server.headers).length > 0) { lines.push(`http_headers = ${formatTomlInlineTable(server.headers)}`) } } lines.push("") } return lines.length > 0 ? lines.join("\n") : null } async function readFileSafe(filePath: string): Promise { try { return await fs.readFile(filePath, "utf-8") } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") { throw err } return "" } } export function mergeCodexConfig(existingContent: string, mcpToml: string | null): string | null { // Strip current and previous managed blocks let stripped = existingContent let removedManagedBlock = false for (const [start, end] of [[MANAGED_START_MARKER, MANAGED_END_MARKER], [PREV_START_MARKER, PREV_END_MARKER]]) { const next = stripped.replace( new RegExp(`${escapeForRegex(start)}[\\s\\S]*?${escapeForRegex(end)}\\n?`, "g"), "", ) if (next !== stripped) removedManagedBlock = true stripped = next } // No MCP servers to write — only remove bounded managed blocks. Do not strip // unmarked legacy markers here: old Codex config files may contain user // settings after "# Generated by compound-plugin", and there is no safe // boundary for deleting only plugin-owned TOML. if (!mcpToml) { if (!existingContent) return null const legacyMarkerIndex = stripped.indexOf(LEGACY_MARKER) if (legacyMarkerIndex !== -1) { return stripped.slice(0, legacyMarkerIndex).trimEnd() } return removedManagedBlock ? stripped.trimEnd() : existingContent } stripped = stripped.trimEnd() // Strip from legacy markers to end of content (old formats wrote everything after the marker) let cleaned = stripped for (const marker of [LEGACY_MARKER, UNMARKED_LEGACY_MARKER]) { const idx = cleaned.indexOf(marker) if (idx !== -1) { cleaned = cleaned.slice(0, idx).trimEnd() } } const managedBlock = [ MANAGED_START_MARKER, mcpToml.trim(), MANAGED_END_MARKER, "", ].join("\n") return cleaned ? `${cleaned}\n\n${managedBlock}` : `${managedBlock}` } function escapeForRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } function formatTomlString(value: string): string { return JSON.stringify(value) } function formatTomlKey(value: string): string { if (/^[A-Za-z0-9_-]+$/.test(value)) return value return JSON.stringify(value) } function formatTomlInlineTable(entries: Record): string { const parts = Object.entries(entries).map( ([key, value]) => `${formatTomlKey(key)} = ${formatTomlString(value)}`, ) return `{ ${parts.join(", ")} }` }