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
16 changes: 14 additions & 2 deletions src/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,25 @@ describe('convertAgent', () => {
expect(result.content).not.toContain('sonnet');
});

it('defaults to gpt-5.6-sol when no model specified', () => {
it('omits model when none specified so the platform default applies', () => {
const noModelAgent: Agent = {
...sampleAgent,
frontmatter: { ...sampleAgent.frontmatter, model: undefined },
};
const result = convertAgent(noModelAgent, 'codex');
expect(result.content).toContain('model = "gpt-5.6-sol"');
expect(result.content).not.toContain('model =');
expect(result.content).toContain('model_reasoning_effort');
});

it('omits model for model: inherit on every platform', () => {
const inheritAgent: Agent = {
...sampleAgent,
frontmatter: { ...sampleAgent.frontmatter, model: 'inherit' },
};
expect(convertAgent(inheritAgent, 'codex').content).not.toContain('model =');
expect(convertAgent(inheritAgent, 'opencode').content).not.toContain('model:');
expect(convertAgent(inheritAgent, 'cursor').content).not.toContain('model:');
expect(convertAgent(inheritAgent, 'antigravity').content).not.toContain('model:');
});

it('maps tools to sandbox_mode for codex', () => {
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@ const sampleHooks: Hooks = {
};

describe('convertHooks', () => {
it('degrades PreCompact to a codex note instead of skipping it', () => {
const hooks: Hooks = {
PreCompact: [{ hooks: [{ type: 'command', command: 'npx --no-install llmdoc hook compact' }] }],
};
const result = convertHooks(hooks, 'codex');
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');
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');
expect(result.converted.length).toBeGreaterThan(0);
Expand Down
34 changes: 24 additions & 10 deletions src/converter/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import { stringifyFrontmatter } from '../utils/frontmatter.js';
import { toToml } from '../utils/toml.js';
import { mapModel } from '../utils/model.js';

// 'inherit' (and an absent model field) means "use the session's default
// model". Hardcoding a mapped model name here would freeze today's default
// into the output and rot; omitting the field lets the target platform pick
// its own current default.
function resolvedModel(agent: Agent, platform: Platform): string | undefined {
const model = agent.frontmatter.model;
if (!model || model === 'inherit') {
return undefined;
}
return mapModel(model, platform);
}

export function convertAgent(agent: Agent, platform: Platform): ConvertedFile {
switch (platform) {
case 'codex':
Expand Down Expand Up @@ -30,10 +42,9 @@ function convertToCodex(agent: Agent): ConvertedFile {
developer_instructions: agent.body.trim(),
};

if (agent.frontmatter.model) {
tomlData.model = mapModel(agent.frontmatter.model, 'codex');
} else {
tomlData.model = mapModel('inherit', 'codex');
const model = resolvedModel(agent, 'codex');
if (model) {
tomlData.model = model;
}

if (agent.frontmatter.tools) {
Expand Down Expand Up @@ -62,8 +73,9 @@ function convertToOpenCode(agent: Agent): ConvertedFile {
mode: 'subagent',
};

if (agent.frontmatter.model) {
fm.model = mapModel(agent.frontmatter.model, 'opencode');
const model = resolvedModel(agent, 'opencode');
if (model) {
fm.model = model;
}

if (agent.frontmatter.maxTurns) {
Expand Down Expand Up @@ -98,8 +110,9 @@ function convertToCursor(agent: Agent): ConvertedFile {
description: agent.frontmatter.description || `Agent: ${name}`,
};

if (agent.frontmatter.model) {
fm.model = mapModel(agent.frontmatter.model, 'cursor');
const model = resolvedModel(agent, 'cursor');
if (model) {
fm.model = model;
}

// Map tools to readonly
Expand All @@ -123,8 +136,9 @@ function convertToAntigravity(agent: Agent): ConvertedFile {
description: agent.frontmatter.description || `Agent: ${name}`,
};

if (agent.frontmatter.model) {
fm.model = mapModel(agent.frontmatter.model, 'antigravity');
const model = resolvedModel(agent, 'antigravity');
if (model) {
fm.model = model;
}

// Antigravity's internal tool identifiers are not published, so we cannot
Expand Down
16 changes: 13 additions & 3 deletions src/converter/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import type { Hooks, Platform, ConvertedFile } from '../types.js';

// Events that have reasonable mapping across platforms
const PORTABLE_EVENTS = ['PostToolUse', 'PreToolUse', 'Stop', 'SessionStart'];
const PORTABLE_EVENTS = ['PostToolUse', 'PreToolUse', 'Stop', 'SessionStart', 'PreCompact'];

// Human-readable timing phrase per event, used when degrading a hook to an
// AGENTS.md note. "Run after PreCompact" would be semantically wrong.
const EVENT_TIMING: Record<string, string> = {
'SessionStart': 'At the start of every session, run',
'Stop': 'When the session stops, run',
'PreCompact': 'Right before context compaction, run',
'PreToolUse': 'Before each tool use, run',
'PostToolUse': 'After each tool use, run',
};

// Claude Code PascalCase → Cursor camelCase event name mapping
const CURSOR_EVENT_MAP: Record<string, string> = {
Expand Down Expand Up @@ -113,14 +123,14 @@ function convertCommandHook(
// Codex doesn't have hooks — add as a note in AGENTS.md
return {
path: `AGENTS.md.hook-${event}`,
content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\nRun after ${event}: \`${command}\`\n`,
content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\n${EVENT_TIMING[event] || `On ${event}, run`}: \`${command}\`\n`,
type: 'hook',
};
case 'opencode':
// OpenCode doesn't have a public hooks system — add as a note
return {
path: `AGENTS.md.hook-${event}`,
content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\nRun after ${event}: \`${command}\`\n`,
content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\n${EVENT_TIMING[event] || `On ${event}, run`}: \`${command}\`\n`,
type: 'hook',
};
case 'antigravity':
Expand Down
9 changes: 6 additions & 3 deletions src/writer/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,16 @@ export function generateCodex(scan: ScanResult): ConvertResult {

// Merge hook notes into AGENTS.md
if (hookResult.converted.length > 0) {
const hookContent = '\n\n---\n\n# Hooks (from Claude Code)\n\n' +
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) {
existingAgentsMd.content += hookContent;
// Separate from prior instructions with a thematic break.
existingAgentsMd.content = existingAgentsMd.content.trimEnd() + '\n\n---\n\n' + hookSection + '\n';
} else {
files.push({ path: 'AGENTS.md', content: hookContent.trim(), type: 'hook' });
// 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' });
}
}
}
Expand Down
9 changes: 6 additions & 3 deletions src/writer/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,16 @@ export function generateOpenCode(scan: ScanResult): ConvertResult {
warnings.push(...hookResult.warnings);

if (hookResult.converted.length > 0) {
const hookContent = '\n\n---\n\n# Hooks (from Claude Code)\n\n' +
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) {
existingAgentsMd.content += hookContent;
// Separate from prior instructions with a thematic break.
existingAgentsMd.content = existingAgentsMd.content.trimEnd() + '\n\n---\n\n' + hookSection + '\n';
} else {
files.push({ path: 'AGENTS.md', content: hookContent.trim(), type: 'hook' });
// 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' });
}
}
}
Expand Down