Skip to content
Merged
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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
44 changes: 32 additions & 12 deletions src/__tests__/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
36 changes: 30 additions & 6 deletions src/converter/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 3 additions & 15 deletions src/writer/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down