diff --git a/src/converters/claude-to-codex.ts b/src/converters/claude-to-codex.ts index 5f7d90c..b4b0e0d 100644 --- a/src/converters/claude-to-codex.ts +++ b/src/converters/claude-to-codex.ts @@ -1,6 +1,8 @@ +import fs, { type Dirent } from "fs" +import path from "path" import { formatFrontmatter } from "../utils/frontmatter" import { type ClaudeAgent, type ClaudeCommand, type ClaudePlugin, type ClaudeSkill, filterSkillsByPlatform } from "../types/claude" -import type { CodexBundle, CodexGeneratedSkill } from "../types/codex" +import type { CodexBundle, CodexGeneratedSkill, CodexGeneratedSkillSidecarDir } from "../types/codex" import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" import { normalizeCodexName, @@ -122,7 +124,7 @@ function convertAgent( } const content = formatFrontmatter(frontmatter, body) - return { name, content } + return { name, content, sidecarDirs: collectReferencedSidecarDirs(agent) } } function convertCommandSkill( @@ -215,3 +217,22 @@ function uniqueName(base: string, used: Set): string { used.add(name) return name } + +function collectReferencedSidecarDirs(agent: ClaudeAgent): CodexGeneratedSkillSidecarDir[] { + const sourceDir = path.dirname(agent.sourcePath) + let entries: Dirent[] + + try { + entries = fs.readdirSync(sourceDir, { withFileTypes: true }) + } catch { + return [] + } + + return entries + .filter((entry) => entry.isDirectory()) + .filter((entry) => agent.body.includes(`${entry.name}/`) || agent.body.includes(`\`${entry.name}\``)) + .map((entry) => ({ + sourceDir: path.join(sourceDir, entry.name), + targetName: entry.name, + })) +} diff --git a/src/targets/codex.ts b/src/targets/codex.ts index a0277da..2efe623 100644 --- a/src/targets/codex.ts +++ b/src/targets/codex.ts @@ -1,6 +1,6 @@ import fs from "fs/promises" import path from "path" -import { backupFile, copySkillDir, ensureDir, sanitizePathName, writeText, writeTextSecure } from "../utils/files" +import { backupFile, copyDir, copySkillDir, ensureDir, sanitizePathName, writeText, writeTextSecure } from "../utils/files" import type { CodexBundle } from "../types/codex" import type { ClaudeMcpServer } from "../types/claude" import { transformContentForCodex } from "../utils/codex-content" @@ -39,7 +39,11 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle): if (bundle.generatedSkills.length > 0) { const skillsRoot = path.join(codexRoot, "skills") for (const skill of bundle.generatedSkills) { - await writeText(path.join(skillsRoot, sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n") + const skillDir = path.join(skillsRoot, sanitizePathName(skill.name)) + await writeText(path.join(skillDir, "SKILL.md"), skill.content + "\n") + for (const sidecar of skill.sidecarDirs ?? []) { + await copyDir(sidecar.sourceDir, path.join(skillDir, sidecar.targetName)) + } } } diff --git a/src/types/codex.ts b/src/types/codex.ts index 8ed494c..4148e2e 100644 --- a/src/types/codex.ts +++ b/src/types/codex.ts @@ -14,6 +14,12 @@ export type CodexSkillDir = { export type CodexGeneratedSkill = { name: string content: string + sidecarDirs?: CodexGeneratedSkillSidecarDir[] +} + +export type CodexGeneratedSkillSidecarDir = { + sourceDir: string + targetName: string } export type CodexBundle = { diff --git a/src/utils/codex-content.ts b/src/utils/codex-content.ts index e773d72..634f499 100644 --- a/src/utils/codex-content.ts +++ b/src/utils/codex-content.ts @@ -41,7 +41,7 @@ export function transformContentForCodex( : `${prefix}Use the $${skillName} skill` }) - const slashCommandPattern = /(?}\]\)])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi result = result.replace(slashCommandPattern, (match, commandName: string) => { if (commandName.includes("/")) return match if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match diff --git a/tests/codex-converter.test.ts b/tests/codex-converter.test.ts index 0460e8b..7de9536 100644 --- a/tests/codex-converter.test.ts +++ b/tests/codex-converter.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import os from "os" +import path from "path" import { convertClaudeToCodex } from "../src/converters/claude-to-codex" import { parseFrontmatter } from "../src/utils/frontmatter" import type { ClaudePlugin } from "../src/types/claude" @@ -344,6 +347,46 @@ Don't confuse with file paths like /tmp/output.md or /dev/null.`, expect(parsed.body).toContain("/dev/null") }) + test("preserves agent script paths and tracks referenced sidecar directories", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-agent-sidecar-")) + const agentDir = path.join(tempRoot, "agents", "research") + const scriptDir = path.join(agentDir, "session-history-scripts") + await fs.mkdir(scriptDir, { recursive: true }) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [], + skills: [], + agents: [ + { + name: "session-historian", + description: "Session history research", + body: [ + "Locate the `session-history-scripts/` directory.", + "Run `bash /discover-sessions.sh repo 7`.", + ].join("\n"), + sourcePath: path.join(agentDir, "session-historian.md"), + }, + ], + } + + const bundle = convertClaudeToCodex(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const agentSkill = bundle.generatedSkills.find((s) => s.name === "session-historian") + expect(agentSkill).toBeDefined() + expect(agentSkill!.sidecarDirs).toEqual([ + { sourceDir: scriptDir, targetName: "session-history-scripts" }, + ]) + + const parsed = parseFrontmatter(agentSkill!.content) + expect(parsed.body).toContain("/discover-sessions.sh") + expect(parsed.body).not.toContain("/prompts:discover-sessions.sh") + }) + test("transforms canonical workflow slash commands to Codex prompt references", () => { const plugin: ClaudePlugin = { ...fixturePlugin, diff --git a/tests/codex-writer.test.ts b/tests/codex-writer.test.ts index eb3b690..fce4664 100644 --- a/tests/codex-writer.test.ts +++ b/tests/codex-writer.test.ts @@ -76,6 +76,38 @@ describe("writeCodexBundle", () => { expect(await exists(path.join(codexRoot, "skills", "skill-one", "SKILL.md"))).toBe(true) }) + test("copies generated skill sidecar directories", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-sidecar-")) + const sidecarDir = path.join(tempRoot, "source", "session-history-scripts") + await fs.mkdir(sidecarDir, { recursive: true }) + await fs.writeFile(path.join(sidecarDir, "discover-sessions.sh"), "#!/usr/bin/env bash\n") + + const bundle: CodexBundle = { + prompts: [], + skillDirs: [], + generatedSkills: [ + { + name: "session-historian", + content: "Skill content", + sidecarDirs: [{ sourceDir: sidecarDir, targetName: "session-history-scripts" }], + }, + ], + } + + await writeCodexBundle(tempRoot, bundle) + + expect(await exists( + path.join( + tempRoot, + ".codex", + "skills", + "session-historian", + "session-history-scripts", + "discover-sessions.sh", + ), + )).toBe(true) + }) + test("preserves existing user config when writing MCP servers", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-backup-")) const codexRoot = path.join(tempRoot, ".codex")