diff --git a/src/targets/codex.ts b/src/targets/codex.ts index dac7ed8a..0ed18d3a 100644 --- a/src/targets/codex.ts +++ b/src/targets/codex.ts @@ -91,10 +91,19 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle): await cleanupRemovedAgents(agentsRoot, manifest, currentAgents) if (agents.length > 0) { for (const agent of agents) { - const agentFile = `${sanitizePathName(agent.name)}.toml` + const agentBaseName = sanitizePathName(agent.name) + const agentFile = `${agentBaseName}.toml` + // If the agent declares no sidecars, remove any same-basename sibling + // directory left behind by a prior install that did. The manifest-driven + // cleanupRemovedAgents sweep above only removes the sibling dir when the + // TOML itself is being removed; a same-name agent that loses its sidecar + // would otherwise leave an orphan directory. + if ((agent.sidecarDirs ?? []).length === 0 && isSafeManagedPath(agentsRoot, agentBaseName)) { + await fs.rm(path.join(agentsRoot, agentBaseName), { recursive: true, force: true }) + } await writeText(path.join(agentsRoot, agentFile), renderCodexAgentToml(agent) + "\n") for (const sidecar of agent.sidecarDirs ?? []) { - await copyDir(sidecar.sourceDir, path.join(agentsRoot, sanitizePathName(agent.name), sidecar.targetName)) + await copyDir(sidecar.sourceDir, path.join(agentsRoot, agentBaseName, sidecar.targetName)) } } } diff --git a/tests/codex-writer.test.ts b/tests/codex-writer.test.ts index 9cd585c8..e46481f0 100644 --- a/tests/codex-writer.test.ts +++ b/tests/codex-writer.test.ts @@ -765,6 +765,87 @@ Workflow handoff: expect(installedSkill).not.toContain("/prompts:settings") expect(installedSkill).not.toContain("https://prompts:www.proofeditor.ai") }) + + test("removes orphan sidecar dir when retained agent declares no sidecars", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-test-")) + const agentsRoot = path.join(tempRoot, ".codex", "agents") + const orphanDir = path.join(agentsRoot, "ce-foo", "stale-content") + await fs.mkdir(orphanDir, { recursive: true }) + await fs.writeFile(path.join(orphanDir, "leftover.txt"), "stale", "utf8") + await fs.writeFile(path.join(agentsRoot, "ce-foo.toml"), "old-toml", "utf8") + + const bundle: CodexBundle = { + prompts: [], + skillDirs: [], + generatedSkills: [], + agents: [ + { + name: "ce-foo", + description: "Foo agent", + instructions: "Do foo.", + }, + ], + mcpServers: {}, + } + + await writeCodexBundle(tempRoot, bundle) + + expect(await entryExists(path.join(agentsRoot, "ce-foo"))).toBe(false) + expect(await exists(path.join(agentsRoot, "ce-foo.toml"))).toBe(true) + }) + + test("keeps sidecar dir when retained agent declares sidecars", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-test-")) + const sidecarSource = await fs.mkdtemp(path.join(os.tmpdir(), "codex-sidecar-src-")) + await fs.writeFile(path.join(sidecarSource, "script.sh"), "#!/bin/sh\necho hi\n", "utf8") + + const bundle: CodexBundle = { + prompts: [], + skillDirs: [], + generatedSkills: [], + agents: [ + { + name: "ce-foo", + description: "Foo agent", + instructions: "Do foo.", + sidecarDirs: [{ sourceDir: sidecarSource, targetName: "scripts" }], + }, + ], + mcpServers: {}, + } + + await writeCodexBundle(tempRoot, bundle) + + const agentsRoot = path.join(tempRoot, ".codex", "agents") + expect(await exists(path.join(agentsRoot, "ce-foo.toml"))).toBe(true) + expect(await exists(path.join(agentsRoot, "ce-foo", "scripts", "script.sh"))).toBe(true) + }) + + test("leaves unrelated directories under agentsRoot alone", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-test-")) + const agentsRoot = path.join(tempRoot, ".codex", "agents") + const unrelatedDir = path.join(agentsRoot, "ce-bar-extra") + await fs.mkdir(unrelatedDir, { recursive: true }) + await fs.writeFile(path.join(unrelatedDir, "keep-me.txt"), "keep", "utf8") + + const bundle: CodexBundle = { + prompts: [], + skillDirs: [], + generatedSkills: [], + agents: [ + { + name: "ce-foo", + description: "Foo agent", + instructions: "Do foo.", + }, + ], + mcpServers: {}, + } + + await writeCodexBundle(tempRoot, bundle) + + expect(await exists(path.join(unrelatedDir, "keep-me.txt"))).toBe(true) + }) }) describe("renderCodexConfig", () => {