fix(codex): remove duplicate skill copies and consolidate hook payloads

This commit is contained in:
Tom Boucher
2026-05-14 13:17:39 -04:00
parent 625ccf5217
commit 1d6284a718
4 changed files with 351 additions and 120 deletions

View File

@@ -843,6 +843,101 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
return { content: updated, changed };
}
/**
* Ensure Codex hooks.json contains exactly one managed SessionStart
* gsd-check-update hook entry, while preserving user-owned entries.
*
* Codex accepts hook config from hooks.json and config.toml. To avoid the
* startup warning for mixed representations in the same layer, GSD now stores
* the managed SessionStart hook in hooks.json and keeps config.toml for
* feature flags / agent metadata only.
*
* Supports both known hooks.json shapes:
* 1) { "SessionStart": [...] }
* 2) { "hooks": { "SessionStart": [...] } }
*
* @param {string} targetDir
* @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts
* @returns {{ changed: boolean, wrote: boolean, path: string }}
*/
function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
const hooksJsonPath = path.join(targetDir, 'hooks.json');
const platform = opts.platform || process.platform;
const absoluteRunner = opts.absoluteRunner || null;
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
let parsed = {};
if (fs.existsSync(hooksJsonPath)) {
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
if (raw.trim()) {
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(`hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
}
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
const usesNestedHooksObject =
parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
const hookTable = usesNestedHooksObject ? parsed.hooks : parsed;
const sessionStart = Array.isArray(hookTable.SessionStart) ? hookTable.SessionStart : [];
let removedLegacy = false;
const sanitizedSessionStart = [];
for (const entry of sessionStart) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
if (originalHooks.length === 0) {
sanitizedSessionStart.push(entry);
continue;
}
const keptHooks = originalHooks.filter((hook) => {
const cmd = hook && typeof hook === 'object' ? hook.command : null;
const managed = isManagedHookCommand(cmd, {
surface: 'codex-hooks-json',
includeLegacyAliases: true,
configDir: targetDir,
});
if (managed) removedLegacy = true;
return !managed;
});
if (keptHooks.length === 0) continue;
const nextEntry = { ...entry, hooks: keptHooks };
sanitizedSessionStart.push(nextEntry);
}
const managedCommand = projectManagedHookCommand({
absoluteRunner,
scriptPath: path.resolve(targetDir, 'hooks', 'gsd-check-update.js'),
runtime: 'codex',
platform,
});
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
sanitizedSessionStart.push({
hooks: [
{
type: 'command',
command: managedCommand,
},
],
});
hookTable.SessionStart = sanitizedSessionStart;
if (usesNestedHooksObject) parsed.hooks = hookTable;
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
const currentContent = fs.existsSync(hooksJsonPath) ? fs.readFileSync(hooksJsonPath, 'utf8') : null;
const changed = currentContent !== nextContent;
if (changed) {
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
}
return { changed: changed || removedLegacy, wrote: changed, path: hooksJsonPath };
}
/**
* Build a hook command path using forward slashes for cross-platform compatibility.
* On Windows, $HOME is not expanded by cmd.exe/PowerShell, so we use the actual path.
@@ -7918,13 +8013,22 @@ function install(isGlobal, runtime = 'claude', options = {}) {
}
} else if (isCodex) {
const skillsDir = path.join(targetDir, 'skills');
const gsdSrc = _stageSkills(_commandsDir);
copyCommandsAsCodexSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime);
const installedSkillNames = listCodexSkillNames(skillsDir);
if (installedSkillNames.length > 0) {
console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`);
// Codex now discovers repo/user/admin/system skills from .agents/skills and
// warns if a layer mixes redundant hook/skill representations. Legacy
// gsd-* copies under ~/.codex/skills are therefore removed and no longer
// regenerated.
let removedLegacyCodexSkills = 0;
if (fs.existsSync(skillsDir)) {
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue;
fs.rmSync(path.join(skillsDir, entry.name), { recursive: true, force: true });
removedLegacyCodexSkills += 1;
}
}
if (removedLegacyCodexSkills > 0) {
console.log(` ${green}✓${reset} Removed ${removedLegacyCodexSkills} legacy Codex gsd-* skill copies from skills/`);
} else {
failures.push('skills/gsd-*');
console.log(` ${dim}↳${reset} Skipped Codex skill-copy generation (Codex discovers official skills directly)`);
}
} else if (isCopilot) {
const skillsDir = path.join(targetDir, 'skills');
@@ -8535,7 +8639,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
}
if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
// Capture pre-install snapshot of config.toml before ANY GSD mutation
// Capture pre-install snapshots before ANY GSD mutation
// (#2760 fix 3). On post-write schema-validation failure OR any throw
// during the mutation sequence (write failure, merge throw, etc.) we
// restore these exact bytes so the user is never left with a broken
@@ -8546,9 +8650,19 @@ function install(isGlobal, runtime = 'claude', options = {}) {
const codexConfigPreInstallSnapshot = fs.existsSync(codexConfigPathPreInstall)
? fs.readFileSync(codexConfigPathPreInstall)
: null;
const codexHooksJsonPathPreInstall = path.join(targetDir, 'hooks.json');
const codexHooksJsonPreInstallSnapshot = fs.existsSync(codexHooksJsonPathPreInstall)
? fs.readFileSync(codexHooksJsonPathPreInstall)
: null;
const migrationTouchesHooksJson =
!!(installerMigrationResult
&& installerMigrationResult.plan
&& Array.isArray(installerMigrationResult.plan.actions)
&& installerMigrationResult.plan.actions.some((action) => action && action.relPath === 'hooks.json'));
// #3245 — unified idempotent rollback. Reverts ALL Codex-specific mutations:
// config.toml — restore pre-install bytes (or remove if was absent)
// hooks.json — restore pre-install bytes (or remove if was absent)
// skills/gsd-* — restore pre-existing dirs from content snapshot; remove
// newly-created dirs (i.e. those not in the pre-install Set)
// agents/gsd-* — restore pre-existing files from content snapshot; remove
@@ -8569,6 +8683,19 @@ function install(isGlobal, runtime = 'claude', options = {}) {
try { fs.rmSync(codexConfigPathPreInstall); } catch (_) { /* best-effort */ }
}
// 1b. hooks.json
// If installer migrations touched hooks.json, rollbackInstallerMigrations()
// already restored the pre-migration file. Don't overwrite that state with
// a post-migration snapshot.
if (!migrationTouchesHooksJson) {
if (codexHooksJsonPreInstallSnapshot !== null) {
try { fs.writeFileSync(codexHooksJsonPathPreInstall, codexHooksJsonPreInstallSnapshot); }
catch (_) { /* best-effort restore — surface the original error */ }
} else if (fs.existsSync(codexHooksJsonPathPreInstall)) {
try { fs.rmSync(codexHooksJsonPathPreInstall); } catch (_) { /* best-effort */ }
}
}
// 2. skills/gsd-*
// • Dirs that pre-existed: wipe current contents, restore snapshotted files.
// The restore iterates the SNAPSHOT manifest (codexPreInstallSkillNames) rather
@@ -8688,9 +8815,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
}
// Copy hook files that are referenced in config.toml (#2153)
// Copy hook files that are referenced by Codex hook configuration (#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.
// gsd-check-update.js through hooks config — the file must physically exist.
const codexHooksSrc = path.join(src, 'hooks', 'dist');
if (fs.existsSync(codexHooksSrc)) {
const codexHooksDest = path.join(targetDir, 'hooks');
@@ -8758,37 +8885,11 @@ function install(isGlobal, runtime = 'claude', options = {}) {
const codexHooksFeature = ensureCodexHooksFeature(configContent);
configContent = setManagedCodexHooksOwnership(codexHooksFeature.content, codexHooksFeature.ownership);
// Add SessionStart hook for update checking. Codex 0.124.0+ requires the
// two-level nested AoT schema: [[hooks.SessionStart]] for the event entry
// (holds optional matcher) and [[hooks.SessionStart.hooks]] for the handler
// (holds type, command, statusMessage, timeout). (#2637, #2760, #2773)
//
// #3017: route through buildCodexHookBlock() so the absolute Node binary
// path is emitted (matching the settings.json branch via #3002), so the
// hook resolves under GUI/minimal-PATH runtimes where bare `node` doesn't.
// GSD-managed Codex hook payloads now live in hooks.json to avoid mixed
// representation warnings when a single layer contains both hooks.json
// and inline [hooks] entries. Keep config.toml focused on feature flags
// and agent metadata.
const codexNodeRunner = resolveNodeRunner();
const hookBlock = buildCodexHookBlock(targetDir, { absoluteRunner: codexNodeRunner, eol });
if (hasEnabledCodexHooksFeature(configContent)) {
// Reinstall path: rewrite a legacy bare-node managed-hook entry to the
// absolute runner. Mirrors rewriteLegacyManagedNodeHookCommands for the
// settings.json surface (#3002 CR).
const rewrite = rewriteLegacyCodexHookBlock(configContent, codexNodeRunner);
if (rewrite.changed) {
configContent = rewrite.content;
console.log(` ${green}✓${reset} Migrated legacy bare-node Codex hook to absolute runner (#3017)`);
}
if (!configContent.includes('gsd-check-update')) {
if (hookBlock !== null) {
configContent += hookBlock;
} else {
// resolveNodeRunner() returned null — process.execPath unavailable.
// Match the settings.json branch's warn-and-skip behavior rather
// than emit a broken bare-node hook (the #2979 / #3017 failure mode).
console.warn(` ${yellow}⚠${reset} Skipping Codex SessionStart hook registration — Node executable path unavailable (process.execPath is empty). See #2979 / #3002 / #3017.`);
}
}
}
// #2760 fix 3 — post-write schema validation. Parse the bytes we are
// about to commit and assert they match Codex's expected shape. If
@@ -8826,7 +8927,24 @@ function install(isGlobal, runtime = 'claude', options = {}) {
);
throw wrapped;
}
console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart)`);
if (hasEnabledCodexHooksFeature(configContent)) {
const checkUpdateFile = path.join(targetDir, 'hooks', 'gsd-check-update.js');
if (!fs.existsSync(checkUpdateFile)) {
console.warn(` ${yellow}⚠${reset} Skipped Codex SessionStart hook registration — gsd-check-update.js not found at target`);
} else if (!codexNodeRunner) {
console.warn(` ${yellow}⚠${reset} Skipping Codex SessionStart hook registration — Node executable path unavailable (process.execPath is empty). See #2979 / #3002 / #3017.`);
} else {
const hookWrite = ensureCodexHooksJsonSessionStart(targetDir, {
absoluteRunner: codexNodeRunner,
platform: process.platform,
});
if (hookWrite.wrote) {
console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart via hooks.json)`);
} else {
console.log(` ${green}✓${reset} Verified Codex hooks (SessionStart via hooks.json)`);
}
}
}
} catch (e) {
// #2760 — schema-validation and write failures must be loud and fatal
// so the user is never left with a config Codex refuses to load (or no