diff --git a/bin/install.js b/bin/install.js index b05cb6d86..7a130043b 100755 --- a/bin/install.js +++ b/bin/install.js @@ -15,6 +15,7 @@ const reset = '\x1b[0m'; // Codex config.toml constants const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by get-shit-done installer'; +const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: '; // Copilot instructions marker constants const GSD_COPILOT_INSTRUCTIONS_MARKER = ''; @@ -1019,24 +1020,29 @@ function stripCodexGsdAgentSections(content) { * Returns cleaned content, or null if file would be empty. */ function stripGsdFromCodexConfig(content) { + const eol = detectLineEnding(content); const markerIndex = content.indexOf(GSD_CODEX_MARKER); + const codexHooksOwnership = getManagedCodexHooksOwnership(content); if (markerIndex !== -1) { // Has GSD marker — remove everything from marker to EOF - let before = content.substring(0, markerIndex).trimEnd(); + let before = content.substring(0, markerIndex); + before = stripCodexHooksFeatureAssignments(before, codexHooksOwnership); // Also strip GSD-injected feature keys above the marker (Case 3 inject) - before = before.replace(/^multi_agent\s*=\s*true\s*\n?/m, ''); - before = before.replace(/^default_mode_request_user_input\s*=\s*true\s*\n?/m, ''); + before = before.replace(/^multi_agent\s*=\s*true\s*(?:\r?\n)?/m, ''); + before = before.replace(/^default_mode_request_user_input\s*=\s*true\s*(?:\r?\n)?/m, ''); before = before.replace(/^\[features\]\s*\n(?=\[|$)/m, ''); - before = before.replace(/\n{3,}/g, '\n\n').trim(); + before = before.replace(/^\[agents\]\s*\n(?=\[|$)/m, ''); + before = before.replace(/^(?:\r?\n)+/, '').trimEnd(); if (!before) return null; - return before + '\n'; + return before + eol; } // No marker but may have GSD-injected feature keys let cleaned = content; - cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*\n?/m, ''); - cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*\n?/m, ''); + cleaned = stripCodexHooksFeatureAssignments(cleaned, codexHooksOwnership); + cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*(?:\r?\n)?/m, ''); + cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*(?:\r?\n)?/m, ''); // Remove [agents.gsd-*] sections (from header to next section or EOF) cleaned = stripCodexGsdAgentSections(cleaned); @@ -1047,11 +1053,822 @@ function stripGsdFromCodexConfig(content) { // Remove [agents] section if now empty cleaned = cleaned.replace(/^\[agents\]\s*\n(?=\[|$)/m, ''); - // Clean up excessive blank lines - cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim(); + cleaned = cleaned.replace(/^(?:\r?\n)+/, '').trimEnd(); if (!cleaned) return null; - return cleaned + '\n'; + return cleaned + eol; +} + +function detectLineEnding(content) { + const firstNewlineIndex = content.indexOf('\n'); + if (firstNewlineIndex === -1) { + return '\n'; + } + return firstNewlineIndex > 0 && content[firstNewlineIndex - 1] === '\r' ? '\r\n' : '\n'; +} + +function splitTomlLines(content) { + const lines = []; + let start = 0; + + while (start < content.length) { + const newlineIndex = content.indexOf('\n', start); + if (newlineIndex === -1) { + lines.push({ + start, + end: content.length, + text: content.slice(start), + eol: '', + }); + break; + } + + const hasCr = newlineIndex > start && content[newlineIndex - 1] === '\r'; + const end = hasCr ? newlineIndex - 1 : newlineIndex; + lines.push({ + start, + end, + text: content.slice(start, end), + eol: hasCr ? '\r\n' : '\n', + }); + start = newlineIndex + 1; + } + + return lines; +} + +function findTomlCommentStart(line) { + let i = 0; + let multilineState = null; + + while (i < line.length) { + if (multilineState === 'literal') { + const closeIndex = line.indexOf('\'\'\'', i); + if (closeIndex === -1) { + return -1; + } + i = closeIndex + 3; + multilineState = null; + continue; + } + + if (multilineState === 'basic') { + const closeIndex = findMultilineBasicStringClose(line, i); + if (closeIndex === -1) { + return -1; + } + i = closeIndex + 3; + multilineState = null; + continue; + } + + const ch = line[i]; + + if (ch === '#') { + return i; + } + + if (ch === '\'') { + if (line.startsWith('\'\'\'', i)) { + multilineState = 'literal'; + i += 3; + continue; + } + const close = line.indexOf('\'', i + 1); + if (close === -1) return -1; + i = close + 1; + continue; + } + + if (ch === '"') { + if (line.startsWith('"""', i)) { + multilineState = 'basic'; + i += 3; + continue; + } + i += 1; + while (i < line.length) { + if (line[i] === '\\') { + i += 2; + continue; + } + if (line[i] === '"') { + i += 1; + break; + } + i += 1; + } + continue; + } + + i += 1; + } + + return -1; +} + +function isEscapedInBasicString(line, index) { + let slashCount = 0; + let cursor = index - 1; + + while (cursor >= 0 && line[cursor] === '\\') { + slashCount += 1; + cursor -= 1; + } + + return slashCount % 2 === 1; +} + +function findMultilineBasicStringClose(line, startIndex) { + let searchIndex = startIndex; + + while (searchIndex < line.length) { + const closeIndex = line.indexOf('"""', searchIndex); + if (closeIndex === -1) { + return -1; + } + if (!isEscapedInBasicString(line, closeIndex)) { + return closeIndex; + } + searchIndex = closeIndex + 1; + } + + return -1; +} + +function advanceTomlMultilineStringState(line, multilineState) { + let i = 0; + let state = multilineState; + + while (i < line.length) { + if (state === 'literal') { + const closeIndex = line.indexOf('\'\'\'', i); + if (closeIndex === -1) { + return state; + } + i = closeIndex + 3; + state = null; + continue; + } + + if (state === 'basic') { + const closeIndex = findMultilineBasicStringClose(line, i); + if (closeIndex === -1) { + return state; + } + i = closeIndex + 3; + state = null; + continue; + } + + const ch = line[i]; + + if (ch === '#') { + return state; + } + + if (ch === '\'') { + if (line.startsWith('\'\'\'', i)) { + state = 'literal'; + i += 3; + continue; + } + const close = line.indexOf('\'', i + 1); + if (close === -1) { + return state; + } + i = close + 1; + continue; + } + + if (ch === '"') { + if (line.startsWith('"""', i)) { + state = 'basic'; + i += 3; + continue; + } + i += 1; + while (i < line.length) { + if (line[i] === '\\') { + i += 2; + continue; + } + if (line[i] === '"') { + i += 1; + break; + } + i += 1; + } + continue; + } + + i += 1; + } + + return state; +} + +function parseTomlBracketHeader(line, array) { + let i = 0; + + while (i < line.length && /\s/.test(line[i])) { + i += 1; + } + + const open = array ? '[[' : '['; + const close = array ? ']]' : ']'; + if (!line.startsWith(open, i)) { + return null; + } + + i += open.length; + const start = i; + + while (i < line.length) { + if (line[i] === '\'' || line[i] === '"') { + const quote = line[i]; + i += 1; + + while (i < line.length) { + if (quote === '"' && line[i] === '\\') { + i += 2; + continue; + } + + if (line[i] === quote) { + i += 1; + break; + } + + i += 1; + } + + continue; + } + + if (line.startsWith(close, i)) { + const rawPath = line.slice(start, i).trim(); + const segments = parseTomlKeyPath(rawPath); + if (!segments) { + return null; + } + + i += close.length; + while (i < line.length && /\s/.test(line[i])) { + i += 1; + } + + if (i < line.length && line[i] !== '#') { + return null; + } + + return { path: segments.join('.'), segments, array }; + } + + if (line[i] === '#' || line[i] === '\r' || line[i] === '\n') { + return null; + } + + i += 1; + } + + return null; +} + +function parseTomlTableHeader(line) { + return parseTomlBracketHeader(line, true) || parseTomlBracketHeader(line, false); +} + +function findTomlAssignmentEquals(line) { + let i = 0; + + while (i < line.length) { + const ch = line[i]; + + if (ch === '#') { + return -1; + } + + if (ch === '\'') { + i += 1; + while (i < line.length) { + if (line[i] === '\'') { + i += 1; + break; + } + i += 1; + } + continue; + } + + if (ch === '"') { + i += 1; + while (i < line.length) { + if (line[i] === '\\') { + i += 2; + continue; + } + if (line[i] === '"') { + i += 1; + break; + } + i += 1; + } + continue; + } + + if (ch === '=') { + return i; + } + + i += 1; + } + + return -1; +} + +function parseTomlKeyPath(keyText) { + const segments = []; + let i = 0; + + while (i < keyText.length) { + while (i < keyText.length && /\s/.test(keyText[i])) { + i += 1; + } + + if (i >= keyText.length) { + break; + } + + if (keyText[i] === '\'' || keyText[i] === '"') { + const quote = keyText[i]; + let segment = ''; + let closed = false; + i += 1; + + while (i < keyText.length) { + if (quote === '"' && keyText[i] === '\\') { + if (i + 1 >= keyText.length) { + return null; + } + segment += keyText[i + 1]; + i += 2; + continue; + } + + if (keyText[i] === quote) { + i += 1; + closed = true; + break; + } + + segment += keyText[i]; + i += 1; + } + + if (!closed) { + return null; + } + + segments.push(segment); + } else { + const match = keyText.slice(i).match(/^[A-Za-z0-9_-]+/); + if (!match) { + return null; + } + segments.push(match[0]); + i += match[0].length; + } + + while (i < keyText.length && /\s/.test(keyText[i])) { + i += 1; + } + + if (i >= keyText.length) { + break; + } + + if (keyText[i] !== '.') { + return null; + } + + i += 1; + } + + return segments.length > 0 ? segments : null; +} + +function parseTomlKey(line) { + const header = parseTomlTableHeader(line); + if (header) { + return null; + } + + const equalsIndex = findTomlAssignmentEquals(line); + if (equalsIndex === -1) { + return null; + } + + const raw = line.slice(0, equalsIndex).trim(); + const segments = parseTomlKeyPath(raw); + if (!segments) { + return null; + } + + return { raw, segments }; +} + +function getTomlLineRecords(content) { + const lines = splitTomlLines(content); + const records = []; + let currentTablePath = null; + let multilineState = null; + + for (const line of lines) { + const startsInMultilineString = multilineState !== null; + const record = { + ...line, + startsInMultilineString, + tablePath: currentTablePath, + tableHeader: null, + keySegments: null, + }; + + if (!startsInMultilineString) { + const header = parseTomlTableHeader(line.text); + if (header) { + record.tableHeader = header; + currentTablePath = header.path; + } else { + const key = parseTomlKey(line.text); + record.keySegments = key ? key.segments : null; + record.keyRaw = key ? key.raw : null; + } + } + + multilineState = advanceTomlMultilineStringState(line.text, multilineState); + records.push(record); + } + + return records; +} + +function getTomlTableSections(content) { + const headerLines = getTomlLineRecords(content).filter((record) => record.tableHeader); + + return headerLines.map((record, index) => ({ + path: record.tableHeader.path, + array: record.tableHeader.array, + start: record.start, + headerEnd: record.end + record.eol.length, + end: index + 1 < headerLines.length ? headerLines[index + 1].start : content.length, + })); +} + +function collapseTomlBlankLines(content) { + const eol = detectLineEnding(content); + return content.replace(/(?:\r?\n){3,}/g, eol + eol); +} + +function removeContentRanges(content, ranges) { + const normalizedRanges = ranges + .filter((range) => range && range.start < range.end) + .sort((a, b) => a.start - b.start); + + if (normalizedRanges.length === 0) { + return content; + } + + const mergedRanges = [{ ...normalizedRanges[0] }]; + + for (let i = 1; i < normalizedRanges.length; i += 1) { + const current = normalizedRanges[i]; + const previous = mergedRanges[mergedRanges.length - 1]; + + if (current.start <= previous.end) { + previous.end = Math.max(previous.end, current.end); + continue; + } + + mergedRanges.push({ ...current }); + } + + let cleaned = ''; + let cursor = 0; + + for (const range of mergedRanges) { + cleaned += content.slice(cursor, range.start); + cursor = range.end; + } + + cleaned += content.slice(cursor); + return cleaned; +} + +function stripCodexHooksFeatureAssignments(content, ownership = null) { + const lineRecords = getTomlLineRecords(content); + const tableSections = getTomlTableSections(content); + const removalRanges = []; + const featuresSection = tableSections.find((section) => !section.array && section.path === 'features'); + const shouldStripSectionKey = ownership === 'section' || ownership === 'all'; + const shouldStripRootDottedKey = ownership === 'root_dotted' || ownership === 'all'; + + if (featuresSection && shouldStripSectionKey) { + const sectionRecords = lineRecords.filter((record) => + !record.tableHeader && + record.start >= featuresSection.headerEnd && + record.end + record.eol.length <= featuresSection.end + ); + + const codexHookRecords = sectionRecords.filter((record) => + !record.startsInMultilineString && + record.keySegments && + record.keySegments.length === 1 && + record.keySegments[0] === 'codex_hooks' + ); + + for (const record of codexHookRecords) { + removalRanges.push({ + start: record.start, + end: findTomlAssignmentBlockEnd(content, record), + }); + } + + if (codexHookRecords.length > 0) { + const removedStarts = new Set(codexHookRecords.map((record) => record.start)); + const hasRemainingContent = sectionRecords.some((record) => { + if (removedStarts.has(record.start)) { + return false; + } + + const trimmed = record.text.trim(); + return trimmed !== '' && !trimmed.startsWith('#'); + }); + const hasRemainingComments = sectionRecords.some((record) => { + if (removedStarts.has(record.start)) { + return false; + } + + return record.text.trim().startsWith('#'); + }); + + if (!hasRemainingContent && !hasRemainingComments) { + removalRanges.push({ + start: featuresSection.start, + end: featuresSection.end, + }); + } + } + } + + if (shouldStripRootDottedKey) { + const rootCodexHookRecords = lineRecords.filter((record) => + !record.tableHeader && + !record.startsInMultilineString && + record.tablePath === null && + record.keySegments && + record.keySegments.length === 2 && + record.keySegments[0] === 'features' && + record.keySegments[1] === 'codex_hooks' + ); + + for (const record of rootCodexHookRecords) { + removalRanges.push({ + start: record.start, + end: findTomlAssignmentBlockEnd(content, record), + }); + } + } + + return removeContentRanges(content, removalRanges); +} + +function getManagedCodexHooksOwnership(content) { + const markerIndex = content.indexOf(GSD_CODEX_MARKER); + if (markerIndex === -1) { + return null; + } + + const afterMarker = content.slice(markerIndex + GSD_CODEX_MARKER.length); + const match = afterMarker.match(/^\r?\n# GSD codex_hooks ownership: (section|root_dotted)\r?\n/); + return match ? match[1] : null; +} + +function setManagedCodexHooksOwnership(content, ownership) { + const markerIndex = content.indexOf(GSD_CODEX_MARKER); + if (markerIndex === -1) { + return content; + } + + const eol = detectLineEnding(content); + const markerEnd = markerIndex + GSD_CODEX_MARKER.length; + const afterMarker = content.slice(markerEnd); + const normalizedAfterMarker = afterMarker.replace( + /^\r?\n# GSD codex_hooks ownership: (?:section|root_dotted)\r?\n/, + eol + ); + + if (!ownership) { + return content.slice(0, markerEnd) + normalizedAfterMarker; + } + + const remainder = normalizedAfterMarker.replace(/^\r?\n/, ''); + return content.slice(0, markerEnd) + + eol + + `${GSD_CODEX_HOOKS_OWNERSHIP_PREFIX}${ownership}${eol}` + + remainder; +} + +function isLegacyGsdAgentsSection(body) { + const lineRecords = getTomlLineRecords(body); + const legacyKeys = new Set(['max_threads', 'max_depth']); + let sawLegacyKey = false; + + for (const record of lineRecords) { + if (record.startsInMultilineString) { + return false; + } + + if (record.tableHeader) { + return false; + } + + const trimmed = record.text.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + if (!record.keySegments || record.keySegments.length !== 1 || !legacyKeys.has(record.keySegments[0])) { + return false; + } + + sawLegacyKey = true; + } + + return sawLegacyKey; +} + +function stripLeakedGsdCodexSections(content) { + const leakedSections = getTomlTableSections(content) + .filter((section) => + section.path.startsWith('agents.gsd-') || + ( + section.path === 'agents' && + isLegacyGsdAgentsSection(content.slice(section.headerEnd, section.end)) + ) + ); + + if (leakedSections.length === 0) { + return content; + } + + let cleaned = ''; + let cursor = 0; + + for (const section of leakedSections) { + cleaned += content.slice(cursor, section.start); + cursor = section.end; + } + + cleaned += content.slice(cursor); + return collapseTomlBlankLines(cleaned); +} + +function normalizeCodexHooksLine(line, key) { + const leadingWhitespace = line.match(/^\s*/)[0]; + const commentStart = findTomlCommentStart(line); + const comment = commentStart === -1 ? '' : line.slice(commentStart); + return `${leadingWhitespace}${key} = true${comment ? ` ${comment}` : ''}`; +} + +function findTomlAssignmentBlockEnd(content, record) { + const equalsIndex = findTomlAssignmentEquals(record.text); + if (equalsIndex === -1) { + return record.end + record.eol.length; + } + + let i = record.start + equalsIndex + 1; + let arrayDepth = 0; + let inlineTableDepth = 0; + + while (i < content.length) { + if (content.startsWith('\'\'\'', i)) { + const closeIndex = content.indexOf('\'\'\'', i + 3); + if (closeIndex === -1) { + return content.length; + } + i = closeIndex + 3; + continue; + } + + if (content.startsWith('"""', i)) { + const closeIndex = findMultilineBasicStringClose(content, i + 3); + if (closeIndex === -1) { + return content.length; + } + i = closeIndex + 3; + continue; + } + + const ch = content[i]; + + if (ch === '\'') { + i += 1; + while (i < content.length) { + if (content[i] === '\'') { + i += 1; + break; + } + i += 1; + } + continue; + } + + if (ch === '"') { + i += 1; + while (i < content.length) { + if (content[i] === '\\') { + i += 2; + continue; + } + if (content[i] === '"') { + i += 1; + break; + } + i += 1; + } + continue; + } + + if (ch === '[') { + arrayDepth += 1; + i += 1; + continue; + } + + if (ch === ']') { + if (arrayDepth > 0) { + arrayDepth -= 1; + } + i += 1; + continue; + } + + if (ch === '{') { + inlineTableDepth += 1; + i += 1; + continue; + } + + if (ch === '}') { + if (inlineTableDepth > 0) { + inlineTableDepth -= 1; + } + i += 1; + continue; + } + + if (ch === '#') { + while (i < content.length && content[i] !== '\n') { + i += 1; + } + continue; + } + + if (ch === '\n' && arrayDepth === 0 && inlineTableDepth === 0) { + return i + 1; + } + + i += 1; + } + + return content.length; +} + +function rewriteTomlKeyLines(content, matches, key) { + if (matches.length === 0) { + return content; + } + + let rewritten = ''; + let cursor = 0; + + matches.forEach((match, index) => { + rewritten += content.slice(cursor, match.start); + if (index === 0) { + const blockEnd = findTomlAssignmentBlockEnd(content, match); + const blockEol = blockEnd > 0 && content[blockEnd - 1] === '\n' + ? (blockEnd > 1 && content[blockEnd - 2] === '\r' ? '\r\n' : '\n') + : ''; + rewritten += normalizeCodexHooksLine(match.text, match.keyRaw || key) + blockEol; + cursor = blockEnd; + return; + } + cursor = findTomlAssignmentBlockEnd(content, match); + }); + + rewritten += content.slice(cursor); + return rewritten; } /** @@ -1066,6 +1883,8 @@ function mergeCodexConfig(configPath, gsdBlock) { } const existing = fs.readFileSync(configPath, 'utf8'); + const eol = detectLineEnding(existing); + const normalizedGsdBlock = gsdBlock.replace(/\r?\n/g, eol); const markerIndex = existing.indexOf(GSD_CODEX_MARKER); // Case 2: Has GSD marker — truncate and re-append @@ -1073,31 +1892,139 @@ 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 = stripCodexGsdAgentSections(before); - before = before.replace(/^\[agents\]\n(?:(?!\[)[^\n]*\n?)*/m, ''); - before = before.replace(/\n{3,}/g, '\n\n').trimEnd(); + before = stripLeakedGsdCodexSections(before).trimEnd(); - fs.writeFileSync(configPath, before + '\n\n' + gsdBlock + '\n'); + fs.writeFileSync(configPath, before + eol + eol + normalizedGsdBlock + eol); } else { - fs.writeFileSync(configPath, gsdBlock + '\n'); + fs.writeFileSync(configPath, normalizedGsdBlock + eol); } return; } // Case 3: No marker — append GSD block - let content = existing; - content = stripCodexGsdAgentSections(content); - content = content.replace(/\n{3,}/g, '\n\n').trimEnd(); - + let content = stripLeakedGsdCodexSections(existing).trimEnd(); if (content) { - content = content + '\n\n' + gsdBlock + '\n'; + content = content + eol + eol + normalizedGsdBlock + eol; } else { - content = gsdBlock + '\n'; + content = normalizedGsdBlock + eol; } fs.writeFileSync(configPath, content); } +function ensureCodexHooksFeature(configContent) { + const eol = detectLineEnding(configContent); + const lineRecords = getTomlLineRecords(configContent); + + const featuresSection = getTomlTableSections(configContent) + .find((section) => !section.array && section.path === 'features'); + + if (featuresSection) { + const sectionLines = lineRecords + .filter((record) => + !record.tableHeader && + !record.startsInMultilineString && + record.tablePath === 'features' && + record.start >= featuresSection.headerEnd && + record.end + record.eol.length <= featuresSection.end && + record.keySegments && + record.keySegments.length === 1 && + record.keySegments[0] === 'codex_hooks' + ); + + if (sectionLines.length > 0) { + return { + content: rewriteTomlKeyLines(configContent, sectionLines, 'codex_hooks'), + ownership: null, + }; + } + + const sectionBody = configContent.slice(featuresSection.headerEnd, featuresSection.end); + const needsSeparator = sectionBody.length > 0 && !sectionBody.endsWith('\n') && !sectionBody.endsWith('\r\n'); + const insertPrefix = sectionBody.length === 0 && featuresSection.headerEnd === configContent.length ? eol : ''; + const insertText = `${insertPrefix}${needsSeparator ? eol : ''}codex_hooks = true${eol}`; + return { + content: configContent.slice(0, featuresSection.end) + insertText + configContent.slice(featuresSection.end), + ownership: 'section', + }; + } + + const rootFeatureLines = lineRecords + .filter((record) => + !record.tableHeader && + !record.startsInMultilineString && + record.tablePath === null && + record.keySegments && + record.keySegments[0] === 'features' + ); + + const rootCodexHooksLines = rootFeatureLines + .filter((record) => record.keySegments.length === 2 && record.keySegments[1] === 'codex_hooks'); + + if (rootCodexHooksLines.length > 0) { + return { + content: rewriteTomlKeyLines(configContent, rootCodexHooksLines, 'features.codex_hooks'), + ownership: null, + }; + } + + const rootFeaturesValueLines = rootFeatureLines + .filter((record) => record.keySegments.length === 1); + + if (rootFeaturesValueLines.length > 0) { + return { content: configContent, ownership: null }; + } + + if (rootFeatureLines.length > 0) { + const lastFeatureLine = rootFeatureLines[rootFeatureLines.length - 1]; + const insertAt = findTomlAssignmentBlockEnd(configContent, lastFeatureLine); + const prefix = insertAt > 0 && configContent[insertAt - 1] === '\n' ? '' : eol; + return { + content: configContent.slice(0, insertAt) + + `${prefix}features.codex_hooks = true${eol}` + + configContent.slice(insertAt), + ownership: 'root_dotted', + }; + } + + const featuresBlock = `[features]${eol}codex_hooks = true${eol}`; + if (!configContent) { + return { content: featuresBlock, ownership: 'section' }; + } + return { content: featuresBlock + eol + configContent, ownership: 'section' }; +} + +function hasEnabledCodexHooksFeature(configContent) { + const lineRecords = getTomlLineRecords(configContent); + + return lineRecords.some((record) => { + if (record.tableHeader || record.startsInMultilineString || !record.keySegments) { + return false; + } + + const isSectionKey = record.tablePath === 'features' && + record.keySegments.length === 1 && + record.keySegments[0] === 'codex_hooks'; + const isRootDottedKey = record.tablePath === null && + record.keySegments.length === 2 && + record.keySegments[0] === 'features' && + record.keySegments[1] === 'codex_hooks'; + + if (!isSectionKey && !isRootDottedKey) { + return false; + } + + const equalsIndex = findTomlAssignmentEquals(record.text); + if (equalsIndex === -1) { + return false; + } + + const commentStart = findTomlCommentStart(record.text); + const valueText = record.text.slice(equalsIndex + 1, commentStart === -1 ? record.text.length : commentStart).trim(); + return valueText === 'true'; + }); +} + /** * Merge GSD instructions into copilot-instructions.md. * Three cases: new file, existing with markers, existing without markers. @@ -3023,46 +3950,19 @@ function install(isGlobal, runtime = 'claude') { const configPath = path.join(targetDir, 'config.toml'); try { let configContent = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : ''; - - // Enable hooks feature flag if not present - if (!configContent.includes('codex_hooks')) { - if (configContent.includes('[features]')) { - // Insert codex_hooks = true right after the [features] header. - // Fixes #1202: previous approach could leave non-boolean keys (like - // model = "gpt-5.4") under [features], causing Codex TOML parse errors. - configContent = configContent.replace(/(\[features\]\n)/, '$1codex_hooks = true\n'); - } else { - configContent = '[features]\ncodex_hooks = true\n\n' + configContent; - } - } - - // Safety check: detect non-boolean keys under [features] that would break Codex (#1202). - // Extract the [features] section content (between [features] and next [section] or EOF). - const featuresMatch = configContent.match(/\[features\]\n([\s\S]*?)(?=\n\[|$)/); - if (featuresMatch) { - const featuresBody = featuresMatch[1]; - const nonBooleanKeys = featuresBody.split('\n') - .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)) - .map(line => line.trim()); - if (nonBooleanKeys.length > 0) { - // Move non-boolean keys above [features] to prevent TOML parse errors - let cleanedFeatures = featuresBody.split('\n') - .filter(line => !line.match(/^\s*\w+\s*=/) || line.match(/=\s*(true|false)\s*(#.*)?$/)) - .join('\n'); - const movedKeys = nonBooleanKeys.join('\n') + '\n'; - configContent = configContent.replace( - /\[features\]\n[\s\S]*?(?=\n\[|$)/, - movedKeys + '\n[features]\n' + cleanedFeatures.trim() + '\n' - ); - console.log(` ${yellow}⚠${reset} Moved ${nonBooleanKeys.length} non-feature key(s) out of [features] section to prevent TOML errors`); - } - } + const eol = detectLineEnding(configContent); + const codexHooksFeature = ensureCodexHooksFeature(configContent); + configContent = setManagedCodexHooksOwnership(codexHooksFeature.content, codexHooksFeature.ownership); // Add SessionStart hook for update checking const updateCheckScript = path.resolve(targetDir, 'get-shit-done', 'hooks', 'gsd-update-check.js').replace(/\\/g, '/'); - const hookBlock = `\n# GSD Hooks\n[[hooks]]\nevent = "SessionStart"\ncommand = "node ${updateCheckScript}"\n`; + const hookBlock = + `${eol}# GSD Hooks${eol}` + + `[[hooks]]${eol}` + + `event = "SessionStart"${eol}` + + `command = "node ${updateCheckScript}"${eol}`; - if (!configContent.includes('gsd-update-check')) { + if (hasEnabledCodexHooksFeature(configContent) && !configContent.includes('gsd-update-check')) { configContent += hookBlock; } @@ -3415,6 +4315,7 @@ if (process.env.GSD_TEST_MODE) { stripGsdFromCodexConfig, mergeCodexConfig, installCodexConfig, + install, convertClaudeCommandToCodexSkill, convertClaudeToOpencodeFrontmatter, neutralizeAgentReferences, diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index 6f7587d29..2747082a3 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -21,10 +21,57 @@ const { generateCodexConfigBlock, stripGsdFromCodexConfig, mergeCodexConfig, + install, GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, } = require('../bin/install.js'); +function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) { + const previousCodeHome = process.env.CODEX_HOME; + const previousCwd = process.cwd(); + process.env.CODEX_HOME = codexHome; + + try { + process.chdir(cwd); + return install(true, 'codex'); + } finally { + process.chdir(previousCwd); + if (previousCodeHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodeHome; + } + } +} + +function readCodexConfig(codexHome) { + return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); +} + +function writeCodexConfig(codexHome, content) { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); +} + +function countMatches(content, pattern) { + return (content.match(pattern) || []).length; +} + +function assertNoDraftRootKeys(content) { + assert.ok(!content.includes('model = "gpt-5.4"'), 'does not inject draft model default'); + assert.ok(!content.includes('model_reasoning_effort = "high"'), 'does not inject draft reasoning default'); + assert.ok(!content.includes('disable_response_storage = true'), 'does not inject draft storage default'); +} + +function assertUsesOnlyEol(content, eol) { + if (eol === '\r\n') { + assert.ok(content.includes('\r\n'), 'contains CRLF line endings'); + assert.ok(!content.replace(/\r\n/g, '').includes('\n'), 'does not contain bare LF line endings'); + return; + } + assert.ok(!content.includes('\r\n'), 'does not contain CRLF line endings'); +} + // ─── getCodexSkillAdapterHeader ───────────────────────────────────────────────── describe('getCodexSkillAdapterHeader', () => { @@ -474,6 +521,77 @@ describe('mergeCodexConfig', () => { assert.ok(!beforeMarker.includes('[agents.gsd-'), 'no leaked [agents.gsd-*] above marker'); }); + test('case 2 strips leaked GSD-managed sections above marker in CRLF files', () => { + const configPath = path.join(tmpDir, 'config.toml'); + const brokenContent = [ + '[features]', + 'child_agents_md = false', + '', + '[agents]', + 'max_threads = 4', + '', + '[agents.gsd-executor]', + 'description = "stale"', + 'config_file = "agents/gsd-executor.toml"', + '', + GSD_CODEX_MARKER, + '', + '[agents.gsd-executor]', + 'description = "Executes plans"', + 'config_file = "agents/gsd-executor.toml"', + '', + ].join('\r\n'); + fs.writeFileSync(configPath, brokenContent, 'utf8'); + + mergeCodexConfig(configPath, sampleBlock); + mergeCodexConfig(configPath, sampleBlock); + + const content = fs.readFileSync(configPath, 'utf8'); + const markerIndex = content.indexOf(GSD_CODEX_MARKER); + const beforeMarker = content.slice(0, markerIndex); + + assert.ok(content.includes('child_agents_md = false'), 'preserves user feature keys'); + assert.strictEqual(countMatches(beforeMarker, /^\[agents\]\s*$/gm), 0, 'removes leaked [agents] above marker'); + assert.strictEqual(countMatches(beforeMarker, /^\[agents\.gsd-executor\]\s*$/gm), 0, 'removes leaked GSD agent section above marker'); + assert.strictEqual(countMatches(content, /^\[agents\.gsd-executor\]\s*$/gm), 1, 'keeps one managed agent section'); + assertUsesOnlyEol(content, '\r\n'); + }); + + test('case 2 preserves user-authored [agents] tables while stripping leaked GSD sections in CRLF files', () => { + const configPath = path.join(tmpDir, 'config.toml'); + const brokenContent = [ + '[features]', + 'child_agents_md = false', + '', + '[agents]', + 'default = "custom-agent"', + '', + '[agents.gsd-executor]', + 'description = "stale"', + 'config_file = "agents/gsd-executor.toml"', + '', + GSD_CODEX_MARKER, + '', + '[agents.gsd-executor]', + 'description = "Executes plans"', + 'config_file = "agents/gsd-executor.toml"', + '', + ].join('\r\n'); + fs.writeFileSync(configPath, brokenContent, 'utf8'); + + mergeCodexConfig(configPath, sampleBlock); + mergeCodexConfig(configPath, sampleBlock); + + const content = fs.readFileSync(configPath, 'utf8'); + const markerIndex = content.indexOf(GSD_CODEX_MARKER); + const beforeMarker = content.slice(0, markerIndex); + + assert.ok(beforeMarker.includes('[agents]\r\ndefault = "custom-agent"\r\n'), 'preserves user-authored [agents] table'); + assert.strictEqual(countMatches(beforeMarker, /^\[agents\.gsd-executor\]\s*$/gm), 0, 'removes leaked GSD agent section above marker'); + assert.strictEqual(countMatches(content, /^\[agents\.gsd-executor\]\s*$/gm), 1, 'keeps one managed agent section in the GSD block'); + assertUsesOnlyEol(content, '\r\n'); + }); + test('case 2 idempotent after case 3 with existing [features]', () => { const configPath = path.join(tmpDir, 'config.toml'); fs.writeFileSync(configPath, '[features]\nother_feature = true\n'); @@ -489,6 +607,29 @@ describe('mergeCodexConfig', () => { assert.strictEqual(first, second, 'idempotent after 2nd merge'); assert.strictEqual(second, third, 'idempotent after 3rd merge'); }); + + test('preserves CRLF when appending GSD block to existing config', () => { + const configPath = path.join(tmpDir, 'config.toml'); + fs.writeFileSync(configPath, '[model]\r\nname = "o3"\r\n', 'utf8'); + + mergeCodexConfig(configPath, sampleBlock); + + const content = fs.readFileSync(configPath, 'utf8'); + assert.ok(content.includes('[model]\r\nname = "o3"\r\n'), 'preserves existing CRLF content'); + assert.ok(content.includes(`${GSD_CODEX_MARKER}\r\n`), 'writes marker with CRLF'); + assertUsesOnlyEol(content, '\r\n'); + }); + + test('uses the first newline style when appending GSD block to mixed-EOL configs', () => { + const configPath = path.join(tmpDir, 'config.toml'); + fs.writeFileSync(configPath, '# first line wins\n[model]\r\nname = "o3"\r\n', 'utf8'); + + mergeCodexConfig(configPath, sampleBlock); + + const content = fs.readFileSync(configPath, 'utf8'); + assert.ok(content.includes('# first line wins\n[model]\r\nname = "o3"'), 'preserves the existing mixed-EOL model content'); + assert.ok(content.includes(`\n\n${GSD_CODEX_MARKER}\n`), 'writes the managed block using the first newline style'); + }); }); // ─── Integration: installCodexConfig ──────────────────────────────────────────── @@ -572,3 +713,709 @@ describe('codex features section safety', () => { assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys in a clean config'); }); }); + +describe('Codex install hook configuration (e2e)', () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-e2e-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('fresh CODEX_HOME enables codex_hooks without draft root defaults', () => { + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('[features]\ncodex_hooks = true\n'), 'writes codex_hooks feature'); + assert.ok(content.includes('# GSD Hooks\n[[hooks]]\nevent = "SessionStart"\n'), 'writes GSD SessionStart hook block'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'writes one codex_hooks key'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'writes one GSD update hook'); + assertNoDraftRootKeys(content); + assertUsesOnlyEol(content, '\n'); + }); + + test('existing LF config without [features] gets one features block and preserves user content', () => { + writeCodexConfig(codexHome, [ + '# user comment', + '[model]', + 'name = "o3"', + '', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "echo custom"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'creates one codex_hooks key'); + assert.ok(content.includes('# user comment'), 'preserves user comment'); + assert.ok(content.includes('[model]\nname = "o3"'), 'preserves model section'); + assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'adds one GSD update hook'); + assertNoDraftRootKeys(content); + }); + + test('existing CRLF config without [features] preserves CRLF and adds codex_hooks', () => { + writeCodexConfig(codexHome, '# user comment\r\n[model]\r\nname = "o3"\r\n'); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'creates one codex_hooks key'); + assert.ok(content.includes('# user comment\r\n[model]\r\nname = "o3"\r\n'), 'preserves existing CRLF content'); + assertUsesOnlyEol(content, '\r\n'); + assertNoDraftRootKeys(content); + }); + + test('existing CRLF [features] comment-only table gets codex_hooks without losing adjacent text', () => { + writeCodexConfig(codexHome, [ + '# user comment', + '[features]', + '# keep me', + '', + '[model]', + 'name = "o3"', + '', + ].join('\r\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.ok(content.includes('[features]\r\n# keep me\r\n\r\ncodex_hooks = true\r\n'), 'adds codex_hooks within comment-only table'); + assert.ok(content.includes('[model]\r\nname = "o3"\r\n'), 'preserves following table'); + assertUsesOnlyEol(content, '\r\n'); + assertNoDraftRootKeys(content); + }); + + test('existing [features] with trailing comment gets one codex_hooks without a second table', () => { + writeCodexConfig(codexHome, [ + '[features] # keep comment', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\s*\[features\](?:\s*#.*)?$/gm), 1, 'keeps one commented [features] header'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.ok(content.includes('[features] # keep comment\nother_feature = true'), 'preserves commented features table'); + assert.ok(content.indexOf('codex_hooks = true') > content.indexOf('[features] # keep comment'), 'adds codex_hooks within existing features table'); + assert.ok(content.indexOf('codex_hooks = true') < content.indexOf('[model]'), 'does not create a second features table before model'); + assertNoDraftRootKeys(content); + }); + + test('existing [features] at EOF without trailing newline is updated in place', () => { + writeCodexConfig(codexHome, '[model]\nname = "o3"\n\n[features]'); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.ok(content.indexOf('codex_hooks = true') > content.indexOf('[features]'), 'adds codex_hooks after the existing EOF features header'); + assert.ok(content.indexOf('codex_hooks = true') < content.indexOf('[agents.gsd-codebase-mapper]'), 'keeps codex_hooks before the next real table'); + assertNoDraftRootKeys(content); + }); + + test('existing empty [features] and codex_hooks = false are normalized and remain idempotent', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = false', + 'other_feature = true', + '', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "echo custom"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'normalizes to one codex_hooks = true'); + assert.ok(!content.includes('codex_hooks = false'), 'removes false codex_hooks value'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'does not duplicate GSD update hook'); + assertNoDraftRootKeys(content); + }); + + test('quoted codex_hooks keys inside [features] are normalized without adding a bare duplicate', () => { + writeCodexConfig(codexHome, [ + '[features]', + '"codex_hooks" = false', + 'other_feature = true', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^"codex_hooks" = true$/gm), 1, 'normalizes the quoted key to true'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 0, 'does not append a bare duplicate codex_hooks key'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + assertNoDraftRootKeys(content); + }); + + test('quoted [features] headers are recognized as the existing features table', () => { + writeCodexConfig(codexHome, [ + '["features"]', + '"codex_hooks" = false', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[(?:"features"|'features'|features)\]\s*$/gm), 1, 'keeps one features table'); + assert.strictEqual(countMatches(content, /^"codex_hooks" = true$/gm), 1, 'normalizes the quoted codex_hooks key to true'); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a second bare features table'); + assert.ok(content.includes('other_feature = true'), 'preserves existing feature keys'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'keeps one GSD update hook'); + assertNoDraftRootKeys(content); + }); + + test('quoted table headers containing # are parsed without treating # as a comment start', () => { + writeCodexConfig(codexHome, [ + '[features."a#b"]', + 'enabled = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('[features."a#b"]\nenabled = true'), 'preserves the quoted nested features table'); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'adds one real top-level features table'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'remains idempotent for the GSD hook block'); + assertNoDraftRootKeys(content); + }); + + test('existing dotted features config stays dotted and does not grow a [features] table', () => { + writeCodexConfig(codexHome, [ + 'features.other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table'); + assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); + assert.ok(content.includes('features.other_feature = true'), 'preserves existing dotted features key'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'adds one GSD update hook for dotted codex_hooks and remains idempotent'); + assertNoDraftRootKeys(content); + }); + + test('root inline-table features assignments are left untouched without appending invalid dotted keys or hooks', () => { + writeCodexConfig(codexHome, [ + 'features = { other_feature = true }', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('features = { other_feature = true }'), 'preserves the root inline-table assignment'); + assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key'); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely'); + assert.ok(content.includes('[agents.gsd-executor]'), 'still installs the managed agent block'); + assertNoDraftRootKeys(content); + }); + + test('root scalar features assignments are left untouched without appending invalid dotted keys or hooks', () => { + writeCodexConfig(codexHome, [ + 'features = "disabled"', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('features = "disabled"'), 'preserves the root scalar assignment'); + assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key'); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely'); + assert.ok(content.includes('[agents.gsd-executor]'), 'still installs the managed agent block'); + assertNoDraftRootKeys(content); + }); + + test('quoted dotted codex_hooks keys stay dotted and are normalized without duplication', () => { + writeCodexConfig(codexHome, [ + 'features."codex_hooks" = false', + 'features.other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table'); + assert.strictEqual(countMatches(content, /^features\."codex_hooks" = true$/gm), 1, 'normalizes the quoted dotted key to true'); + assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append a bare dotted duplicate'); + assert.ok(content.includes('features.other_feature = true'), 'preserves other dotted features keys'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'adds one GSD update hook for quoted dotted codex_hooks and remains idempotent'); + assertNoDraftRootKeys(content); + }); + + test('multiline dotted features assignments insert codex_hooks after the full assignment block', () => { + writeCodexConfig(codexHome, [ + 'features.notes = """', + 'keep-me', + '"""', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('features.notes = """\nkeep-me\n"""'), 'preserves the multiline dotted assignment'); + assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); + assert.ok(content.indexOf('features.codex_hooks = true') > content.indexOf('"""'), 'inserts codex_hooks after the multiline assignment closes'); + assert.ok(content.indexOf('features.codex_hooks = true') < content.indexOf('[model]'), 'inserts codex_hooks before the next table'); + assertNoDraftRootKeys(content); + }); + + test('existing empty [features] table is populated with one codex_hooks key', () => { + writeCodexConfig(codexHome, '[features]\r\n\r\n[model]\r\nname = "o3"\r\n'); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.ok(content.includes('[features]\r\n\r\ncodex_hooks = true\r\n'), 'adds codex_hooks to empty table'); + assertUsesOnlyEol(content, '\r\n'); + assertNoDraftRootKeys(content); + }); + + test('multiline strings inside [features] do not create fake tables or fake codex_hooks matches', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'notes = \'\'\'', + '[model]', + 'codex_hooks = false', + '\'\'\'', + 'other_feature = true', + '', + '[[hooks]]', + 'event = "AfterCommand"', + 'command = "echo custom-after-command"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds a real codex_hooks key once'); + assert.ok(content.includes('notes = \'\'\'\n[model]\ncodex_hooks = false\n\'\'\''), 'preserves multiline string content'); + assert.strictEqual(countMatches(content, /^codex_hooks = false$/gm), 1, 'does not rewrite codex_hooks text inside multiline string'); + assert.ok(content.indexOf('codex_hooks = true') > content.indexOf('other_feature = true'), 'does not stop the features section at multiline string content'); + assert.ok(content.indexOf('codex_hooks = true') < content.indexOf('[[hooks]]'), 'inserts the real codex_hooks key before the next table'); + assertNoDraftRootKeys(content); + }); + + test('non-boolean codex_hooks assignments are normalized to true without duplication', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = "sometimes"', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'normalizes to one true value'); + assert.ok(!content.includes('codex_hooks = "sometimes"'), 'removes non-boolean value'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + assertNoDraftRootKeys(content); + }); + + test('multiline basic-string codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = """', + 'multiline-basic-sentinel', + 'still-in-string', + '"""', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline basic-string assignment with one true value'); + assert.ok(!content.includes('multiline-basic-sentinel'), 'removes multiline basic-string continuation lines'); + assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'remains idempotent for the GSD hook block'); + assertNoDraftRootKeys(content); + }); + + test('multiline literal-string codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = \'\'\'', + 'multiline-literal-sentinel', + 'still-in-literal', + '\'\'\'', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline literal-string assignment with one true value'); + assert.ok(!content.includes('multiline-literal-sentinel'), 'removes multiline literal-string continuation lines'); + assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'remains idempotent for the GSD hook block'); + assertNoDraftRootKeys(content); + }); + + test('multiline array codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = [', + ' "array-sentinel-1",', + ' "array-sentinel-2",', + ']', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline array assignment with one true value'); + assert.ok(!content.includes('array-sentinel-1'), 'removes multiline array continuation lines'); + assert.ok(!content.includes('array-sentinel-2'), 'removes multiline array continuation lines'); + assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'remains idempotent for the GSD hook block'); + assertNoDraftRootKeys(content); + }); + + test('triple-quoted codex_hooks values keep inline comments when normalized', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = """sometimes""" # keep me', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'normalizes to true and preserves inline comment'); + assert.ok(!content.includes('"""sometimes"""'), 'removes the old triple-quoted value'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + assertNoDraftRootKeys(content); + }); + + test('existing CRLF codex_hooks = true stays single and preserves non-GSD hooks', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = true', + 'other_feature = true', + '', + '[[hooks]]', + 'event = "AfterCommand"', + 'command = "echo custom-after-command"', + '', + ].join('\r\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'keeps one codex_hooks = true'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + assert.strictEqual(countMatches(content, /echo custom-after-command/g), 1, 'preserves non-GSD hook exactly once'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'keeps one GSD update hook'); + assertUsesOnlyEol(content, '\r\n'); + assertNoDraftRootKeys(content); + }); + + test('codex_hooks = true with an inline comment is treated as enabled for hook installation', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = true # keep me', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); + assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'preserves the commented true value'); + assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'adds the GSD update hook once'); + assertNoDraftRootKeys(content); + }); + + test('mixed-EOL configs use the first newline style for inserted Codex content', () => { + writeCodexConfig(codexHome, '# first line wins\n[model]\r\nname = "o3"\r\n'); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('[features]\ncodex_hooks = true\n\n# first line wins\n'), 'prepends the features block using the first newline style'); + assert.ok(content.includes(`# GSD Agent Configuration — managed by get-shit-done installer\n`), 'writes the managed agent block using the first newline style'); + assert.ok(content.includes('# GSD Hooks\n[[hooks]]\nevent = "SessionStart"\n'), 'writes the GSD hook block using the first newline style'); + assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves the existing CRLF model lines'); + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'remains idempotent on repeated installs'); + assert.strictEqual(countMatches(content, /gsd-update-check\.js/g), 1, 'does not duplicate the GSD hook block'); + assertNoDraftRootKeys(content); + }); +}); + +describe('Codex uninstall symmetry for hook-enabled configs', () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-uninstall-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('fresh install removes the GSD-added codex_hooks feature on uninstall', () => { + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.strictEqual(cleaned, null, 'fresh GSD-only config strips back to nothing'); + }); + + test('install then uninstall removes [features].codex_hooks while preserving other feature keys, comments, hooks, and CRLF', () => { + writeCodexConfig(codexHome, [ + '[features]', + '# keep me', + 'other_feature = true', + '', + '[[hooks]]', + 'event = "AfterCommand"', + 'command = "echo custom-after-command"', + '', + '[model]', + 'name = "o3"', + '', + ].join('\r\n')); + + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned, 'preserves user config after uninstall cleanup'); + assert.strictEqual(countMatches(cleaned, /^\[features\](?:\s*#.*)?$/gm), 1, 'keeps the existing features table'); + assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 0, 'removes the GSD-added codex_hooks key'); + assert.ok(cleaned.includes('# keep me'), 'preserves user comments in [features]'); + assert.ok(cleaned.includes('other_feature = true'), 'preserves other feature keys'); + assert.strictEqual(countMatches(cleaned, /echo custom-after-command/g), 1, 'preserves non-GSD hooks'); + assert.strictEqual(countMatches(cleaned, /gsd-update-check\.js/g), 0, 'removes only the GSD update hook'); + assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); + assertUsesOnlyEol(cleaned, '\r\n'); + }); + + test('install then uninstall removes dotted features.codex_hooks without creating a [features] table', () => { + writeCodexConfig(codexHome, [ + 'features.other_feature = true', + '', + '[[hooks]]', + 'event = "AfterCommand"', + 'command = "echo custom-after-command"', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned.includes('features.other_feature = true'), 'preserves other dotted feature keys'); + assert.strictEqual(countMatches(cleaned, /^features\.codex_hooks = true$/gm), 0, 'removes the dotted GSD codex_hooks key'); + assert.strictEqual(countMatches(cleaned, /^\[features\]\s*$/gm), 0, 'does not leave behind a [features] table'); + assert.strictEqual(countMatches(cleaned, /echo custom-after-command/g), 1, 'preserves non-GSD hooks'); + assert.strictEqual(countMatches(cleaned, /gsd-update-check\.js/g), 0, 'removes the GSD update hook'); + }); + + test('install then uninstall preserves a pre-existing [features].codex_hooks = true', () => { + writeCodexConfig(codexHome, [ + '[features]', + 'codex_hooks = true', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned.includes('[features]\ncodex_hooks = true\nother_feature = true'), 'preserves the user-authored codex_hooks assignment'); + assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 1, 'keeps the pre-existing codex_hooks key'); + assert.strictEqual(countMatches(cleaned, /gsd-update-check\.js/g), 0, 'removes the GSD update hook'); + assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); + }); + + test('install then uninstall preserves a pre-existing quoted [features].\"codex_hooks\" = true', () => { + writeCodexConfig(codexHome, [ + '[features]', + '"codex_hooks" = true', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned.includes('[features]\n"codex_hooks" = true\nother_feature = true'), 'preserves the user-authored quoted codex_hooks assignment'); + assert.strictEqual(countMatches(cleaned, /^"codex_hooks" = true$/gm), 1, 'keeps the pre-existing quoted codex_hooks key'); + assert.strictEqual(countMatches(cleaned, /gsd-update-check\.js/g), 0, 'removes the GSD update hook'); + assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); + }); + + test('install then uninstall preserves a pre-existing root dotted features.codex_hooks = true', () => { + writeCodexConfig(codexHome, [ + 'features.codex_hooks = true', + 'features.other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned.includes('features.codex_hooks = true\nfeatures.other_feature = true'), 'preserves the user-authored dotted codex_hooks assignment'); + assert.strictEqual(countMatches(cleaned, /^features\.codex_hooks = true$/gm), 1, 'keeps the pre-existing dotted codex_hooks key'); + assert.strictEqual(countMatches(cleaned, /gsd-update-check\.js/g), 0, 'removes the GSD update hook'); + assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); + }); + + test('install then uninstall leaves short-circuited root features assignments untouched', () => { + const cases = [ + 'features = { other_feature = true }\n\n[model]\nname = "o3"\n', + 'features = "disabled"\n\n[model]\nname = "o3"\n', + ]; + + for (const initialContent of cases) { + writeCodexConfig(codexHome, initialContent); + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.strictEqual(cleaned, initialContent, `preserves short-circuited root features assignment: ${initialContent.split('\n')[0]}`); + + fs.rmSync(codexHome, { recursive: true, force: true }); + fs.mkdirSync(codexHome, { recursive: true }); + } + }); + + test('install then uninstall keeps mixed-EOL user content stable while removing GSD hook state', () => { + const initialContent = [ + '# first line wins', + '[features]', + 'other_feature = true', + '', + '[model]', + 'name = "o3"', + '', + ].join('\r\n').replace(/^# first line wins\r\n/, '# first line wins\n'); + + writeCodexConfig(codexHome, initialContent); + runCodexInstall(codexHome); + + const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome)); + assert.ok(cleaned.includes('# first line wins\n[features]\r\nother_feature = true\r\n\r\n[model]\r\nname = "o3"'), 'preserves the original mixed-EOL user content'); + assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 0, 'removes the injected codex_hooks key'); + assert.strictEqual(countMatches(cleaned, /gsd-update-check\.js/g), 0, 'removes the GSD update hook'); + assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); + }); +});