diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cbccad3a6..0b77305278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ If you use GSD **as a workflow**—milestones, phases, `.planning/` artifacts, b ### Changed +- **Codex install now writes the GSD SessionStart hook into `hooks.json` instead of inline `[[hooks]]` TOML** — fixes invalid `config.toml` shapes that made Codex reject model-setting updates with `invalid type: map, expected a sequence`. Install merges the exact managed GSD SessionStart command into existing `hooks.json` content without clobbering user-owned hooks, preserves malformed existing `hooks.json` files instead of rewriting them, and falls back to `hooks/` when `hooks/dist/` is absent in a source checkout. Uninstall removes only the exact managed GSD hook references from `hooks.json` and legacy inline TOML blocks, preserves user root keys and mixed user-owned entries, and deletes `hooks.json` only when it is GSD-only. - **SDK Phase 3 — runner hot path uses the registry directly** — When you run **phase lifecycle** or **new-project init** through the SDK, the common STATE/roadmap/plan-index/complete/commit/config calls **skip extra subprocess overhead** on the default path (workstreams and test overrides unchanged). *Contributors:* `GSDTools` → `initPhaseOp`, `phasePlanIndex`, `phaseComplete`, `initNewProject`, `configSet`, `commit` (#2302). - **Docs — `docs/CLI-TOOLS.md`** — New **SDK and programmatic access** section (registry-first guidance, CJS→`gsd-sdk query` examples, `GSDTools`/workstream behavior, `state load` vs registry state handlers, CLI-only commands); **See also** links to `QUERY-HANDLERS.md`, Architecture, and COMMANDS (#2302). - **Docs — `docs/USER-GUIDE.md`** — Programmatic CLI subsection: corrected CLI-only vs registry commands; anchor link to CLI-TOOLS SDK section; `state load` caveat cross-reference (#2302). @@ -58,6 +59,7 @@ If you use GSD **as a workflow**—milestones, phases, `.planning/` artifacts, b ### Fixed +- **Codex hook migration follow-up** — Codex installs refresh `gsd-file-manifest.json` after the Codex-specific hook copy/config pass so edited hook files are preserved on later updates; legacy inline hook cleanup decodes TOML literal-string doubled apostrophes before matching managed commands; and uninstall preserves `hooks/gsd-check-update.js` when `hooks.json` cannot be parsed or safely cleaned, avoiding a dangling `hooks.json` reference after users repair the file (#2637). - **End-of-phase routing suggestions now use `/gsd-` (not the retired `/gsd:`)** — All user-visible command suggestions in workflows (`execute-phase.md`, `transition.md`), tool output (`profile-output.cjs`, `init.cjs`), references, and templates have been updated from `/gsd:` to `/gsd-`, matching the Claude Code skill directory name and the user-typed slash-command format. Internal `Skill(skill="gsd:")` calls (no leading slash) are preserved unchanged — those resolve by frontmatter `name:` not directory name. The namespace test (`bug-2543-gsd-slash-namespace.test.cjs`) has been updated to enforce the current invariant. Closes #2697. - **`gsd-sdk query` now resolves parent `.planning/` root in multi-repo (`sub_repos`) workspaces** — when invoked from inside a `sub_repos`-listed child repo (e.g. `workspace/app/`), the SDK now walks up to the parent workspace that owns `.planning/`, matching the legacy `gsd-tools.cjs` `findProjectRoot` behavior. Previously `gsd-sdk query init.new-milestone` reported `project_exists: false` from the sub-repo, while `gsd-tools.cjs` resolved the parent root correctly. Resolution happens once in `cli.ts` before dispatch; if `projectDir` already owns `.planning/` (including explicit `--project-dir`), the walk is a no-op. Ported as `findProjectRoot` in `sdk/src/query/helpers.ts` with the same detection order (own `.planning/` wins, then parent `sub_repos` match, then legacy `multiRepo: true`, then `.git` heuristic), capped at 10 parent levels and never crossing `$HOME`. Closes #2623. diff --git a/bin/install.js b/bin/install.js index 30bcc721c0..9a334d82ba 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2113,11 +2113,413 @@ function stripCodexGsdAgentSections(content) { ); } +function compareSemver(a, b) { + for (const key of ['major', 'minor', 'patch']) { + const diff = a[key] - b[key]; + if (diff !== 0) { + return diff; + } + } + return 0; +} + +function parseCodexCliVersion(output) { + if (typeof output !== 'string') { + return null; + } + + const match = output.match(/\bv?(\d+)\.(\d+)\.(\d+)\b/); + if (!match) { + return null; + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +function formatCodexCliVersion(version) { + return `${version.major}.${version.minor}.${version.patch}`; +} + +function selectCodexHookInstallModeForVersion(versionOutput) { + const version = parseCodexCliVersion(versionOutput); + if (!version) { + return { + mode: 'skip', + reason: 'Codex version could not be detected', + }; + } + + const hooksJsonMinVersion = { major: 0, minor: 124, patch: 0 }; + if (compareSemver(version, hooksJsonMinVersion) >= 0) { + return { + mode: 'hooks-json', + reason: `Codex ${formatCodexCliVersion(version)} supports hooks.json`, + }; + } + + return { + mode: 'legacy-inline', + reason: `Codex ${formatCodexCliVersion(version)} predates hooks.json support`, + }; +} + +function detectCodexHookInstallMode() { + const override = process.env.GSD_CODEX_HOOKS_MODE; + if (override) { + if (override === 'hooks-json' || override === 'legacy-inline' || override === 'skip') { + return { mode: override, reason: `GSD_CODEX_HOOKS_MODE=${override}` }; + } + return { + mode: 'skip', + reason: `invalid GSD_CODEX_HOOKS_MODE=${override}`, + }; + } + + let result; + try { + const { spawnSync } = require('child_process'); + result = spawnSync('codex', ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }); + } catch (error) { + return { + mode: 'skip', + reason: `Codex version probe failed: ${error.message}`, + }; + } + + if (result.error) { + return { + mode: 'skip', + reason: `Codex version probe failed: ${result.error.message}`, + }; + } + + if (result.status !== 0) { + const stderr = (result.stderr || '').trim(); + return { + mode: 'skip', + reason: stderr ? `Codex version probe exited ${result.status}: ${stderr}` : `Codex version probe exited ${result.status}`, + }; + } + + return selectCodexHookInstallModeForVersion(`${result.stdout || ''}\n${result.stderr || ''}`); +} + +function getManagedCodexUpdateHookCommands(configDir) { + const commands = new Set(); + if (!configDir) { + return commands; + } + + const normalized = configDir.replace(/\\/g, '/'); + const home = os.homedir().replace(/\\/g, '/'); + const portableBase = normalized.startsWith(home) + ? '$HOME' + normalized.slice(home.length) + : normalized; + const legacyUpdateHookName = 'gsd-update-check' + '.js'; + + for (const hookName of ['gsd-check-update.js', legacyUpdateHookName]) { + for (const baseDir of new Set([normalized, portableBase])) { + const hookPath = `${baseDir}/hooks/${hookName}`; + commands.add(`node ${hookPath}`); + commands.add(`node "${hookPath}"`); + commands.add(`node '${hookPath}'`); + } + } + + return commands; +} + +function parseSimpleTomlStringAssignment(line, key) { + const commentStart = findTomlCommentStart(line); + const content = (commentStart === -1 ? line : line.slice(0, commentStart)).trim(); + const match = content.match(new RegExp(`^${escapeRegExp(key)}\\s*=\\s*(.+)$`)); + if (!match) { + return null; + } + + const valueText = match[1].trim(); + if (valueText.startsWith('"')) { + try { + const parsed = JSON.parse(valueText); + return typeof parsed === 'string' ? parsed : null; + } catch { + return null; + } + } + + const literalMatch = valueText.match(/^'(.*)'$/); + return literalMatch ? literalMatch[1].replace(/''/g, "'") : null; +} + +function stripManagedGsdCodexInlineHooks(content, configDir) { + const managedCommands = getManagedCodexUpdateHookCommands(configDir); + if (managedCommands.size === 0) { + return content; + } + + const lines = splitTomlLines(content); + const kept = []; + let removedHookBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const header = parseTomlTableHeader(line.text); + if (!header || !header.array || header.path !== 'hooks') { + kept.push(line); + continue; + } + + const block = [line]; + let commandValue = null; + let j = i + 1; + for (; j < lines.length; j++) { + const nextLine = lines[j]; + if (parseTomlTableHeader(nextLine.text)) { + break; + } + block.push(nextLine); + const parsedCommand = parseSimpleTomlStringAssignment(nextLine.text, 'command'); + if (parsedCommand !== null) { + commandValue = parsedCommand; + } + } + + if (commandValue !== null && managedCommands.has(commandValue)) { + removedHookBlock = true; + let commentIndex = kept.length - 1; + while (commentIndex >= 0 && kept[commentIndex].text.trim() === '') { + commentIndex--; + } + if (commentIndex >= 0 && kept[commentIndex].text.trim() === '# GSD Hooks') { + kept.splice(commentIndex); + } + i = j - 1; + continue; + } + + kept.push(...block); + i = j - 1; + } + + if (!removedHookBlock) { + return content; + } + + return kept.map((line) => line.text + line.eol).join(''); +} + +function parseCodexHooksJson(content, hooksPath = 'hooks.json') { + let parsed; + try { + parsed = JSON.parse(stripJsonComments(content)); + } catch (error) { + throw new Error(`Could not parse ${hooksPath}: ${error.message}`); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Could not parse ${hooksPath}: root must be an object`); + } + + if (parsed.hooks === undefined) { + parsed.hooks = {}; + } + + if (!parsed.hooks || typeof parsed.hooks !== 'object' || Array.isArray(parsed.hooks)) { + throw new Error(`Could not parse ${hooksPath}: \"hooks\" must be an object`); + } + + return parsed; +} + +function getCodexHookGroups(parsed, eventName, hooksPath = 'hooks.json') { + const groups = parsed.hooks[eventName]; + if (groups === undefined) { + return []; + } + if (!Array.isArray(groups)) { + throw new Error(`Could not parse ${hooksPath}: hooks.${eventName} must be an array`); + } + return groups; +} + +function isManagedGsdCodexHook(handler, managedCommands) { + return Boolean( + handler && + typeof handler === 'object' && + typeof handler.command === 'string' && + managedCommands.has(handler.command.trim()) + ); +} + +function serializeCodexHooksJson(parsed, eol = '\n') { + return JSON.stringify(parsed, null, 2).replace(/\n/g, eol) + eol; +} + +function filterManagedCodexHookGroup(group, managedCommands) { + if (!group || typeof group !== 'object' || Array.isArray(group) || !Array.isArray(group.hooks)) { + return { group, changed: false }; + } + + const nextHooks = group.hooks.filter((handler) => !isManagedGsdCodexHook(handler, managedCommands)); + if (nextHooks.length === group.hooks.length) { + return { group, changed: false }; + } + if (nextHooks.length === 0) { + return { group: null, changed: true }; + } + return { group: { ...group, hooks: nextHooks }, changed: true }; +} + +function stripGsdFromCodexHooksJson(content, hooksPath = 'hooks.json', configDir) { + const eol = detectLineEnding(content); + const parsed = parseCodexHooksJson(content, hooksPath); + const managedCommands = getManagedCodexUpdateHookCommands(configDir); + // Remove references to the managed GSD update-check command from any event. + // Uninstall also removes the underlying hook file from targetDir/hooks/, so + // keeping a user-edited hook that still points at that managed command would + // leave a broken hook configuration behind. + let changed = false; + + for (const [eventName, groups] of Object.entries(parsed.hooks)) { + const normalizedGroups = Array.isArray(groups) + ? groups + : (groups && typeof groups === 'object' && !Array.isArray(groups) && Array.isArray(groups.hooks) + ? [groups] + : null); + if (!normalizedGroups) { + continue; + } + + const nextGroups = []; + for (const group of normalizedGroups) { + const { group: nextGroup, changed: groupChanged } = filterManagedCodexHookGroup(group, managedCommands); + if (groupChanged) { + changed = true; + } + if (nextGroup) { + nextGroups.push(nextGroup); + } + } + + if (Array.isArray(groups)) { + if (nextGroups.length > 0) { + parsed.hooks[eventName] = nextGroups; + continue; + } + + if (groups.length > 0) { + changed = true; + } + delete parsed.hooks[eventName]; + continue; + } + + if (nextGroups.length > 0) { + parsed.hooks[eventName] = nextGroups[0]; + continue; + } + + changed = true; + delete parsed.hooks[eventName]; + } + + if (!changed) { + return content; + } + + if (Object.keys(parsed.hooks).length === 0) { + const userRootKeys = Object.keys(parsed).filter((key) => key !== 'hooks'); + if (userRootKeys.length === 0) { + return null; + } + } + + return serializeCodexHooksJson(parsed, eol); +} + +function assertCodexHooksJsonInstallSafe(parsed, hooksPath = 'hooks.json') { + for (const [eventName, groups] of Object.entries(parsed.hooks)) { + if (!Array.isArray(groups)) { + throw new Error(`Could not parse ${hooksPath}: hooks.${eventName} must be an array`); + } + + for (const group of groups) { + if (!group || typeof group !== 'object' || Array.isArray(group)) { + throw new Error(`Could not parse ${hooksPath}: hooks.${eventName} groups must be objects`); + } + if (!Array.isArray(group.hooks)) { + throw new Error(`Could not parse ${hooksPath}: hooks.${eventName} groups must have a hooks array`); + } + } + } +} + +function mergeGsdIntoCodexHooksJson(existingContent, command, hooksPath = 'hooks.json', configDir) { + const hasExistingFile = existingContent !== null && existingContent !== undefined; + const eol = hasExistingFile && existingContent.length > 0 ? detectLineEnding(existingContent) : '\n'; + const parsed = hasExistingFile + ? parseCodexHooksJson(existingContent, hooksPath) + : { hooks: {} }; + assertCodexHooksJsonInstallSafe(parsed, hooksPath); + const managedCommands = getManagedCodexUpdateHookCommands(configDir); + if (typeof command === 'string') { + managedCommands.add(command.trim()); + } + + const sessionStartGroups = getCodexHookGroups(parsed, 'SessionStart', hooksPath); + const cleanedGroups = []; + + for (const group of sessionStartGroups) { + if (!group || typeof group !== 'object' || Array.isArray(group) || !Array.isArray(group.hooks)) { + cleanedGroups.push(group); + continue; + } + + const nextHooks = group.hooks.filter((handler) => !isManagedGsdCodexHook(handler, managedCommands)); + if (nextHooks.length > 0) { + cleanedGroups.push({ ...group, hooks: nextHooks }); + } + } + + cleanedGroups.push({ + hooks: [ + { + type: 'command', + command, + } + ] + }); + + parsed.hooks.SessionStart = cleanedGroups; + return serializeCodexHooksJson(parsed, eol); +} + +function mergeGsdIntoCodexLegacyInlineHooks(configContent, command, configDir) { + const eol = detectLineEnding(configContent); + const cleaned = stripManagedGsdCodexInlineHooks(configContent, configDir).trimEnd(); + const hookBlock = [ + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + `command = ${JSON.stringify(command)}`, + ].join(eol); + + return (cleaned ? `${cleaned}${eol}${eol}` : '') + hookBlock + eol; +} + /** * Strip GSD sections from Codex config.toml content. * Returns cleaned content, or null if file would be empty. */ -function stripGsdFromCodexConfig(content) { +function stripGsdFromCodexConfig(content, configDir) { const eol = detectLineEnding(content); const markerIndex = content.indexOf(GSD_CODEX_MARKER); const codexHooksOwnership = getManagedCodexHooksOwnership(content); @@ -2125,6 +2527,7 @@ function stripGsdFromCodexConfig(content) { if (markerIndex !== -1) { // Has GSD marker — remove everything from marker to EOF let before = content.substring(0, markerIndex); + before = stripManagedGsdCodexInlineHooks(before, configDir); 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*(?:\r?\n)?/m, ''); @@ -2137,7 +2540,7 @@ function stripGsdFromCodexConfig(content) { } // No marker but may have GSD-injected feature keys - let cleaned = content; + let cleaned = stripManagedGsdCodexInlineHooks(content, configDir); 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, ''); @@ -3200,6 +3603,119 @@ function ensureCodexHooksFeature(configContent) { return { content: configContent + (needsGap ? eol : '') + featuresBlock, ownership: 'section' }; } +function splitTomlInlineTableEntries(inner) { + const entries = []; + let start = 0; + let i = 0; + let arrayDepth = 0; + let inlineTableDepth = 0; + + while (i < inner.length) { + const ch = inner[i]; + + if (ch === '\'') { + i += 1; + while (i < inner.length) { + if (inner[i] === '\'') { + i += 1; + break; + } + i += 1; + } + continue; + } + + if (ch === '"') { + i += 1; + while (i < inner.length) { + if (inner[i] === '\\') { + i += 2; + continue; + } + if (inner[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 === ',' && arrayDepth === 0 && inlineTableDepth === 0) { + const entry = inner.slice(start, i).trim(); + if (entry) { + entries.push(entry); + } + start = i + 1; + } + + i += 1; + } + + const finalEntry = inner.slice(start).trim(); + if (finalEntry) { + entries.push(finalEntry); + } + return entries; +} + +function inlineTomlFeaturesTableEnablesCodexHooks(record) { + 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(); + if (!valueText.startsWith('{') || !valueText.endsWith('}')) { + return false; + } + + return splitTomlInlineTableEntries(valueText.slice(1, -1)).some((entry) => { + const entryEqualsIndex = findTomlAssignmentEquals(entry); + if (entryEqualsIndex === -1) { + return false; + } + + const keySegments = parseTomlKeyPath(entry.slice(0, entryEqualsIndex).trim()); + if (!keySegments || keySegments.length !== 1 || keySegments[0] !== 'codex_hooks') { + return false; + } + + return entry.slice(entryEqualsIndex + 1).trim() === 'true'; + }); +} + function hasEnabledCodexHooksFeature(configContent) { const lineRecords = getTomlLineRecords(configContent); @@ -3215,6 +3731,13 @@ function hasEnabledCodexHooksFeature(configContent) { record.keySegments.length === 2 && record.keySegments[0] === 'features' && record.keySegments[1] === 'codex_hooks'; + const isRootInlineFeaturesTable = record.tablePath === null && + record.keySegments.length === 1 && + record.keySegments[0] === 'features'; + + if (isRootInlineFeaturesTable) { + return inlineTomlFeaturesTableEnablesCodexHooks(record); + } if (!isSectionKey && !isRootDottedKey) { return false; @@ -4795,6 +5318,7 @@ function uninstall(isGlobal, runtime = 'claude') { } let removedCount = 0; + let codexHooksJsonCleanFailed = false; // 1. Remove GSD commands/skills if (isOpencode || isKilo) { @@ -4850,7 +5374,7 @@ function uninstall(isGlobal, runtime = 'claude') { const configPath = path.join(targetDir, 'config.toml'); if (fs.existsSync(configPath)) { const content = fs.readFileSync(configPath, 'utf8'); - const cleaned = stripGsdFromCodexConfig(content); + const cleaned = stripGsdFromCodexConfig(content, targetDir); if (cleaned === null) { // File is empty after stripping — delete it fs.unlinkSync(configPath); @@ -4862,6 +5386,26 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Cleaned GSD sections from config.toml`); } } + + const hooksJsonPath = path.join(targetDir, 'hooks.json'); + if (fs.existsSync(hooksJsonPath)) { + try { + const hooksContent = fs.readFileSync(hooksJsonPath, 'utf8'); + const cleanedHooks = stripGsdFromCodexHooksJson(hooksContent, hooksJsonPath, targetDir); + if (cleanedHooks === null) { + fs.unlinkSync(hooksJsonPath); + removedCount++; + console.log(` ${green}✓${reset} Removed hooks.json (was GSD-only)`); + } else if (cleanedHooks !== hooksContent) { + fs.writeFileSync(hooksJsonPath, cleanedHooks); + removedCount++; + console.log(` ${green}✓${reset} Cleaned GSD hooks from hooks.json`); + } + } catch (error) { + codexHooksJsonCleanFailed = true; + console.warn(` ${yellow}⚠${reset} Could not clean hooks.json: ${error.message}`); + } + } } } else if (isCopilot) { // Copilot: remove skills/gsd-*/ directories (same layout as Codex skills) @@ -5070,8 +5614,13 @@ function uninstall(isGlobal, runtime = 'claude') { const hooksDir = path.join(targetDir, 'hooks'); if (fs.existsSync(hooksDir)) { const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh']; + const gsdHookHelpers = ['gsd-check-update-worker.js']; + const keepUpdateHookChain = isCodex && codexHooksJsonCleanFailed; let hookCount = 0; - for (const hook of gsdHooks) { + for (const hook of [...gsdHooks, ...gsdHookHelpers]) { + if (keepUpdateHookChain && (hook === 'gsd-check-update.js' || hook === 'gsd-check-update-worker.js')) { + continue; + } const hookPath = path.join(hooksDir, hook); if (fs.existsSync(hookPath)) { fs.unlinkSync(hookPath); @@ -5612,9 +6161,9 @@ function writeManifest(configDir, runtime = 'claude') { } } - // Track hook files so saveLocalPatches() can detect user modifications - // Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline) - if (!isCodex && !isCopilot && !isCline) { + // Track hook files so saveLocalPatches() can detect user modifications. + // Hooks are installed for managed runtimes except Copilot and Cline. + if (!isCopilot && !isCline) { const hooksDir = path.join(configDir, 'hooks'); if (fs.existsSync(hooksDir)) { for (const file of fs.readdirSync(hooksDir)) { @@ -6243,10 +6792,11 @@ function install(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Generated config.toml with ${agentCount} agent roles`); console.log(` ${green}✓${reset} Generated ${agentCount} agent .toml config files`); - // Copy hook files that are referenced in config.toml (#2153) - // The main hook-copy block is gated to non-Codex runtimes, but Codex registers - // gsd-check-update.js in config.toml — the file must physically exist. - const codexHooksSrc = path.join(src, 'hooks', 'dist'); + // Copy hook files referenced by hooks.json/config. Prefer bundled hooks/dist in published + // packages, but fall back to source hooks/ when running from a checkout. + const codexHooksSrc = fs.existsSync(path.join(src, 'hooks', 'dist')) + ? path.join(src, 'hooks', 'dist') + : path.join(src, 'hooks'); if (fs.existsSync(codexHooksSrc)) { const codexHooksDest = path.join(targetDir, 'hooks'); fs.mkdirSync(codexHooksDest, { recursive: true }); @@ -6277,42 +6827,61 @@ function install(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Installed hooks`); } - // Add Codex hooks (SessionStart for update checking) — requires codex_hooks feature flag + // Add Codex hooks (SessionStart for update checking) — use hooks.json only on Codex builds that support it. const configPath = path.join(targetDir, 'config.toml'); + const hooksJsonPath = path.join(targetDir, 'hooks.json'); try { - let configContent = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : ''; - 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, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); - const hookBlock = - `${eol}# GSD Hooks${eol}` + - `[[hooks]]${eol}` + - `event = "SessionStart"${eol}` + - `command = "node ${updateCheckScript}"${eol}`; - - // Migrate legacy gsd-update-check entries from prior installs (#1755 followup) - // Remove stale hook blocks that used the inverted filename or wrong path. - // Single \r?\n-aware regex handles LF, CRLF, and block-at-file-start (#2698). - if (configContent.includes('gsd-update-check')) { - configContent = configContent.replace( - /(?:\r?\n|^)# GSD Hooks\r?\n\[\[hooks\]\]\r?\nevent = "SessionStart"\r?\ncommand = "node [^\r\n]*gsd-update-check\.js"\r?\n/gm, - (match) => (match.startsWith('\r\n') ? '\r\n' : match.startsWith('\n') ? '\n' : ''), - ); - } - - if (hasEnabledCodexHooksFeature(configContent) && !configContent.includes('gsd-check-update')) { - configContent += hookBlock; + const hookInstallMode = detectCodexHookInstallMode(); + if (hookInstallMode.mode === 'skip') { + console.warn(` ${yellow}⚠${reset} Skipped Codex hook configuration: ${hookInstallMode.reason}`); + } else { + const originalConfigContent = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : ''; + const codexHooksFeature = ensureCodexHooksFeature(originalConfigContent); + const nextConfigContent = setManagedCodexHooksOwnership(codexHooksFeature.content, codexHooksFeature.ownership); + const updateCheckCommand = buildHookCommand(targetDir, 'gsd-check-update.js', { portableHooks: hasPortableHooks }); + + // Migrate legacy gsd-update-check entries from prior installs (#1755 followup). + // Remove stale hook blocks that used the inverted filename or wrong path. + // Single \r?\n-aware regex handles LF, CRLF, and block-at-file-start (#2698). + const cleanedConfigContent = nextConfigContent.includes('gsd-update-check') + ? nextConfigContent.replace( + /(?:\r?\n|^)# GSD Hooks\r?\n\[\[hooks\]\]\r?\nevent = "SessionStart"\r?\ncommand = "node [^\r\n]*gsd-update-check\.js"\r?\n/gm, + (match) => (match.startsWith('\r\n') ? '\r\n' : match.startsWith('\n') ? '\n' : ''), + ) + : nextConfigContent; + + if (hasEnabledCodexHooksFeature(cleanedConfigContent)) { + if (hookInstallMode.mode === 'hooks-json') { + const hooksJsonContent = fs.existsSync(hooksJsonPath) ? fs.readFileSync(hooksJsonPath, 'utf8') : null; + const mergedHooksJson = mergeGsdIntoCodexHooksJson(hooksJsonContent, updateCheckCommand, hooksJsonPath, targetDir); + fs.writeFileSync(hooksJsonPath, mergedHooksJson, 'utf8'); + + const migratedConfigContent = stripManagedGsdCodexInlineHooks(cleanedConfigContent, targetDir); + fs.writeFileSync(configPath, migratedConfigContent, 'utf-8'); + console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart via hooks.json)`); + } else { + const legacyConfigContent = mergeGsdIntoCodexLegacyInlineHooks(cleanedConfigContent, updateCheckCommand, targetDir); + fs.writeFileSync(configPath, legacyConfigContent, 'utf-8'); + console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart via legacy config.toml)`); + } + } else { + if (cleanedConfigContent !== originalConfigContent) { + fs.writeFileSync(configPath, cleanedConfigContent, 'utf-8'); + } + console.warn(` ${yellow}⚠${reset} Skipped Codex hook configuration: codex_hooks could not be enabled safely`); + } } - - fs.writeFileSync(configPath, configContent, 'utf-8'); - console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart)`); } catch (e) { console.warn(` ${yellow}⚠${reset} Could not configure Codex hooks: ${e.message}`); } + try { + writeManifest(targetDir, runtime); + console.log(` ${green}✓${reset} Refreshed file manifest (${MANIFEST_NAME})`); + } catch (e) { + console.warn(` ${yellow}⚠${reset} Could not refresh file manifest: ${e.message}`); + } + return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir }; } @@ -7319,6 +7888,9 @@ if (process.env.GSD_TEST_MODE) { generateCodexConfigBlock, stripGsdFromCodexConfig, mergeCodexConfig, + parseCodexCliVersion, + selectCodexHookInstallModeForVersion, + detectCodexHookInstallMode, installCodexConfig, readGsdRuntimeProfileResolver, readGsdEffectiveModelOverrides, diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index 2eb1704a5f..9dfbdfa62c 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -23,18 +23,21 @@ const { stripGsdFromCodexConfig, mergeCodexConfig, install, + uninstall, + parseCodexCliVersion, + selectCodexHookInstallModeForVersion, GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, } = require('../bin/install.js'); -function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) { +function withCodexHome(codexHome, cwd, callback) { const previousCodeHome = process.env.CODEX_HOME; const previousCwd = process.cwd(); process.env.CODEX_HOME = codexHome; try { process.chdir(cwd); - return install(true, 'codex'); + return callback(); } finally { process.chdir(previousCwd); if (previousCodeHome === undefined) { @@ -45,6 +48,33 @@ function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) { } } +function withCodexHooksMode(mode, callback) { + const previousMode = process.env.GSD_CODEX_HOOKS_MODE; + if (mode === null || mode === undefined) { + delete process.env.GSD_CODEX_HOOKS_MODE; + } else { + process.env.GSD_CODEX_HOOKS_MODE = mode; + } + + try { + return callback(); + } finally { + if (previousMode === undefined) { + delete process.env.GSD_CODEX_HOOKS_MODE; + } else { + process.env.GSD_CODEX_HOOKS_MODE = previousMode; + } + } +} + +function runCodexInstall(codexHome, cwd = path.join(__dirname, '..'), hooksMode = 'hooks-json') { + return withCodexHooksMode(hooksMode, () => withCodexHome(codexHome, cwd, () => install(true, 'codex'))); +} + +function runCodexUninstall(codexHome, cwd = path.join(__dirname, '..')) { + return withCodexHome(codexHome, cwd, () => uninstall(true, 'codex')); +} + function readCodexConfig(codexHome) { return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); } @@ -54,6 +84,52 @@ function writeCodexConfig(codexHome, content) { fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); } +function readCodexHooksJson(codexHome) { + return JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); +} + +function writeCodexHooksJson(codexHome, content) { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify(content, null, 2) + '\n', 'utf8'); +} + +function getManagedGsdUpdateHookCommands(codexHome) { + const hooksDir = path.join(codexHome, 'hooks').replace(/\\/g, '/'); + const home = os.homedir().replace(/\\/g, '/'); + const portableHooksDir = hooksDir.startsWith(home) + ? '$HOME' + hooksDir.slice(home.length) + : hooksDir; + const commands = new Set(); + + for (const hookName of ['gsd-check-update.js', 'gsd-update-check.js']) { + for (const baseDir of new Set([hooksDir, portableHooksDir])) { + const hookPath = `${baseDir}/${hookName}`; + commands.add(`node ${hookPath}`); + commands.add(`node "${hookPath}"`); + commands.add(`node '${hookPath}'`); + } + } + + return commands; +} + +function isManagedGsdUpdateHookCommand(command, codexHome) { + return typeof command === 'string' && getManagedGsdUpdateHookCommands(codexHome).has(command); +} + +function countGsdUpdateHooksInHooksJson(codexHome) { + const hooksPath = path.join(codexHome, 'hooks.json'); + if (!fs.existsSync(hooksPath)) { + return 0; + } + + return Object.values(readCodexHooksJson(codexHome).hooks || {}) + .flatMap(eventGroups => Array.isArray(eventGroups) ? eventGroups : []) + .flatMap(group => group && Array.isArray(group.hooks) ? group.hooks : []) + .filter(hook => isManagedGsdUpdateHookCommand(hook && hook.command, codexHome)) + .length; +} + function countMatches(content, pattern) { return (content.match(pattern) || []).length; } @@ -73,6 +149,21 @@ function assertUsesOnlyEol(content, eol) { assert.ok(!content.includes('\r\n'), 'does not contain CRLF line endings'); } +describe('Codex hook compatibility detection', () => { + test('classifies Codex versions around the hooks.json migration boundary', () => { + assert.deepStrictEqual(parseCodexCliVersion('codex-cli 0.124.0'), { major: 0, minor: 124, patch: 0 }); + assert.strictEqual(selectCodexHookInstallModeForVersion('codex-cli 0.124.0').mode, 'hooks-json'); + assert.strictEqual(selectCodexHookInstallModeForVersion('codex-cli 0.124.1').mode, 'hooks-json'); + assert.strictEqual(selectCodexHookInstallModeForVersion('codex-cli 0.123.9').mode, 'legacy-inline'); + }); + + test('skips Codex hook installation when version detection is ambiguous', () => { + assert.strictEqual(parseCodexCliVersion('codex experimental build'), null); + assert.strictEqual(selectCodexHookInstallModeForVersion('codex experimental build').mode, 'skip'); + assert.strictEqual(selectCodexHookInstallModeForVersion('').mode, 'skip'); + }); +}); + // ─── getCodexSkillAdapterHeader ───────────────────────────────────────────────── describe('getCodexSkillAdapterHeader', () => { @@ -962,34 +1053,308 @@ describe('Codex install hook configuration (e2e)', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - test('Codex install copies hook file that is referenced in config.toml (#2153)', () => { - // Regression test: Codex install writes gsd-check-update hook reference into - // config.toml but must also copy the hook file to ~/$CODEX_HOME/hooks/ + test('Codex install copies update hook file and writes a hooks.json SessionStart entry', () => { runCodexInstall(codexHome); const configContent = readCodexConfig(codexHome); - // config.toml must reference the hook - assert.ok(configContent.includes('gsd-check-update.js'), 'config.toml references gsd-check-update.js'); - // The hook file must physically exist at the referenced path + const hooksJson = readCodexHooksJson(codexHome); const hookFile = path.join(codexHome, 'hooks', 'gsd-check-update.js'); - assert.ok( - fs.existsSync(hookFile), - `gsd-check-update.js must exist at ${hookFile} — config.toml references it but file was not installed` + const manifest = JSON.parse(fs.readFileSync(path.join(codexHome, 'gsd-file-manifest.json'), 'utf8')); + + assert.ok(!configContent.includes('gsd-check-update.js'), 'config.toml no longer embeds the update hook command'); + assert.ok(fs.existsSync(hookFile), `gsd-check-update.js must exist at ${hookFile}`); + assert.ok(manifest.files['hooks/gsd-check-update.js'], 'manifest tracks the Codex update hook after Codex-specific hook installation'); + assert.deepStrictEqual( + hooksJson, + { + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: `node "${hookFile.replace(/\\/g, '/')}"` + } + ] + } + ] + } + }, + 'writes a Codex-compatible hooks.json SessionStart hook' ); }); - test('fresh CODEX_HOME enables codex_hooks without draft root defaults', () => { + test('fresh CODEX_HOME enables codex_hooks without inline TOML hooks or 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.ok(!content.includes('[[hooks]]'), 'does not write legacy inline TOML hooks'); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'writes one codex_hooks key'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'writes one GSD update hook'); assertNoDraftRootKeys(content); assertUsesOnlyEol(content, '\n'); }); + test('older Codex installs fall back to one legacy inline SessionStart hook', () => { + runCodexInstall(codexHome, path.join(__dirname, '..'), 'legacy-inline'); + runCodexInstall(codexHome, path.join(__dirname, '..'), 'legacy-inline'); + + const content = readCodexConfig(codexHome); + const hookFile = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); + + assert.ok(content.includes('[features]\ncodex_hooks = true\n'), 'enables codex_hooks for legacy Codex'); + assert.strictEqual(countMatches(content, /^\[\[hooks\]\]$/gm), 1, 'keeps one legacy inline hook block after repeated installs'); + assert.ok(content.includes('event = "SessionStart"'), 'writes the SessionStart event'); + assert.ok(content.includes(`command = "node \\"${hookFile}\\""`), 'writes the managed update command as a TOML string'); + assert.ok(!fs.existsSync(path.join(codexHome, 'hooks.json')), 'does not write hooks.json in legacy-inline mode'); + }); + + test('ambiguous Codex hook detection skips hook feature enablement and hook config writes', () => { + runCodexInstall(codexHome, path.join(__dirname, '..'), 'skip'); + + const content = readCodexConfig(codexHome); + + assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 0, 'does not enable codex_hooks when detection is ambiguous'); + assert.ok(!content.includes('[[hooks]]'), 'does not write legacy inline hooks when detection is ambiguous'); + assert.ok(!fs.existsSync(path.join(codexHome, 'hooks.json')), 'does not write hooks.json when detection is ambiguous'); + }); + + test('install merges the GSD SessionStart hook into an existing hooks.json file', () => { + writeCodexHooksJson(codexHome, { + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: 'echo custom-session-start' + } + ] + } + ], + PreToolUse: [ + { + matcher: '^Read$', + hooks: [ + { + type: 'command', + command: 'echo custom-pre-tool' + } + ] + } + ] + } + }); + + runCodexInstall(codexHome); + + const hooksJson = readCodexHooksJson(codexHome); + const sessionStartHooks = hooksJson.hooks.SessionStart.flatMap(entry => entry.hooks || []); + + assert.ok(sessionStartHooks.some(hook => hook.command === 'echo custom-session-start'), 'preserves existing SessionStart hooks'); + assert.ok(sessionStartHooks.some(hook => hook.command.includes('gsd-check-update.js')), 'adds the GSD SessionStart hook'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'adds the GSD hook once'); + assert.deepStrictEqual( + hooksJson.hooks.PreToolUse, + [ + { + matcher: '^Read$', + hooks: [ + { + type: 'command', + command: 'echo custom-pre-tool' + } + ] + } + ], + 'preserves non-SessionStart hooks' + ); + }); + + test('install preserves zero-byte hooks.json instead of overwriting it', () => { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'hooks.json'), '', 'utf8'); + writeCodexConfig(codexHome, [ + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + assert.strictEqual(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'), '', 'leaves zero-byte hooks.json untouched instead of replacing it'); + assert.ok(readCodexConfig(codexHome).includes('name = "o3"'), 'preserves user config when hooks.json migration cannot complete safely'); + assert.strictEqual(countMatches(readCodexConfig(codexHome), /^codex_hooks = true$/gm), 0, 'does not enable codex_hooks when zero-byte hooks.json prevents safe migration'); + }); + + test('install preserves malformed hooks.json SessionStart entries instead of overwriting them', () => { + const malformedHooksJsonRaw = [ + '{', + ' "hooks": {', + ' "SessionStart": {', + ' "hooks": [', + ' { "command": "echo keep-me", "type": "command" }', + ' ]', + ' }', + ' }', + '}', + '', + ].join('\n'); + const malformedHooksJson = JSON.parse(malformedHooksJsonRaw); + const legacyManagedInlineCommand = `node ${path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}`; + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'hooks.json'), malformedHooksJsonRaw, 'utf8'); + writeCodexConfig(codexHome, [ + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + `command = "${legacyManagedInlineCommand}"`, + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + assert.strictEqual(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'), malformedHooksJsonRaw, 'leaves malformed hooks.json bytes untouched instead of rewriting them'); + assert.deepStrictEqual(readCodexHooksJson(codexHome), malformedHooksJson, 'leaves malformed hooks.json untouched instead of overwriting it'); + assert.ok(readCodexConfig(codexHome).includes(`command = "${legacyManagedInlineCommand}"`), 'preserves the legacy inline hook when hooks.json migration cannot complete safely'); + assert.strictEqual(countMatches(readCodexConfig(codexHome), /^codex_hooks = true$/gm), 0, 'does not enable codex_hooks when hooks.json migration fails'); + }); + + test('install preserves malformed hooks.json event objects outside SessionStart instead of overwriting them', () => { + const malformedHooksJsonRaw = [ + '{', + ' "hooks": {', + ' "AfterCommand": { "hooks": [ { "command": "echo keep-me", "type": "command" } ] }', + ' }', + '}', + '', + ].join('\n'); + const malformedHooksJson = JSON.parse(malformedHooksJsonRaw); + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'hooks.json'), malformedHooksJsonRaw, 'utf8'); + + runCodexInstall(codexHome); + + assert.strictEqual(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'), malformedHooksJsonRaw, 'leaves malformed non-SessionStart hooks.json bytes untouched instead of rewriting them'); + assert.deepStrictEqual(readCodexHooksJson(codexHome), malformedHooksJson, 'leaves malformed non-SessionStart hooks.json entries untouched instead of rewriting them'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 0, 'does not add the managed GSD hook when hooks.json cannot be rewritten safely'); + assert.strictEqual(countMatches(readCodexConfig(codexHome), /^codex_hooks = true$/gm), 0, 'does not enable codex_hooks when malformed hooks.json prevents safe migration'); + }); + + test('install preserves user SessionStart hooks whose command mentions gsd-check-update', () => { + const customWrapperCommand = 'node "/custom/my-gsd-check-update-wrapper.js"'; + writeCodexHooksJson(codexHome, { + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: customWrapperCommand, + } + ] + } + ] + } + }); + + runCodexInstall(codexHome); + + const sessionStartHooks = readCodexHooksJson(codexHome).hooks.SessionStart.flatMap(entry => entry.hooks || []); + assert.ok(sessionStartHooks.some(hook => hook.command === customWrapperCommand), 'preserves the user-managed wrapper hook'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'adds the managed GSD hook separately'); + }); + + test('install preserves custom inline SessionStart hooks whose command path mentions gsd-check-update', () => { + const customInlineCommand = 'node /custom/my-gsd-check-update.js'; + writeCodexConfig(codexHome, [ + '# user comment', + '[[hooks]]', + 'event = "SessionStart"', + `command = "${customInlineCommand}"`, + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes(`command = "${customInlineCommand}"`), 'preserves the user inline SessionStart hook'); + assert.ok(!content.includes(`# GSD Hooks\n[[hooks]]\nevent = "SessionStart"\ncommand = "${customInlineCommand}"`), 'does not misclassify the user inline hook as a GSD-managed legacy block'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'adds the managed GSD hook in hooks.json separately'); + }); + + test('install removes legacy inline GSD hooks whose TOML command escapes quotes', () => { + const managedCommand = `node "${path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; + writeCodexConfig(codexHome, [ + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + `command = ${JSON.stringify(managedCommand)}`, + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('name = "o3"'), 'preserves user config after removing the legacy hook'); + assert.ok(!content.includes('[[hooks]]'), 'removes the legacy inline GSD hook when TOML escaped quotes are decoded'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'adds the managed GSD hook to hooks.json'); + }); + + test('install removes legacy inline GSD hooks whose TOML literal command doubles apostrophes', () => { + const codexHomeWithApostrophe = path.join(tmpDir, "codex-O'Brien"); + const managedCommand = `node "${path.join(codexHomeWithApostrophe, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; + const tomlLiteralCommand = managedCommand.replace(/'/g, "''"); + writeCodexConfig(codexHomeWithApostrophe, [ + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + `command = '${tomlLiteralCommand}'`, + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHomeWithApostrophe); + + const content = readCodexConfig(codexHomeWithApostrophe); + assert.ok(content.includes('name = "o3"'), 'preserves user config after removing the legacy hook'); + assert.ok(!content.includes('[[hooks]]'), 'removes the legacy inline GSD hook when TOML literal apostrophes are decoded'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHomeWithApostrophe), 1, 'adds the managed GSD hook to hooks.json'); + }); + + test('install removes legacy inline GSD hooks whose decoded command single-quotes the hook path', () => { + const managedCommand = `node '${path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}'`; + const tomlLiteralCommand = managedCommand.replace(/'/g, "''"); + writeCodexConfig(codexHome, [ + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + `command = '${tomlLiteralCommand}'`, + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('name = "o3"'), 'preserves user config after removing the legacy hook'); + assert.ok(!content.includes('[[hooks]]'), 'removes the legacy inline GSD hook when the decoded command single-quotes the path'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'adds the managed GSD hook to hooks.json'); + }); + test('config_file paths are absolute using CODEX_HOME', () => { runCodexInstall(codexHome); @@ -1067,7 +1432,7 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 1, 'adds one GSD update hook'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'adds one GSD update hook'); assertNoDraftRootKeys(content); }); @@ -1204,7 +1569,7 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 1, 'does not duplicate GSD update hook'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'does not duplicate GSD update hook'); assertNoDraftRootKeys(content); }); @@ -1246,7 +1611,7 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 1, 'keeps one GSD update hook'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'keeps one GSD update hook'); assertNoDraftRootKeys(content); }); @@ -1267,7 +1632,7 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 1, 'remains idempotent for the GSD hook block'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'remains idempotent for the GSD hook block'); assertNoDraftRootKeys(content); }); @@ -1287,7 +1652,7 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 1, 'adds one GSD update hook for dotted codex_hooks and remains idempotent'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'adds one GSD update hook for dotted codex_hooks and remains idempotent'); assertNoDraftRootKeys(content); }); @@ -1307,7 +1672,35 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely'); + assert.ok(content.includes('name = "gsd-executor"'), 'still installs the managed agent block'); + assertNoDraftRootKeys(content); + }); + + test('root inline-table codex_hooks = true runs hooks.json migration without rewriting features', () => { + const legacyManagedInlineCommand = `node ${path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}`; + writeCodexConfig(codexHome, [ + 'features = { codex_hooks = true, other_feature = true }', + '', + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + `command = "${legacyManagedInlineCommand}"`, + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('features = { codex_hooks = true, 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.ok(!content.includes('[[hooks]]'), 'removes the legacy inline GSD hook after migrating to hooks.json'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'adds the managed GSD hook to hooks.json and remains idempotent'); assert.ok(content.includes('name = "gsd-executor"'), 'still installs the managed agent block'); assertNoDraftRootKeys(content); }); @@ -1328,11 +1721,33 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely'); assert.ok(content.includes('name = "gsd-executor"'), 'still installs the managed agent block'); assertNoDraftRootKeys(content); }); + test('root scalar features assignments preserve an existing legacy inline GSD hook until hooks.json can be enabled', () => { + const legacyManagedInlineCommand = `node ${path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}`; + writeCodexConfig(codexHome, [ + 'features = "disabled"', + '', + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + `command = "${legacyManagedInlineCommand}"`, + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes(`command = "${legacyManagedInlineCommand}"`), 'preserves the legacy inline GSD hook when hooks.json migration cannot complete safely'); + assert.ok(!fs.existsSync(path.join(codexHome, 'hooks.json')), 'does not create hooks.json when codex_hooks cannot be enabled safely'); + }); + test('quoted dotted codex_hooks keys stay dotted and are normalized without duplication', () => { writeCodexConfig(codexHome, [ 'features."codex_hooks" = false', @@ -1351,7 +1766,7 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 1, 'adds one GSD update hook for quoted dotted codex_hooks and remains idempotent'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'adds one GSD update hook for quoted dotted codex_hooks and remains idempotent'); assertNoDraftRootKeys(content); }); @@ -1459,7 +1874,7 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 1, 'remains idempotent for the GSD hook block'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'remains idempotent for the GSD hook block'); assertNoDraftRootKeys(content); }); @@ -1484,7 +1899,7 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 1, 'remains idempotent for the GSD hook block'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'remains idempotent for the GSD hook block'); assertNoDraftRootKeys(content); }); @@ -1510,7 +1925,7 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 1, 'remains idempotent for the GSD hook block'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'remains idempotent for the GSD hook block'); assertNoDraftRootKeys(content); }); @@ -1555,7 +1970,7 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 1, 'keeps one GSD update hook'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'keeps one GSD update hook'); assertUsesOnlyEol(content, '\r\n'); assertNoDraftRootKeys(content); }); @@ -1578,7 +1993,7 @@ describe('Codex install hook configuration (e2e)', () => { 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-check-update\.js/g), 1, 'adds the GSD update hook once'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'adds the GSD update hook once'); assertNoDraftRootKeys(content); }); @@ -1592,10 +2007,10 @@ describe('Codex install hook configuration (e2e)', () => { // [features] is inserted after top-level lines, before [model] — not prepended assert.ok(content.includes('# first line wins\n\n[features]\ncodex_hooks = true\n'), 'inserts features after top-level lines using 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('# GSD Hooks\n[[hooks]]\nevent = "SessionStart"\n'), 'does not reintroduce inline GSD hook TOML blocks'); 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-check-update\.js/g), 1, 'does not duplicate the GSD hook block'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'does not duplicate the GSD hook block'); assertNoDraftRootKeys(content); }); }); @@ -1620,6 +2035,240 @@ describe('Codex uninstall symmetry for hook-enabled configs', () => { assert.strictEqual(cleaned, null, 'fresh GSD-only config strips back to nothing'); }); + test('fresh install removes a GSD-only hooks.json on uninstall', () => { + runCodexInstall(codexHome); + assert.ok(fs.existsSync(path.join(codexHome, 'hooks.json')), 'install writes hooks.json'); + + runCodexUninstall(codexHome); + + assert.ok(!fs.existsSync(path.join(codexHome, 'hooks.json')), 'uninstall removes a hooks.json owned only by GSD'); + }); + + test('fresh install removes managed Codex hook files on uninstall', () => { + runCodexInstall(codexHome); + const updateHook = path.join(codexHome, 'hooks', 'gsd-check-update.js'); + const updateWorker = path.join(codexHome, 'hooks', 'gsd-check-update-worker.js'); + assert.ok(fs.existsSync(updateHook), 'install writes the managed update hook'); + assert.ok(fs.existsSync(updateWorker), 'install writes the managed update worker'); + + runCodexUninstall(codexHome); + + assert.ok(!fs.existsSync(updateHook), 'uninstall removes the managed update hook'); + assert.ok(!fs.existsSync(updateWorker), 'uninstall removes the managed update worker'); + }); + + test('install then uninstall removes only the GSD hooks.json entry and preserves user hooks', () => { + writeCodexHooksJson(codexHome, { + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: 'echo keep-sessionstart' }] }, + ], + AfterCommand: [ + { hooks: [{ type: 'command', command: 'echo keep-after-command' }] }, + ], + }, + }); + + runCodexInstall(codexHome); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 1, 'install merges the GSD SessionStart hook'); + + runCodexUninstall(codexHome); + + const hooksJson = readCodexHooksJson(codexHome); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 0, 'uninstall removes the GSD hooks.json entry'); + assert.deepStrictEqual(hooksJson.hooks.SessionStart, [ + { hooks: [{ type: 'command', command: 'echo keep-sessionstart' }] }, + ], 'preserves user SessionStart hooks'); + assert.deepStrictEqual(hooksJson.hooks.AfterCommand, [ + { hooks: [{ type: 'command', command: 'echo keep-after-command' }] }, + ], 'preserves non-SessionStart hooks'); + }); + + test('install then uninstall preserves user wrapper hooks that mention gsd-check-update', () => { + const customWrapperCommand = 'node "/custom/my-gsd-check-update-wrapper.js"'; + writeCodexHooksJson(codexHome, { + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: customWrapperCommand }] }, + ], + }, + }); + + runCodexInstall(codexHome); + runCodexUninstall(codexHome); + + const hooksJson = readCodexHooksJson(codexHome); + assert.deepStrictEqual(hooksJson.hooks.SessionStart, [ + { hooks: [{ type: 'command', command: customWrapperCommand }] }, + ], 'preserves wrapper hooks that are not the managed GSD command'); + assert.strictEqual(countGsdUpdateHooksInHooksJson(codexHome), 0, 'removes only the managed GSD hook'); + }); + + test('install then uninstall preserves hooks.json files that still have user root keys', () => { + writeCodexHooksJson(codexHome, { + version: 1, + hooks: {}, + }); + + runCodexInstall(codexHome); + runCodexUninstall(codexHome); + + assert.deepStrictEqual(readCodexHooksJson(codexHome), { + version: 1, + hooks: {}, + }, 'keeps hooks.json when user-owned root keys remain'); + }); + + test('install then uninstall removes legacy inline GSD hooks even when hooks.json migration previously bailed out', () => { + const malformedHooksJson = { + hooks: { + SessionStart: { + hooks: [ + { type: 'command', command: 'echo keep-me' }, + ], + }, + }, + }; + const legacyManagedInlineCommand = `node ${path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}`; + writeCodexHooksJson(codexHome, malformedHooksJson); + writeCodexConfig(codexHome, [ + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + `command = "${legacyManagedInlineCommand}"`, + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + runCodexUninstall(codexHome); + + assert.ok(readCodexConfig(codexHome).includes('name = "o3"'), 'preserves user config after uninstall'); + assert.ok(!readCodexConfig(codexHome).includes(legacyManagedInlineCommand), 'removes the legacy inline GSD hook even after a failed hooks.json migration'); + assert.deepStrictEqual(readCodexHooksJson(codexHome), malformedHooksJson, 'leaves malformed user hooks.json entries untouched when they are not managed commands'); + }); + + test('install then uninstall removes managed-command hooks from malformed hooks.json event objects', () => { + const managedCommand = `node "${path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; + writeCodexHooksJson(codexHome, { + hooks: { + AfterCommand: { + hooks: [ + { type: 'command', command: managedCommand }, + ], + }, + }, + }); + + runCodexUninstall(codexHome); + + assert.ok(!fs.existsSync(path.join(codexHome, 'hooks.json')), 'removes malformed event objects when they only reference the managed GSD hook'); + }); + + test('uninstall preserves the update hook file when hooks.json cannot be cleaned', () => { + const hookFile = path.join(codexHome, 'hooks', 'gsd-check-update.js'); + const workerFile = path.join(codexHome, 'hooks', 'gsd-check-update-worker.js'); + const hooksJsonRaw = [ + '{', + ' "hooks": {', + ' "SessionStart": [', + ' { "hooks": [ { "type": "command", "command": "node \\\"' + hookFile.replace(/\\/g, '/') + '\\\"" } ] }', + ' ]', + ' ', + '', + ].join('\n'); + fs.mkdirSync(path.dirname(hookFile), { recursive: true }); + fs.writeFileSync(hookFile, '// managed update hook\n', 'utf8'); + fs.writeFileSync(workerFile, '// managed update worker\n', 'utf8'); + fs.writeFileSync(path.join(codexHome, 'hooks.json'), hooksJsonRaw, 'utf8'); + + runCodexUninstall(codexHome); + + assert.strictEqual(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'), hooksJsonRaw, 'leaves uncleanable hooks.json bytes untouched'); + assert.ok(fs.existsSync(hookFile), 'keeps gsd-check-update.js so the uncleaned hooks.json does not point at a deleted command'); + assert.ok(fs.existsSync(workerFile), 'keeps gsd-check-update-worker.js so the retained update hook can still start'); + }); + + test('install then uninstall removes unquoted managed-command hooks from hooks.json arrays', () => { + runCodexInstall(codexHome); + + const managedCommand = `node ${path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}`; + writeCodexHooksJson(codexHome, { + hooks: { + AfterCommand: [ + { + hooks: [ + { type: 'command', command: managedCommand }, + ], + }, + ], + }, + }); + + runCodexUninstall(codexHome); + + assert.ok(!fs.existsSync(path.join(codexHome, 'hooks.json')), 'removes unquoted managed-command hooks before deleting the underlying GSD hook file'); + }); + + test('install then uninstall removes managed inline TOML hooks even outside SessionStart', () => { + const managedCommand = `node ${path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}`; + writeCodexConfig(codexHome, [ + '# GSD Hooks', + '[[hooks]]', + 'event = "AfterCommand"', + `command = "${managedCommand}"`, + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexUninstall(codexHome); + + assert.ok(readCodexConfig(codexHome).includes('name = "o3"'), 'preserves user config after removing the managed inline hook'); + assert.ok(!readCodexConfig(codexHome).includes(managedCommand), 'removes managed inline TOML hooks even when they are not SessionStart hooks'); + }); + + test('install then uninstall removes managed inline TOML hooks with compact assignment formatting', () => { + const managedCommand = `node ${path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}`; + writeCodexConfig(codexHome, [ + '[[hooks]] # keep comment', + 'event="AfterCommand"', + `command="${managedCommand}"`, + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexUninstall(codexHome); + + assert.ok(readCodexConfig(codexHome).includes('name = "o3"'), 'preserves user config after removing the compact managed inline hook'); + assert.ok(!readCodexConfig(codexHome).includes(managedCommand), 'removes managed inline TOML hooks even when spacing around assignments differs'); + }); + + test('install then uninstall removes managed inline TOML hooks whose command escapes quotes', () => { + const managedCommand = `node "${path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; + writeCodexConfig(codexHome, [ + '# GSD Hooks', + '[[hooks]]', + 'event = "AfterCommand"', + `command = ${JSON.stringify(managedCommand)}`, + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexUninstall(codexHome); + + const content = readCodexConfig(codexHome); + assert.ok(content.includes('name = "o3"'), 'preserves user config after removing the quoted managed inline hook'); + assert.ok(!content.includes('gsd-check-update.js'), 'removes managed inline TOML hooks whose command uses escaped quotes'); + }); + test('install then uninstall removes [features].codex_hooks while preserving other feature keys, comments, hooks, and CRLF', () => { writeCodexConfig(codexHome, [ '[features]', diff --git a/tests/install-hooks-copy.test.cjs b/tests/install-hooks-copy.test.cjs index 2eb821a9b3..acb05a2142 100644 --- a/tests/install-hooks-copy.test.cjs +++ b/tests/install-hooks-copy.test.cjs @@ -307,6 +307,18 @@ describe('writeManifest includes .sh hooks', () => { ); } }); + + test('manifest contains hook entries for codex installs too', () => { + writeManifest(tmpDir, 'codex'); + + const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + + assert.ok( + manifest.files['hooks/gsd-check-update.js'], + 'codex manifest should track hooks/gsd-check-update.js so local edits can be preserved on reinstall' + ); + }); }); // ─────────────────────────────────────────────────────────────────────────────