diff --git a/CHANGELOG.md b/CHANGELOG.md index 3143ddda9..df8e24d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.clawd.bot ### Changes - Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). - Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07. +- Plugins: add LLM-free plugin slash commands and include them in `/commands`. (#1558) Thanks @Glucksberg. - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. - CLI: add live auth probes to `clawdbot models status` for per-profile verification. - CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. diff --git a/src/auto-reply/reply/commands-plugin.test.ts b/src/auto-reply/reply/commands-plugin.test.ts new file mode 100644 index 000000000..c19005c50 --- /dev/null +++ b/src/auto-reply/reply/commands-plugin.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; +import type { HandleCommandsParams } from "./commands-types.js"; +import { handlePluginCommand } from "./commands-plugin.js"; + +describe("handlePluginCommand", () => { + beforeEach(() => { + clearPluginCommands(); + }); + + it("skips plugin commands when text commands are disabled", async () => { + registerPluginCommand("plugin-core", { + name: "ping", + description: "Ping", + handler: () => ({ text: "pong" }), + }); + + const params = { + command: { + commandBodyNormalized: "/ping", + senderId: "user-1", + channel: "test", + isAuthorizedSender: true, + }, + cfg: {} as ClawdbotConfig, + } as HandleCommandsParams; + + const result = await handlePluginCommand(params, false); + expect(result).toBeNull(); + }); + + it("executes plugin commands when text commands are enabled", async () => { + registerPluginCommand("plugin-core", { + name: "ping", + description: "Ping", + handler: () => ({ text: "pong" }), + }); + + const params = { + command: { + commandBodyNormalized: "/ping", + senderId: "user-1", + channel: "test", + isAuthorizedSender: true, + }, + cfg: {} as ClawdbotConfig, + } as HandleCommandsParams; + + const result = await handlePluginCommand(params, true); + expect(result?.reply?.text).toBe("pong"); + }); +}); diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index 86a99e6bc..56849859b 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -15,8 +15,9 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js"; */ export const handlePluginCommand: CommandHandler = async ( params, - _allowTextCommands, + allowTextCommands, ): Promise => { + if (!allowTextCommands) return null; const { command, cfg } = params; // Try to match a plugin command diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 31b6b92ec..87717b5ee 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -4,9 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; import type { ClawdbotConfig } from "../config/config.js"; +import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js"; import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js"; afterEach(() => { + clearPluginCommands(); vi.restoreAllMocks(); }); @@ -423,6 +425,19 @@ describe("buildCommandsMessage", () => { ); expect(text).toContain("/demo_skill - Demo skill"); }); + + it("includes plugin commands when registered", () => { + registerPluginCommand("plugin-core", { + name: "plugstatus", + description: "Plugin status", + handler: () => ({ text: "ok" }), + }); + const text = buildCommandsMessage({ + commands: { config: false, debug: false }, + } as ClawdbotConfig); + expect(text).toContain("🔌 Plugin commands"); + expect(text).toContain("/plugstatus - Plugin status"); + }); }); describe("buildHelpMessage", () => { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index eaf2d20a8..982c0ee0c 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -22,6 +22,7 @@ import { } from "../utils/usage-format.js"; import { VERSION } from "../version.js"; import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js"; +import { listPluginCommands } from "../plugins/commands.js"; import type { SkillCommandSpec } from "../agents/skills.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js"; import type { MediaUnderstandingDecision } from "../media-understanding/types.js"; @@ -442,5 +443,12 @@ export function buildCommandsMessage( const scopeLabel = command.scope === "text" ? " (text-only)" : ""; lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`); } + const pluginCommands = listPluginCommands(); + if (pluginCommands.length > 0) { + lines.push("🔌 Plugin commands"); + for (const command of pluginCommands) { + lines.push(`/${command.name} - ${command.description}`); + } + } return lines.join("\n"); } diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts new file mode 100644 index 000000000..c10fdaee2 --- /dev/null +++ b/src/plugins/commands.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; +import { + clearPluginCommands, + executePluginCommand, + matchPluginCommand, + registerPluginCommand, + validateCommandName, +} from "./commands.js"; + +describe("validateCommandName", () => { + it("rejects reserved aliases from built-in commands", () => { + const error = validateCommandName("id"); + expect(error).toContain("reserved"); + }); +}); + +describe("plugin command registry", () => { + beforeEach(() => { + clearPluginCommands(); + }); + + it("normalizes command names for registration and matching", () => { + const result = registerPluginCommand("plugin-core", { + name: " ping ", + description: "Ping", + handler: () => ({ text: "pong" }), + }); + expect(result.ok).toBe(true); + + const match = matchPluginCommand("/ping"); + expect(match?.command.name).toBe("ping"); + }); + + it("blocks registration while a command is executing", async () => { + let nestedResult: { ok: boolean; error?: string } | undefined; + + registerPluginCommand("plugin-core", { + name: "outer", + description: "Outer", + handler: () => { + nestedResult = registerPluginCommand("plugin-inner", { + name: "inner", + description: "Inner", + handler: () => ({ text: "ok" }), + }); + return { text: "done" }; + }, + }); + + await executePluginCommand({ + command: matchPluginCommand("/outer")!.command, + senderId: "user-1", + channel: "test", + isAuthorizedSender: true, + commandBody: "/outer", + config: {} as ClawdbotConfig, + }); + + expect(nestedResult?.ok).toBe(false); + expect(nestedResult?.error).toContain("processing is in progress"); + }); +}); diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 68f232b39..28cabc21f 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -6,6 +6,7 @@ */ import type { ClawdbotConfig } from "../config/config.js"; +import { listChatCommands } from "../auto-reply/commands-registry.js"; import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js"; import { logVerbose } from "../globals.js"; @@ -16,53 +17,33 @@ type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & { // Registry of plugin commands const pluginCommands: Map = new Map(); -// Lock to prevent modifications during command execution -let registryLocked = false; +// Lock counter to prevent modifications during command execution +let registryLockCount = 0; // Maximum allowed length for command arguments (defense in depth) const MAX_ARGS_LENGTH = 4096; -/** - * Reserved command names that plugins cannot override. - * These are built-in commands from commands-registry.data.ts. - */ -const RESERVED_COMMANDS = new Set([ - // Core commands - "help", - "commands", - "status", - "whoami", - "context", - // Session management - "stop", - "restart", - "reset", - "new", - "compact", - // Configuration - "config", - "debug", - "allowlist", - "activation", - // Agent control - "skill", - "subagents", - "model", - "models", - "queue", - // Messaging - "send", - // Execution - "bash", - "exec", - // Mode toggles - "think", - "verbose", - "reasoning", - "elevated", - // Billing - "usage", -]); +let cachedReservedCommands: Set | null = null; + +function getReservedCommands(): Set { + if (cachedReservedCommands) return cachedReservedCommands; + const reserved = new Set(); + for (const command of listChatCommands()) { + if (command.nativeName) { + const normalized = command.nativeName.trim().toLowerCase(); + if (normalized) reserved.add(normalized); + } + for (const alias of command.textAliases ?? []) { + const trimmed = alias.trim(); + if (!trimmed) continue; + const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed; + const normalized = withoutSlash.trim().toLowerCase(); + if (normalized) reserved.add(normalized); + } + } + cachedReservedCommands = reserved; + return reserved; +} /** * Validate a command name. @@ -82,7 +63,7 @@ export function validateCommandName(name: string): string | null { } // Check reserved commands - if (RESERVED_COMMANDS.has(trimmed)) { + if (getReservedCommands().has(trimmed)) { return `Command name "${trimmed}" is reserved by a built-in command`; } @@ -103,7 +84,7 @@ export function registerPluginCommand( command: ClawdbotPluginCommandDefinition, ): CommandRegistrationResult { // Prevent registration while commands are being processed - if (registryLocked) { + if (registryLockCount > 0) { return { ok: false, error: "Cannot register commands while processing is in progress" }; } @@ -117,7 +98,8 @@ export function registerPluginCommand( return { ok: false, error: validationError }; } - const key = `/${command.name.toLowerCase()}`; + const normalizedName = command.name.trim(); + const key = `/${normalizedName.toLowerCase()}`; // Check for duplicate registration if (pluginCommands.has(key)) { @@ -128,7 +110,7 @@ export function registerPluginCommand( }; } - pluginCommands.set(key, { ...command, pluginId }); + pluginCommands.set(key, { ...command, name: normalizedName, pluginId }); logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); return { ok: true }; } @@ -139,6 +121,7 @@ export function registerPluginCommand( */ export function clearPluginCommands(): void { pluginCommands.clear(); + registryLockCount = 0; } /** @@ -190,12 +173,28 @@ function sanitizeArgs(args: string | undefined): string | undefined { if (!args) return undefined; // Enforce length limit - if (args.length > MAX_ARGS_LENGTH) { - return args.slice(0, MAX_ARGS_LENGTH); - } + const trimmed = args.length > MAX_ARGS_LENGTH ? args.slice(0, MAX_ARGS_LENGTH) : args; // Remove control characters (except newlines and tabs which may be intentional) - return args.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + let needsSanitize = false; + for (let i = 0; i < trimmed.length; i += 1) { + const code = trimmed.charCodeAt(i); + if (code === 0x09 || code === 0x0a) continue; + if (code < 0x20 || code === 0x7f) { + needsSanitize = true; + break; + } + } + if (!needsSanitize) return trimmed; + + let sanitized = ""; + for (let i = 0; i < trimmed.length; i += 1) { + const code = trimmed.charCodeAt(i); + if (code === 0x09 || code === 0x0a || (code >= 0x20 && code !== 0x7f)) { + sanitized += trimmed[i]; + } + } + return sanitized; } /** @@ -237,7 +236,7 @@ export async function executePluginCommand(params: { }; // Lock registry during execution to prevent concurrent modifications - registryLocked = true; + registryLockCount += 1; try { const result = await command.handler(ctx); logVerbose( @@ -250,7 +249,7 @@ export async function executePluginCommand(params: { // Don't leak internal error details - return a safe generic message return { text: "⚠️ Command failed. Please try again later." }; } finally { - registryLocked = false; + registryLockCount = Math.max(0, registryLockCount - 1); } } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 048e490f3..64cb5e88c 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -376,7 +376,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } // Register with the plugin command system (validates name and checks for duplicates) - const result = registerPluginCommand(record.id, command); + const normalizedCommand = { ...command, name }; + const result = registerPluginCommand(record.id, normalizedCommand); if (!result.ok) { pushDiagnostic({ level: "error", @@ -390,7 +391,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.commands.push(name); registry.commands.push({ pluginId: record.id, - command, + command: normalizedCommand, source: record.source, }); };