// Bundles plugin command metadata for package output. import fs from "node:fs"; import path from "node:path"; import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { parseFrontmatterBlock, stripFrontmatterBlock, } from "../../packages/markdown-core/src/frontmatter.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { readRootJsonObjectSync } from "../infra/json-files.js"; import { readRegularFileSync } from "../infra/regular-file.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isPathInsideWithRealpath } from "../security/scan-paths.js"; import { parseFrontmatterBool } from "../shared/frontmatter.js"; import { CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, mergeBundlePathLists, normalizeBundlePathList, } from "./bundle-manifest.js"; import { hasExplicitPluginConfig, normalizePluginsConfig, resolveEffectivePluginActivationState, } from "./config-state.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry-contributions.js"; type ClaudeBundleCommandSpec = { pluginId: string; rawName: string; description: string; promptTemplate: string; sourceFilePath: string; }; const BUNDLE_COMMAND_MAX_BYTES = 1 * 1024 * 1024; const log = createSubsystemLogger("plugins/bundle-commands"); function readClaudeBundleManifest(rootDir: string): Record { const result = readRootJsonObjectSync({ rootDir, relativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, boundaryLabel: "plugin root", rejectHardlinks: true, }); return result.ok ? result.value : {}; } function resolveClaudeCommandRootDirs(rootDir: string): string[] { const raw = readClaudeBundleManifest(rootDir); const declared = normalizeBundlePathList(raw.commands); const defaults = fs.existsSync(path.join(rootDir, "commands")) ? ["commands"] : []; return mergeBundlePathLists(defaults, declared); } function listMarkdownFilesRecursive(rootDir: string): string[] { const pending = [rootDir]; const files: string[] = []; while (pending.length > 0) { const current = pending.pop(); if (!current) { continue; } let entries: fs.Dirent[]; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (entry.name.startsWith(".")) { continue; } const fullPath = path.join(current, entry.name); if (entry.isDirectory()) { pending.push(fullPath); continue; } if (entry.isFile() && normalizeOptionalLowercaseString(entry.name)?.endsWith(".md")) { files.push(fullPath); } } } return files.toSorted((a, b) => a.localeCompare(b)); } function toDefaultCommandName(rootDir: string, filePath: string): string { const relativePath = path.relative(rootDir, filePath); const withoutExt = relativePath.replace(/\.[^.]+$/u, ""); return withoutExt.split(path.sep).join(":"); } function toDefaultDescription(promptTemplate: string): string { // stripFrontmatterBlock already normalized line endings and trimmed the nonempty body. const lineEnd = promptTemplate.indexOf("\n"); return (lineEnd < 0 ? promptTemplate : promptTemplate.slice(0, lineEnd)).trimEnd(); } function loadBundleCommandsFromRoot(params: { pluginId: string; commandRoot: string; }): ClaudeBundleCommandSpec[] { const entries: ClaudeBundleCommandSpec[] = []; for (const filePath of listMarkdownFilesRecursive(params.commandRoot)) { let raw: string; try { raw = readRegularFileSync({ filePath, maxBytes: BUNDLE_COMMAND_MAX_BYTES }).buffer.toString( "utf-8", ); } catch (error) { log.warn(`skipping unreadable bundle command file ${filePath}: ${formatErrorMessage(error)}`); continue; } const frontmatter = parseFrontmatterBlock(raw); if (!parseFrontmatterBool(frontmatter["user-invocable"], true)) { continue; } const promptTemplate = stripFrontmatterBlock(raw); if (!promptTemplate) { continue; } const rawName = normalizeOptionalString(frontmatter.name) || toDefaultCommandName(params.commandRoot, filePath); if (!rawName) { continue; } const description = normalizeOptionalString(frontmatter.description) || toDefaultDescription(promptTemplate); entries.push({ pluginId: params.pluginId, rawName, description, promptTemplate, sourceFilePath: filePath, }); } return entries; } export function loadEnabledClaudeBundleCommands(params: { workspaceDir: string; cfg?: OpenClawConfig; }): ClaudeBundleCommandSpec[] { if (!hasExplicitPluginConfig(params.cfg?.plugins)) { return []; } const registry = loadPluginManifestRegistryForPluginRegistry({ workspaceDir: params.workspaceDir, config: params.cfg, includeDisabled: true, }); const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins); const commands: ClaudeBundleCommandSpec[] = []; for (const record of registry.plugins) { if ( record.format !== "bundle" || record.bundleFormat !== "claude" || !(record.bundleCapabilities ?? []).includes("commands") ) { continue; } const activationState = resolveEffectivePluginActivationState({ id: record.id, origin: record.origin, channelIds: record.channels, config: normalizedPlugins, rootConfig: params.cfg, }); if (!activationState.activated) { continue; } for (const relativeRoot of resolveClaudeCommandRootDirs(record.rootDir)) { const commandRoot = path.resolve(record.rootDir, relativeRoot); if (!fs.existsSync(commandRoot)) { continue; } if (!isPathInsideWithRealpath(record.rootDir, commandRoot, { requireRealpath: true })) { continue; } commands.push(...loadBundleCommandsFromRoot({ pluginId: record.id, commandRoot })); } } return commands; }