import { afterAll, describe, expect, test } from "bun:test" import { existsSync, promises as fs, readdirSync, readFileSync } from "fs" import os from "os" import path from "path" import { parseFrontmatter } from "../src/utils/frontmatter" // Conversion-drift smoke test. // // Converts the REAL root plugin -- not fixtures -- // to every implemented target via the CLI, then structurally validates each // output tree. Expected counts are // re-derived from the source tree (agents/, commands/, skills/ plus // `ce_platforms` frontmatter) rather than from the converter code, so drift on // either side fails here. Fixture-based behavior (field mappings, deep-merge, // legacy cleanup) is covered elsewhere; this file owns whole-tree shape for // the shipping plugins. // // Sandbox safety: every conversion writes only inside a fresh os.tmpdir() // root, with HOME redirected into that root and CODEX_HOME / // OPENCODE_CONFIG_DIR scrubbed so no target default can escape to the real // home directory. const repoRoot = path.join(import.meta.dir, "..") const cliEntry = path.join(repoRoot, "src", "index.ts") const IMPLEMENTED_TARGETS = ["opencode", "codex", "pi", "antigravity"] as const type Target = (typeof IMPLEMENTED_TARGETS)[number] const PLUGIN_NAMES = ["compound-engineering"] as const type PluginName = (typeof PLUGIN_NAMES)[number] // Note on skill body size: an "8KB Codex skill body cap" circulates in // ecosystem lint tooling (e.g. wshobson/agents harness_portability.py), but it // is not in the Codex source (codex-rs/core-skills has no body-size constant) // or the official docs. The real Codex limit is an ~8,000-character budget on // the injected skills METADATA LIST; full SKILL.md bodies are read from disk // on demand. So no body-size check is enforced here. The codex-path // description cap is converter-enforced (src/converters/claude-to-codex.ts // CODEX_DESCRIPTION_MAX_LENGTH). // --------------------------------------------------------------------------- // Source inventory -- read independently from the plugin source tree. // --------------------------------------------------------------------------- type SourceInventory = { agents: string[] commands: string[] skills: { name: string; cePlatforms?: string[]; userInvocable?: boolean }[] } function listFileBasenames(dir: string, extension: string): string[] { try { return readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) .map((entry) => entry.name.slice(0, -extension.length)) .sort() } catch { return [] } } function listDirNames(dir: string): string[] { try { return readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort() } catch { return [] } } function walkFiles(dir: string): string[] { const out: string[] = [] for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name) if (entry.isDirectory()) { out.push(...walkFiles(full)) } else if (entry.isFile()) { out.push(full) } } return out } function loadSourceInventory(pluginName: PluginName): SourceInventory { const pluginRoot = pluginName === "compound-engineering" ? repoRoot : path.join(repoRoot, "plugins", pluginName) const agents = listFileBasenames(path.join(pluginRoot, "agents"), ".md") const commands = listFileBasenames(path.join(pluginRoot, "commands"), ".md") const skills: SourceInventory["skills"] = [] const skillsRoot = path.join(pluginRoot, "skills") for (const name of listDirNames(skillsRoot)) { const skillFile = path.join(skillsRoot, name, "SKILL.md") let raw: string try { raw = readFileSync(skillFile, "utf8") } catch { continue } const { data } = parseFrontmatter(raw, skillFile) const cePlatforms = Array.isArray(data.ce_platforms) ? (data.ce_platforms as string[]) : undefined const userInvocable = data["user-invocable"] === false ? false : undefined skills.push({ name, cePlatforms, userInvocable }) } return { agents, commands, skills } } // Mirrors filterSkillsByPlatform (src/types/claude.ts) on purpose: the // expected skill list must come from an independent reading of the source // frontmatter so a regression in the converter's platform filter (or an // undocumented new exclusion) shows up as a set difference here. The only // intentional exclusion today is `ce_platforms`. function skillsForPlatform(inventory: SourceInventory, target: Target): string[] { return inventory.skills .filter((skill) => !skill.cePlatforms || skill.cePlatforms.includes(target)) .map((skill) => skill.name) .sort() } // Mirrors convertSkillsToCommands (src/converters/claude-to-opencode.ts) on // purpose: OpenCode generates one slash-command stub per platform-eligible // skill, excluding only those that opt out of user invocation // (`user-invocable: false`). `disable-model-invocation` does NOT exclude a skill // -- it marks user-invocation-only skills, which need the slash command most. // Derived independently from source frontmatter so a regression in the exclusion // shows up as a set difference here. function commandStubsForPlatform(inventory: SourceInventory, target: Target): string[] { return inventory.skills .filter((skill) => !skill.cePlatforms || skill.cePlatforms.includes(target)) .filter((skill) => skill.userInvocable !== false) .map((skill) => skill.name) .sort() } // --------------------------------------------------------------------------- // Conversion harness -- one CLI `convert` per (plugin, target), reused by all // downstream assertions. // --------------------------------------------------------------------------- type Conversion = { // Directory containing the target's output tree (e.g. /codex-home, // /gemini-out/.gemini). root: string } const conversions = new Map() function conversionKey(pluginName: PluginName, target: Target): string { return `${pluginName}:${target}` } function getConversion(pluginName: PluginName, target: Target): Conversion { const conversion = conversions.get(conversionKey(pluginName, target)) if (!conversion) { throw new Error( `No conversion recorded for ${pluginName} -> ${target}. ` + `The "${pluginName} converts to every implemented target" test must pass first.`, ) } return conversion } function targetInvocation(target: Target, tempRoot: string): { args: string[]; root: string } { switch (target) { case "opencode": { // The output basename must not be "opencode"/".opencode" -- that flips // the writer into the flat global layout. Pin --permissions explicitly: // `convert` defaults to broad while `install` defaults to none, and the // opencode.json assertion below depends on the permission block. const out = path.join(tempRoot, "opencode-out") return { args: ["--output", out, "--permissions", "broad"], root: out } } case "codex": { // codex ignores --output entirely (resolveTargetOutputRoot returns the // codex home); without --codex-home it would write to ~/.codex. // --include-skills exercises the full standalone bundle. const out = path.join(tempRoot, "codex-home") return { args: ["--codex-home", out, "--include-skills"], root: out } } case "pi": { // pi also ignores --output. The basename must be ".pi" (or "agent") to // get the flat canonical layout instead of nesting under /.pi/. const out = path.join(tempRoot, "pi-home", ".pi") return { args: ["--pi-home", out], root: out } } case "antigravity": { // Without --output antigravity defaults to /.agy. const out = path.join(tempRoot, "antigravity-out") return { args: ["--output", out], root: path.join(out, ".agy") } } } } async function runConvert(pluginName: PluginName, target: Target, tempRoot: string, fakeHome: string): Promise { const { args, root } = targetInvocation(target, tempRoot) const env: Record = { ...process.env, HOME: fakeHome } // Inherited target overrides would silently redirect writes outside tempRoot. delete env.CODEX_HOME delete env.OPENCODE_CONFIG_DIR const proc = Bun.spawn([ "bun", "run", cliEntry, "convert", pluginName === "compound-engineering" ? repoRoot : path.join(repoRoot, "plugins", pluginName), "--to", target, ...args, ], { cwd: repoRoot, stdout: "pipe", stderr: "pipe", env, }) 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( `convert ${pluginName} --to ${target} failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`, ) } conversions.set(conversionKey(pluginName, target), { root }) } async function exists(filePath: string): Promise { try { await fs.access(filePath) return true } catch { return false } } function readJson(filePath: string): Record { return JSON.parse(readFileSync(filePath, "utf8")) as Record } function expectSkillDirsHaveSkillMd(skillsRoot: string, skillNames: string[]): void { for (const name of skillNames) { const skillFile = path.join(skillsRoot, name, "SKILL.md") expect(existsSync(skillFile), `Converted skill dir ${path.join(skillsRoot, name)} is missing SKILL.md`).toBe(true) } } // --------------------------------------------------------------------------- // Per-plugin drift tests. // --------------------------------------------------------------------------- // Conversion output is read by tests across several describe blocks, so temp // roots are only removed once the whole file has run. const tempRoots: string[] = [] afterAll(async () => { await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true }))) }) for (const pluginName of PLUGIN_NAMES) { const inventory = loadSourceInventory(pluginName) describe(`real-plugin conversion drift: ${pluginName}`, () => { test("converts to every implemented target", async () => { const outputRoot = await fs.mkdtemp(path.join(os.tmpdir(), `real-convert-${pluginName}-`)) tempRoots.push(outputRoot) const fakeHome = path.join(outputRoot, "home") await fs.mkdir(fakeHome) await Promise.all(IMPLEMENTED_TARGETS.map((target) => runConvert(pluginName, target, outputRoot, fakeHome))) // Sandbox safety: with explicit output flags, no target may fall back to // a home-relative default (the redirected HOME would catch it). for (const leaked of [".codex", ".pi", ".agy", ".agents", path.join(".config", "opencode")]) { expect( await exists(path.join(fakeHome, leaked)), `convert leaked ${leaked} into HOME despite explicit output flags`, ).toBe(false) } }, 120_000) test("opencode output matches the source inventory", () => { const { root } = getConversion(pluginName, "opencode") const expectedSkills = skillsForPlatform(inventory, "opencode") // OpenCode emits one slash-command stub per invocable skill, plus any // explicit commands/ entries (none for the skills-only root plugin). const expectedCommands = [...new Set([...inventory.commands, ...commandStubsForPlatform(inventory, "opencode")])].sort() const config = readJson(path.join(root, "opencode.json")) expect(config.$schema).toBe("https://opencode.ai/config.json") expect(config.permission, "--permissions broad was pinned, so opencode.json must carry a permission block").toBeDefined() const opencodeRoot = path.join(root, ".opencode") expect(listFileBasenames(path.join(opencodeRoot, "agents"), ".md")).toEqual(inventory.agents) expect(listDirNames(path.join(opencodeRoot, "skills"))).toEqual(expectedSkills) expectSkillDirsHaveSkillMd(path.join(opencodeRoot, "skills"), expectedSkills) expect(listFileBasenames(path.join(opencodeRoot, "commands"), ".md")).toEqual(expectedCommands) const manifest = readJson(path.join(opencodeRoot, pluginName, "install-manifest.json")) expect(manifest.version).toBe(1) expect(manifest.pluginName).toBe(pluginName) const groups = manifest.groups as Record expect(groups.agents.length).toBe(inventory.agents.length) expect(groups.skills.length).toBe(expectedSkills.length) expect(groups.commands.length).toBe(expectedCommands.length) }) test("codex output matches the source inventory", () => { const { root } = getConversion(pluginName, "codex") // Codex converts commands into prompts AND generated command-skills, so // the skills dir is the platform-filtered skills plus the commands. const expectedSkills = [...new Set([...skillsForPlatform(inventory, "codex"), ...inventory.commands])].sort() const agentsMd = readFileSync(path.join(root, "AGENTS.md"), "utf8") expect(agentsMd).toContain("") expect(agentsMd).toContain("") const tomlNames = listFileBasenames(path.join(root, "agents", pluginName), ".toml") expect(tomlNames).toEqual(inventory.agents) for (const name of tomlNames) { const toml = readFileSync(path.join(root, "agents", pluginName, `${name}.toml`), "utf8") for (const key of ["name", "description", "developer_instructions"]) { expect(toml, `${name}.toml is missing the ${key} field`).toMatch(new RegExp(`^${key} = `, "m")) } } expect(listDirNames(path.join(root, "skills", pluginName))).toEqual(expectedSkills) expectSkillDirsHaveSkillMd(path.join(root, "skills", pluginName), expectedSkills) expect(listFileBasenames(path.join(root, "prompts"), ".md")).toEqual(inventory.commands) const manifest = readJson(path.join(root, pluginName, "install-manifest.json")) expect(manifest.version).toBe(1) expect(manifest.pluginName).toBe(pluginName) expect((manifest.agents as string[]).length).toBe(inventory.agents.length) expect((manifest.skills as string[]).length).toBe(expectedSkills.length) expect((manifest.prompts as string[]).length).toBe(inventory.commands.length) }) test("pi output matches the source inventory", () => { const { root } = getConversion(pluginName, "pi") const expectedSkills = skillsForPlatform(inventory, "pi") const agentsMd = readFileSync(path.join(root, "AGENTS.md"), "utf8") expect(agentsMd).toContain("") expect(agentsMd).toContain("") expect(listFileBasenames(path.join(root, "agents"), ".md")).toEqual(inventory.agents) expect(listDirNames(path.join(root, "skills"))).toEqual(expectedSkills) expectSkillDirsHaveSkillMd(path.join(root, "skills"), expectedSkills) expect(listFileBasenames(path.join(root, "prompts"), ".md")).toEqual(inventory.commands) const manifest = readJson(path.join(root, pluginName, "install-manifest.json")) expect(manifest.version).toBe(1) expect(manifest.pluginName).toBe(pluginName) expect((manifest.agents as string[]).length).toBe(inventory.agents.length) expect((manifest.skills as string[]).length).toBe(expectedSkills.length) expect((manifest.prompts as string[]).length).toBe(inventory.commands.length) }) test("antigravity output matches the source inventory", () => { const { root } = getConversion(pluginName, "antigravity") const expectedSkills = skillsForPlatform(inventory, "antigravity") expect(listFileBasenames(path.join(root, "agents"), ".md")).toEqual(inventory.agents) expect(listDirNames(path.join(root, "skills"))).toEqual(expectedSkills) expectSkillDirsHaveSkillMd(path.join(root, "skills"), expectedSkills) expect(listFileBasenames(path.join(root, "commands"), ".toml")).toEqual(inventory.commands) // agy ingests a plugin bundle with a root plugin.json {name, version}; // it does not use the gemini install-manifest. const manifest = readJson(path.join(root, "plugin.json")) expect(manifest.name).toBe(pluginName) expect(typeof manifest.version).toBe("string") }) test("every emitted .json parses and every emitted .md has parseable frontmatter", () => { const errors: string[] = [] let scanned = 0 for (const target of IMPLEMENTED_TARGETS) { const { root } = getConversion(pluginName, target) for (const file of walkFiles(root)) { if (file.endsWith(".json")) { scanned += 1 try { JSON.parse(readFileSync(file, "utf8")) } catch (err) { errors.push(`${target}: ${file}: ${err instanceof Error ? err.message : err}`) } } else if (file.endsWith(".md")) { scanned += 1 try { parseFrontmatter(readFileSync(file, "utf8"), file) } catch (err) { errors.push(`${target}: ${file}: ${err instanceof Error ? err.message : err}`) } } } } expect(scanned, "expected the converted trees to contain .json/.md files").toBeGreaterThan(0) expect(errors, `Converted output contains unparseable files:\n${errors.join("\n")}`).toEqual([]) }) }) }