different-ai/openwork · error · ApiError

invalid_plugin_manifest

invalid_plugin_manifest

Error message

${manifestPath} is not valid JSON

What it means

After fetching the manifest text, resolveClaudePluginBundle parses it with JSON.parse and requires the result to be a plain object. Any parse failure (malformed JSON, trailing commas, HTML error page returned instead of JSON) or non-object result throws this 400 ApiError identifying the manifest path.

Source

Thrown at apps/server/src/claude-plugin-bundle.ts:283

  return false;
}

export async function resolveClaudePluginBundle(input: { url: string; ref?: string }): Promise<ClaudePluginBundle> {
  const source = parseClaudePluginSource(input.url);
  const { ref, dir, tree } = await resolveRefAndTree(source, input.ref?.trim() || undefined);
  const root = locatePluginRoot(tree, dir);
  const treeByPath = new Map(tree.map((entry) => [entry.path, entry]));
  const warnings: string[] = [];

  const manifestPath = `${root}.claude-plugin/plugin.json`;
  const manifestText = await fetchGithubText(rawFileUrl(source, ref, manifestPath));
  let manifest: Record<string, unknown>;
  try {
    const parsed: unknown = JSON.parse(manifestText);
    if (!isRecord(parsed)) throw new Error("not an object");
    manifest = parsed;
  } catch {
    throw new ApiError(400, "invalid_plugin_manifest", `${manifestPath} is not valid JSON`);
  }

  const pluginName = readString(manifest.displayName) ?? readString(manifest.name);
  if (!pluginName) {
    throw new ApiError(400, "invalid_plugin_manifest", `${manifestPath} is missing a plugin name`);
  }
  const description = readString(manifest.description);
  const version = readString(manifest.version);
  if (manifest.hooks !== undefined) {
    warnings.push("This plugin declares hooks, which OpenWork does not support yet. Hooks were skipped.");
  }

  // --- Collect component file paths -----------------------------------------
  const inTree = (path: string) => treeByPath.has(path);

  const collectMarkdown = (declared: string[], defaultDir: string): string[] => {
    const roots = declared.length > 0
      ? declared.map((entry) => normalizeRelative(root, entry)).filter(Boolean)

View on GitHub (pinned to 2b7df46e8a)

Solutions

  1. Validate the plugin.json locally with `node -e "JSON.parse(require('fs').readFileSync('.claude-plugin/plugin.json'))"` or a JSON linter.
  2. Remove comments, trailing commas, BOM, and non-ASCII quotes; JSON does not allow JSONC syntax.
  3. Confirm the file at that path/ref is the real manifest and not an HTML/error page.
  4. Re-fetch after fixing and pushing the corrected manifest to the repo.

Example fix

// before (.claude-plugin/plugin.json)
{ "name": "my-plugin", }  // trailing comma
// after
{ "name": "my-plugin" }
Defensive patterns

Strategy: validation

Validate before calling

import { readFileSync } from "node:fs";
const raw = readFileSync(".claude-plugin/plugin.json", "utf8").replace(/^\uFEFF/, "");
JSON.parse(raw); // throws locally before install if invalid

Type guard

function isJsonObject(v: unknown): v is Record<string, unknown> {
  return typeof v === "object" && v !== null && !Array.isArray(v);
}

Try / catch

try {
  await installPlugin(url);
} catch (e) {
  if (isApiError(e) && e.code === "invalid_plugin_manifest") {
    // report the manifest path from e.message back to the plugin vendor
  }
}

Prevention

When it happens

Trigger: The .claude-plugin/plugin.json in the repo is syntactically invalid JSON, contains comments/BOM, or the fetched text is not the manifest at all (e.g. a GitHub error page captured as text).

Common situations: Hand-edited manifest with a missing comma or trailing comma; manifest saved with comments (JSONC instead of JSON); wrong file committed with plugin.json name; encoding issues (BOM, smart quotes from editors).

Related errors


AI-assisted analysis of different-ai/openwork@2b7df46e8a (2026-09-01). Data as JSON: /api/errors/7c1c20ab06791bf6. Report an issue: GitHub.