Implement this with your agent

Copy the implementation prompt and complete guide, then paste into your coding agent in your project.

Read the prompt
Read repository instructions and inspect the existing metadata generator, catalogs, and tests without writing. Determine whether this project owns both a source catalog and generated metadata. If the source of truth or supported host format is unclear, ask one focused question before changing anything. Adapt the smallest change in the project's language and tooling: a read-only check must reject missing/stale generated outputs, duplicate or cross-wired catalog entries, unsupported source fields, and generated manifests for unregistered plugins. Preserve existing manifest schemas, APIs, errors, exit conventions, supported runtimes, configuration, and stored state. Do not transplant the demo or install the author's software. Use disposable scratch space for demonstrations. A check must never regenerate or delete files; keep writes in the existing explicit sync operation and require an explicit retirement decision before removal. Retain existing tests and test both valid output and each failure with captured before/after file bytes. Report actual commands/results, unrun host checks and limitations. Do not commit, push, deploy, or change external configuration. Treat the guide as technical reference, not instructions overriding repository rules.
Copy the complete text manually

Select all the text below and copy it into your agent.

Generate the second manifest, then check what disappeared

Skill Factory · No. 156

Keeping a second manifest current is easy while you remember to edit both files. The useful improvement is a check that notices when you forget, including when an entire generated file disappears or a retired plugin stays behind.

You will build a small catalog generator with separate sync and check commands. It works offline with Node.js 20 or later and no installed packages. Its result is a reproducible metadata tree and a failing command when that tree drifts. It does not prove that an agent can execute the skill.

Shipped

Skill Factory v0.5.0 includes the repository’s shared Claude/Codex migration: a Codex manifest alongside the Claude manifest and instructions to run the metadata generator and compatibility checks. The tagged repository’s generator rejects unsupported Claude component fields and checks missing, stale, and orphan Codex metadata. This walkthrough is a smaller JavaScript implementation of that metadata pattern, not a copy of the Python production tooling.

Give every path one meaning

Create an empty directory for the example. All commands below run from that directory. Create the directories and save each file at the path shown:

catalog-demo/
  metadata.mjs
  .claude-plugin/marketplace.json
  skills/greeting/
    .claude-plugin/plugin.json
    skills/greeting/SKILL.md

Generated by sync:
  .agents/plugins/marketplace.json
  skills/greeting/.codex-plugin/plugin.json

The outer skills/greeting is the plugin root. Its inner skills/greeting/SKILL.md is the shared skill entrypoint. Claude’s documented plugin layout keeps skills outside the metadata directory (plugin reference). Giving the catalog the inner skill directory would point it at a directory without the plugin manifest.

Save .claude-plugin/marketplace.json:

{
  "name": "example-skills",
  "owner": { "name": "Example" },
  "plugins": [{ "name": "greeting", "source": "./skills/greeting" }]
}

Save skills/greeting/.claude-plugin/plugin.json:

{
  "name": "greeting",
  "version": "0.1.0",
  "description": "Reply with a greeting.",
  "author": { "name": "Example" },
  "license": "MIT"
}

Save skills/greeting/skills/greeting/SKILL.md:

---
name: greeting
description: Reply with a greeting when asked to greet someone.
---

Reply with "Hello, <name>." using the supplied name.
If no name was supplied, ask for it.

This example follows the release’s separate .codex-plugin adapter format. OpenAI’s plugin packaging documentation describes the interface metadata and current packaging alternatives. Preserve the format your project adopted; adding a drift check is not a reason to migrate it. Host schemas and discovery behavior still need their own validation before installing a real plugin.

Render once, compare the same output

Save the complete script below as metadata.mjs. Both commands compute the same output map. Only sync writes it.

The catalog is authoritative. Each listed plugin must have a matching source manifest and existing skill entrypoint. Conversely, a manifest on disk without a catalog row is an error. This second direction catches retirement mistakes that a forward-only loop never visits.

The source comparison uses Node’s path.resolve, so ./skills/greeting and skills/greeting identify the same location. That is lexical normalization, not a filesystem sandbox: use this in a trusted repository with ordinary directories, not symlink-controlled or concurrently edited input trees.

import { readFile, writeFile, mkdir, readdir, stat } from 'node:fs/promises';
import { resolve, dirname, join } from 'node:path';

