Primary Role
Detect and parse plugin manifests from Codex, Claude, and Cursor bundle formats into a normalized BundlePluginManifest.
Multi-format manifest detection, loading, and normalization
The bundle manifest system detects plugin descriptor files across three competing format conventions — Codex, Claude, and Cursor — reads their JSON manifests, and normalizes them into a single BundlePluginManifest type so the rest of the plugin pipeline can work with one consistent shape.
The Bundle Manifest module is the entry gate for every bundled plugin. Before OpenClaw can load a plugin’s commands, capabilities, or MCP servers, it first needs to read the plugin’s self-description: its ID, name, version, and the lists of files it provides. Because different AI-assistant ecosystems invented their own manifest formats, this module acts as a universal adapter that reads any of them and produces one normalized shape.
Imagine an airport customs desk that accepts passports from three different countries, each with a different layout. The customs officer (Bundle Manifest) doesn’t need to know the quirks of each passport format — they extract the same core fields (name, nationality, photo) into a standard entry form. Downstream systems (border control, airline check-in) only ever see the normalized form.
Detect and parse plugin manifests from Codex, Claude, and Cursor bundle formats into a normalized BundlePluginManifest.
Plugin root directory path, bundle format type, raw manifest JSON.
Which format to detect (.codex-plugin/, .claude-plugin/, .cursor-plugin/), how to normalize path lists and capabilities.
BundleManifestLoadResult — either a normalized manifest or an error diagnostic.
flowchart LR
Dir["Plugin Root Dir"] --> Detect{"Detect Format"}
Detect -->|".codex-plugin/"| Codex["Load Codex Manifest"]
Detect -->|".claude-plugin/"| Claude["Load Claude Manifest"]
Detect -->|".cursor-plugin/"| Cursor["Load Cursor Manifest"]
Codex --> Normalize["Normalize Fields"]
Claude --> Normalize
Cursor --> Normalize
Normalize --> Result["BundleManifestLoadResult"]
The loader follows a strict probe order: it checks for format-specific directories inside the plugin root, reads the JSON manifest from the first match, then normalizes every field into the unified BundlePluginManifest shape that downstream consumers expect.
detectBundleManifestFormat() probes the plugin root directory for well-known format marker directories. It returns the first matching BundleManifestFormat enum value, or undefined if no format is found.
// Probe order mirrors priority: Codex > Claude > Cursor
const FORMAT_MARKERS: Record<BundleManifestFormat, string> = {
codex: ".codex-plugin",
claude: ".claude-plugin",
cursor: ".cursor-plugin",
};
function detectBundleManifestFormat(
rootDir: string
): BundleManifestFormat | undefined {
for (const [fmt, dir] of Object.entries(FORMAT_MARKERS)) {
if (existsSync(join(rootDir, dir, "manifest.json"))) {
return fmt as BundleManifestFormat;
}
}
return undefined;
}
.codex-plugin/ folder with no manifest.json is correctly skipped.
Once a format is detected, the manifest JSON is read through openBoundaryFileSync(), which enforces a security boundary: the resolved path must remain inside the plugin root. Symlinks that escape are rejected.
The raw file content is parsed with JSON5 to tolerate trailing commas, single-quoted strings, and comments that some plugin authors include in their manifests.
Core fields (id, name, description, version) are extracted directly. Path list fields (skills[], hooks[], capabilities[]) go through normalizeBundlePathList() which resolves relative paths, removes duplicates via mergeBundlePathLists(), and validates that each entry points to a real file.
The function returns a BundleManifestLoadResult — a discriminated union of { ok: true, manifest } or { ok: false, error }.
stateDiagram-v2 [*] --> Detecting : detectBundleManifestFormat() Detecting --> NotFound : No manifest dir Detecting --> Reading : Format detected Reading --> ParseError : Invalid JSON Reading --> Normalizing : Valid JSON Normalizing --> Success : All fields valid Normalizing --> PartialSuccess : Missing optional fields NotFound --> [*] ParseError --> [*] Success --> [*] PartialSuccess --> [*]
The state machine shows the happy path from detection through normalization to success, along with the three failure exits: format not found, JSON parse error, and partial success when optional fields are missing but the manifest is still usable.
Trace through the full loadBundleManifest() pipeline from directory input to normalized result:
Detect which bundle format the plugin uses by probing for marker directories:
export function loadBundleManifest(
rootDir: string
): BundleManifestLoadResult {
const format = detectBundleManifestFormat(rootDir);
if (!format) {
return { ok: false, error: "no-manifest-format" };
}
Resolve the manifest file path and read it through the security boundary checker:
const manifestPath = join(
rootDir,
FORMAT_MARKERS[format],
"manifest.json"
);
const raw = openBoundaryFileSync(rootDir, manifestPath);
if (!raw.ok) {
return { ok: false, error: "boundary-violation" };
}
openBoundaryFileSync() resolves symlinks and rejects any path that escapes rootDir. This prevents a malicious plugin from reading files outside its own directory.
Parse the JSON content with JSON5 for lenient formatting support:
let parsed: unknown;
try {
parsed = JSON5.parse(raw.content);
} catch (e) {
return { ok: false, error: "invalid-json" };
}
Extract core identity fields from the parsed manifest object:
const obj = parsed as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id : undefined;
const name = typeof obj.name === "string" ? obj.name : id;
const description = typeof obj.description === "string"
? obj.description : undefined;
const version = typeof obj.version === "string"
? obj.version : "0.0.0";
Normalize path lists, deduplicating entries and resolving relative paths:
const skills = normalizeBundlePathList(rootDir, obj.skills);
const hooks = normalizeBundlePathList(rootDir, obj.hooks);
const capabilities = mergeBundlePathLists(
normalizeBundlePathList(rootDir, obj.capabilities),
normalizeBundlePathList(rootDir, obj.inlineCapabilities)
);
mergeBundlePathLists() uses a Set internally, so overlapping entries in capabilities and inlineCapabilities are merged cleanly.
Assemble the normalized manifest and return the success result:
return {
ok: true,
manifest: {
format,
id: id ?? path.basename(rootDir),
name: name ?? id ?? path.basename(rootDir),
description,
version,
skills,
hooks,
capabilities,
},
};
}
| Scenario | Behavior | Location |
|---|---|---|
| Format directory exists but manifest file missing | Detection skips that format and probes the next one; returns no-manifest-format if all fail | detectBundleManifestFormat() |
| Manifest has invalid JSON (syntax error) | JSON5 parse throws; caught and returned as { ok: false, error: "invalid-json" } | loadBundleManifest() |
| Path lists contain duplicates | mergeBundlePathLists() deduplicates via Set, keeping first occurrence order | normalizeBundlePathList() |
| Capabilities have inline values vs arrays | normalizeBundlePathList() accepts both string and string[], wrapping scalars into arrays | normalizeBundlePathList() |
| Security boundary check fails (symlink escape) | openBoundaryFileSync() rejects the read; error returned as boundary-violation | openBoundaryFileSync() |
See also:
src/plugins/
├── bundle-manifest.ts # Main entry: loadBundleManifest(), detectBundleManifestFormat()
├── bundle-manifest-types.ts # BundlePluginManifest, BundleManifestLoadResult types
├── bundle-manifest-normalize.ts # normalizeBundlePathList(), mergeBundlePathLists()
├── bundle-manifest-formats.ts # FORMAT_MARKERS, BundleManifestFormat enum
└── boundary-fs.ts # openBoundaryFileSync() security boundary checker