/** Reads Agent/Codex/Claude/Cursor bundle manifests into OpenClaw plugin manifest metadata. */ import path from "node:path"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import { matchRootFileOpenFailure } from "../infra/boundary-file-read.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isRecord } from "../utils.js"; import type { PluginBundleFormat } from "./manifest-types.js"; import type { PluginManifestActivation } from "./manifest.js"; import { DEFAULT_PLUGIN_ENTRY_CANDIDATES, normalizeManifestActivation, PLUGIN_MANIFEST_FILENAME, } from "./manifest.js"; import { parsePluginCacheJson, pluginCacheExistsSync, pluginCacheStatSync, readPluginCacheFile, } from "./plugin-cache-files.js"; /** Relative manifest path for Codex-style plugin bundles. */ export const CODEX_BUNDLE_MANIFEST_RELATIVE_PATH = ".codex-plugin/plugin.json"; export const CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH = ".claude-plugin/plugin.json"; export const CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH = ".cursor-plugin/plugin.json"; export const AGENT_BUNDLE_MANIFEST_RELATIVE_PATH = "plugin.json"; const AGENT_BUNDLE_EXTENSION_NAMESPACE = "ai.openclaw"; const AGENT_BUNDLE_MANIFEST_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; const MAX_AGENT_BUNDLE_MANIFEST_BYTES = 256 * 1024; const log = createSubsystemLogger("plugins/bundle-manifest"); /** Normalized bundle manifest shape consumed by plugin discovery. */ type BundlePluginManifest = { id: string; name?: string; description?: string; version?: string; skills: string[]; settingsFiles?: string[]; // Only include hook roots that OpenClaw can execute via HOOK.md + handler files. hooks: string[]; bundleFormat: PluginBundleFormat; activation?: PluginManifestActivation; capabilities: string[]; }; type BundleManifestLoadResult = | { ok: true; manifest: BundlePluginManifest; manifestPath: string } | { ok: false; error: string; manifestPath: string }; type BundleManifestFileLoadResult = | { ok: true; raw: Record; manifestPath: string } | { ok: false; error: string; manifestPath: string }; /** Normalizes string-or-list path fields from bundle manifests. */ export function normalizeBundlePathList(value: unknown): string[] { return normalizeUniqueSingleOrTrimmedStringList(value); } export function mergeBundlePathLists(...groups: string[][]): string[] { const merged: string[] = []; const seen = new Set(); for (const group of groups) { for (const entry of group) { if (seen.has(entry)) { continue; } seen.add(entry); merged.push(entry); } } return merged; } function hasInlineCapabilityValue(value: unknown): boolean { if (typeof value === "string") { return value.trim().length > 0; } if (Array.isArray(value)) { return value.length > 0; } if (isRecord(value)) { return Object.keys(value).length > 0; } return value === true; } function slugifyPluginId(raw: string | undefined, rootDir: string): string { const fallback = path.basename(rootDir); const source = normalizeLowercaseStringOrEmpty(raw) || normalizeLowercaseStringOrEmpty(fallback); const slug = source .replace(/[^a-z0-9]+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, ""); return slug || "bundle-plugin"; } function loadBundleManifestFile(params: { rootDir: string; rootRealPath?: string; manifestRelativePath: string; rejectHardlinks: boolean; allowMissing?: boolean; strictJson?: boolean; maxBytes?: number; }): BundleManifestFileLoadResult { const manifestPath = path.join(params.rootDir, params.manifestRelativePath); const file = readPluginCacheFile({ rootDir: params.rootDir, rootRealPath: params.rootRealPath, relativePath: params.manifestRelativePath, rejectHardlinks: params.rejectHardlinks, maxBytes: params.maxBytes ?? MAX_AGENT_BUNDLE_MANIFEST_BYTES, }); if (!file.ok) { return matchRootFileOpenFailure(file.failure, { path: () => { if (params.allowMissing) { return { ok: true, raw: {}, manifestPath }; } return { ok: false, error: `plugin manifest not found: ${manifestPath}`, manifestPath }; }, fallback: (failure) => ({ ok: false, error: `unsafe plugin manifest path: ${manifestPath} (${failure.reason})`, manifestPath, }), }); } const result = parsePluginCacheJson(file, { json5: !params.strictJson }); if (!result.ok) { return { ok: false, error: `failed to parse plugin manifest: ${formatErrorMessage(result.error)}`, manifestPath, }; } if (!isRecord(result.value)) { return { ok: false, error: "plugin manifest must be an object", manifestPath }; } return { ok: true, raw: result.value, manifestPath }; } function resolveCodexSkillDirs(raw: Record, rootDir: string): string[] { const declared = normalizeBundlePathList(raw.skills); if (declared.length > 0) { return declared; } return pluginCacheExistsSync(path.join(rootDir, "skills")) ? ["skills"] : []; } function resolveCodexHookDirs(raw: Record, rootDir: string): string[] { const declared = normalizeBundlePathList(raw.hooks); if (declared.length > 0) { return declared; } return pluginCacheExistsSync(path.join(rootDir, "hooks")) ? ["hooks"] : []; } function resolveCursorSkillsRootDirs(raw: Record, rootDir: string): string[] { const declared = normalizeBundlePathList(raw.skills); const defaults = pluginCacheExistsSync(path.join(rootDir, "skills")) ? ["skills"] : []; return mergeBundlePathLists(defaults, declared); } function resolveCursorCommandRootDirs(raw: Record, rootDir: string): string[] { const declared = normalizeBundlePathList(raw.commands); const defaults = pluginCacheExistsSync(path.join(rootDir, ".cursor", "commands")) ? [".cursor/commands"] : []; return mergeBundlePathLists(defaults, declared); } function resolveCursorSkillDirs(raw: Record, rootDir: string): string[] { return mergeBundlePathLists( resolveCursorSkillsRootDirs(raw, rootDir), resolveCursorCommandRootDirs(raw, rootDir), ); } function resolveCursorAgentDirs(raw: Record, rootDir: string): string[] { const declared = normalizeBundlePathList(raw.subagents ?? raw.agents); const defaults = pluginCacheExistsSync(path.join(rootDir, ".cursor", "agents")) ? [".cursor/agents"] : []; return mergeBundlePathLists(defaults, declared); } function hasCursorHookCapability(raw: Record, rootDir: string): boolean { return ( hasInlineCapabilityValue(raw.hooks) || pluginCacheExistsSync(path.join(rootDir, ".cursor", "hooks.json")) ); } function hasCursorRulesCapability(raw: Record, rootDir: string): boolean { return ( hasInlineCapabilityValue(raw.rules) || pluginCacheExistsSync(path.join(rootDir, ".cursor", "rules")) ); } function hasCursorMcpCapability(raw: Record, rootDir: string): boolean { return ( hasInlineCapabilityValue(raw.mcpServers) || pluginCacheExistsSync(path.join(rootDir, ".mcp.json")) ); } function resolveClaudeComponentPaths( raw: Record, key: string, rootDir: string, defaults: string[], ): string[] { const declared = normalizeBundlePathList(raw[key]); const existingDefaults = defaults.filter((candidate) => pluginCacheExistsSync(path.join(rootDir, candidate)), ); return mergeBundlePathLists(existingDefaults, declared); } function buildCodexCapabilities(raw: Record, rootDir: string): string[] { const capabilities: string[] = []; if (resolveCodexSkillDirs(raw, rootDir).length > 0) { capabilities.push("skills"); } if (resolveCodexHookDirs(raw, rootDir).length > 0) { capabilities.push("hooks"); } if ( hasInlineCapabilityValue(raw.mcpServers) || pluginCacheExistsSync(path.join(rootDir, ".mcp.json")) ) { capabilities.push("mcpServers"); } if ( hasInlineCapabilityValue(raw.apps) || pluginCacheExistsSync(path.join(rootDir, ".app.json")) ) { capabilities.push("apps"); } return capabilities; } function buildCursorCapabilities(raw: Record, rootDir: string): string[] { const capabilities: string[] = []; if (resolveCursorSkillDirs(raw, rootDir).length > 0) { capabilities.push("skills"); } if (resolveCursorCommandRootDirs(raw, rootDir).length > 0) { capabilities.push("commands"); } if (resolveCursorAgentDirs(raw, rootDir).length > 0) { capabilities.push("agents"); } if (hasCursorHookCapability(raw, rootDir)) { capabilities.push("hooks"); } if (hasCursorRulesCapability(raw, rootDir)) { capabilities.push("rules"); } if (hasCursorMcpCapability(raw, rootDir)) { capabilities.push("mcpServers"); } return capabilities; } function resolveAgentSkillDirs(rootDir: string): string[] { try { return pluginCacheStatSync(path.join(rootDir, "skills"))?.isDirectory() ? ["skills"] : []; } catch { return []; } } function buildAgentCapabilities(rootDir: string): string[] { const capabilities: string[] = []; if (resolveAgentSkillDirs(rootDir).length > 0) { capabilities.push("skills"); } if (pluginCacheExistsSync(path.join(rootDir, "mcp.json"))) { capabilities.push("mcpServers"); } return capabilities; } function resolveAgentActivation( raw: Record, manifestPath: string, ): PluginManifestActivation | undefined { if (raw.extensions === undefined) { return undefined; } if (!isRecord(raw.extensions)) { log.warn(`ignoring Agent Plugins extensions in ${manifestPath}: expected an object`); return undefined; } const openclawExtension = raw.extensions[AGENT_BUNDLE_EXTENSION_NAMESPACE]; if (openclawExtension === undefined) { return undefined; } if (!isRecord(openclawExtension)) { log.warn( `ignoring Agent Plugins ${AGENT_BUNDLE_EXTENSION_NAMESPACE} extension in ${manifestPath}: expected an object`, ); return undefined; } return normalizeManifestActivation(openclawExtension.activation); } export function loadBundleManifest(params: { rootDir: string; rootRealPath?: string; bundleFormat: PluginBundleFormat; rejectHardlinks?: boolean; }): BundleManifestLoadResult { const rejectHardlinks = params.rejectHardlinks ?? true; const manifestRelativePath = params.bundleFormat === "codex" ? CODEX_BUNDLE_MANIFEST_RELATIVE_PATH : params.bundleFormat === "cursor" ? CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH : params.bundleFormat === "agent" ? AGENT_BUNDLE_MANIFEST_RELATIVE_PATH : CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH; const loaded = loadBundleManifestFile({ rootDir: params.rootDir, ...(params.rootRealPath !== undefined ? { rootRealPath: params.rootRealPath } : {}), manifestRelativePath, rejectHardlinks, allowMissing: params.bundleFormat === "claude", strictJson: params.bundleFormat === "agent", ...(params.bundleFormat === "agent" ? { maxBytes: MAX_AGENT_BUNDLE_MANIFEST_BYTES } : {}), }); if (!loaded.ok) { return loaded; } const raw = loaded.raw; const interfaceRecord = isRecord(raw.interface) ? raw.interface : undefined; const name = normalizeOptionalString(raw.name); const description = normalizeOptionalString(raw.description) ?? normalizeOptionalString(raw.shortDescription) ?? normalizeOptionalString(interfaceRecord?.shortDescription); const version = normalizeOptionalString(raw.version); if (params.bundleFormat === "agent") { if (raw.$schema !== AGENT_BUNDLE_MANIFEST_SCHEMA) { return { ok: false, error: `root plugin.json is not an Agent Plugins manifest; expected $schema ${AGENT_BUNDLE_MANIFEST_SCHEMA}`, manifestPath: loaded.manifestPath, }; } if (!name) { return { ok: false, error: "agent plugin manifest name must be a non-empty string", manifestPath: loaded.manifestPath, }; } return { ok: true, manifest: { id: slugifyPluginId(name, params.rootDir), name, description, version, skills: resolveAgentSkillDirs(params.rootDir), settingsFiles: [], hooks: [], bundleFormat: "agent", activation: resolveAgentActivation(raw, loaded.manifestPath), capabilities: buildAgentCapabilities(params.rootDir), }, manifestPath: loaded.manifestPath, }; } if (params.bundleFormat === "codex") { const skills = resolveCodexSkillDirs(raw, params.rootDir); const hooks = resolveCodexHookDirs(raw, params.rootDir); return { ok: true, manifest: { id: slugifyPluginId(name, params.rootDir), name, description, version, skills, settingsFiles: [], hooks, bundleFormat: "codex", activation: normalizeManifestActivation(raw.activation), capabilities: buildCodexCapabilities(raw, params.rootDir), }, manifestPath: loaded.manifestPath, }; } if (params.bundleFormat === "cursor") { return { ok: true, manifest: { id: slugifyPluginId(name, params.rootDir), name, description, version, skills: resolveCursorSkillDirs(raw, params.rootDir), settingsFiles: [], hooks: [], bundleFormat: "cursor", activation: normalizeManifestActivation(raw.activation), capabilities: buildCursorCapabilities(raw, params.rootDir), }, manifestPath: loaded.manifestPath, }; } const id = slugifyPluginId(name, params.rootDir); const skillRoots = resolveClaudeComponentPaths(raw, "skills", params.rootDir, ["skills"]); const commands = resolveClaudeComponentPaths(raw, "commands", params.rootDir, ["commands"]); const agents = resolveClaudeComponentPaths(raw, "agents", params.rootDir, ["agents"]); const outputStyles = resolveClaudeComponentPaths(raw, "outputStyles", params.rootDir, [ "output-styles", ]); const skills = mergeBundlePathLists(skillRoots, commands, agents, outputStyles); const settingsFiles = pluginCacheExistsSync(path.join(params.rootDir, "settings.json")) ? ["settings.json"] : []; const hooks = resolveClaudeComponentPaths(raw, "hooks", params.rootDir, ["hooks/hooks.json"]); const activation = normalizeManifestActivation(raw.activation); const capabilities: string[] = []; if (skills.length > 0) { capabilities.push("skills"); } if (commands.length > 0) { capabilities.push("commands"); } if (agents.length > 0) { capabilities.push("agents"); } if (hasInlineCapabilityValue(raw.hooks) || hooks.length > 0) { capabilities.push("hooks"); } if ( hasInlineCapabilityValue(raw.mcpServers) || resolveClaudeComponentPaths(raw, "mcpServers", params.rootDir, [".mcp.json"]).length > 0 ) { capabilities.push("mcpServers"); } if ( hasInlineCapabilityValue(raw.lspServers) || resolveClaudeComponentPaths(raw, "lspServers", params.rootDir, [".lsp.json"]).length > 0 ) { capabilities.push("lspServers"); } if (hasInlineCapabilityValue(raw.outputStyles) || outputStyles.length > 0) { capabilities.push("outputStyles"); } if (settingsFiles.length > 0) { capabilities.push("settings"); } return { ok: true, manifest: { id, name, description, version, skills, settingsFiles, hooks, bundleFormat: "claude", activation, capabilities, }, manifestPath: loaded.manifestPath, }; } export function detectBundleManifestFormat( rootDir: string, hasPackageExtensions = false, ): PluginBundleFormat | null { // Explicit package entrypoints own native manifests; bundles only precede native fallback. if (hasPackageExtensions && pluginCacheExistsSync(path.join(rootDir, PLUGIN_MANIFEST_FILENAME))) { return null; } if (pluginCacheExistsSync(path.join(rootDir, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH))) { return "codex"; } if (pluginCacheExistsSync(path.join(rootDir, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH))) { return "cursor"; } if (pluginCacheExistsSync(path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH))) { return "claude"; } if (pluginCacheExistsSync(path.join(rootDir, PLUGIN_MANIFEST_FILENAME))) { return null; } // Client-specific bundle dirs and native OpenClaw manifests take precedence; // the portable root manifest is the fallback when neither is present. if (pluginCacheExistsSync(path.join(rootDir, AGENT_BUNDLE_MANIFEST_RELATIVE_PATH))) { const agentManifest = loadBundleManifestFile({ rootDir, manifestRelativePath: AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, rejectHardlinks: false, strictJson: true, maxBytes: MAX_AGENT_BUNDLE_MANIFEST_BYTES, }); if (agentManifest.ok && agentManifest.raw.$schema === AGENT_BUNDLE_MANIFEST_SCHEMA) { return "agent"; } } if ( DEFAULT_PLUGIN_ENTRY_CANDIDATES.some((candidate) => pluginCacheExistsSync(path.join(rootDir, candidate)), ) ) { return null; } const manifestlessClaudeMarkers = [ path.join(rootDir, "skills"), path.join(rootDir, "commands"), path.join(rootDir, "agents"), path.join(rootDir, "hooks", "hooks.json"), path.join(rootDir, ".mcp.json"), path.join(rootDir, ".lsp.json"), path.join(rootDir, "settings.json"), ]; if (manifestlessClaudeMarkers.some((candidate) => pluginCacheExistsSync(candidate))) { return "claude"; } return null; }