feat(codex): native plugin install manifests + agents-only converter (#616)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user