diff --git a/src/targets/codex.ts b/src/targets/codex.ts index 2efe623..046e256 100644 --- a/src/targets/codex.ts +++ b/src/targets/codex.ts @@ -116,12 +116,29 @@ async function readFileSafe(filePath: string): Promise { 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]]) { - stripped = stripped.replace( + 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) @@ -133,12 +150,6 @@ export function mergeCodexConfig(existingContent: string, mcpToml: string | null } } - // 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(), diff --git a/tests/codex-writer.test.ts b/tests/codex-writer.test.ts index fce4664..69de0fa 100644 --- a/tests/codex-writer.test.ts +++ b/tests/codex-writer.test.ts @@ -524,6 +524,53 @@ describe("mergeCodexConfig", () => { expect(result).toContain("[mcp_servers.new]") }) + test("preserves unmarked legacy content when no MCP servers are incoming", () => { + const existing = [ + 'model = "gpt-5.4"', + "", + "# Generated by compound-plugin", + "", + "[projects.example]", + 'trust_level = "trusted"', + ].join("\n") + + const result = mergeCodexConfig(existing, null)! + expect(result).toContain("# Generated by compound-plugin") + expect(result).toContain("[projects.example]") + expect(result).toContain('trust_level = "trusted"') + }) + + test("strips bounded legacy MCP block when no MCP servers are incoming", () => { + const existing = [ + "[user]", + 'model = "gpt-5.4"', + "", + "# MCP servers synced from Claude Code", + "", + "[mcp_servers.old]", + 'command = "old"', + ].join("\n") + + const result = mergeCodexConfig(existing, null)! + expect(result).toContain("[user]") + expect(result).not.toContain("# MCP servers synced from Claude Code") + expect(result).not.toContain("[mcp_servers.old]") + }) + + test("returns existing content byte-for-byte when no MCP servers or managed blocks exist", () => { + const existing = [ + 'model = "gpt-5.4"', + "", + "# Generated by compound-plugin", + "", + "[projects.example]", + 'trust_level = "trusted"', + "", + ].join("\n") + + expect(mergeCodexConfig(existing, null)).toBe(existing) + }) + test("preserves user config before unmarked legacy format", () => { const existing = [ "[user]",