openclaw/scripts/generate-plugin-inventory-doc.mts
Peter Steinberger c432db8665
feat(slack-huddles): join Slack huddles as a signed-in Slack user (#159879)
Add an opt-in slack-huddles plugin that joins active Slack huddles as a
dedicated signed-in Slack user. Slack has no bot or app huddle API, so the
plugin drives the Slack web client in the OpenClaw Chrome profile on the
shared meeting runtime, with in-page audio capture and a virtual microphone
for agent, bidi, and transcribe modes, mirroring zoom-meetings and
teams-meetings.

Membership is proven only by Slack's in-huddle channel header on the
requested channel. Sessions are workspace-scoped, joins are always muted,
talk-back unmutes only after Slack reports the virtual input, and every
click rechecks live authority. The shared meeting status source gains
optional liveOwnershipSource and afterAudioRoutingSource hooks. They
recheck ownership after awaited device and sink work, and roll back only
this pass's effects. Absent hooks emit no code, so the generated Zoom and
Teams scripts are byte-identical.

The plugin is disabled by default and requires OpenClaw 2026.9.8 or newer,
the first release that can carry the ownership hook.

Release note: Add an opt-in Slack huddles plugin that joins active huddles
as a dedicated signed-in Slack user through the shared meeting runtime.
Requires OpenClaw 2026.9.8 or newer.
2026-09-28 17:34:03 -07:00

786 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.",
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;
}