From 4ee808dbcb74f82813d485b8bc97a17c86e3373d Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Fri, 23 Jan 2026 03:17:10 +0000 Subject: [PATCH] feat: add plugin command API for LLM-free auto-reply commands This adds a new `api.registerCommand()` method to the plugin API, allowing plugins to register slash commands that execute without invoking the AI agent. Features: - Plugin commands are processed before built-in commands and the agent - Commands can optionally require authorization - Commands can accept arguments - Async handlers are supported Use case: plugins can implement toggle commands (like /tts_on, /tts_off) that respond immediately without consuming LLM API calls. Co-Authored-By: Claude Opus 4.5 --- docs/plugin.md | 57 ++++++++ src/auto-reply/reply/commands-core.ts | 3 + src/auto-reply/reply/commands-plugin.ts | 46 +++++++ src/gateway/server/__tests__/test-utils.ts | 1 + src/gateway/test-helpers.mocks.ts | 1 + src/plugins/commands.ts | 151 +++++++++++++++++++++ src/plugins/loader.ts | 1 + src/plugins/registry.ts | 34 +++++ src/plugins/runtime.ts | 1 + src/plugins/types.ts | 59 ++++++++ src/test-utils/channel-plugins.ts | 1 + 11 files changed, 355 insertions(+) create mode 100644 src/auto-reply/reply/commands-plugin.ts create mode 100644 src/plugins/commands.ts diff --git a/docs/plugin.md b/docs/plugin.md index e954b8418..1715be072 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -62,6 +62,7 @@ Plugins can register: - Background services - Optional config validation - **Skills** (by listing `skills` directories in the plugin manifest) +- **Auto-reply commands** (execute without invoking the AI agent) Plugins run **in‑process** with the Gateway, so treat them as trusted code. Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). @@ -494,6 +495,62 @@ export default function (api) { } ``` +### Register auto-reply commands + +Plugins can register custom slash commands that execute **without invoking the +AI agent**. This is useful for toggle commands, status checks, or quick actions +that don't need LLM processing. + +```ts +export default function (api) { + api.registerCommand({ + name: "mystatus", + description: "Show plugin status", + handler: (ctx) => ({ + text: `Plugin is running! Channel: ${ctx.channel}`, + }), + }); +} +``` + +Command handler context: + +- `senderId`: The sender's ID (if available) +- `channel`: The channel where the command was sent +- `isAuthorizedSender`: Whether the sender is an authorized user +- `args`: Arguments passed after the command (if `acceptsArgs: true`) +- `commandBody`: The full command text +- `config`: The current Clawdbot config + +Command options: + +- `name`: Command name (without the leading `/`) +- `description`: Help text shown in command lists +- `acceptsArgs`: Whether the command accepts arguments (default: false) +- `requireAuth`: Whether to require authorized sender (default: false) +- `handler`: Function that returns `{ text: string }` (can be async) + +Example with authorization and arguments: + +```ts +api.registerCommand({ + name: "setmode", + description: "Set plugin mode", + acceptsArgs: true, + requireAuth: true, + handler: async (ctx) => { + const mode = ctx.args?.trim() || "default"; + await saveMode(mode); + return { text: `Mode set to: ${mode}` }; + }, +}); +``` + +Notes: +- Plugin commands are processed **before** built-in commands and the AI agent +- Commands are registered globally and work across all channels +- Command names are case-insensitive (`/MyStatus` matches `/mystatus`) + ### Register background services ```ts diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index f974dec74..ad39e198c 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -24,6 +24,7 @@ import { handleStopCommand, handleUsageCommand, } from "./commands-session.js"; +import { handlePluginCommand } from "./commands-plugin.js"; import type { CommandHandler, CommandHandlerResult, @@ -31,6 +32,8 @@ import type { } from "./commands-types.js"; const HANDLERS: CommandHandler[] = [ + // Plugin commands are processed first, before built-in commands + handlePluginCommand, handleBashCommand, handleActivationCommand, handleSendPolicyCommand, diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts new file mode 100644 index 000000000..e7b2f207a --- /dev/null +++ b/src/auto-reply/reply/commands-plugin.ts @@ -0,0 +1,46 @@ +/** + * Plugin Command Handler + * + * Handles commands registered by plugins, bypassing the LLM agent. + * This handler is called before built-in command handlers. + */ + +import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js"; +import type { CommandHandler, CommandHandlerResult } from "./commands-types.js"; + +/** + * Handle plugin-registered commands. + * Returns a result if a plugin command was matched and executed, + * or null to continue to the next handler. + */ +export const handlePluginCommand: CommandHandler = async ( + params, + _allowTextCommands, +): Promise => { + const { command, cfg } = params; + + // Try to match a plugin command + const match = matchPluginCommand(command.commandBodyNormalized); + if (!match) return null; + + // Execute the plugin command + const result = await executePluginCommand({ + command: match.command, + args: match.args, + senderId: command.senderId, + channel: command.channel, + isAuthorizedSender: command.isAuthorizedSender, + commandBody: command.commandBodyNormalized, + config: cfg, + }); + + if (result) { + return { + shouldContinue: false, + reply: { text: result.text }, + }; + } + + // Command was blocked (e.g., unauthorized) - don't continue to agent + return { shouldContinue: false }; +}; diff --git a/src/gateway/server/__tests__/test-utils.ts b/src/gateway/server/__tests__/test-utils.ts index 6aabd8b5d..697c9b73b 100644 --- a/src/gateway/server/__tests__/test-utils.ts +++ b/src/gateway/server/__tests__/test-utils.ts @@ -12,6 +12,7 @@ export const createTestRegistry = (overrides: Partial = {}): Plu httpHandlers: [], cliRegistrars: [], services: [], + commands: [], diagnostics: [], }; const merged = { ...base, ...overrides }; diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 5268bd459..2a402201a 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -140,6 +140,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ httpHandlers: [], cliRegistrars: [], services: [], + commands: [], diagnostics: [], }); diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts new file mode 100644 index 000000000..298dbd344 --- /dev/null +++ b/src/plugins/commands.ts @@ -0,0 +1,151 @@ +/** + * Plugin Command Registry + * + * Manages commands registered by plugins that bypass the LLM agent. + * These commands are processed before built-in commands and before agent invocation. + */ + +import type { ClawdbotConfig } from "../config/config.js"; +import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js"; +import { logVerbose } from "../globals.js"; + +type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & { + pluginId: string; +}; + +// Registry of plugin commands +const pluginCommands: Map = new Map(); + +/** + * Register a plugin command. + */ +export function registerPluginCommand( + pluginId: string, + command: ClawdbotPluginCommandDefinition, +): void { + const key = `/${command.name.toLowerCase()}`; + if (pluginCommands.has(key)) { + logVerbose( + `Plugin command ${key} already registered, overwriting with plugin ${pluginId}`, + ); + } + pluginCommands.set(key, { ...command, pluginId }); + logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); +} + +/** + * Clear all registered plugin commands. + * Called during plugin reload. + */ +export function clearPluginCommands(): void { + pluginCommands.clear(); +} + +/** + * Clear plugin commands for a specific plugin. + */ +export function clearPluginCommandsForPlugin(pluginId: string): void { + for (const [key, cmd] of pluginCommands.entries()) { + if (cmd.pluginId === pluginId) { + pluginCommands.delete(key); + } + } +} + +/** + * Check if a command body matches a registered plugin command. + * Returns the command definition and parsed args if matched. + */ +export function matchPluginCommand( + commandBody: string, +): { command: RegisteredPluginCommand; args?: string } | null { + const trimmed = commandBody.trim(); + if (!trimmed.startsWith("/")) return null; + + // Extract command name and args + const spaceIndex = trimmed.indexOf(" "); + const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex); + const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim(); + + const key = commandName.toLowerCase(); + const command = pluginCommands.get(key); + + if (!command) return null; + + // If command doesn't accept args but args were provided, don't match + if (args && !command.acceptsArgs) return null; + + return { command, args: args || undefined }; +} + +/** + * Execute a plugin command handler. + */ +export async function executePluginCommand(params: { + command: RegisteredPluginCommand; + args?: string; + senderId?: string; + channel: string; + isAuthorizedSender: boolean; + commandBody: string; + config: ClawdbotConfig; +}): Promise<{ text: string } | null> { + const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = + params; + + // Check authorization + const requireAuth = command.requireAuth !== false; // Default to true + if (requireAuth && !isAuthorizedSender) { + logVerbose( + `Plugin command /${command.name} blocked: unauthorized sender ${senderId || ""}`, + ); + return null; // Silently ignore unauthorized commands + } + + const ctx: PluginCommandContext = { + senderId, + channel, + isAuthorizedSender, + args, + commandBody, + config, + }; + + try { + const result = await command.handler(ctx); + return { text: result.text }; + } catch (err) { + const error = err as Error; + logVerbose(`Plugin command /${command.name} error: ${error.message}`); + return { text: `⚠️ Command failed: ${error.message}` }; + } +} + +/** + * List all registered plugin commands. + * Used for /help and /commands output. + */ +export function listPluginCommands(): Array<{ + name: string; + description: string; + pluginId: string; +}> { + return Array.from(pluginCommands.values()).map((cmd) => ({ + name: cmd.name, + description: cmd.description, + pluginId: cmd.pluginId, + })); +} + +/** + * Get plugin command specs for native command registration (e.g., Telegram). + */ +export function getPluginCommandSpecs(): Array<{ + name: string; + description: string; +}> { + return Array.from(pluginCommands.values()).map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })); +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 1406e5ef5..14ba1740a 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -147,6 +147,7 @@ function createPluginRecord(params: { gatewayMethods: [], cliCommands: [], services: [], + commands: [], httpHandlers: 0, hookCount: 0, configSchema: params.configSchema, diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index f1fc64c0d..870a345c3 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -11,6 +11,7 @@ import type { ClawdbotPluginApi, ClawdbotPluginChannelRegistration, ClawdbotPluginCliRegistrar, + ClawdbotPluginCommandDefinition, ClawdbotPluginHttpHandler, ClawdbotPluginHookOptions, ProviderPlugin, @@ -26,6 +27,7 @@ import type { PluginHookHandlerMap, PluginHookRegistration as TypedPluginHookRegistration, } from "./types.js"; +import { registerPluginCommand } from "./commands.js"; import type { PluginRuntime } from "./runtime/types.js"; import type { HookEntry } from "../hooks/types.js"; import path from "node:path"; @@ -77,6 +79,12 @@ export type PluginServiceRegistration = { source: string; }; +export type PluginCommandRegistration = { + pluginId: string; + command: ClawdbotPluginCommandDefinition; + source: string; +}; + export type PluginRecord = { id: string; name: string; @@ -96,6 +104,7 @@ export type PluginRecord = { gatewayMethods: string[]; cliCommands: string[]; services: string[]; + commands: string[]; httpHandlers: number; hookCount: number; configSchema: boolean; @@ -114,6 +123,7 @@ export type PluginRegistry = { httpHandlers: PluginHttpRegistration[]; cliRegistrars: PluginCliRegistration[]; services: PluginServiceRegistration[]; + commands: PluginCommandRegistration[]; diagnostics: PluginDiagnostic[]; }; @@ -135,6 +145,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { httpHandlers: [], cliRegistrars: [], services: [], + commands: [], diagnostics: [], }; const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {})); @@ -352,6 +363,27 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerCommand = (record: PluginRecord, command: ClawdbotPluginCommandDefinition) => { + const name = command.name.trim(); + if (!name) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "command registration missing name", + }); + return; + } + record.commands.push(name); + registry.commands.push({ + pluginId: record.id, + command, + source: record.source, + }); + // Register with the plugin command system + registerPluginCommand(record.id, command); + }; + const registerTypedHook = ( record: PluginRecord, hookName: K, @@ -401,6 +433,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), + registerCommand: (command) => registerCommand(record, command), resolvePath: (input: string) => resolveUserPath(input), on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts), }; @@ -416,6 +449,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerGatewayMethod, registerCli, registerService, + registerCommand, registerHook, registerTypedHook, }; diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 0553717d7..0da06ae63 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -11,6 +11,7 @@ const createEmptyRegistry = (): PluginRegistry => ({ httpHandlers: [], cliRegistrars: [], services: [], + commands: [], diagnostics: [], }); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index d13f6c944..ea7a392f2 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -129,6 +129,59 @@ export type ClawdbotPluginGatewayMethod = { handler: GatewayRequestHandler; }; +// ============================================================================= +// Plugin Commands +// ============================================================================= + +/** + * Context passed to plugin command handlers. + */ +export type PluginCommandContext = { + /** The sender's identifier (e.g., Telegram user ID) */ + senderId?: string; + /** The channel/surface (e.g., "telegram", "discord") */ + channel: string; + /** Whether the sender is on the allowlist */ + isAuthorizedSender: boolean; + /** Raw command arguments after the command name */ + args?: string; + /** The full normalized command body */ + commandBody: string; + /** Current clawdbot configuration */ + config: ClawdbotConfig; +}; + +/** + * Result returned by a plugin command handler. + */ +export type PluginCommandResult = { + /** Text response to send back to the user */ + text: string; +}; + +/** + * Handler function for plugin commands. + */ +export type PluginCommandHandler = ( + ctx: PluginCommandContext, +) => PluginCommandResult | Promise; + +/** + * Definition for a plugin-registered command. + */ +export type ClawdbotPluginCommandDefinition = { + /** Command name without leading slash (e.g., "tts_on") */ + name: string; + /** Description shown in /help and command menus */ + description: string; + /** Whether this command accepts arguments */ + acceptsArgs?: boolean; + /** Whether only authorized senders can use this command (default: true) */ + requireAuth?: boolean; + /** The handler function */ + handler: PluginCommandHandler; +}; + export type ClawdbotPluginHttpHandler = ( req: IncomingMessage, res: ServerResponse, @@ -201,6 +254,12 @@ export type ClawdbotPluginApi = { registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerService: (service: ClawdbotPluginService) => void; registerProvider: (provider: ProviderPlugin) => void; + /** + * Register a custom command that bypasses the LLM agent. + * Plugin commands are processed before built-in commands and before agent invocation. + * Use this for simple state-toggling or status commands that don't need AI reasoning. + */ + registerCommand: (command: ClawdbotPluginCommandDefinition) => void; resolvePath: (input: string) => string; /** Register a lifecycle hook handler */ on: ( diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 525369abb..6bac4cfc2 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -19,6 +19,7 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P httpHandlers: [], cliRegistrars: [], services: [], + commands: [], diagnostics: [], });