mirror of https://github.com/openclaw/openclaw.git
205 lines
6.0 KiB
TypeScript
205 lines
6.0 KiB
TypeScript
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<string, unknown> {
|
|
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<string, unknown>)
|
|
: {};
|
|
} 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;
|
|
}
|