Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/converters/claude-to-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function convertClaudeToCodex(
agents,
invocationTargets,
mcpServers: undefined,
hooks: plugin.hooks,
externallyManagedSkillNames,
}
}
Expand All @@ -126,6 +127,7 @@ export function convertClaudeToCodex(
agents,
invocationTargets,
mcpServers: plugin.mcpServers,
hooks: plugin.hooks,
}
}

Expand Down
76 changes: 76 additions & 0 deletions src/targets/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) ?? {}).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 {
Expand Down Expand Up @@ -614,3 +636,57 @@ function formatTomlInlineTable(entries: Record<string, string>): string {
)
return `{ ${parts.join(", ")} }`
}

// ── Hooks ──────────────────────────────────────────────────

async function readJsonSafe(filePath: string): Promise<Record<string, unknown> | 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<string, unknown> | null,
pluginHooks: Record<string, HookEntry[]>,
pluginName?: string,
): Record<string, unknown> {
const source = pluginName ?? "coco"
const result: Record<string, unknown[]> = {}

// Preserve existing hooks that aren't from this plugin
const existingHooks = (existing?.hooks ?? {}) as Record<string, unknown[]>
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<string, unknown>)._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 }
}
3 changes: 2 additions & 1 deletion src/types/codex.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -37,6 +37,7 @@ export type CodexBundle = {
agents?: CodexAgent[]
invocationTargets?: CodexInvocationTargets
mcpServers?: Record<string, ClaudeMcpServer>
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
Expand Down