Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
65 changes: 14 additions & 51 deletions src/sync/codex.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,31 @@
import fs from "fs/promises"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import { renderCodexConfig } from "../targets/codex"
import { mergeCodexConfig, renderCodexConfig } from "../targets/codex"
import { writeTextSecure } from "../utils/files"
import { syncCodexCommands } from "./commands"
import { syncSkills } from "./skills"

const CURRENT_START_MARKER = "# BEGIN compound-plugin Claude Code MCP"
const CURRENT_END_MARKER = "# END compound-plugin Claude Code MCP"
const LEGACY_MARKER = "# MCP servers synced from Claude Code"

export async function syncToCodex(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncCodexCommands(config, outputRoot)

// Write MCP servers to config.toml (TOML format)
if (Object.keys(config.mcpServers).length > 0) {
const configPath = path.join(outputRoot, "config.toml")
const mcpToml = renderCodexConfig(config.mcpServers)
if (!mcpToml) {
return
}

// 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
}
// Write MCP servers to config.toml, or clean up stale managed block if none remain
const configPath = path.join(outputRoot, "config.toml")
let existingContent = ""
try {
existingContent = await fs.readFile(configPath, "utf-8")
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err
}

const managedBlock = [
CURRENT_START_MARKER,
mcpToml.trim(),
CURRENT_END_MARKER,
"",
].join("\n")

const withoutCurrentBlock = existingContent.replace(
new RegExp(
`${escapeForRegex(CURRENT_START_MARKER)}[\\s\\S]*?${escapeForRegex(CURRENT_END_MARKER)}\\n?`,
"g",
),
"",
).trimEnd()

const legacyMarkerIndex = withoutCurrentBlock.indexOf(LEGACY_MARKER)
const cleaned = legacyMarkerIndex === -1
? withoutCurrentBlock
: withoutCurrentBlock.slice(0, legacyMarkerIndex).trimEnd()

const newContent = cleaned
? `${cleaned}\n\n${managedBlock}`
: `${managedBlock}`

await writeTextSecure(configPath, newContent)
}
}

function escapeForRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const mcpToml = renderCodexConfig(config.mcpServers)
const merged = mergeCodexConfig(existingContent, mcpToml)
if (merged !== null) {
await writeTextSecure(configPath, merged)
}
}
79 changes: 72 additions & 7 deletions src/targets/codex.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import fs from "fs/promises"
import path from "path"
import { backupFile, copySkillDir, ensureDir, sanitizePathName, writeText } from "../utils/files"
import { backupFile, 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)
Expand Down Expand Up @@ -35,14 +43,16 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
}
}

const config = renderCodexConfig(bundle.mcpServers)
if (config) {
const configPath = path.join(codexRoot, "config.toml")
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 writeText(configPath, config)
await writeTextSecure(configPath, merged)
}
}

