fix(converters): preserve Codex agent sidecar scripts (#563)
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
|
import fs, { type Dirent } from "fs"
|
||||||
|
import path from "path"
|
||||||
import { formatFrontmatter } from "../utils/frontmatter"
|
import { formatFrontmatter } from "../utils/frontmatter"
|
||||||
import { type ClaudeAgent, type ClaudeCommand, type ClaudePlugin, type ClaudeSkill, filterSkillsByPlatform } from "../types/claude"
|
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 type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
|
||||||
import {
|
import {
|
||||||
normalizeCodexName,
|
normalizeCodexName,
|
||||||
@@ -122,7 +124,7 @@ function convertAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const content = formatFrontmatter(frontmatter, body)
|
const content = formatFrontmatter(frontmatter, body)
|
||||||
return { name, content }
|
return { name, content, sidecarDirs: collectReferencedSidecarDirs(agent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertCommandSkill(
|
function convertCommandSkill(
|
||||||
@@ -215,3 +217,22 @@ function uniqueName(base: string, used: Set<string>): string {
|
|||||||
used.add(name)
|
used.add(name)
|
||||||
return 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
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 { CodexBundle } from "../types/codex"
|
||||||
import type { ClaudeMcpServer } from "../types/claude"
|
import type { ClaudeMcpServer } from "../types/claude"
|
||||||
import { transformContentForCodex } from "../utils/codex-content"
|
import { transformContentForCodex } from "../utils/codex-content"
|
||||||
@@ -39,7 +39,11 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
|
|||||||
if (bundle.generatedSkills.length > 0) {
|
if (bundle.generatedSkills.length > 0) {
|
||||||
const skillsRoot = path.join(codexRoot, "skills")
|
const skillsRoot = path.join(codexRoot, "skills")
|
||||||
for (const skill of bundle.generatedSkills) {
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export type CodexSkillDir = {
|
|||||||
export type CodexGeneratedSkill = {
|
export type CodexGeneratedSkill = {
|
||||||
name: string
|
name: string
|
||||||
content: string
|
content: string
|
||||||
|
sidecarDirs?: CodexGeneratedSkillSidecarDir[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodexGeneratedSkillSidecarDir = {
|
||||||
|
sourceDir: string
|
||||||
|
targetName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CodexBundle = {
|
export type CodexBundle = {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function transformContentForCodex(
|
|||||||
: `${prefix}Use the $${skillName} skill`
|
: `${prefix}Use the $${skillName} skill`
|
||||||
})
|
})
|
||||||
|
|
||||||
const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
|
const slashCommandPattern = /(?<![:\w>}\]\)])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
|
||||||
result = result.replace(slashCommandPattern, (match, commandName: string) => {
|
result = result.replace(slashCommandPattern, (match, commandName: string) => {
|
||||||
if (commandName.includes("/")) return match
|
if (commandName.includes("/")) return match
|
||||||
if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match
|
if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
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 { convertClaudeToCodex } from "../src/converters/claude-to-codex"
|
||||||
import { parseFrontmatter } from "../src/utils/frontmatter"
|
import { parseFrontmatter } from "../src/utils/frontmatter"
|
||||||
import type { ClaudePlugin } from "../src/types/claude"
|
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")
|
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 <script-dir>/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("<script-dir>/discover-sessions.sh")
|
||||||
|
expect(parsed.body).not.toContain("<script-dir>/prompts:discover-sessions.sh")
|
||||||
|
})
|
||||||
|
|
||||||
test("transforms canonical workflow slash commands to Codex prompt references", () => {
|
test("transforms canonical workflow slash commands to Codex prompt references", () => {
|
||||||
const plugin: ClaudePlugin = {
|
const plugin: ClaudePlugin = {
|
||||||
...fixturePlugin,
|
...fixturePlugin,
|
||||||
|
|||||||
@@ -76,6 +76,38 @@ describe("writeCodexBundle", () => {
|
|||||||
expect(await exists(path.join(codexRoot, "skills", "skill-one", "SKILL.md"))).toBe(true)
|
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 () => {
|
test("preserves existing user config when writing MCP servers", async () => {
|
||||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-backup-"))
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-backup-"))
|
||||||
const codexRoot = path.join(tempRoot, ".codex")
|
const codexRoot = path.join(tempRoot, ".codex")
|
||||||
|
|||||||
Reference in New Issue
Block a user