feat(plugins): register claude bundle commands natively

This commit is contained in:
Vincent Koc 2026-03-21 08:12:23 -07:00
parent 2b68d20ab3
commit 265386cd6b
7 changed files with 472 additions and 5 deletions

View File

@ -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", () => {

View File

@ -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 = {

View File

@ -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;
}

View File

@ -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",
}),
}),
);
});
});

View File

@ -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;

View File

@ -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();
}
});
});

View File

@ -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;
}