Expand All @@ -53,9 +63,11 @@ function resolveCodexRoot(outputRoot: string): string {
export function renderCodexConfig(mcpServers?: Record<string, ClaudeMcpServer>): string | null {
if (!mcpServers || Object.keys(mcpServers).length === 0) return null

const lines: string[] = ["# Generated by compound-plugin", ""]
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}]`)

Expand Down Expand Up @@ -83,7 +95,60 @@ export function renderCodexConfig(mcpServers?: Record<string, ClaudeMcpServer>):
lines.push("")
}

return lines.join("\n")
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 {
Expand Down
64 changes: 60 additions & 4 deletions src/targets/copilot.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "path"
import { backupFile, copySkillDir, ensureDir, sanitizePathName, writeJson, writeText } from "../utils/files"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJsonSecure, writeText } from "../utils/files"
import { transformContentForCopilot } from "../converters/claude-to-copilot"
import type { CopilotBundle } from "../types/copilot"

Expand Down Expand Up @@ -28,13 +28,69 @@ export async function writeCopilotBundle(outputRoot: string, bundle: CopilotBund
}
}

if (bundle.mcpConfig && Object.keys(bundle.mcpConfig).length > 0) {
const mcpPath = path.join(paths.githubDir, "copilot-mcp-config.json")
const mcpPath = path.join(paths.githubDir, "copilot-mcp-config.json")
const merged = await mergeCopilotMcpConfig(mcpPath, bundle.mcpConfig ?? {})
if (merged !== null) {
const backupPath = await backupFile(mcpPath)
if (backupPath) {
console.log(`Backed up existing copilot-mcp-config.json to ${backupPath}`)
}
await writeJson(mcpPath, { mcpServers: bundle.mcpConfig })
await writeJsonSecure(mcpPath, merged)
}
}

const MANAGED_KEY = "_compound_managed_mcp"

async function mergeCopilotMcpConfig(
configPath: string,
incoming: Record<string, unknown>,
): Promise<Record<string, unknown> | null> {
let existing: Record<string, unknown> = {}
if (await pathExists(configPath)) {
try {
const parsed = await readJson<unknown>(configPath)
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
existing = parsed as Record<string, unknown>
}
} catch {
// Unparseable file — proceed with incoming only
}
}

const existingMcp = (typeof existing.mcpServers === "object" && existing.mcpServers !== null && !Array.isArray(existing.mcpServers))
? { ...(existing.mcpServers as Record<string, unknown>) }
: {}

// Remove previously-managed plugin servers that are no longer in the bundle.
// Legacy migration: if no tracking key exists, assume all existing servers are plugin-managed
// (the old writer overwrote the entire file, so there are no user servers to preserve).
const prevManaged = Array.isArray(existing[MANAGED_KEY])
? existing[MANAGED_KEY] as string[]
: Object.keys(existingMcp)
for (const name of prevManaged) {
if (!(name in incoming)) {
Comment thread
tmchow marked this conversation as resolved.
Outdated
delete existingMcp[name]
}
}

const mergedMcp = { ...existingMcp, ...incoming }
const incomingKeys = Object.keys(incoming)

// Nothing to write — no user servers, no plugin servers, no existing file
if (Object.keys(mergedMcp).length === 0 && Object.keys(existing).length === 0) {
return null
}

// Strip tracking key when plugin has no servers and no managed entries remain
const { [MANAGED_KEY]: _, ...rest } = existing
if (incomingKeys.length === 0 && Object.keys(mergedMcp).length === 0) {
return { ...rest, mcpServers: {} }
Comment thread
tmchow marked this conversation as resolved.
Outdated
}

return {
...existing,
mcpServers: mergedMcp,
[MANAGED_KEY]: incomingKeys,
}
}

Expand Down
59 changes: 56 additions & 3 deletions src/targets/qwen.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import path from "path"
import { backupFile, copyDir, ensureDir, resolveCommandPath, sanitizePathName, writeJson, writeText } from "../utils/files"
import { backupFile, copyDir, ensureDir, readJson, resolveCommandPath, sanitizePathName, pathExists, writeJsonSecure, writeText } from "../utils/files"
import type { QwenBundle, QwenExtensionConfig } from "../types/qwen"

export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): Promise<void> {
const qwenPaths = resolveQwenPaths(outputRoot)
await ensureDir(qwenPaths.root)

// Write qwen-extension.json config
// Merge qwen-extension.json config, preserving existing user MCP servers
const configPath = qwenPaths.configPath
const backupPath = await backupFile(configPath)
if (backupPath) {
console.log(`Backed up existing config to ${backupPath}`)
}
await writeJson(configPath, bundle.config)
const merged = await mergeQwenConfig(configPath, bundle.config)
await writeJsonSecure(configPath, merged)

// Write context file (QWEN.md)
if (bundle.contextFile) {
Expand Down Expand Up @@ -45,6 +46,58 @@ export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): P
}
}

const MANAGED_KEY = "_compound_managed_mcp"

async function mergeQwenConfig(
configPath: string,
incoming: QwenExtensionConfig,
): Promise<QwenExtensionConfig> {
let existing: Record<string, unknown> = {}
if (await pathExists(configPath)) {
try {
const parsed = await readJson<unknown>(configPath)
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
existing = parsed as Record<string, unknown>
}
} catch {
// Unparseable file — proceed with incoming only
}
}

const existingMcp = (typeof existing.mcpServers === "object" && existing.mcpServers !== null && !Array.isArray(existing.mcpServers))
? { ...(existing.mcpServers as Record<string, unknown>) }
: {}

// Remove previously-managed plugin servers that are no longer in the bundle.
// Legacy migration: if no tracking key exists, assume all existing servers are plugin-managed
// (the old writer overwrote the entire file, so there are no user servers to preserve).
const prevManaged = Array.isArray(existing[MANAGED_KEY])
? existing[MANAGED_KEY] as string[]
: Object.keys(existingMcp)
const incomingMcp = incoming.mcpServers ?? {}
for (const name of prevManaged) {
if (!(name in incomingMcp)) {
delete existingMcp[name]
}
}

const mergedMcp = { ...existingMcp, ...incomingMcp }
const { mcpServers: _, ...incomingRest } = incoming
const merged = { ...existing, ...incomingRest } as QwenExtensionConfig & Record<string, unknown>

Comment thread
tmchow marked this conversation as resolved.
if (Object.keys(mergedMcp).length > 0) {
merged.mcpServers = mergedMcp as QwenExtensionConfig["mcpServers"]
} else {
delete merged.mcpServers
}

// Always write the tracking key (even as []) so the legacy fallback
// doesn't treat user servers as plugin-managed on a future install.
merged[MANAGED_KEY] = Object.keys(incomingMcp)

return merged as QwenExtensionConfig
}

function resolveQwenPaths(outputRoot: string) {
return {
root: outputRoot,
Expand Down
Loading