Download src/plugins/bundle-manifest.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 18 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/plugins/bundle-manifest.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/plugins/bundle-manifest.ts
-
curl -L -o bundle-manifest.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/plugins/bundle-manifest.ts
18 kB
| /** 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<string, unknown>; 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<string>(); | |
| 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, rootDir: string): string[] { | |
| const declared = normalizeBundlePathList(raw.skills); | |
| const defaults = pluginCacheExistsSync(path.join(rootDir, "skills")) ? ["skills"] : []; | |
| return mergeBundlePathLists(defaults, declared); | |
| } | |
| function resolveCursorCommandRootDirs(raw: Record<string, unknown>, 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<string, unknown>, rootDir: string): string[] { | |
| return mergeBundlePathLists( | |
| resolveCursorSkillsRootDirs(raw, rootDir), | |
| resolveCursorCommandRootDirs(raw, rootDir), | |
| ); | |
| } | |
| function resolveCursorAgentDirs(raw: Record<string, unknown>, 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<string, unknown>, rootDir: string): boolean { | |
| return ( | |
| hasInlineCapabilityValue(raw.hooks) || | |
| pluginCacheExistsSync(path.join(rootDir, ".cursor", "hooks.json")) | |
| ); | |
| } | |
| function hasCursorRulesCapability(raw: Record<string, unknown>, rootDir: string): boolean { | |
| return ( | |
| hasInlineCapabilityValue(raw.rules) || | |
| pluginCacheExistsSync(path.join(rootDir, ".cursor", "rules")) | |
| ); | |
| } | |
| function hasCursorMcpCapability(raw: Record<string, unknown>, rootDir: string): boolean { | |
| return ( | |
| hasInlineCapabilityValue(raw.mcpServers) || | |
| pluginCacheExistsSync(path.join(rootDir, ".mcp.json")) | |
| ); | |
| } | |
| function resolveClaudeComponentPaths( | |
| raw: Record<string, unknown>, | |
| 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, | |
| 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; | |
| } | |