feat(codex): native plugin install manifests + agents-only converter (#616)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user