Bundle Manifest

Deep Dive

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.

📚 What is this & why does it matter?

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.

💡 Real-world analogy

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.

Contents

Overview

Primary Role

Detect and parse plugin manifests from Codex, Claude, and Cursor bundle formats into a normalized BundlePluginManifest.

Main Inputs

Plugin root directory path, bundle format type, raw manifest JSON.

Key Decisions

Which format to detect (.codex-plugin/, .claude-plugin/, .cursor-plugin/), how to normalize path lists and capabilities.

Outputs

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.

How It Works

Step 1 — Format Detection

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;
}
Key detail: Detection checks for the manifest file inside the marker directory, not just the directory itself. A stale .codex-plugin/ folder with no manifest.json is correctly skipped.

Step 2 — Secure File Reading

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.

Step 3 — JSON Parsing

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.

Step 4 — Field Normalization

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.

Step 5 — Result Assembly

The function returns a BundleManifestLoadResult — a discriminated union of { ok: true, manifest } or { ok: false, error }.

Behavior State Machine

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.

Annotated Code Walkthrough

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" };
  }
Security: 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)
  );
Deduplication: 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,
    },
  };
}

Edge Cases & Error Handling

ScenarioBehaviorLocation
Format directory exists but manifest file missingDetection skips that format and probes the next one; returns no-manifest-format if all faildetectBundleManifestFormat()
Manifest has invalid JSON (syntax error)JSON5 parse throws; caught and returned as { ok: false, error: "invalid-json" }loadBundleManifest()
Path lists contain duplicatesmergeBundlePathLists() deduplicates via Set, keeping first occurrence ordernormalizeBundlePathList()
Capabilities have inline values vs arraysnormalizeBundlePathList() accepts both string and string[], wrapping scalars into arraysnormalizeBundlePathList()
Security boundary check fails (symlink escape)openBoundaryFileSync() rejects the read; error returned as boundary-violationopenBoundaryFileSync()

See also:

Directory Map

Source files for bundle manifest handling
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