From 2f4323577b7f94550fb0252758bc67a889c3e189 Mon Sep 17 00:00:00 2001 From: DJJ Date: Mon, 24 Aug 2026 07:59:07 -0700 Subject: [PATCH] feat: pass hooks through natively for codex instead of degrading to prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex plugins do support native hooks: the default hook file is hooks/hooks.json at the plugin root with a Claude-compatible schema — same events (SessionStart/Stop/PreCompact and more, incl. SubagentStart), matchers, type:command, additionalContextLimit. Verified against https://developers.openai.com/plugins/build/plugins and https://learn.chatgpt.com/docs/hooks. convertHooks(codex) now emits hooks/hooks.json losslessly (fields beyond our HookEntry type survive since the scanner keeps the parsed JSON) and warns that plugin hooks are non-managed (users trust via /hooks). The AGENTS.md degradation path remains for opencode only; the PreCompact inclusion and timing phrases from the previous commit still apply there. --- README.md | 2 +- src/__tests__/hooks.test.ts | 44 +++++++++++++++++++++++++++---------- src/converter/hooks.ts | 36 +++++++++++++++++++++++++----- src/writer/codex.ts | 18 +++------------ 4 files changed, 66 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 0beeac2..419881d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ acplugin scan anthropics/claude-code | **MCP Servers** | `.codex/config.toml` | `opencode.json` | `.cursor/mcp.json` | `.agents/mcp_config.json` | Unsupported (warn) | | **Agents** | `.codex/agents/*.toml` | `.opencode/agents/*.md` | `.cursor/agents/*.md` | `.agents/agents/*.md` | Unsupported (warn) | | **Commands** | Converted to Skills | `.opencode/commands/` | `.cursor/commands/` | Converted to Skills | `.pi/prompts/*.md` | -| **Hooks** | Documented in `AGENTS.md` | Documented in `AGENTS.md` | Warnings only | Warnings only | Warnings only | +| **Hooks** | `hooks/hooks.json` (native) | Documented in `AGENTS.md` | Warnings only | Warnings only | Warnings only | [Pi](https://github.com/earendil-works/pi) (pi-coding-agent) is a minimal terminal harness whose only native file formats are Claude-style Skills and instruction files. Commands degrade to prompt templates; MCP/Agents/Hooks have no target format (Pi extends via TypeScript extensions) and emit warnings. diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts index 3563e22..baee409 100644 --- a/src/__tests__/hooks.test.ts +++ b/src/__tests__/hooks.test.ts @@ -22,42 +22,62 @@ const sampleHooks: Hooks = { }; describe('convertHooks', () => { - it('degrades PreCompact to a codex note instead of skipping it', () => { + it('passes hooks through natively for codex as hooks/hooks.json', () => { const hooks: Hooks = { - PreCompact: [{ hooks: [{ type: 'command', command: 'npx --no-install llmdoc hook compact' }] }], + PreCompact: [{ hooks: [{ type: 'command', command: 'npx --no-install llmdoc hook compact', additionalContextLimit: 300 } as never] }], }; const result = convertHooks(hooks, 'codex'); + expect(result.converted).toHaveLength(1); + const file = result.converted[0]; + expect(file.path).toBe('hooks/hooks.json'); + const parsed = JSON.parse(file.content) as { hooks: Hooks }; + expect(parsed.hooks.PreCompact[0].hooks[0].command).toBe('npx --no-install llmdoc hook compact'); + // fields beyond HookEntry's declared type survive the passthrough + expect((parsed.hooks.PreCompact[0].hooks[0] as { additionalContextLimit?: number }).additionalContextLimit).toBe(300); + expect(result.warnings.some(w => w.includes('review and trust'))).toBe(true); + }); + + it('keeps codex-native events like SubagentStart in the passthrough without warnings', () => { + const result = convertHooks(sampleHooks, 'codex'); + const parsed = JSON.parse(result.converted[0].content) as { hooks: Hooks }; + expect(parsed.hooks.SubagentStart).toBeDefined(); + expect(result.warnings.find(w => w.includes('SubagentStart'))).toBeUndefined(); + }); + + it('degrades PreCompact to an opencode note with event-appropriate phrasing', () => { + const hooks: Hooks = { + PreCompact: [{ hooks: [{ type: 'command', command: 'npx --no-install llmdoc hook compact' }] }], + }; + const result = convertHooks(hooks, 'opencode'); const note = result.converted.find(f => f.content.includes('PreCompact')); expect(note).toBeDefined(); expect(note!.content).toContain('Right before context compaction'); - expect(note!.content).toContain('npx --no-install llmdoc hook compact'); expect(result.warnings.find(w => w.includes('PreCompact'))).toBeUndefined(); }); - it('uses event-appropriate timing phrases in degraded notes', () => { - const result = convertHooks(sampleHooks, 'codex'); + it('uses event-appropriate timing phrases in degraded opencode notes', () => { + const result = convertHooks(sampleHooks, 'opencode'); const sessionStart = result.converted.find(f => f.content.includes('SessionStart')); expect(sessionStart!.content).toContain('At the start of every session'); expect(sessionStart!.content).not.toContain('Run after SessionStart'); }); - it('converts portable command hooks to codex notes', () => { - const result = convertHooks(sampleHooks, 'codex'); + it('converts portable command hooks to opencode notes', () => { + const result = convertHooks(sampleHooks, 'opencode'); expect(result.converted.length).toBeGreaterThan(0); const postToolUse = result.converted.find(f => f.content.includes('PostToolUse')); expect(postToolUse).toBeDefined(); expect(postToolUse!.content).toContain('npx prettier --write'); }); - it('warns about non-portable events', () => { - const result = convertHooks(sampleHooks, 'codex'); + it('warns about non-portable events on opencode', () => { + const result = convertHooks(sampleHooks, 'opencode'); const subagentWarning = result.warnings.find(w => w.includes('SubagentStart')); expect(subagentWarning).toBeDefined(); }); - it('warns about non-portable events with non-command hook types', () => { - const result = convertHooks(sampleHooks, 'codex'); - // SubagentStart is not portable, so it gets skipped with a warning about the event + it('warns about non-portable events with non-command hook types on opencode', () => { + const result = convertHooks(sampleHooks, 'opencode'); const warning = result.warnings.find(w => w.includes('SubagentStart') && w.includes('not portable')); expect(warning).toBeDefined(); }); diff --git a/src/converter/hooks.ts b/src/converter/hooks.ts index 917a8bf..bb7099e 100644 --- a/src/converter/hooks.ts +++ b/src/converter/hooks.ts @@ -30,6 +30,9 @@ export function convertHooks(hooks: Hooks, platform: Platform): HookReport { if (platform === 'cursor') { return convertCursorHooks(hooks); } + if (platform === 'codex') { + return convertCodexHooks(hooks); + } const warnings: string[] = []; const converted: ConvertedFile[] = []; @@ -61,6 +64,31 @@ export function convertHooks(hooks: Hooks, platform: Platform): HookReport { return { converted, warnings }; } +/** + * Codex plugins support native hooks: the default hook file is hooks/hooks.json + * at the plugin root, using a Claude-compatible schema (same events incl. + * SessionStart/Stop/PreCompact, matchers, type:command, additionalContextLimit). + * See https://developers.openai.com/plugins/build/plugins and + * https://learn.chatgpt.com/docs/hooks — so we pass hooks through losslessly + * instead of degrading them to AGENTS.md prose. JSON.parse preserved any fields + * beyond our HookEntry type, so stringifying keeps them intact. Codex parses + * but skips non-command handler types itself. + */ +function convertCodexHooks(hooks: Hooks): HookReport { + return { + converted: [ + { + path: 'hooks/hooks.json', + content: JSON.stringify({ hooks }, null, 2) + '\n', + type: 'hook', + }, + ], + warnings: [ + 'Codex plugin hooks are non-managed: users must review and trust them via /hooks before they run', + ], + }; +} + /** * Convert Claude Code hooks to Cursor hooks format. * Cursor hooks use camelCase event names, no matcher, and a version field. @@ -120,12 +148,8 @@ function convertCommandHook( // Cursor doesn't have hooks yet in a config file format we can write return null; case 'codex': - // Codex doesn't have hooks — add as a note in AGENTS.md - return { - path: `AGENTS.md.hook-${event}`, - content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\n${EVENT_TIMING[event] || `On ${event}, run`}: \`${command}\`\n`, - type: 'hook', - }; + // Unreachable: codex is handled by convertCodexHooks (native passthrough). + return null; case 'opencode': // OpenCode doesn't have a public hooks system — add as a note return { diff --git a/src/writer/codex.ts b/src/writer/codex.ts index b4db38d..334dc97 100644 --- a/src/writer/codex.ts +++ b/src/writer/codex.ts @@ -40,23 +40,11 @@ export function generateCodex(scan: ScanResult): ConvertResult { // Hooks if (scan.hooks) { + // Codex supports native plugin hooks (default file: hooks/hooks.json, + // Claude-compatible schema) — pass through instead of degrading to prose. const hookResult = convertHooks(scan.hooks, 'codex'); warnings.push(...hookResult.warnings); - - // Merge hook notes into AGENTS.md - if (hookResult.converted.length > 0) { - const hookSection = '# Hooks (from Claude Code)\n\n' + - hookResult.converted.map(f => f.content).join('\n\n'); - const existingAgentsMd = files.find(f => f.path === 'AGENTS.md'); - if (existingAgentsMd) { - // Separate from prior instructions with a thematic break. - existingAgentsMd.content = existingAgentsMd.content.trimEnd() + '\n\n---\n\n' + hookSection + '\n'; - } else { - // A fresh AGENTS.md must not open with "---": that reads as a - // frontmatter fence / stray horizontal rule. - files.push({ path: 'AGENTS.md', content: hookSection + '\n', type: 'hook' }); - } - } + files.push(...hookResult.converted); } // Plugin-level resource files (scripts/, etc. referenced by MCP)