openclaw/scripts/generate-plugin-inventory-doc.mts
Peter Steinberger 37ffe3ce94
feat: back up to external disks and Cloudflare R2 with storage locations (#161913)
Adds named storage locations as a generic, pluggable capability, with backup as its first consumer.

- Core storage owner (src/storage): storage.locations config, a location marker that binds identity (runtime never creates it, so unplugged disks and different disks at the same path are refused), client-side streaming encryption (scrypt key from a SecretRef passphrase, per-object HKDF keys, AES-256-GCM segments), and a built-in filesystem provider for external disks and mounts.
- Plugin SDK: api.registerStorageProvider plus manifest contracts.storageProviders; providers move opaque bytes only.
- Bundled cloudflare plugin: an r2 provider over the S3 API with conditional writes and bounded multipart uploads; auto-enabled when a location uses provider "r2".
- Backups: backup create --to <location> with verified archives, UTC retention, list/verify/restore --from, Gateway-owned offsite schedules (installed Git schedules unchanged), per-installation namespace claims fenced at publication and deletion, backup record for external jobs, backup.status RPC, Doctor/status hints, and a Systems page Backups section.

No config or state migration; the storage section is new and optional. Proof: live R2 and mounted-disk round trips, namespace takeover trace, and a published 2026.9.7 upgrade cell with an existing Git backup schedule.
2026-10-01 02:05:44 -07:00

787 lines
26 KiB
TypeScript

#!/usr/bin/env node
// Generates the plugin inventory documentation page.
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { collectExcludedPackagedExtensionDirs } from "./lib/packaged-extension-dirs.mts";
import {
assertPluginInventoryCoverage,
resolvePluginSurface,
} from "./lib/plugin-inventory-doc.mts";
import {
collectPluginSourceEntries,
exportPluginInventory,
resolvePluginStatus,
type PluginManifest,
type PluginPackageJson,
type PluginSourceEntry,
type PluginStatus,
} from "./lib/plugin-inventory.mts";
const DOC_PATH = "docs/plugins/plugin-inventory.md";
const REFERENCE_INDEX_PATH = "docs/plugins/reference.md";
const REFERENCE_DIR = "docs/plugins/reference";
const ROOT = process.cwd();
const EXTENSIONS_DIR = path.join(ROOT, "extensions");
const PROVIDER_DOC_ALIASES = new Map([
["amazon-bedrock", "/providers/bedrock"],
["amazon-bedrock-mantle", "/providers/bedrock-mantle"],
["kimi", "/providers/moonshot"],
["perplexity", "/providers/perplexity-provider"],
]);
const PLUGIN_DOC_ALIASES = new Map([
["acpx", "/tools/acp-agents-setup"],
["brave", "/tools/brave-search"],
["browser", "/tools/browser"],
["codex", "/plugins/codex-harness"],
["document-extract", "/tools/pdf"],
["geolocation", "/plugins/geolocation"],
["duckduckgo", "/tools/duckduckgo-search"],
["exa", "/tools/exa-search"],
["firecrawl", "/tools/firecrawl"],
["imap", "/automation/imap"],
["parallel", "/tools/parallel-search"],
["perplexity", "/tools/perplexity-search"],
["policy", "/cli/policy"],
["tavily", "/tools/tavily"],
["tokenjuice", "/tools/tokenjuice"],
]);
const SKIPPED_REFERENCE_PAGE_IDS = new Set(["parallel"]);
const MANUAL_SECTION_START = "<!-- openclaw-plugin-reference:manual-start -->";
const MANUAL_SECTION_END = "<!-- openclaw-plugin-reference:manual-end -->";
const GENERATED_NOTICE = `<!-- Generated file. Do not edit by hand.
Run \`pnpm plugins:inventory:gen\` to rebuild it. -->`;
// Keep the marker names in this notice unbracketed. A bracketed copy would make
// extractManualReferenceSections match the notice instead of the real marker.
const GENERATED_REFERENCE_NOTICE = `<!-- Generated file. Do not edit by hand.
Run \`pnpm plugins:inventory:gen\` to rebuild it. Hand-written text survives only
between the openclaw-plugin-reference:manual-start and
openclaw-plugin-reference:manual-end comment markers. -->`;
// Generated link labels are user-visible product names and translation source.
const RELATED_DOC_PRODUCT_IDS = new Set([
"chutes",
"discord",
"fireworks",
"googlechat",
"imessage",
"line",
"matrix",
"meta",
"msteams",
"raft",
"runway",
"signal",
"slack",
"synthetic",
"telegram",
"tokenjuice",
"whatsapp",
]);
type DocLink = { label: string; href: string };
function createPluginRecord(entry: PluginSourceEntry, status: PluginStatus) {
const { id, manifest, packageJson } = entry;
return {
description: resolveDescription(entry),
docs: resolveDocs(entry),
id,
installRoute: resolveInstallRoute(packageJson, status),
name: humanizeId(id),
packageName: packageJson.name ?? (status === "core" ? "openclaw" : "-"),
status,
surface: resolvePluginSurface(manifest),
};
}
type PluginRecord = ReturnType<typeof createPluginRecord>;
function readJsonPath(filePath: string): unknown {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
}
function fileExists(relativePath: string) {
return fs.existsSync(path.join(ROOT, relativePath));
}
function normalizeDocPath(value: unknown) {
if (typeof value !== "string" || !value.startsWith("/")) {
return null;
}
return value.replace(/\.mdx?$/u, "");
}
function docLink({ label, href }: DocLink) {
return `[${label}](${href})`;
}
function relatedDocLabel(value: string) {
return RELATED_DOC_PRODUCT_IDS.has(value) ? humanizeId(value) : value;
}
function pluginReferencePath(id: string) {
return `/plugins/reference/${id}`;
}
function hasGeneratedReferencePage(record: PluginRecord) {
if (!SKIPPED_REFERENCE_PAGE_IDS.has(record.id)) {
return true;
}
if (PLUGIN_DOC_ALIASES.has(record.id)) {
return false;
}
throw new Error(`skipped plugin reference page ${record.id} needs a plugin doc alias`);
}
function pluginInventoryHref(record: PluginRecord) {
if (hasGeneratedReferencePage(record)) {
return pluginReferencePath(record.id);
}
return PLUGIN_DOC_ALIASES.get(record.id) ?? null;
}
function pluginReferenceLabel(record: PluginRecord) {
const label = escapeInventoryText(record.id);
const href = pluginInventoryHref(record);
return href ? docLink({ href, label }) : label;
}
function humanizeId(value: string) {
if (value === "slack-huddles") {
return "Slack huddles";
}
if (value === "teams-meetings") {
return "Microsoft Teams meetings";
}
if (value === "zoom-meetings") {
return "Zoom meetings";
}
const names = new Map([
["acpx", "ACPx"],
["ai", "AI"],
["api", "API"],
["aws", "AWS"],
["azure", "Azure"],
["byteplus", "BytePlus"],
["clawrouter", "ClawRouter"],
["codex", "Codex"],
["cli", "CLI"],
["comfy", "ComfyUI"],
["dashscope", "DashScope"],
["deepgram", "Deepgram"],
["deepinfra", "DeepInfra"],
["deepseek", "DeepSeek"],
["duckduckgo", "DuckDuckGo"],
["exa", "Exa"],
["fal", "fal"],
["feishu", "Feishu"],
["github", "GitHub"],
["googlechat", "Google Chat"],
["gpt", "GPT"],
["groq", "Groq"],
["huggingface", "Hugging Face"],
["imessage", "iMessage"],
["irc", "IRC"],
["kimi", "Kimi"],
["line", "LINE"],
["litellm", "LiteLLM"],
["llm", "LLM"],
["lmstudio", "LM Studio"],
["longcat", "LongCat"],
["mdns", "mDNS"],
["minimax", "MiniMax"],
["modelstudio", "Model Studio"],
["msteams", "Microsoft Teams"],
["nextcloud", "Nextcloud"],
["nvidia", "NVIDIA"],
["openai", "OpenAI"],
["opencode", "OpenCode"],
["openrouter", "OpenRouter"],
["otel", "OpenTelemetry"],
["pixverse", "PixVerse"],
["qa", "QA"],
["qqbot", "QQ Bot"],
["qwen", "Qwen"],
["qwencloud", "Qwen Cloud"],
["raft", "Raft"],
["searxng", "SearXNG"],
["sglang", "SGLang"],
["stepfun", "StepFun"],
["tokenhub", "TokenHub"],
["tts", "TTS"],
["twitch", "Twitch"],
["ui", "UI"],
["vllm", "vLLM"],
["whatsapp", "WhatsApp"],
["xai", "xAI"],
["zai", "Z.AI"],
["zalouser", "Zalo Personal"],
]);
return value
.split("-")
.map((part) => names.get(part) ?? part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function displayList(values: unknown[]) {
return values
.filter((value): value is string => typeof value === "string" && value.length > 0)
.map(humanizeId)
.join(", ");
}
function normalizePackageDescription(value: unknown) {
if (typeof value !== "string") {
return null;
}
return value.trim().replace(/\s+/gu, " ").replace(/\.$/u, "");
}
function resolveDescription({ manifest, packageJson }: PluginSourceEntry) {
const manifestDescription = normalizePackageDescription(manifest.description);
if (manifestDescription) {
return `${manifestDescription}.`;
}
const channels = Array.isArray(manifest.channels) ? manifest.channels : [];
if (channels.length > 0) {
const channelLabel = displayList(channels);
const channelNoun = channelLabel.toLowerCase().includes("channel") ? "" : " channel";
return `Adds the ${channelLabel}${channelNoun} surface for sending and receiving OpenClaw messages.`;
}
const providers = Array.isArray(manifest.providers) ? manifest.providers : [];
if (providers.length > 0) {
return `Adds ${displayList(providers)} model provider support to OpenClaw.`;
}
const contracts = Object.keys(manifest.contracts ?? {}).toSorted((left, right) =>
left.localeCompare(right),
);
const contractDescriptions: Record<string, string> = {
agentToolResultMiddleware: "Adds agent tool-result middleware.",
documentExtractors: "Adds document extraction for local attachments.",
imageGenerationProviders: "Adds image generation provider support.",
mediaUnderstandingProviders: "Adds media understanding provider support.",
embeddingProviders: "Adds embedding provider support, including memory search.",
migrationProviders: "Adds migration import support.",
musicGenerationProviders: "Adds music generation provider support.",
realtimeTranscriptionProviders: "Adds realtime transcription provider support.",
realtimeVoiceProviders: "Adds realtime voice provider support.",
speechProviders: "Adds text-to-speech provider support.",
storageProviders: "Adds storage location transport support.",
tools: "Adds agent-callable tools.",
videoGenerationProviders: "Adds video generation provider support.",
webContentExtractors: "Adds readable web content extraction.",
webFetchProviders: "Adds web fetch provider support.",
webSearchProviders: "Adds web search provider support.",
workerProviders: "Adds cloud worker provider support.",
};
const describedContracts = contracts
.map((contract) => contractDescriptions[contract])
.filter((value) => typeof value === "string");
if (describedContracts.length > 0) {
return describedContracts.join(" ");
}
const packageDescription = normalizePackageDescription(packageJson.description);
return packageDescription ? `${packageDescription}.` : "Provides an OpenClaw plugin.";
}
function pushUniqueDocLink(values: DocLink[], value: DocLink | null) {
if (
value &&
!values.some((existing) => existing.label === value.label && existing.href === value.href)
) {
values.push(value);
}
}
function resolveDocs({ dirName, manifest, packageJson }: PluginSourceEntry) {
const links: DocLink[] = [];
const manifestId = typeof manifest.id === "string" ? manifest.id : dirName;
const pluginAlias = PLUGIN_DOC_ALIASES.get(manifestId) ?? PLUGIN_DOC_ALIASES.get(dirName);
if (pluginAlias) {
const pluginAliasLabel = relatedDocLabel(manifestId);
pushUniqueDocLink(links, { href: pluginAlias, label: pluginAliasLabel });
}
const channelDoc = normalizeDocPath(packageJson.openclaw?.channel?.docsPath);
if (channelDoc) {
pushUniqueDocLink(links, {
href: channelDoc,
label: relatedDocLabel(channelDoc.replace(/^\/channels\//u, "")),
});
}
for (const channel of manifest.channels ?? []) {
if (typeof channel !== "string") {
continue;
}
const relativePath = `docs/channels/${channel}.md`;
if (fileExists(relativePath)) {
pushUniqueDocLink(links, { href: `/channels/${channel}`, label: relatedDocLabel(channel) });
}
}
for (const provider of manifest.providers ?? []) {
if (typeof provider !== "string") {
continue;
}
const alias = PROVIDER_DOC_ALIASES.get(provider);
if (alias) {
pushUniqueDocLink(links, { href: alias, label: relatedDocLabel(provider) });
continue;
}
const relativePath = `docs/providers/${provider}.md`;
if (fileExists(relativePath)) {
pushUniqueDocLink(links, {
href: `/providers/${provider}`,
label: relatedDocLabel(provider),
});
}
}
for (const candidate of [manifest.id, dirName, ...(manifest.legacyPluginIds ?? [])]) {
if (typeof candidate !== "string") {
continue;
}
for (const section of ["channels", "providers", "plugins"]) {
if (fileExists(`docs/${section}/${candidate}.md`)) {
pushUniqueDocLink(links, {
href: `/${section}/${candidate}`,
label: relatedDocLabel(candidate),
});
}
}
}
return links;
}
function resolveInstallRoute(packageJson: PluginPackageJson, status: PluginStatus): string {
if (status === "source") {
return "source checkout only";
}
if (status === "core") {
// Explicit bundle ownership describes the current install surface; release flags may stage future publication.
if (packageJson.openclaw?.build?.bundledDist === true) {
return "included in OpenClaw";
}
const release = packageJson.openclaw?.release;
if (release?.publishToClawHub === true || release?.publishToNpm === true) {
return `included in OpenClaw, and also from ${resolveInstallRoute(packageJson, "external")}`;
}
return "included in OpenClaw";
}
const install = packageJson.openclaw?.install;
const release = packageJson.openclaw?.release;
const clawhubSpec =
typeof install?.clawhubSpec === "string" ? `: \`${install.clawhubSpec}\`` : "";
const npmSpec =
typeof install?.npmSpec === "string" && install.npmSpec !== packageJson.name
? `: \`${install.npmSpec}\``
: "";
if (release?.publishToClawHub === true && release?.publishToNpm === true) {
if (install?.defaultChoice === "clawhub") {
return clawhubSpec ? `ClawHub${clawhubSpec} or npm${npmSpec}` : `ClawHub + npm${npmSpec}`;
}
return clawhubSpec ? `npm${npmSpec} or ClawHub${clawhubSpec}` : `npm${npmSpec} or ClawHub`;
}
if (release?.publishToClawHub === true) {
return `ClawHub${clawhubSpec || npmSpec}`;
}
if (release?.publishToNpm === true || typeof install?.npmSpec === "string") {
return `npm${npmSpec}`;
}
return "installable plugin";
}
function escapeInventoryText(value: unknown) {
return String(value).replaceAll("\n", " ").trim();
}
function renderInventoryList(records: PluginRecord[]) {
if (records.length === 0) {
return "_None._";
}
return records
.map(
(record) =>
`- **${pluginReferenceLabel(record)}** (\`${escapeInventoryText(record.packageName)}\`) - ${escapeInventoryText(record.installRoute)}. ${escapeInventoryText(record.description)}`,
)
.join("\n\n");
}
function renderRelatedDocs(record: PluginRecord) {
if (record.docs.length === 0) {
return "";
}
return `## Related docs
${record.docs.map((link) => `- ${docLink(link)}`).join("\n")}`;
}
function stripGeneratedNotice(value: string) {
const noticeStart = value.indexOf(GENERATED_REFERENCE_NOTICE);
return noticeStart === -1 ? value : value.slice(noticeStart + GENERATED_REFERENCE_NOTICE.length);
}
function extractManualReferenceSections(rawContent: string) {
const content = stripGeneratedNotice(rawContent);
const markerStart = content.indexOf(MANUAL_SECTION_START);
if (markerStart !== -1) {
const contentStart = markerStart + MANUAL_SECTION_START.length;
const markerEnd = content.indexOf(MANUAL_SECTION_END, contentStart);
if (markerEnd !== -1) {
return content.slice(contentStart, markerEnd).trim();
}
}
// The surface block is a list, so consume every non-blank line under the heading.
// A single-line pattern here would treat later surface bullets as manual text.
const surfaceMatch = /\n## Surface\n\n(?:[^\n]+\n)*/u.exec(content);
if (!surfaceMatch?.index) {
return "";
}
const manualStart = surfaceMatch.index + surfaceMatch[0].length;
const relatedDocsStart = content.indexOf("\n## Related docs\n", manualStart);
const manualEnd = relatedDocsStart === -1 ? content.length : relatedDocsStart;
return content.slice(manualStart, manualEnd).trim();
}
function readManualReferenceSections(relativePath: string) {
const fullPath = path.join(ROOT, relativePath);
if (!fs.existsSync(fullPath)) {
return "";
}
return extractManualReferenceSections(fs.readFileSync(fullPath, "utf8"));
}
function renderManualReferenceSections(manualSections: string) {
if (!manualSections) {
return "";
}
return `${MANUAL_SECTION_START}
${manualSections}
${MANUAL_SECTION_END}`;
}
// Generated reference titles carry a "reference" suffix so they never collide with a
// hand-written plugin guide title such as "Beam plugin" in docs/plugins/<id>.md.
function referencePageTitle(record: PluginRecord) {
return `${record.name} plugin reference`;
}
function renderSurface(surface: string[]) {
if (surface.length === 0) {
return "This plugin declares no channels, providers, commands, or contracts.";
}
return surface.map((part) => `- ${part}`).join("\n");
}
function renderReferencePage(record: PluginRecord, manualSections = "") {
const relatedDocs = renderRelatedDocs(record);
const manualBlock = renderManualReferenceSections(manualSections);
return `---
summary: "${record.description.replaceAll('"', '\\"')}"
read_when:
- You are installing, configuring, or auditing the ${record.id} plugin
title: "${referencePageTitle(record)}"
---
${GENERATED_REFERENCE_NOTICE}
${record.description}
## Distribution
- Package: \`${record.packageName}\`
- Install route: ${record.installRoute}
## Surface
${renderSurface(record.surface)}${manualBlock ? `\n\n${manualBlock}` : ""}${relatedDocs ? `\n\n${relatedDocs}` : ""}
`;
}
function renderReferenceIndex(records: PluginRecord[]) {
const referenceCount = records.filter(hasGeneratedReferencePage).length;
return `---
summary: "Pointer to the generated OpenClaw plugin reference pages"
read_when:
- You need a reference page for a specific OpenClaw plugin
- You are auditing plugin docs coverage
title: "Plugin reference"
---
${GENERATED_NOTICE}
This section holds one reference page for each OpenClaw plugin. Each page states
the package, the install route, and the surface the plugin adds.
This page is a pointer, not the index. The browsable list of all
${referenceCount} generated plugin reference pages lives in
[Plugin inventory](/plugins/plugin-inventory), sorted by distribution, package,
and description.
## How this page is built
OpenClaw generates this page from the top-level
\`extensions/*/openclaw.plugin.json\` manifests. Package metadata enriches
entries when \`package.json\` is present. Regenerate the page with:
\`\`\`bash
pnpm plugins:inventory:gen
\`\`\`
`;
}
function enumerateTopLevelPluginManifests() {
return fs
.readdirSync(EXTENSIONS_DIR)
.toSorted((left, right) => left.localeCompare(right))
.flatMap((dirName) => {
const manifestPath = path.join(EXTENSIONS_DIR, dirName, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {
return [];
}
const manifest = readJsonPath(manifestPath) as PluginManifest;
const id = typeof manifest.id === "string" && manifest.id ? manifest.id : dirName;
return [{ dirName, id }];
});
}
type ExternalPluginDocsInventorySeedEntry = {
openclaw?: {
channel?: NonNullable<PluginPackageJson["openclaw"]>["channel"];
channelHostConfig?: {
docsInventory?: {
package?: PluginPackageJson;
manifest?: PluginManifest;
};
};
};
};
function collectExternalPluginDocsInventoryEntries(): PluginSourceEntry[] {
const seed = readJsonPath(path.join(ROOT, "scripts/lib/official-external-channel-seed.json")) as {
entries?: ExternalPluginDocsInventorySeedEntry[];
};
const entries: PluginSourceEntry[] = [];
for (const entry of Array.isArray(seed.entries) ? seed.entries : []) {
const inventory = entry?.openclaw?.channelHostConfig?.docsInventory;
const packageMetadata = inventory?.package;
const manifest = inventory?.manifest;
if (!inventory) {
continue;
}
if (
typeof packageMetadata?.name !== "string" ||
typeof manifest?.id !== "string" ||
!entry?.openclaw?.channel
) {
throw new Error("external plugin docs inventory metadata is incomplete");
}
entries.push({
dirName: manifest.id,
id: manifest.id,
manifest,
packageJson: {
...packageMetadata,
openclaw: {
...packageMetadata.openclaw,
channel: entry.openclaw.channel,
},
},
});
}
return entries;
}
function collectPluginRecords() {
const rootPackageJson = readJsonPath(path.join(ROOT, "package.json")) as { files?: unknown[] };
const excludedDirs = collectExcludedPackagedExtensionDirs(rootPackageJson);
const sourceEntries = collectPluginSourceEntries(ROOT);
assertPluginInventoryCoverage(sourceEntries, enumerateTopLevelPluginManifests());
const records = sourceEntries.map((entry) =>
createPluginRecord(entry, resolvePluginStatus(entry, excludedDirs)),
);
const sourceIds = new Set(sourceEntries.map((entry) => entry.id));
for (const entry of collectExternalPluginDocsInventoryEntries()) {
if (!sourceIds.has(entry.id)) {
records.push(createPluginRecord(entry, "external"));
}
}
return records.toSorted((left, right) => left.id.localeCompare(right.id));
}
function writeGeneratedDocs(records: PluginRecord[]) {
fs.mkdirSync(path.join(ROOT, REFERENCE_DIR), { recursive: true });
for (const record of records.filter(hasGeneratedReferencePage)) {
const relativePath = path.join(REFERENCE_DIR, `${record.id}.md`);
const manualSections = readManualReferenceSections(relativePath);
fs.writeFileSync(
path.join(ROOT, relativePath),
renderReferencePage(record, manualSections),
"utf8",
);
}
fs.writeFileSync(path.join(ROOT, REFERENCE_INDEX_PATH), renderReferenceIndex(records), "utf8");
}
function readGeneratedDocs(records: PluginRecord[]) {
return [
[REFERENCE_INDEX_PATH, renderReferenceIndex(records)] satisfies [string, string],
...records.filter(hasGeneratedReferencePage).map((record) => {
const relativePath = path.join(REFERENCE_DIR, `${record.id}.md`);
return [
relativePath,
renderReferencePage(record, readManualReferenceSections(relativePath)),
] satisfies [string, string];
}),
];
}
function renderDocument(records: PluginRecord[]) {
const groups = {
core: records.filter((record) => record.status === "core"),
external: records.filter((record) => record.status === "external"),
source: records.filter((record) => record.status === "source"),
};
return `---
summary: "Generated inventory of OpenClaw plugins shipped in core, published externally, or kept source-only"
read_when:
- You are deciding whether a plugin ships in the core npm package or installs separately
- You are updating bundled plugin package metadata or release automation
- You need the canonical internal vs external plugin list
title: "Plugin inventory"
---
${GENERATED_NOTICE}
This page lists every OpenClaw plugin with its package, install route, and
description. Operators use it to find a plugin and to see whether that plugin
needs a separate install. Maintainers use it to check bundled plugin metadata
and release automation.
## Definitions
- **Core npm package:** built into the \`openclaw\` npm package and available without a separate plugin install.
- **Official external package:** OpenClaw-maintained plugin omitted from the core npm package, kept in this official inventory, and installed on demand through ClawHub and/or npm.
- **Source checkout only:** repo-local plugin omitted from published npm artifacts and not advertised as an installable package.
Source checkouts are different from npm installs: after \`pnpm install\`, bundled
plugins load from \`extensions/<id>\` so local edits and package-local workspace
dependencies are available.
## Install a plugin
Use the install route in each entry to decide whether install is needed. Plugins
that say \`included in OpenClaw\` are already present in the core package.
Official external packages need one install. Installation applies to the running
local Gateway without restarting it; start the Gateway if it was stopped.
For example, Discord is an official external package:
\`\`\`bash
openclaw plugins install @openclaw/discord
openclaw plugins inspect discord --runtime --json
\`\`\`
Ordinary bare package specs install from npm. Use \`clawhub:@openclaw/discord\`
or \`npm:@openclaw/discord\` when you need an explicit source. After install,
follow the plugin's setup doc, such as [Discord](/channels/discord), to add
credentials and channel config. See
[Manage plugins](/plugins/manage-plugins) for update, uninstall, and publishing
commands.
Each entry lists the package, distribution route, and description.
## Core npm package
${groups.core.length} plugins
${renderInventoryList(groups.core)}
## Official external packages
${groups.external.length} plugins
${renderInventoryList(groups.external)}
## Source checkout only
${groups.source.length} plugins
${renderInventoryList(groups.source)}
## How this page is built
OpenClaw generates this page from the top-level
\`extensions/*/openclaw.plugin.json\` manifests and the root npm package
\`files\` exclusions. Optional \`package.json\` metadata enriches package and
distribution details. Regenerate the page with:
\`\`\`bash
pnpm plugins:inventory:gen
\`\`\`
`;
}
function main(argv = process.argv.slice(2)) {
const [mode = "", ...args] = argv;
if (
!["--write", "--check", "--json"].includes(mode) ||
(mode === "--json"
? args.length !== 0 && (args.length !== 2 || args[0] !== "--commit")
: args.length !== 0)
) {
console.error(
"usage: node scripts/generate-plugin-inventory-doc.mts --write|--check|--json [--commit <SHA>]",
);
console.error("[plugin-inventory] FAILED (exit 2)");
process.exitCode = 2;
return;
}
if (mode === "--json") {
console.log(JSON.stringify(exportPluginInventory(ROOT, args[1]), null, 2));
return;
}
const write = mode === "--write";
const records = collectPluginRecords();
const next = renderDocument(records);
const docPath = path.join(ROOT, DOC_PATH);
if (write) {
fs.writeFileSync(docPath, next, "utf8");
writeGeneratedDocs(records);
return;
}
const current = fs.existsSync(docPath) ? fs.readFileSync(docPath, "utf8") : "";
if (current !== next) {
throw new Error(`${DOC_PATH} is stale. Run \`pnpm plugins:inventory:gen\`.`);
}
for (const [relativePath, expected] of readGeneratedDocs(records)) {
const fullPath = path.join(ROOT, relativePath);
const actual = fs.existsSync(fullPath) ? fs.readFileSync(fullPath, "utf8") : "";
if (actual !== expected) {
throw new Error(`${relativePath} is stale. Run \`pnpm plugins:inventory:gen\`.`);
}
}
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
console.error("[plugin-inventory] FAILED (exit 1)");
process.exitCode = 1;
}