mirror of https://github.com/openclaw/openclaw.git
feat(plugins): register claude bundle commands natively
This commit is contained in:
parent
2b68d20ab3
commit
265386cd6b
|
|
@ -154,6 +154,55 @@ describe("buildWorkspaceSkillCommandSpecs", () => {
|
|||
const cmd = commands.find((entry) => entry.skillName === "tool-dispatch");
|
||||
expect(cmd?.dispatch).toEqual({ kind: "tool", toolName: "sessions_send", argMode: "raw" });
|
||||
});
|
||||
|
||||
it("includes enabled Claude bundle markdown commands as native OpenClaw slash commands", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const pluginRoot = path.join(tempHome!.home, ".openclaw", "extensions", "compound-bundle");
|
||||
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
||||
await fs.mkdir(path.join(pluginRoot, "commands"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
|
||||
`${JSON.stringify({ name: "compound-bundle" }, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, "commands", "workflows-review.md"),
|
||||
[
|
||||
"---",
|
||||
"name: workflows:review",
|
||||
"description: Review code with a structured checklist",
|
||||
"---",
|
||||
"Review the branch carefully.",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, {
|
||||
...resolveTestSkillDirs(workspaceDir),
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"compound-bundle": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "workflows_review",
|
||||
skillName: "workflows:review",
|
||||
description: "Review code with a structured checklist",
|
||||
promptTemplate: "Review the branch carefully.",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
commands.find((entry) => entry.skillName === "workflows:review")?.sourceFilePath,
|
||||
).toContain("/.openclaw/extensions/compound-bundle/commands/workflows-review.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkspaceSkillsPrompt", () => {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ export type SkillCommandSpec = {
|
|||
description: string;
|
||||
/** Optional deterministic dispatch behavior for this command. */
|
||||
dispatch?: SkillCommandDispatchSpec;
|
||||
/** Native prompt template used by Claude-bundle command markdown files. */
|
||||
promptTemplate?: string;
|
||||
/** Source markdown path for bundle-backed commands. */
|
||||
sourceFilePath?: string;
|
||||
};
|
||||
|
||||
export type SkillsInstallPreferences = {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { loadEnabledClaudeBundleCommands } from "../../plugins/bundle-commands.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
|
||||
import { resolveSandboxPath } from "../sandbox-paths.js";
|
||||
import { resolveBundledSkillsDir } from "./bundled-dir.js";
|
||||
|
|
@ -931,5 +932,40 @@ export function buildWorkspaceSkillCommandSpecs(
|
|||
...(dispatch ? { dispatch } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const bundleCommands = loadEnabledClaudeBundleCommands({
|
||||
workspaceDir,
|
||||
cfg: opts?.config,
|
||||
});
|
||||
for (const entry of bundleCommands) {
|
||||
const base = sanitizeSkillCommandName(entry.rawName);
|
||||
if (base !== entry.rawName) {
|
||||
debugSkillCommandOnce(
|
||||
`bundle-sanitize:${entry.rawName}:${base}`,
|
||||
`Sanitized bundle command name "${entry.rawName}" to "/${base}".`,
|
||||
{ rawName: entry.rawName, sanitized: `/${base}` },
|
||||
);
|
||||
}
|
||||
const unique = resolveUniqueSkillCommandName(base, used);
|
||||
if (unique !== base) {
|
||||
debugSkillCommandOnce(
|
||||
`bundle-dedupe:${entry.rawName}:${unique}`,
|
||||
`De-duplicated bundle command name for "${entry.rawName}" to "/${unique}".`,
|
||||
{ rawName: entry.rawName, deduped: `/${unique}` },
|
||||
);
|
||||
}
|
||||
used.add(unique.toLowerCase());
|
||||
const description =
|
||||
entry.description.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH
|
||||
? entry.description.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…"
|
||||
: entry.description;
|
||||
specs.push({
|
||||
name: unique,
|
||||
skillName: entry.rawName,
|
||||
description,
|
||||
promptTemplate: entry.promptTemplate,
|
||||
sourceFilePath: entry.sourceFilePath,
|
||||
});
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SkillCommandSpec } from "../../agents/skills.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import { clearInlineDirectives } from "./get-reply-directives-utils.js";
|
||||
|
|
@ -6,6 +7,7 @@ import { buildTestCtx } from "./test-ctx.js";
|
|||
import type { TypingController } from "./typing.js";
|
||||
|
||||
const handleCommandsMock = vi.fn();
|
||||
const getChannelPluginMock = vi.fn();
|
||||
|
||||
vi.mock("./commands.js", () => ({
|
||||
handleCommands: (...args: unknown[]) => handleCommandsMock(...args),
|
||||
|
|
@ -13,6 +15,14 @@ vi.mock("./commands.js", () => ({
|
|||
buildCommandContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../channels/plugins/index.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks.
|
||||
const { handleInlineActions } = await import("./get-reply-inline-actions.js");
|
||||
type HandleInlineActionsInput = Parameters<typeof handleInlineActions>[0];
|
||||
|
|
@ -100,6 +110,11 @@ async function expectInlineActionSkipped(params: {
|
|||
describe("handleInlineActions", () => {
|
||||
beforeEach(() => {
|
||||
handleCommandsMock.mockReset();
|
||||
handleCommandsMock.mockResolvedValue({ shouldContinue: true, reply: undefined });
|
||||
getChannelPluginMock.mockReset();
|
||||
getChannelPluginMock.mockImplementation((channelId?: string) =>
|
||||
channelId === "whatsapp" ? { commands: { skipWhenConfigEmpty: true } } : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips whatsapp replies when config is empty and From !== To", async () => {
|
||||
|
|
@ -222,4 +237,52 @@ describe("handleInlineActions", () => {
|
|||
expect(sessionStore["s:main"]?.abortCutoffTimestamp).toBeUndefined();
|
||||
expect(handleCommandsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rewrites Claude bundle markdown commands into a native agent prompt", async () => {
|
||||
const typing = createTypingController();
|
||||
handleCommandsMock.mockResolvedValue({ shouldContinue: false, reply: { text: "done" } });
|
||||
const ctx = buildTestCtx({
|
||||
Body: "/office_hours build me a deployment plan",
|
||||
CommandBody: "/office_hours build me a deployment plan",
|
||||
});
|
||||
const skillCommands: SkillCommandSpec[] = [
|
||||
{
|
||||
name: "office_hours",
|
||||
skillName: "office-hours",
|
||||
description: "Office hours",
|
||||
promptTemplate: "Act as an engineering advisor.\n\nFocus on:\n$ARGUMENTS",
|
||||
sourceFilePath: "/tmp/plugin/commands/office-hours.md",
|
||||
},
|
||||
];
|
||||
|
||||
const result = await handleInlineActions(
|
||||
createHandleInlineActionsInput({
|
||||
ctx,
|
||||
typing,
|
||||
cleanedBody: "/office_hours build me a deployment plan",
|
||||
command: {
|
||||
isAuthorizedSender: true,
|
||||
rawBodyNormalized: "/office_hours build me a deployment plan",
|
||||
commandBodyNormalized: "/office_hours build me a deployment plan",
|
||||
},
|
||||
overrides: {
|
||||
allowTextCommands: true,
|
||||
cfg: { commands: { text: true } },
|
||||
skillCommands,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ kind: "reply", reply: { text: "done" } });
|
||||
expect(ctx.Body).toBe(
|
||||
"Act as an engineering advisor.\n\nFocus on:\nbuild me a deployment plan",
|
||||
);
|
||||
expect(handleCommandsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
Body: "Act as an engineering advisor.\n\nFocus on:\nbuild me a deployment plan",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,6 +61,17 @@ function resolveSlashCommandName(commandBodyNormalized: string): string | null {
|
|||
return name ? name : null;
|
||||
}
|
||||
|
||||
function expandBundleCommandPromptTemplate(template: string, args?: string): string {
|
||||
const normalizedArgs = args?.trim() || "";
|
||||
const rendered = template.includes("$ARGUMENTS")
|
||||
? template.replaceAll("$ARGUMENTS", normalizedArgs)
|
||||
: template;
|
||||
if (!normalizedArgs || template.includes("$ARGUMENTS")) {
|
||||
return rendered.trim();
|
||||
}
|
||||
return `${rendered.trim()}\n\nUser input:\n${normalizedArgs}`;
|
||||
}
|
||||
|
||||
export type InlineActionResult =
|
||||
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
|
||||
| {
|
||||
|
|
@ -248,11 +259,17 @@ export async function handleInlineActions(params: {
|
|||
}
|
||||
}
|
||||
|
||||
const promptParts = [
|
||||
`Use the "${skillInvocation.command.skillName}" skill for this request.`,
|
||||
skillInvocation.args ? `User input:\n${skillInvocation.args}` : null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const rewrittenBody = promptParts.join("\n\n");
|
||||
const rewrittenBody = skillInvocation.command.promptTemplate
|
||||
? expandBundleCommandPromptTemplate(
|
||||
skillInvocation.command.promptTemplate,
|
||||
skillInvocation.args,
|
||||
)
|
||||
: [
|
||||
`Use the "${skillInvocation.command.skillName}" skill for this request.`,
|
||||
skillInvocation.args ? `User input:\n${skillInvocation.args}` : null,
|
||||
]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join("\n\n");
|
||||
ctx.Body = rewrittenBody;
|
||||
ctx.BodyForAgent = rewrittenBody;
|
||||
sessionCtx.Body = rewrittenBody;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { loadEnabledClaudeBundleCommands } from "./bundle-commands.js";
|
||||
import { createBundleMcpTempHarness } from "./bundle-mcp.test-support.js";
|
||||
|
||||
const tempHarness = createBundleMcpTempHarness();
|
||||
|
||||
afterEach(async () => {
|
||||
await tempHarness.cleanup();
|
||||
});
|
||||
|
||||
describe("loadEnabledClaudeBundleCommands", () => {
|
||||
it("loads enabled Claude bundle markdown commands and skips disabled-model-invocation entries", async () => {
|
||||
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
|
||||
try {
|
||||
const homeDir = await tempHarness.createTempDir("openclaw-bundle-commands-home-");
|
||||
const workspaceDir = await tempHarness.createTempDir("openclaw-bundle-commands-workspace-");
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "compound-bundle");
|
||||
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
||||
await fs.mkdir(path.join(pluginRoot, "commands", "workflows"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
|
||||
`${JSON.stringify({ name: "compound-bundle" }, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, "commands", "office-hours.md"),
|
||||
[
|
||||
"---",
|
||||
"description: Help with scoping and architecture",
|
||||
"---",
|
||||
"Give direct engineering advice.",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, "commands", "workflows", "review.md"),
|
||||
[
|
||||
"---",
|
||||
"name: workflows:review",
|
||||
"description: Run a structured review",
|
||||
"---",
|
||||
"Review the code. $ARGUMENTS",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, "commands", "disabled.md"),
|
||||
["---", "disable-model-invocation: true", "---", "Do not load me.", ""].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const commands = loadEnabledClaudeBundleCommands({
|
||||
workspaceDir,
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"compound-bundle": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: "compound-bundle",
|
||||
rawName: "office-hours",
|
||||
description: "Help with scoping and architecture",
|
||||
promptTemplate: "Give direct engineering advice.",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pluginId: "compound-bundle",
|
||||
rawName: "workflows:review",
|
||||
description: "Run a structured review",
|
||||
promptTemplate: "Review the code. $ARGUMENTS",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
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;
|
||||
}
|
||||
Loading…
Reference in New Issue