import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { parseFrontmatterBlock } from "../markdown/frontmatter.js"; import { isPathInsideWithRealpath } from "../security/scan-paths.js"; import { CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, mergeBundlePathLists, normalizeBundlePathList, } from "./bundle-manifest.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; export type ClaudeBundleCommandSpec = { pluginId: string; rawName: string; description: string; promptTemplate: string; sourceFilePath: string; }; function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean { if (typeof value !== "string") { return fallback; } const normalized = value.trim().toLowerCase(); if (normalized === "true" || normalized === "yes" || normalized === "1") { return true; } if (normalized === "false" || normalized === "no" || normalized === "0") { return false; } return fallback; } function stripFrontmatter(content: string): string { const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); if (!normalized.startsWith("---")) { return normalized.trim(); } const endIndex = normalized.indexOf("\n---", 3); if (endIndex === -1) { return normalized.trim(); } return normalized.slice(endIndex + 4).trim(); } function readClaudeBundleManifest(rootDir: string): Record { const manifestPath = path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH); const opened = openBoundaryFileSync({ absolutePath: manifestPath, rootPath: rootDir, boundaryLabel: "plugin root", rejectHardlinks: true, }); if (!opened.ok) { return {}; } try { const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; return raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; } catch { return {}; } finally { fs.closeSync(opened.fd); } } 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() && entry.name.toLowerCase().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(rawName: string, promptTemplate: string): string { const firstLine = promptTemplate .split(/\r?\n/u) .map((line) => line.trim()) .find(Boolean); return firstLine || rawName; } function loadBundleCommandsFromRoot(params: { pluginId: string; commandRoot: string; }): ClaudeBundleCommandSpec[] { const entries: ClaudeBundleCommandSpec[] = []; for (const filePath of listMarkdownFilesRecursive(params.commandRoot)) { let raw: string; try { raw = fs.readFileSync(filePath, "utf-8"); } catch { continue; } const frontmatter = parseFrontmatterBlock(raw); if (parseFrontmatterBool(frontmatter["disable-model-invocation"], false)) { continue; } const promptTemplate = stripFrontmatter(raw); if (!promptTemplate) { continue; } const rawName = ( frontmatter.name?.trim() || toDefaultCommandName(params.commandRoot, filePath) ).trim(); if (!rawName) { continue; } const description = frontmatter.description?.trim() || toDefaultDescription(rawName, promptTemplate); entries.push({ pluginId: params.pluginId, rawName, description, promptTemplate, sourceFilePath: filePath, }); } return entries; } export function loadEnabledClaudeBundleCommands(params: { workspaceDir: string; cfg?: OpenClawConfig; }): ClaudeBundleCommandSpec[] { const registry = loadPluginManifestRegistry({ workspaceDir: params.workspaceDir, config: params.cfg, }); 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 enableState = resolveEffectiveEnableState({ id: record.id, origin: record.origin, config: normalizedPlugins, rootConfig: params.cfg, }); if (!enableState.enabled) { 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; }