diff --git a/src/converters/claude-to-codex.ts b/src/converters/claude-to-codex.ts index a99877625..a91ae97be 100644 --- a/src/converters/claude-to-codex.ts +++ b/src/converters/claude-to-codex.ts @@ -104,6 +104,7 @@ export function convertClaudeToCodex( agents, invocationTargets, mcpServers: undefined, + hooks: plugin.hooks, externallyManagedSkillNames, } } @@ -126,6 +127,7 @@ export function convertClaudeToCodex( agents, invocationTargets, mcpServers: plugin.mcpServers, + hooks: plugin.hooks, } } diff --git a/src/targets/codex.ts b/src/targets/codex.ts index 5bfeb80dd..ef0f539cf 100644 --- a/src/targets/codex.ts +++ b/src/targets/codex.ts @@ -134,6 +134,28 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle): } await writeTextSecure(configPath, merged) } + + // Write hooks to .codex/hooks.json — Codex uses the same hooks format + // as Claude Code. Hooks are merged with any existing hooks file to avoid + // clobbering hooks from other plugins or manual configuration. + // Always run the merge (even with empty hooks) so previously installed + // hooks tagged with this plugin's _source are cleaned up on upgrade. + { + const hooksPath = path.join(codexRoot, "hooks.json") + const existingHooks = await readJsonSafe(hooksPath) + const pluginHooks = bundle.hooks?.hooks ?? {} + const mergedHooks = mergeCodexHooks(existingHooks, pluginHooks, pluginName) + const hasHooks = Object.keys((mergedHooks.hooks as Record) ?? {}).length > 0 + if (hasHooks || existingHooks !== null) { + if (existingHooks !== null) { + const backupPath = await backupFile(hooksPath) + if (backupPath) { + console.log(`Backed up existing hooks to ${backupPath}`) + } + } + await writeTextSecure(hooksPath, JSON.stringify(mergedHooks, null, 2) + "\n") + } + } } function resolveCodexRoot(outputRoot: string): string { @@ -614,3 +636,57 @@ function formatTomlInlineTable(entries: Record): string { ) return `{ ${parts.join(", ")} }` } + +// ── Hooks ────────────────────────────────────────────────── + +async function readJsonSafe(filePath: string): Promise | null> { + try { + const content = await fs.readFile(filePath, "utf8") + return JSON.parse(content) + } catch { + return null + } +} + +type HookEntry = { matcher?: string; hooks: Array<{ type: string; command?: string; prompt?: string; agent?: string; timeout?: number }> } + +/** + * Merge plugin hooks into an existing .codex/hooks.json, preserving hooks + * from other sources. Uses a managed-block pattern: each plugin's hooks are + * tagged with a `_source` field so re-installs can replace them cleanly. + */ +function mergeCodexHooks( + existing: Record | null, + pluginHooks: Record, + pluginName?: string, +): Record { + const source = pluginName ?? "coco" + const result: Record = {} + + // Preserve existing hooks that aren't from this plugin + const existingHooks = (existing?.hooks ?? {}) as Record + for (const [event, matchers] of Object.entries(existingHooks)) { + if (!Array.isArray(matchers)) continue + result[event] = matchers.filter((m) => { + if (typeof m === "object" && m !== null && "_source" in m) { + return (m as Record)._source !== source + } + return true // keep hooks without a source tag (manual or other plugins) + }) + } + + // Add this plugin's hooks with source tag + for (const [event, matchers] of Object.entries(pluginHooks)) { + if (!result[event]) result[event] = [] + for (const matcher of matchers) { + result[event].push({ ...matcher, _source: source }) + } + } + + // Remove empty event arrays + for (const event of Object.keys(result)) { + if (result[event].length === 0) delete result[event] + } + + return { hooks: result } +} diff --git a/src/types/codex.ts b/src/types/codex.ts index 361e01200..213e85458 100644 --- a/src/types/codex.ts +++ b/src/types/codex.ts @@ -1,4 +1,4 @@ -import type { ClaudeMcpServer } from "./claude" +import type { ClaudeMcpServer, ClaudeHooks } from "./claude" import type { CodexInvocationTargets } from "../utils/codex-content" export type CodexPrompt = { @@ -37,6 +37,7 @@ export type CodexBundle = { agents?: CodexAgent[] invocationTargets?: CodexInvocationTargets mcpServers?: Record + hooks?: ClaudeHooks /** * Names of skills CE owns in the Codex managed tree that are NOT written by * this bundle. Used in agents-only installs (default `--to codex`) where