different-ai/openwork · error · ApiError

plugin_ambiguous

plugin_ambiguous

Error message

Multiple plugins found (${candidates}). Add the plugin directory to the URL, e.g. /tree/main/<dir>.

What it means

When no dir is given and locatePluginRoot finds multiple .claude-plugin/plugin.json files at the same shallowest depth, the plugin is ambiguous — it cannot pick one automatically. It throws this 400 ApiError listing the candidate directories and instructs the caller to add the plugin directory to the URL.

Source

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

    const normalized = dir.replace(/\/+$/, "");
    const expected = `${normalized}/.claude-plugin/plugin.json`;
    if (!manifestPaths.includes(expected)) {
      throw new ApiError(404, "plugin_manifest_not_found", `No .claude-plugin/plugin.json found under ${normalized}/`);
    }
    return `${normalized}/`;
  }
  if (manifestPaths.length === 0) {
    throw new ApiError(404, "plugin_manifest_not_found", "No .claude-plugin/plugin.json found in this repository");
  }
  manifestPaths.sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
  const shallowest = manifestPaths[0]!;
  const root = shallowest.slice(0, shallowest.length - ".claude-plugin/plugin.json".length);
  const sameDepth = manifestPaths.filter((path) => path.split("/").length === shallowest.split("/").length);
  if (sameDepth.length > 1) {
    const candidates = sameDepth
      .map((path) => path.slice(0, path.length - "/.claude-plugin/plugin.json".length))
      .join(", ");
    throw new ApiError(400, "plugin_ambiguous", `Multiple plugins found (${candidates}). Add the plugin directory to the URL, e.g. /tree/main/<dir>.`);
  }
  return root;
}

function readString(value: unknown): string | null {
  return typeof value === "string" && value.trim() ? value.trim() : null;
}

function readPathList(value: unknown): string[] {
  if (typeof value === "string") return value.trim() ? [value.trim()] : [];
  if (Array.isArray(value)) return value.flatMap((entry) => (typeof entry === "string" && entry.trim() ? [entry.trim()] : []));
  return [];
}

function normalizeRelative(root: string, path: string): string {
  const cleaned = path.replace(/^\.\//, "").replace(/^\/+/, "");
  if (cleaned.split("/").some((part) => part === "..")) return "";
  return `${root}${cleaned}`;

View on GitHub (pinned to 2b7df46e8a)

Solutions

  1. Append the desired plugin directory to the URL as the message suggests: /tree/main/<dir>.
  2. Enumerate the candidates listed in the message and pick the intended one.
  3. If you meant to install all plugins, install each with its own URL.
  4. For stable automation, always include the <dir> segment in monorepos.

Example fix

// before
GET /plugin/tree/main        // ambiguous: plugins/foo, plugins/bar
// after
GET /plugin/tree/main/plugins/foo
Defensive patterns

Strategy: validation

Validate before calling

const tree = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`).then(r => r.json());
const roots = (tree.tree ?? []).map((e: { path: string }) => e.path).filter((p: string) => p.endsWith("/.claude-plugin/plugin.json"));
if (roots.length > 1) throw new Error(`ambiguous: ${roots.join(", ")} — include /<dir>`);

Try / catch

try {
  await installPlugin(url);
} catch (e) {
  if (isApiError(e) && e.code === "plugin_ambiguous") {
    // parse candidates from e.message and retry with chosen /<dir>
  }
}

Prevention

When it happens

Trigger: Requesting /plugin/tree/<ref> against a monorepo containing two or more sibling plugin roots (e.g. plugins/a/.claude-plugin/plugin.json and plugins/b/.claude-plugin/plugin.json) without specifying which plugin to install.

Common situations: Monorepos bundling several plugins; installing from an org's plugins collection repo; a repo that recently gained a second plugin so previously working URLs became ambiguous.

Related errors


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