diff --git a/bin/install.js b/bin/install.js index 4f8a828f3..bac1afdc3 100755 --- a/bin/install.js +++ b/bin/install.js @@ -1002,6 +1002,10 @@ function generateCodexConfigBlock(agents) { return lines.join('\n'); } +function stripCodexGsdAgentSections(content) { + return content.replace(/^\[agents\.gsd-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, ''); +} + /** * Strip GSD sections from Codex config.toml content. * Returns cleaned content, or null if file would be empty. @@ -1027,7 +1031,7 @@ function stripGsdFromCodexConfig(content) { cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*\n?/m, ''); // Remove [agents.gsd-*] sections (from header to next section or EOF) - cleaned = cleaned.replace(/^\[agents\.gsd-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, ''); + cleaned = stripCodexGsdAgentSections(cleaned); // Remove [features] section if now empty (only header, no keys before next section) cleaned = cleaned.replace(/^\[features\]\s*\n(?=\[|$)/m, ''); @@ -1061,7 +1065,7 @@ function mergeCodexConfig(configPath, gsdBlock) { let before = existing.substring(0, markerIndex).trimEnd(); if (before) { // Strip any GSD-managed sections that leaked above the marker from previous installs - before = before.replace(/^\[agents\.gsd-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, ''); + before = stripCodexGsdAgentSections(before); before = before.replace(/^\[agents\]\n(?:(?!\[)[^\n]*\n?)*/m, ''); before = before.replace(/\n{3,}/g, '\n\n').trimEnd(); @@ -1074,7 +1078,14 @@ function mergeCodexConfig(configPath, gsdBlock) { // Case 3: No marker — append GSD block let content = existing; - content = content.trimEnd() + '\n\n' + gsdBlock + '\n'; + content = stripCodexGsdAgentSections(content); + content = content.replace(/\n{3,}/g, '\n\n').trimEnd(); + + if (content) { + content = content + '\n\n' + gsdBlock + '\n'; + } else { + content = gsdBlock + '\n'; + } fs.writeFileSync(configPath, content); } diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index e5303d2cb..46dab864a 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -354,6 +354,36 @@ describe('mergeCodexConfig', () => { assert.ok(content.includes('[agents.gsd-executor]'), 'has agent'); }); + test('case 3 strips existing [agents.gsd-*] sections before appending fresh block', () => { + const configPath = path.join(tmpDir, 'config.toml'); + const existing = [ + '[model]', + 'name = "o3"', + '', + '[agents.custom-agent]', + 'description = "user agent"', + '', + '', + '[agents.gsd-executor]', + 'description = "old"', + 'config_file = "agents/gsd-executor.toml"', + '', + ].join('\n'); + fs.writeFileSync(configPath, existing); + + mergeCodexConfig(configPath, sampleBlock); + + const content = fs.readFileSync(configPath, 'utf8'); + const gsdAgentCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length; + const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length; + + assert.ok(content.includes('[model]'), 'preserves user content'); + assert.ok(content.includes('[agents.custom-agent]'), 'preserves non-GSD agent section'); + assert.strictEqual(gsdAgentCount, 1, 'keeps exactly one GSD agent section'); + assert.strictEqual(markerCount, 1, 'adds exactly one marker block'); + assert.ok(!/\n{3,}# GSD Agent Configuration/.test(content), 'does not leave extra blank lines before marker block'); + }); + test('idempotent: re-merge produces same result', () => { const configPath = path.join(tmpDir, 'config.toml'); mergeCodexConfig(configPath, sampleBlock);