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
61 changes: 59 additions & 2 deletions src/converters/claude-to-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,76 @@ function convertCommandSkill(command: ClaudeCommand, usedNames: Set<string>): Co
if (command.allowedTools && command.allowedTools.length > 0) {
sections.push(`## Allowed tools\n${command.allowedTools.map((tool) => `- ${tool}`).join("\n")}`)
}
sections.push(command.body.trim())
// Transform Task agent calls to Codex skill references
const transformedBody = transformTaskCalls(command.body.trim())
sections.push(transformedBody)
const body = sections.filter(Boolean).join("\n\n").trim()
const content = formatFrontmatter(frontmatter, body.length > 0 ? body : command.body)
return { name, content }
}

/**
* Transform Claude Code content to Codex-compatible content.
*
* Handles multiple syntax differences:
* 1. Task agent calls: Task agent-name(args) → Use the $agent-name skill to: args
* 2. Slash commands: /command-name → /prompts:command-name
* 3. Agent references: @agent-name → $agent-name skill
*
* This bridges the gap since Claude Code and Codex have different syntax
* for invoking commands, agents, and skills.
*/
function transformContentForCodex(body: string): string {
let result = body

// 1. Transform Task agent calls
// Match: Task repo-research-analyst(feature_description)
// Match: - Task learnings-researcher(args)
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
const skillName = normalizeName(agentName)
const trimmedArgs = args.trim()
return `${prefix}Use the $${skillName} skill to: ${trimmedArgs}`
})

// 2. Transform slash command references
// Match: /command-name or /workflows:command but NOT /path/to/file or URLs
// Look for slash commands in contexts like "Run /command", "use /command", etc.
// Avoid matching file paths (contain multiple slashes) or URLs (contain ://)
const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
result = result.replace(slashCommandPattern, (match, commandName: string) => {
// Skip if it looks like a file path (contains /)
if (commandName.includes('/')) return match
// Skip common non-command patterns
if (['dev', 'tmp', 'etc', 'usr', 'var', 'bin', 'home'].includes(commandName)) return match
// Transform to Codex prompt syntax
const normalizedName = normalizeName(commandName)
return `/prompts:${normalizedName}`
})

// 3. Transform @agent-name references
// Match: @agent-name in text (not emails)
const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
result = result.replace(agentRefPattern, (_match, agentName: string) => {
const skillName = normalizeName(agentName)
return `$${skillName} skill`
})

return result
}

// Alias for backward compatibility
const transformTaskCalls = transformContentForCodex

function renderPrompt(command: ClaudeCommand, skillName: string): string {
const frontmatter: Record<string, unknown> = {
description: command.description,
"argument-hint": command.argumentHint,
}
const instructions = `Use the $${skillName} skill for this command and follow its instructions.`
const body = [instructions, "", command.body].join("\n").trim()
// Transform Task calls in prompt body too (not just skill body)
const transformedBody = transformTaskCalls(command.body)
const body = [instructions, "", transformedBody].join("\n").trim()
return formatFrontmatter(frontmatter, body)
}

Expand Down
83 changes: 83 additions & 0 deletions tests/codex-converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,89 @@ describe("convertClaudeToCodex", () => {
expect(bundle.mcpServers?.local?.args).toEqual(["hello"])
})

test("transforms Task agent calls to skill references", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
{
name: "plan",
description: "Planning with agents",
body: `Run these agents in parallel:

- Task repo-research-analyst(feature_description)
- Task learnings-researcher(feature_description)

Then consolidate findings.

Task best-practices-researcher(topic)`,
sourcePath: "/tmp/plugin/commands/plan.md",
},
],
agents: [],
skills: [],
}

const bundle = convertClaudeToCodex(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})

const commandSkill = bundle.generatedSkills.find((s) => s.name === "plan")
expect(commandSkill).toBeDefined()
const parsed = parseFrontmatter(commandSkill!.content)

// Task calls should be transformed to skill references
expect(parsed.body).toContain("Use the $repo-research-analyst skill to: feature_description")
expect(parsed.body).toContain("Use the $learnings-researcher skill to: feature_description")
expect(parsed.body).toContain("Use the $best-practices-researcher skill to: topic")

// Original Task syntax should not remain
expect(parsed.body).not.toContain("Task repo-research-analyst")
expect(parsed.body).not.toContain("Task learnings-researcher")
})

test("transforms slash commands to prompts syntax", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
{
name: "plan",
description: "Planning with commands",
body: `After planning, you can:

1. Run /deepen-plan to enhance
2. Run /plan_review for feedback
3. Start /workflows:work to implement

Don't confuse with file paths like /tmp/output.md or /dev/null.`,
sourcePath: "/tmp/plugin/commands/plan.md",
},
],
agents: [],
skills: [],
}

const bundle = convertClaudeToCodex(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})

const commandSkill = bundle.generatedSkills.find((s) => s.name === "plan")
expect(commandSkill).toBeDefined()
const parsed = parseFrontmatter(commandSkill!.content)

// Slash commands should be transformed to /prompts: syntax
expect(parsed.body).toContain("/prompts:deepen-plan")
expect(parsed.body).toContain("/prompts:plan_review")
expect(parsed.body).toContain("/prompts:workflows-work")

// File paths should NOT be transformed
expect(parsed.body).toContain("/tmp/output.md")
expect(parsed.body).toContain("/dev/null")
})

test("truncates generated skill descriptions to Codex limits and single line", () => {
const longDescription = `Line one\nLine two ${"a".repeat(2000)}`
const plugin: ClaudePlugin = {
Expand Down