import { promises as fs } from "fs" import path from "path" import { readJson, writeJson } from "../utils/files" import type { ReleaseComponent } from "./types" type ClaudePluginManifest = { version: string description?: string mcpServers?: Record } type CursorPluginManifest = { version: string description?: string } type CodexPluginManifest = { name: string version: string description?: string skills?: string } type MarketplaceManifest = { metadata: { version: string description?: string } plugins: Array<{ name: string version?: string description?: string }> } type CodexMarketplaceManifest = { name: string plugins: Array<{ name: string }> } type SyncOptions = { root?: string componentVersions?: Partial> write?: boolean } type FileUpdate = { path: string changed: boolean } export type MetadataSyncResult = { updates: FileUpdate[] errors: string[] } export type CompoundEngineeringCounts = { agents: number skills: number mcpServers: number } const COMPOUND_ENGINEERING_DESCRIPTION = "AI-powered development tools for code review, research, design, and workflow automation." const COMPOUND_ENGINEERING_MARKETPLACE_DESCRIPTION = "AI-powered development tools that get smarter with every use. Make each unit of engineering work easier than the last." function resolveExpectedVersion( explicitVersion: string | undefined, fallbackVersion: string, ): string { return explicitVersion ?? fallbackVersion } export async function countMarkdownFiles(root: string): Promise { const entries = await fs.readdir(root, { withFileTypes: true }) let total = 0 for (const entry of entries) { const fullPath = path.join(root, entry.name) if (entry.isDirectory()) { total += await countMarkdownFiles(fullPath) continue } if (entry.isFile() && entry.name.endsWith(".md")) { total += 1 } } return total } export async function countSkillDirectories(root: string): Promise { const entries = await fs.readdir(root, { withFileTypes: true }) let total = 0 for (const entry of entries) { if (!entry.isDirectory()) continue const skillPath = path.join(root, entry.name, "SKILL.md") try { await fs.access(skillPath) total += 1 } catch { // Ignore non-skill directories. } } return total } export async function countMcpServers(pluginRoot: string): Promise { const mcpPath = path.join(pluginRoot, ".mcp.json") try { const manifest = await readJson<{ mcpServers?: Record }>(mcpPath) return Object.keys(manifest.mcpServers ?? {}).length } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return 0 throw err } } export async function getCompoundEngineeringCounts(root: string): Promise { const pluginRoot = path.join(root, "plugins", "compound-engineering") const [agents, skills, mcpServers] = await Promise.all([ countMarkdownFiles(path.join(pluginRoot, "agents")), countSkillDirectories(path.join(pluginRoot, "skills")), countMcpServers(pluginRoot), ]) return { agents, skills, mcpServers } } export async function buildCompoundEngineeringDescription(_root: string): Promise { return COMPOUND_ENGINEERING_DESCRIPTION } export async function buildCompoundEngineeringMarketplaceDescription(_root: string): Promise { return COMPOUND_ENGINEERING_MARKETPLACE_DESCRIPTION } export async function syncReleaseMetadata(options: SyncOptions = {}): Promise { const root = options.root ?? process.cwd() 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) const compoundClaudePath = path.join(root, "plugins", "compound-engineering", ".claude-plugin", "plugin.json") const compoundCursorPath = path.join(root, "plugins", "compound-engineering", ".cursor-plugin", "plugin.json") const codingTutorClaudePath = path.join(root, "plugins", "coding-tutor", ".claude-plugin", "plugin.json") const codingTutorCursorPath = path.join(root, "plugins", "coding-tutor", ".cursor-plugin", "plugin.json") const marketplaceClaudePath = path.join(root, ".claude-plugin", "marketplace.json") const marketplaceCursorPath = path.join(root, ".cursor-plugin", "marketplace.json") const compoundClaude = await readJson(compoundClaudePath) const compoundCursor = await readJson(compoundCursorPath) const codingTutorClaude = await readJson(codingTutorClaudePath) const codingTutorCursor = await readJson(codingTutorCursorPath) const marketplaceClaude = await readJson(marketplaceClaudePath) const marketplaceCursor = await readJson(marketplaceCursorPath) const expectedCompoundVersion = resolveExpectedVersion( versions["compound-engineering"], compoundClaude.version, ) const expectedCodingTutorVersion = resolveExpectedVersion( versions["coding-tutor"], codingTutorClaude.version, ) let changed = false if (compoundClaude.version !== expectedCompoundVersion) { compoundClaude.version = expectedCompoundVersion changed = true } if (compoundClaude.description !== compoundDescription) { compoundClaude.description = compoundDescription changed = true } updates.push({ path: compoundClaudePath, changed }) if (write && changed) await writeJson(compoundClaudePath, compoundClaude) changed = false if (compoundCursor.version !== expectedCompoundVersion) { compoundCursor.version = expectedCompoundVersion changed = true } if (compoundCursor.description !== compoundDescription) { compoundCursor.description = compoundDescription changed = true } updates.push({ path: compoundCursorPath, changed }) if (write && changed) await writeJson(compoundCursorPath, compoundCursor) changed = false if (codingTutorClaude.version !== expectedCodingTutorVersion) { codingTutorClaude.version = expectedCodingTutorVersion changed = true } updates.push({ path: codingTutorClaudePath, changed }) if (write && changed) await writeJson(codingTutorClaudePath, codingTutorClaude) changed = false if (codingTutorCursor.version !== expectedCodingTutorVersion) { codingTutorCursor.version = expectedCodingTutorVersion changed = true } updates.push({ path: codingTutorCursorPath, changed }) if (write && changed) await writeJson(codingTutorCursorPath, codingTutorCursor) changed = false if (versions.marketplace && marketplaceClaude.metadata.version !== versions.marketplace) { marketplaceClaude.metadata.version = versions.marketplace changed = true } for (const plugin of marketplaceClaude.plugins) { if (plugin.name === "compound-engineering") { if (plugin.description !== compoundMarketplaceDescription) { plugin.description = compoundMarketplaceDescription changed = true } } // Plugin versions are not synced in marketplace.json -- the canonical // version lives in each plugin's own plugin.json. Duplicating versions // here creates drift that release-please can't maintain. } updates.push({ path: marketplaceClaudePath, changed }) if (write && changed) await writeJson(marketplaceClaudePath, marketplaceClaude) changed = false if (versions["cursor-marketplace"] && marketplaceCursor.metadata.version !== versions["cursor-marketplace"]) { marketplaceCursor.metadata.version = versions["cursor-marketplace"] changed = true } for (const plugin of marketplaceCursor.plugins) { if (plugin.name === "compound-engineering") { if (plugin.description !== compoundMarketplaceDescription) { plugin.description = compoundMarketplaceDescription changed = true } } } updates.push({ path: marketplaceCursorPath, changed }) if (write && changed) await writeJson(marketplaceCursorPath, marketplaceCursor) // 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(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(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 } }