173 lines
5.7 KiB
TypeScript
173 lines
5.7 KiB
TypeScript
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<void> {
|
|
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, ClaudeMcpServer>): 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<string> {
|
|
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
|
|
for (const [start, end] of [[MANAGED_START_MARKER, MANAGED_END_MARKER], [PREV_START_MARKER, PREV_END_MARKER]]) {
|
|
stripped = stripped.replace(
|
|
new RegExp(`${escapeForRegex(start)}[\\s\\S]*?${escapeForRegex(end)}\\n?`, "g"),
|
|
"",
|
|
)
|
|
}
|
|
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()
|
|
}
|
|
}
|
|
|
|
// No MCP servers to write — return cleaned content, or null only if there was never a file
|
|
if (!mcpToml) {
|
|
if (!existingContent) return null
|
|
return cleaned
|
|
}
|
|
|
|
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, string>): string {
|
|
const parts = Object.entries(entries).map(
|
|
([key, value]) => `${formatTomlKey(key)} = ${formatTomlString(value)}`,
|
|
)
|
|
return `{ ${parts.join(", ")} }`
|
|
}
|