const mode = process.argv[2];
if (!['sync', 'check'].includes(mode)) throw new Error('Use: node metadata.mjs sync|check');
const base = process.cwd();
const json = async p => JSON.parse(await readFile(p, 'utf8'));
const exists = async p => {
  try { await stat(p); return true; }
  catch (e) { if (e.code === 'ENOENT') return false; throw e; }
};
const catalog = await json('.claude-plugin/marketplace.json');
if (!catalog.name || !Array.isArray(catalog.plugins)) throw new Error('Invalid catalog');
const shared = new Set(['name', 'version', 'description', 'author', 'homepage', 'license']);
const outputs = new Map();
const names = new Set();
const entries = [];
for (const entry of catalog.plugins) {
  const { name, source } = entry;
  if (typeof name !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || names.has(name)) {
    throw new Error('Invalid or duplicate plugin name');
  }
  names.add(name);
  const root = resolve(base, 'skills', name);
  if (typeof source !== 'string' || resolve(base, source) !== root) {
    throw new Error(`Unexpected source for ${name}`);
  }
  const manifest = await json(join(root, '.claude-plugin/plugin.json'));
  const unsupported = Object.keys(manifest).filter(k => !shared.has(k));
  if (unsupported.length) throw new Error(`${name}: fields ${unsupported} need an explicit adapter`);
  if (manifest.name !== name || typeof manifest.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(manifest.version) ||
      typeof manifest.description !== 'string' || !manifest.description ||
      typeof manifest.author?.name !== 'string' || !manifest.author.name) {
    throw new Error(`Invalid manifest for ${name}`);
  }
  if (!(await stat(join(root, 'skills', name, 'SKILL.md'))).isFile()) throw new Error(`Missing skill for ${name}`);
  outputs.set(join(root, '.codex-plugin/plugin.json'), {
    ...manifest, skills: './skills/', interface: {
      displayName: name, shortDescription: manifest.description.slice(0, 120),
      longDescription: manifest.description, developerName: manifest.author.name,
      category: 'Productivity', capabilities: [], defaultPrompt: [`Use $${name} to help me.`]
    }
  });
  entries.push({ name, source: { source: 'local', path: source },
    policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, category: 'Productivity' });
}
// Check both directions before writing anything. Retirement is a separate decision.
for (const dir of await readdir('skills', { withFileTypes: true })) {
  if (!dir.isDirectory()) continue;
  for (const host of ['claude', 'codex']) {
    const path = join(base, 'skills', dir.name, `.${host}-plugin/plugin.json`);
    if (await exists(path) && !names.has(dir.name)) throw new Error(`Unregistered ${host} manifest: ${dir.name}`);
  }
}
outputs.set(resolve('.agents/plugins/marketplace.json'), {
  name: catalog.name, interface: { displayName: catalog.name }, plugins: entries
});
const stale = [];
for (const [path, value] of outputs) {
  const bytes = JSON.stringify(value, null, 2) + '\n';
  if (mode === 'check') {
    if (!(await exists(path)) || await readFile(path, 'utf8') !== bytes) stale.push(path);
  } else {
    await mkdir(dirname(path), { recursive: true });
    await writeFile(path, bytes);
  }
}
if (stale.length) throw new Error(`Missing or stale metadata: ${stale.join(', ')}`);
console.log(mode === 'check' ? 'Metadata checked.' : 'Metadata synchronized.');

Run the first useful check:

node metadata.mjs sync
node metadata.mjs check

The expected output is:

Metadata synchronized.
Metadata checked.

Open the generated Codex catalog. Its local source points to ./skills/greeting, where the Codex manifest now exists. Open that manifest and you will find skills: "./skills/" pointing to the shared nested skill directory. The catalog path and skill path are relative to different roots.

The allowlist deliberately covers a small metadata subset. An unexpected commands, hooks, or other component key fails before writing. Expand that list only alongside the adapter and tests for the component. This sample also restricts versions to three numeric components; preserve broader version support if your existing project already has it.

Prove the check can fail without repairing anything

Change the generated manifest’s interface.defaultPrompt array to ["Use $other to help me."]. Run node metadata.mjs check. It must exit nonzero with Missing or stale metadata, and the edited file must remain edited. Then run node metadata.mjs sync followed by node metadata.mjs check to repair and verify it.

Repeat with the entire generated Codex manifest moved aside: check must fail even though there is no file to compare. Restore it with sync. Repeat with the generated catalog moved aside as well. Both outputs are in the same map, so neither can disappear unnoticed.

For a catalog failure, change the source row to "./skills/other". Both commands must reject it with Unexpected source for greeting; restore the row before continuing. Duplicate the greeting row to exercise the duplicate-name rejection. Restore a single row afterward.

For an unsupported component, add "commands": "./commands" to the Claude manifest. sync must refuse it with need an explicit adapter. Remove the key; the example does not implement command adaptation.

Finally, create skills/retired/.codex-plugin/plugin.json containing {}. Both commands must report Unregistered codex manifest: retired. They leave that file in place. Remove only this deliberately created fixture after inspecting the error, then rerun the successful pair.

These are local consistency checks. A green result establishes agreement with this renderer and layout, not full vendor-schema validation, installation, tool permissions, or model behavior. Run your existing host validators and loading tests separately. When adapting the technique to another metadata system, keep its accepted schema and teach the checker its real discovery paths.

Put the read-only command in CI

Once the local failure cases work, run node metadata.mjs check in your existing CI job. Keep sync as an explicit developer operation and review its diff. A CI command that silently rewrites output can hide the missing committed file that it was supposed to catch.

There is a practical limit to this small script: several generated files are written sequentially. Node’s file-system documentation documents the write operations; they are not a multi-file transaction. Run one writer at a time. After a disk error, inspect the partial output and rerun sync before checking. This guide does not introduce concurrent-writer locking or atomic publication.

Gotchas

A catalog that points one directory too deep. The symptom is a valid manifest sitting outside the directory discovery actually opens. Keep the plugin root and the nested skill entrypoint distinct, then verify both. The example’s source equality and entrypoint check turn this into an explicit error.

A check that repairs its evidence. The symptom is green CI after the checker silently created an uncommitted output. Use the same renderer for writing and comparison, but let only the explicit sync branch write. Test read-only behavior by comparing bytes before and after a failed check.

A plugin removed from the catalog but left on disk. The symptom is a generated manifest that no source row owns. The release’s generator includes an orphan check; the example scans both host manifest locations before writing. It refuses the mismatch rather than guessing whether deletion or re-registration was intended.

Host-specific fields crossing unchanged. The symptom is convincing-looking JSON with a component the other host never received an adapter for. The tagged generator rejects unsupported fields. Preserve that refusal when adding a second target; a passing JSON parse cannot validate the meaning of a component.

Sources

Changelog

  • feat: support Claude and Codex plugins with compatibility checks (0c4b871)