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

@@ -24,6 +24,7 @@ import {
} from "../data/plugin-legacy-artifacts"
import { moveLegacyArtifactToBackup } from "../targets/managed-artifacts"
import { isManagedCodexAgentsSymlink, readCodexInstallManifest, resolveCodexManagedRoots } from "../targets/codex"
import { classifyCodexLegacyPromptOwnership } from "../utils/legacy-cleanup"
import { isSafeManagedPath, pathExists, readJson, sanitizePathName } from "../utils/files"
import { resolveOpenCodeGlobalRoot } from "../utils/opencode-config"
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
@@ -252,6 +253,11 @@ async function cleanupCodex(plugin: Awaited<ReturnType<typeof loadClaudePlugin>>
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
// Cleanup needs the FULL bundle (skills, command-skills, agents) to know
// what's "current" vs "legacy." The agents-only default of `--to codex`
// is wrong here; it would make cleanup think every existing skill is
// legacy and remove them.
codexIncludeSkills: true,
})
const artifacts = getLegacyCodexArtifacts(bundle)
const currentNamespacedSkills = new Set([
@@ -275,6 +281,19 @@ async function cleanupCodex(plugin: Awaited<ReturnType<typeof loadClaudePlugin>>
}
}
for (const promptFile of artifacts.prompts) {
// Ownership gate: `~/.codex/prompts/` is a shared directory across plugins
// and user-authored prompts. A filename match against the historical CE
// allow-list is not a strong enough signal — a user who creates
// `~/.codex/prompts/ce-plan.md` for their own workflow would otherwise see
// it swept into `compound-engineering/legacy-backup/` on every cleanup run.
// Mirror the body + frontmatter check used by `cleanupStalePrompts` so
// install-time and standalone cleanup paths treat ownership identically.
// "unknown" (no fingerprint on record) falls through so fully-retired
// historical wrappers still get cleaned up. Manifest-driven migration
// below is already safe because it only touches files CE recorded writing.
const promptPath = path.join(codexRoot, "prompts", promptFile)
const ownership = await classifyCodexLegacyPromptOwnership(promptPath)
if (ownership === "foreign") continue
moved += await moveIfExists(managedDir, "prompts", path.join(codexRoot, "prompts"), promptFile, "Codex")
}
@@ -336,6 +355,9 @@ async function cleanupCodexSharedAgents(
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
// Same reason as cleanupCodex: cleanup needs the full bundle to make
// current-vs-legacy decisions correctly.
codexIncludeSkills: true,
})
const artifacts = getLegacyCodexArtifacts(bundle)
const managedDir = path.join(agentsRoot, "compound-engineering")

View File

