Files
claude-engineering-plugin/src/release/metadata.ts
Trevin Chow 3ed4a4fa0f
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
feat(codex): native plugin install manifests + agents-only converter (#616)
2026-04-20 19:44:25 -07:00

366 lines
13 KiB
TypeScript

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<string, unknown>
}
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<Record<ReleaseComponent, string>>
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<number> {
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<number> {
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<number> {
const mcpPath = path.join(pluginRoot, ".mcp.json")
try {
const manifest = await readJson<{ mcpServers?: Record<string, unknown> }>(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<CompoundEngineeringCounts> {
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<string> {
return COMPOUND_ENGINEERING_DESCRIPTION
}
export async function buildCompoundEngineeringMarketplaceDescription(_root: string): Promise<string> {
return COMPOUND_ENGINEERING_MARKETPLACE_DESCRIPTION
}
export async function syncReleaseMetadata(options: SyncOptions = {}): Promise<MetadataSyncResult> {
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<ClaudePluginManifest>(compoundClaudePath)
const compoundCursor = await readJson<CursorPluginManifest>(compoundCursorPath)
const codingTutorClaude = await readJson<ClaudePluginManifest>(codingTutorClaudePath)
const codingTutorCursor = await readJson<CursorPluginManifest>(codingTutorCursorPath)
const marketplaceClaude = await readJson<MarketplaceManifest>(marketplaceClaudePath)
const marketplaceCursor = await readJson<MarketplaceManifest>(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<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 }
}