diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index c5c8c2077d9..2e9b8f808fb 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -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", () => { diff --git a/src/agents/skills/types.ts b/src/agents/skills/types.ts index e3eef67a2fd..aef4dd20e86 100644 --- a/src/agents/skills/types.ts +++ b/src/agents/skills/types.ts @@ -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 = { diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index 80624a30139..b53cbf5cd3f 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -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; } diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index 36b5910ecae..7d5707d893f 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -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(); + return { + ...actual, + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), + }; +}); + // Import after mocks. const { handleInlineActions } = await import("./get-reply-inline-actions.js"); type HandleInlineActionsInput = Parameters[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", + }), + }), + ); + }); }); diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 44d006a5ccb..bb8f37a862b 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -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; diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts new file mode 100644 index 00000000000..a5b2855eaf3 --- /dev/null +++ b/src/plugins/bundle-commands.test.ts @@ -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(); + } + }); +}); diff --git a/src/plugins/bundle-commands.ts b/src/plugins/bundle-commands.ts new file mode 100644 index 00000000000..6797626f128 --- /dev/null +++ b/src/plugins/bundle-commands.ts @@ -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 { + 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; +}