feat(codex): native plugin install manifests + agents-only converter (#616)
Some checks failed
CI / pr-title (push) Has been cancelled
CI / test (push) Has been cancelled
Release PR / release-pr (push) Has been cancelled
Release PR / publish-cli (push) Has been cancelled

This commit is contained in:
Trevin Chow
2026-04-20 19:44:25 -07:00
committed by GitHub
parent c2d60b47be
commit 3ed4a4fa0f
21 changed files with 1649 additions and 14 deletions

View File

@@ -1088,6 +1088,7 @@ describe("CLI", () => {
"compound-engineering",
"--to",
"codex",
"--include-skills",
], {
cwd: workspaceRoot,
stdout: "pipe",
@@ -1114,6 +1115,50 @@ describe("CLI", () => {
expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true)
})
test("install --to codex default is agents-only (skills handled by native plugin install)", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-codex-agents-only-"))
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-codex-agents-only-ws-"))
const projectRoot = path.join(import.meta.dir, "..")
const codexRoot = path.join(tempRoot, ".codex")
const proc = Bun.spawn([
"bun",
"run",
path.join(projectRoot, "src", "index.ts"),
"install",
"compound-engineering",
"--to",
"codex",
], {
cwd: workspaceRoot,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
HOME: tempRoot,
COMPOUND_PLUGIN_GITHUB_SOURCE: "/definitely-not-a-valid-plugin-source",
},
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
}
expect(stdout).toContain("Installed compound-engineering")
// Default omits skills; they're expected from `codex plugin install`.
expect(await exists(path.join(codexRoot, "skills", "ce-plan", "SKILL.md"))).toBe(false)
// Agents still land (as generated skills for now — Codex's native plugin
// spec does not register custom agents, so the Bun converter fills the gap).
expect(await exists(path.join(codexRoot, "skills"))).toBe(true)
// AGENTS.md is emitted because --to codex always ensures a root AGENTS.md
// exists for Codex's discovery chain.
expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true)
})
test("install by name ignores same-named local directory", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-shadow-"))
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-shadow-workspace-"))
@@ -1285,6 +1330,7 @@ describe("CLI", () => {
"codex",
"--codex-home",
codexRoot,
"--include-skills",
], {
cwd: path.join(import.meta.dir, ".."),
stdout: "pipe",
@@ -1326,6 +1372,7 @@ describe("CLI", () => {
codexRoot,
"--output",
tempRoot,
"--include-skills",
], {
cwd: path.join(import.meta.dir, ".."),
stdout: "pipe",
@@ -1805,7 +1852,11 @@ describe("CLI", () => {
expect(stdout).not.toContain("cursor")
expect(await exists(path.join(tempHome, ".config", "opencode", "opencode.json"))).toBe(true)
expect(await exists(path.join(tempHome, ".codex", "skills", "compound-engineering", "skill-one", "SKILL.md"))).toBe(true)
// Codex `--to all` install uses the agents-only default — skills come from
// `codex plugin install`, not the Bun converter. Verify agents landed
// (the gap the converter fills) rather than skills (which the default suppresses).
expect(await exists(path.join(tempHome, ".codex", "agents", "compound-engineering", "security-sentinel.toml"))).toBe(true)
expect(await exists(path.join(tempHome, ".codex", "skills", "compound-engineering", "skill-one", "SKILL.md"))).toBe(false)
expect(await exists(path.join(tempHome, ".pi", "agent", "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempCwd, ".gemini", "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempCwd, ".kiro", "skills", "skill-one", "SKILL.md"))).toBe(true)

View File

@@ -46,11 +46,59 @@ const fixturePlugin: ClaudePlugin = {
}
describe("convertClaudeToCodex", () => {
test("default (agents-only): emits only agent conversions, no skills or prompts or command-skills", () => {
const bundle = convertClaudeToCodex(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
// codexIncludeSkills omitted -> defaults to false
})
// Native Codex plugin install handles skills, commands, and MCP via the
// .codex-plugin/plugin.json manifest. The Bun converter only fills the
// agent gap, so skillDirs / prompts / generatedSkills / mcpServers are
// all empty by default.
expect(bundle.skillDirs).toEqual([])
expect(bundle.prompts).toEqual([])
expect(bundle.generatedSkills).toEqual([])
expect(bundle.mcpServers).toBeUndefined()
// Custom agents (TOML) still land with instructions populated.
expect(bundle.agents).toHaveLength(1)
const agent = bundle.agents[0]!
expect(agent.name).toBe("security-reviewer")
expect(agent.description).toBe("Security-focused agent")
expect(agent.instructions).toContain("Focus on vulnerabilities.")
expect(agent.instructions).toContain("Threat modeling")
})
test("default with zero agents: emits fully empty bundle (no duplicate install possible)", () => {
const pluginWithNoAgents: ClaudePlugin = {
...fixturePlugin,
agents: [],
}
const bundle = convertClaudeToCodex(pluginWithNoAgents, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
expect(bundle.skillDirs).toEqual([])
expect(bundle.prompts).toEqual([])
expect(bundle.generatedSkills).toEqual([])
expect(bundle.agents).toEqual([])
expect(bundle.mcpServers).toBeUndefined()
// invocationTargets still populated so any future --include-skills call
// on the same plugin would have a consistent reference graph.
expect(bundle.invocationTargets).toBeDefined()
})
test("converts commands to prompts and agents to custom agents", () => {
const bundle = convertClaudeToCodex(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
expect(bundle.prompts).toHaveLength(1)
@@ -101,6 +149,7 @@ describe("convertClaudeToCodex", () => {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
const agent = bundle.agents.find((s) => s.name === "fast-agent")
@@ -136,6 +185,7 @@ describe("convertClaudeToCodex", () => {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
// No prompt wrappers for workflow skills — they're directly invocable as skills
@@ -173,6 +223,7 @@ describe("convertClaudeToCodex", () => {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
expect(bundle.prompts).toHaveLength(0)
@@ -184,6 +235,7 @@ describe("convertClaudeToCodex", () => {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
expect(bundle.mcpServers?.local?.command).toBe("echo")
@@ -235,6 +287,7 @@ Task best-practices-researcher(topic)`,
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
const commandSkill = bundle.generatedSkills.find((s) => s.name === "plan")
@@ -295,6 +348,7 @@ Task compound-engineering:review:ce-security-reviewer(code_diff)`,
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
const commandSkill = bundle.generatedSkills.find((s) => s.name === "plan")
@@ -335,6 +389,7 @@ Task compound-engineering:review:ce-security-reviewer(code_diff)`,
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
const commandSkill = bundle.generatedSkills.find((s) => s.name === "review")
@@ -370,6 +425,7 @@ Don't confuse with file paths like /tmp/output.md or /dev/null.`,
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
const commandSkill = bundle.generatedSkills.find((s) => s.name === "plan")
@@ -413,6 +469,7 @@ Don't confuse with file paths like /tmp/output.md or /dev/null.`,
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
const agent = bundle.agents.find((s) => s.name === "research-session-historian")
@@ -469,6 +526,7 @@ If planning is complete, continue with /ce-work.`,
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
const commandSkill = bundle.generatedSkills.find((s) => s.name === "review")
@@ -506,6 +564,7 @@ If planning is complete, continue with /ce-work.`,
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
// Only normal command should produce a prompt
@@ -541,6 +600,7 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
const commandSkill = bundle.generatedSkills.find((s) => s.name === "review")
@@ -570,6 +630,7 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
const agent = bundle.agents.find((s) => s.name === "config-reader")
@@ -597,6 +658,7 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
codexIncludeSkills: true,
})
const description = bundle.agents[0].description

View File

@@ -178,6 +178,47 @@ describe("writeCodexBundle", () => {
expect(await exists(path.join(promptsDir, "ce-plan.md"))).toBe(true)
})
test("preserves same-named user prompts when pluginName triggers legacy allow-list cleanup", async () => {
// Regression: `cleanupKnownLegacyCodexArtifacts` used to move any
// allow-listed filename under `~/.codex/prompts/` into
// `compound-engineering/legacy-backup/` whenever `pluginName` was set,
// without checking that CE authored the file. A user-authored
// `ce-plan.md` prompt was therefore destroyed on `install --to codex`
// even though the content was not a CE-emitted wrapper. The install path
// now requires the same body + frontmatter ownership fingerprint that
// the standalone `cleanupStalePrompts` helper uses before touching a
// prompt file at a colliding legacy name.
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-prompts-legacy-preserve-"))
const codexRoot = path.join(tempRoot, ".codex")
const promptsDir = path.join(codexRoot, "prompts")
await fs.mkdir(promptsDir, { recursive: true })
const userPromptBody =
"---\ndescription: \"Project-local ce-plan helper\"\n---\n\nCustom prompt body\n"
await fs.writeFile(path.join(promptsDir, "ce-plan.md"), userPromptBody)
await writeCodexBundle(codexRoot, {
pluginName: "compound-engineering",
prompts: [],
skillDirs: [],
generatedSkills: [],
})
expect(await exists(path.join(promptsDir, "ce-plan.md"))).toBe(true)
expect(await fs.readFile(path.join(promptsDir, "ce-plan.md"), "utf8")).toBe(userPromptBody)
const backupRoot = path.join(codexRoot, "compound-engineering", "legacy-backup")
// The legacy-backup directory should not contain the user-authored prompt.
if (await exists(backupRoot)) {
const timestamps = await fs.readdir(backupRoot)
for (const timestamp of timestamps) {
const promptsBackup = path.join(backupRoot, timestamp, "prompts")
if (await exists(promptsBackup)) {
const backedUp = await fs.readdir(promptsBackup)
expect(backedUp).not.toContain("ce-plan.md")
}
}
}
})
test("writes plugin skills under a namespaced Codex skills root without .agents symlinks", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-managed-plugin-"))
const codexRoot = path.join(tempRoot, ".codex")
@@ -354,6 +395,65 @@ describe("writeCodexBundle", () => {
}
})
test("agents-only install preserves namespaced skills previously installed via Codex native plugin flow", async () => {
// Regression for the bug where re-running `install --to codex` after a
// native `/plugins` install moved currently-active namespaced skills
// (e.g., `.codex/skills/compound-engineering/ce-plan/`) into
// legacy-backup. The agents-only default produces an empty `skillDirs` /
// `generatedSkills`, but the converter now populates
// `externallyManagedSkillNames` with the allow-listed current skills so
// `cleanupLegacyAgentSkillDirs` treats them as current rather than legacy.
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-agents-only-preserve-"))
const codexRoot = path.join(tempRoot, ".codex")
// Simulate the tree produced by a native Codex plugin install: active
// namespaced skills under `.codex/skills/<plugin>/<skill>/SKILL.md`.
const namespacedSkillsRoot = path.join(codexRoot, "skills", "compound-engineering")
for (const skillName of ["ce-plan", "ce-debug", "ce-brainstorm"]) {
await fs.mkdir(path.join(namespacedSkillsRoot, skillName), { recursive: true })
await fs.writeFile(
path.join(namespacedSkillsRoot, skillName, "SKILL.md"),
`# ${skillName} skill installed via native Codex plugin flow`,
)
}
const plugin = await loadClaudePlugin(path.join(import.meta.dir, "..", "plugins", "compound-engineering"))
const bundle = convertClaudeToCodex(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
// codexIncludeSkills omitted -> agents-only default
})
// Sanity: agents-only bundle does not request any skill writes, but it
// does advertise the current skill names so cleanup preserves them.
expect(bundle.skillDirs).toEqual([])
expect(bundle.generatedSkills).toEqual([])
expect(bundle.externallyManagedSkillNames).toContain("ce-plan")
expect(bundle.externallyManagedSkillNames).toContain("ce-debug")
await writeCodexBundle(codexRoot, bundle)
// Currently-active skills survive an agents-only re-install.
expect(await exists(path.join(namespacedSkillsRoot, "ce-plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(namespacedSkillsRoot, "ce-debug", "SKILL.md"))).toBe(true)
expect(await exists(path.join(namespacedSkillsRoot, "ce-brainstorm", "SKILL.md"))).toBe(true)
// And none of them were silently relocated into legacy-backup.
const backupRoot = path.join(codexRoot, "compound-engineering", "legacy-backup")
if (await exists(backupRoot)) {
const timestamps = await fs.readdir(backupRoot)
for (const ts of timestamps) {
const skillsBackup = path.join(backupRoot, ts, "skills")
if (!(await exists(skillsBackup))) continue
const backed = await fs.readdir(skillsBackup)
expect(backed).not.toContain("ce-plan")
expect(backed).not.toContain("ce-debug")
expect(backed).not.toContain("ce-brainstorm")
}
}
})
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")

View File

@@ -32,14 +32,24 @@ async function makeFixtureRoot(): Promise<string> {
await mkdir(path.join(root, "plugins", "compound-engineering", ".cursor-plugin"), {
recursive: true,
})
await mkdir(path.join(root, "plugins", "compound-engineering", ".codex-plugin"), {
recursive: true,
})
await mkdir(path.join(root, "plugins", "coding-tutor", "skills", "coding-tutor"), {
recursive: true,
})
await mkdir(path.join(root, "plugins", "coding-tutor", ".claude-plugin"), {
recursive: true,
})
await mkdir(path.join(root, "plugins", "coding-tutor", ".cursor-plugin"), {
recursive: true,
})
await mkdir(path.join(root, "plugins", "coding-tutor", ".codex-plugin"), {
recursive: true,
})
await mkdir(path.join(root, ".claude-plugin"), { recursive: true })
await mkdir(path.join(root, ".cursor-plugin"), { recursive: true })
await mkdir(path.join(root, ".agents", "plugins"), { recursive: true })
await writeFile(
path.join(root, "plugins", "compound-engineering", "agents", "review", "agent.md"),
@@ -69,6 +79,38 @@ async function makeFixtureRoot(): Promise<string> {
path.join(root, "plugins", "coding-tutor", ".cursor-plugin", "plugin.json"),
JSON.stringify({ version: "1.2.1" }, null, 2),
)
await writeFile(
path.join(root, "plugins", "compound-engineering", ".codex-plugin", "plugin.json"),
JSON.stringify(
{
name: "compound-engineering",
version: "2.42.0",
description: "old",
skills: "./skills/",
},
null,
2,
),
)
await writeFile(
path.join(root, "plugins", "coding-tutor", ".codex-plugin", "plugin.json"),
JSON.stringify(
{ name: "coding-tutor", version: "1.2.1", skills: "./skills/" },
null,
2,
),
)
await writeFile(
path.join(root, ".agents", "plugins", "marketplace.json"),
JSON.stringify(
{
name: "compound-engineering-plugin",
plugins: [{ name: "compound-engineering" }, { name: "coding-tutor" }],
},
null,
2,
),
)
await writeFile(
path.join(root, ".claude-plugin", "marketplace.json"),
JSON.stringify(
@@ -132,4 +174,202 @@ describe("release metadata", () => {
expect(changedPaths).toContain(path.join(root, ".claude-plugin", "marketplace.json"))
expect(changedPaths).toContain(path.join(root, ".cursor-plugin", "marketplace.json"))
})
test("reports Codex plugin.json version drift without auto-correcting", async () => {
const root = await makeFixtureRoot()
// Claude is at 2.42.0; fixture Codex is also 2.42.0 — drift Codex to 2.41.0.
await writeFile(
path.join(root, "plugins", "compound-engineering", ".codex-plugin", "plugin.json"),
JSON.stringify(
{ name: "compound-engineering", version: "2.41.0", skills: "./skills/" },
null,
2,
),
)
const result = await syncReleaseMetadata({ root, write: true })
const codexPath = path.join(root, "plugins", "compound-engineering", ".codex-plugin", "plugin.json")
const codexUpdate = result.updates.find((u) => u.path === codexPath)
expect(codexUpdate).toBeDefined()
expect(codexUpdate!.changed).toBe(true)
// Crucially: write: true did NOT bump the Codex version to match Claude.
// release-please owns version writes via extra-files; syncReleaseMetadata detects but does not correct.
const afterContents = JSON.parse(await Bun.file(codexPath).text())
expect(afterContents.version).toBe("2.41.0")
})
test("rewrites Codex plugin.json description on write when drifted from Claude", async () => {
const root = await makeFixtureRoot()
// Fixture Claude description is "old"; Codex starts at "old" too. Give Claude a canonical description and drift Codex.
await writeFile(
path.join(root, "plugins", "compound-engineering", ".claude-plugin", "plugin.json"),
JSON.stringify(
{
version: "2.42.0",
description: "AI-powered development tools for code review, research, design, and workflow automation.",
},
null,
2,
),
)
await writeFile(
path.join(root, "plugins", "compound-engineering", ".codex-plugin", "plugin.json"),
JSON.stringify(
{
name: "compound-engineering",
version: "2.42.0",
description: "stale codex description",
skills: "./skills/",
},
null,
2,
),
)
const codexPath = path.join(root, "plugins", "compound-engineering", ".codex-plugin", "plugin.json")
await syncReleaseMetadata({ root, write: true })
const afterContents = JSON.parse(await Bun.file(codexPath).text())
expect(afterContents.description).toBe(
"AI-powered development tools for code review, research, design, and workflow automation.",
)
})
test("reports missing Codex manifest as a structural error", async () => {
const root = await makeFixtureRoot()
await Bun.$`rm ${path.join(root, "plugins", "compound-engineering", ".codex-plugin", "plugin.json")}`.quiet()
const result = await syncReleaseMetadata({ root, write: false })
expect(result.errors.some((err) => err.includes(".codex-plugin/plugin.json is missing"))).toBe(true)
})
test("reports Codex plugin.json name mismatch as structural error", async () => {
const root = await makeFixtureRoot()
await writeFile(
path.join(root, "plugins", "compound-engineering", ".codex-plugin", "plugin.json"),
JSON.stringify(
{ name: "wrong-name", version: "2.42.0", skills: "./skills/" },
null,
2,
),
)
const result = await syncReleaseMetadata({ root, write: false })
expect(
result.errors.some((err) =>
err.includes('name "wrong-name" does not match expected "compound-engineering"'),
),
).toBe(true)
})
test("reports missing skills field on Codex manifest as structural error", async () => {
const root = await makeFixtureRoot()
// Drop the `skills` field entirely from the coding-tutor Codex manifest.
await writeFile(
path.join(root, "plugins", "coding-tutor", ".codex-plugin", "plugin.json"),
JSON.stringify({ name: "coding-tutor", version: "1.2.1" }, null, 2),
)
const result = await syncReleaseMetadata({ root, write: false })
expect(
result.errors.some(
(err) =>
err.includes("coding-tutor") &&
err.includes("missing required field") &&
err.includes("skills"),
),
).toBe(true)
})
test("reports missing skills directory when Codex manifest declares one", async () => {
const root = await makeFixtureRoot()
// Remove coding-tutor's skills dir but keep the skills declaration.
await Bun.$`rm -rf ${path.join(root, "plugins", "coding-tutor", "skills")}`.quiet()
const result = await syncReleaseMetadata({ root, write: false })
expect(
result.errors.some(
(err) =>
err.includes("coding-tutor") && err.includes("skills:") && err.includes("does not exist"),
),
).toBe(true)
})
test("reports Codex marketplace plugin-list mismatch as structural error", async () => {
const root = await makeFixtureRoot()
// Remove one plugin from Codex marketplace so Claude has a plugin Codex doesn't.
await writeFile(
path.join(root, ".agents", "plugins", "marketplace.json"),
JSON.stringify(
{
name: "compound-engineering-plugin",
plugins: [{ name: "compound-engineering" }],
},
null,
2,
),
)
const result = await syncReleaseMetadata({ root, write: false })
expect(
result.errors.some(
(err) => err.includes(".agents/plugins/marketplace.json") && err.includes("does not match"),
),
).toBe(true)
})
test("reports Codex marketplace asymmetric extra plugin as structural error", async () => {
const root = await makeFixtureRoot()
await writeFile(
path.join(root, ".agents", "plugins", "marketplace.json"),
JSON.stringify(
{
name: "compound-engineering-plugin",
plugins: [
{ name: "compound-engineering" },
{ name: "coding-tutor" },
{ name: "rogue-plugin" },
],
},
null,
2,
),
)
const result = await syncReleaseMetadata({ root, write: false })
expect(
result.errors.some(
(err) => err.includes(".agents/plugins/marketplace.json") && err.includes("does not match"),
),
).toBe(true)
})
test("happy path: fixture with matching Codex manifests produces no Codex errors", async () => {
const root = await makeFixtureRoot()
// Align Claude <-> Codex versions and descriptions so there's no drift.
await writeFile(
path.join(root, "plugins", "compound-engineering", ".claude-plugin", "plugin.json"),
JSON.stringify({ version: "2.42.0", description: "aligned description" }, null, 2),
)
await writeFile(
path.join(root, "plugins", "compound-engineering", ".codex-plugin", "plugin.json"),
JSON.stringify(
{
name: "compound-engineering",
version: "2.42.0",
description: "aligned description",
skills: "./skills/",
},
null,
2,
),
)
const result = await syncReleaseMetadata({ root, write: false })
const codexErrors = result.errors.filter(
(err) => err.includes(".codex-plugin") || err.includes(".agents/plugins"),
)
expect(codexErrors).toEqual([])
})
})