@@ -65,6 +65,12 @@ export default defineCommand({
default: true,
description: "Infer agent temperature from name/description",
},
includeSkills: {
type: "boolean",
default: false,
alias: "include-skills",
description: "For --to codex only: also emit skills and commands. Default is agents-only, the recommended pairing with `codex plugin install`. Set this flag for a legacy / standalone install without Codex native plugin install. Ignored by other targets.",
},
},
async run({ args }) {
const targetName = String(args.to)
@@ -84,6 +90,7 @@ export default defineCommand({
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
inferTemperature: Boolean(args.inferTemperature),
permissions: permissions as PermissionMode,
codexIncludeSkills: Boolean(args.includeSkills),
}
if (targetName === "all") {

View File

@@ -68,6 +68,12 @@ export default defineCommand({
default: true,
description: "Infer agent temperature from name/description",
},
includeSkills: {
type: "boolean",
default: false,
alias: "include-skills",
description: "For --to codex only: also emit skills and commands. Default is agents-only, the recommended pairing with `codex plugin install`. Set this flag for a legacy / standalone install without Codex native plugin install. Ignored by other targets.",
},
branch: {
type: "string",
description: "Git branch to clone from (e.g. feat/new-agents)",
@@ -95,6 +101,7 @@ export default defineCommand({
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
inferTemperature: Boolean(args.inferTemperature),
permissions: permissions as PermissionMode,
codexIncludeSkills: Boolean(args.includeSkills),
}
if (targetName === "all") {

View File

@@ -1,7 +1,7 @@
import fs, { type Dirent } from "fs"
import path from "path"
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, filterSkillsByPlatform } from "../types/claude"
import type { CodexAgent, CodexBundle, CodexGeneratedSkill, CodexGeneratedSkillSidecarDir } from "../types/codex"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
import {
@@ -16,8 +16,16 @@ const CODEX_DESCRIPTION_MAX_LENGTH = 1024
export function convertClaudeToCodex(
plugin: ClaudePlugin,
_options: ClaudeToCodexOptions,
options: ClaudeToCodexOptions,
): CodexBundle {
// Agents-only is the default for --to codex. Skills and commands are
// expected to install via Codex's native plugin flow (`codex plugin install`)
// which reads the plugin's .codex-plugin/plugin.json manifest. The Bun
// converter fills the one gap Codex's native spec leaves open: custom
// agents. Emitting skills too would double-register them — once from native
// install, once from this converter.
const includeSkills = options.codexIncludeSkills ?? false
const platformSkills = filterSkillsByPlatform(plugin.skills, "codex")
const invocableCommands = plugin.commands.filter((command) => !command.disableModelInvocation)
const applyCompoundWorkflowModel = shouldApplyCompoundWorkflowModel(plugin)
@@ -57,10 +65,50 @@ export function convertClaudeToCodex(
}
}
// Agents are always converted to TOML custom agents regardless of mode —
// that's the whole point of --to codex. invocationTargets is populated from
// the full plugin so agent bodies can reference skills correctly; native
// install makes those skills discoverable at runtime.
const agents = plugin.agents.map(convertAgent)
const agentTargets = buildAgentTargets(plugin, agents)
const invocationTargets: CodexInvocationTargets = { promptTargets, skillTargets, agentTargets }
if (!includeSkills) {
// Default: agents-only. Skills, prompts, command-skills, and MCP are
// suppressed so native plugin install is the sole source for those
// artifact types.
//
// Pass through current skill NAMES (not contents) so `writeCodexBundle`
// treats them as "current" and `cleanupLegacyAgentSkillDirs` doesn't
// move still-active skills under `.codex/skills/<plugin>/<name>/` into
// legacy-backup. Without this, re-running `install --to codex` after a
// native plugin install would sweep allow-listed names like `ce-plan`
// into backup because `currentSkills` (derived from skillDirs and
// generatedSkills) would be empty while the legacy allow-list still
// lists them.
// Mirror the skill-name set that full mode would emit via `skillDirs`:
// current skills plus the canonical rewrites of deprecated workflow
// aliases. Deduped via Set so the caller doesn't have to worry about
// overlap between `copiedSkills` names and `skillTargets` values.
const externallyManagedSkillNames = Array.from(new Set([
...copiedSkills.map((skill) => skill.name),
...deprecatedWorkflowAliases
.map((alias) => toCanonicalWorkflowSkillName(alias.name))
.filter((name): name is string => name !== null),
]))
return {
pluginName: plugin.manifest.name,
prompts: [],
skillDirs: [],
generatedSkills: [],
agents,
invocationTargets,
mcpServers: undefined,
externallyManagedSkillNames,
}
}
// Full / legacy / standalone mode: everything goes through the converter.
const commandSkills: CodexGeneratedSkill[] = []
const prompts = invocableCommands.map((command) => {
const promptName = commandPromptNames.get(command.name)!
@@ -70,13 +118,11 @@ export function convertClaudeToCodex(
return { name: promptName, content }
})
const generatedSkills = [...commandSkills]
return {
pluginName: plugin.manifest.name,
prompts,
skillDirs,
generatedSkills,
generatedSkills: [...commandSkills],
agents,
invocationTargets,
mcpServers: plugin.mcpServers,

View File

@@ -21,6 +21,24 @@ export type ClaudeToOpenCodeOptions = {
agentMode: "primary" | "subagent"
inferTemperature: boolean
permissions: PermissionMode
/**
* Codex-only option. Ignored by other targets.
*
* When false (default), `convertClaudeToCodex` emits only agent conversions.
* Skills and commands are expected to install via Codex's native plugin flow
* (`codex plugin install`), which the Bun converter complements rather than
* duplicates. Without this setting, running both native install and the Bun
* converter registers skills twice — once from the native plugin manifest,
* once from the converter output — creating conflicts.
*
* When true, the converter emits skills (copied as-is), commands (as prompts
* and generated skills), and agents together. Use when installing without
* Codex native plugin install (legacy / standalone flow).
*
* Obsolete once Codex's native plugin spec supports custom agents; at that
* point the entire `--to codex` converter path is expected to be deprecated.
*/
codexIncludeSkills?: boolean
}
const TOOL_MAP: Record<string, string> = {

View File

@@ -1,6 +1,6 @@
import { promises as fs } from "fs"
import path from "path"
import { readJson, readText, writeJson, writeText } from "../utils/files"
import { readJson, writeJson } from "../utils/files"
import type { ReleaseComponent } from "./types"
type ClaudePluginManifest = {
@@ -14,6 +14,13 @@ type CursorPluginManifest = {
description?: string
}
type CodexPluginManifest = {
name: string
version: string
description?: string
skills?: string
}
type MarketplaceManifest = {
metadata: {
version: string
@@ -26,6 +33,13 @@ type MarketplaceManifest = {
}>
}
type CodexMarketplaceManifest = {
name: string
plugins: Array<{
name: string
}>
}
type SyncOptions = {
root?: string
componentVersions?: Partial<Record<ReleaseComponent, string>>
@@ -39,6 +53,7 @@ type FileUpdate = {
export type MetadataSyncResult = {
updates: FileUpdate[]
errors: string[]
}
export type CompoundEngineeringCounts = {
@@ -131,6 +146,7 @@ export async function syncReleaseMetadata(options: SyncOptions = {}): Promise<Me
const write = options.write ?? false
const versions = options.componentVersions ?? {}
const updates: FileUpdate[] = []
const errors: string[] = []
const compoundDescription = await buildCompoundEngineeringDescription(root)
const compoundMarketplaceDescription = await buildCompoundEngineeringMarketplaceDescription(root)
@@ -236,5 +252,114 @@ export async function syncReleaseMetadata(options: SyncOptions = {}): Promise<Me
updates.push({ path: marketplaceCursorPath, changed })
if (write && changed) await writeJson(marketplaceCursorPath, marketplaceCursor)
return { updates }
// Codex manifests. Unlike Claude/Cursor, the Codex plugin.json is a
// different schema at `.codex-plugin/plugin.json` and the marketplace lives
// at `.agents/plugins/marketplace.json` (no metadata.version field). Plugin
// version sync is DETECT-ONLY here — release-please owns the bump via
// `extra-files` in `.github/release-please-config.json`. Duplicating the
// write would create a second authority for the same field.
const compoundCodexPath = path.join(root, "plugins", "compound-engineering", ".codex-plugin", "plugin.json")
const codingTutorCodexPath = path.join(root, "plugins", "coding-tutor", ".codex-plugin", "plugin.json")
const marketplaceCodexPath = path.join(root, ".agents", "plugins", "marketplace.json")
const codexPluginTargets: Array<{
claudePath: string
claude: ClaudePluginManifest
codexPath: string
expectedName: string
}> = [
{
claudePath: compoundClaudePath,
claude: compoundClaude,
codexPath: compoundCodexPath,
expectedName: "compound-engineering",
},
{
claudePath: codingTutorClaudePath,
claude: codingTutorClaude,
codexPath: codingTutorCodexPath,
expectedName: "coding-tutor",
},
]
for (const { claudePath, claude, codexPath, expectedName } of codexPluginTargets) {
let codex: CodexPluginManifest
try {
codex = await readJson<CodexPluginManifest>(codexPath)
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
errors.push(`${codexPath} is missing but ${claudePath} exists. Codex manifest parity required.`)
updates.push({ path: codexPath, changed: false })
continue
}
throw err
}
if (codex.name !== expectedName) {
errors.push(`${codexPath}: name "${codex.name}" does not match expected "${expectedName}"`)
}
let codexChanged = false
// Version: detect-only (release-please owns the write via extra-files).
if (codex.version !== claude.version) {
codexChanged = true
}
// Description: write-enabled (same pattern as Claude/Cursor description sync).
if (claude.description !== undefined && codex.description !== claude.description) {
codex.description = claude.description
codexChanged = true
}
// Skills declaration: required. Codex native install is the source of
// skills for each plugin (and `--to codex` defaults to agents-only), so a
// missing `skills` field silently produces a broken install with no skills
// registered. Enforce presence, then verify the directory exists.
if (codex.skills === undefined) {
errors.push(`${codexPath} (${expectedName}): missing required field "skills". Codex plugins must declare a skills path (e.g., "./skills/").`)
} else {
const pluginDir = path.dirname(path.dirname(codexPath))
const skillsDir = path.resolve(pluginDir, codex.skills)
try {
const stat = await fs.stat(skillsDir)
if (!stat.isDirectory()) {
errors.push(`${codexPath} declares skills: "${codex.skills}" but ${skillsDir} is not a directory`)
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
errors.push(`${codexPath} declares skills: "${codex.skills}" but ${skillsDir} does not exist`)
} else {
throw err
}
}
}
updates.push({ path: codexPath, changed: codexChanged })
if (write && codexChanged) await writeJson(codexPath, codex)
}
// Codex marketplace: plugin-list parity with Claude marketplace. The Codex
// marketplace has no metadata.version field and is treated as static content
// (no release-please entry). Plugin list must mirror Claude exactly.
try {
const marketplaceCodex = await readJson<CodexMarketplaceManifest>(marketplaceCodexPath)
const claudeNames = [...marketplaceClaude.plugins.map((p) => p.name)].sort()
const codexNames = [...marketplaceCodex.plugins.map((p) => p.name)].sort()
if (claudeNames.join("|") !== codexNames.join("|")) {
errors.push(
`${marketplaceCodexPath}: plugin list [${codexNames.join(", ")}] does not match ${marketplaceClaudePath} [${claudeNames.join(", ")}]`,
)
}
updates.push({ path: marketplaceCodexPath, changed: false })
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
errors.push(`${marketplaceCodexPath} is missing but ${marketplaceClaudePath} exists. Codex marketplace parity required.`)
updates.push({ path: marketplaceCodexPath, changed: false })
} else {
throw err
}
}
return { updates, errors }
}

View File

@@ -5,6 +5,7 @@ import type { CodexBundle } from "../types/codex"
import type { ClaudeMcpServer } from "../types/claude"
import { transformContentForCodex } from "../utils/codex-content"
import { getLegacyCodexArtifacts } from "../data/plugin-legacy-artifacts"
import { classifyCodexLegacyPromptOwnership } from "../utils/legacy-cleanup"
const MANAGED_START_MARKER = "# BEGIN Compound Engineering plugin MCP -- do not edit this block"
const MANAGED_END_MARKER = "# END Compound Engineering plugin MCP"
@@ -49,10 +50,17 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
const skillsRoot = pluginName
? path.join(codexRoot, "skills", pluginName)
: path.join(codexRoot, "skills")
const currentSkills = [
// Include `externallyManagedSkillNames` so agents-only installs (default
// `--to codex`) treat skills installed via Codex's native plugin flow as
// "current" for cleanup purposes. Without this, `cleanupLegacyAgentSkillDirs`
// would see an empty `currentSkills` set and sweep allow-listed names such
// as `ce-plan` out of `.codex/skills/<plugin>/` into legacy-backup on every
// re-run of `install --to codex`.
const currentSkills = Array.from(new Set([
...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)),
...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)),
]
...(bundle.externallyManagedSkillNames ?? []).map((name) => sanitizePathName(name)),
]))
await cleanupRemovedSkills(skillsRoot, manifest, currentSkills)
if (bundle.skillDirs.length > 0) {
@@ -257,6 +265,18 @@ async function cleanupKnownLegacyCodexArtifacts(codexRoot: string, bundle: Codex
for (const promptFile of legacyArtifacts.prompts) {
const legacyPromptPath = path.join(codexRoot, "prompts", promptFile)
// Ownership gate: `~/.codex/prompts/` is a shared directory across plugins
// and user-authored prompts. A filename match against the legacy allow-list
// is not a strong enough signal to move a file — a user who creates
// `~/.codex/prompts/ce-plan.md` for their own workflow would otherwise see
// it swept into `compound-engineering/legacy-backup/` on every install.
// Mirror the body + frontmatter check used by the standalone
// `cleanupStalePrompts` helper. "unknown" (no fingerprint on record, e.g.
// fully-retired wrappers like `reproduce-bug.md`) falls through to the
// historical allow-list behavior — user collisions at those names are
// unlikely and a strict gate would strand genuinely-owned orphans.
const ownership = await classifyCodexLegacyPromptOwnership(legacyPromptPath)
if (ownership === "foreign") continue
await moveLegacyArtifactToBackup(codexRoot, pluginName, "prompts", legacyPromptPath)
}
}

View File

@@ -37,4 +37,13 @@ export type CodexBundle = {
agents?: CodexAgent[]
invocationTargets?: CodexInvocationTargets
mcpServers?: Record<string, ClaudeMcpServer>
/**
* Names of skills CE owns in the Codex managed tree that are NOT written by
* this bundle. Used in agents-only installs (default `--to codex`) where
* skill contents are installed via Codex's native plugin flow, but cleanup
* still needs to recognize those skill names as "current" (and therefore
* not legacy) when re-running the install. Entries are sanitized skill
* names (same shape as `skillDirs[].name` after `sanitizePathName`).
*/
externallyManagedSkillNames?: string[]
}

View File

@@ -667,3 +667,44 @@ export async function cleanupStalePrompts(promptsDir: string): Promise<number> {
}
return removed
}
/**
* Ownership verdict for an individual Codex prompt file at a shared path like
* `~/.codex/prompts/<file>.md`. Used by callers in the Codex install and
* standalone-cleanup paths to gate legacy-name allow-list moves before
* renaming a file into `compound-engineering/legacy-backup/`.
*
* Verdicts:
* - `"ce-owned"`: body + frontmatter fingerprint match a known
* compound-engineering prompt-wrapper shape. Safe to move.
* - `"foreign"`: we have a fingerprint on record for this filename and the
* file does NOT match it. A user or sibling plugin authored this file —
* leave it alone. `~/.codex/prompts/` is a cross-plugin directory, so a
* name-only match (e.g. `ce-plan.md`) is not a strong enough signal.
* - `"unknown"`: we have no fingerprint on record for this filename. This
* applies to historical prompt wrappers whose corresponding CE skill no
* longer ships (e.g. `reproduce-bug.md`, `report-bug.md`) — user
* collisions at those names are unlikely, and the historical allow-list
* was written specifically to clean them up. Callers may fall back to
* name-only cleanup in this case.
*
* Rationale for the three-way split: `LEGACY_PROMPT_CURRENT_SKILL_FOR_FILE`
* + `LEGACY_PROMPT_DESCRIPTION_ALIASES` only cover prompt filenames whose
* corresponding ce-* skill is still shipped. For names that are fully
* retired, we have no description to compare against, so a strict ownership
* gate would strand genuinely-owned orphan wrappers. Reporting `"unknown"`
* lets callers keep the historical allow-list behavior for those while still
* gating the realistic collision vectors.
*/
export type CodexPromptOwnership = "ce-owned" | "foreign" | "unknown"
export async function classifyCodexLegacyPromptOwnership(
promptPath: string,
): Promise<CodexPromptOwnership> {
const fileName = path.basename(promptPath)
const { prompts } = await loadLegacyFingerprints()
const hasFingerprint = prompts.has(fileName) || fileName in LEGACY_PROMPT_DESCRIPTION_ALIASES
if (!hasFingerprint) return "unknown"
const ceOwned = await isLegacyPromptWrapper(promptPath, prompts.get(fileName))
return ceOwned ? "ce-owned" : "foreign"
}