diff --git a/src/plugins/command-registration.ts b/src/plugins/command-registration.ts new file mode 100644 index 00000000000..31de597c061 --- /dev/null +++ b/src/plugins/command-registration.ts @@ -0,0 +1,187 @@ +import { logVerbose } from "../globals.js"; +import { + clearPluginCommands, + clearPluginCommandsForPlugin, + getPluginCommandSpecs, + isPluginCommandRegistryLocked, + pluginCommands, + type RegisteredPluginCommand, +} from "./command-registry-state.js"; +import type { OpenClawPluginCommandDefinition } from "./types.js"; + +/** + * Reserved command names that plugins cannot override (built-in commands). + * + * Constructed lazily inside validateCommandName to avoid TDZ errors: the + * bundler can place this module's body after call sites within the same + * output chunk, so any module-level const/let would be uninitialized when + * first accessed during plugin registration. + */ +// eslint-disable-next-line no-var -- var avoids TDZ when bundler reorders module bodies in a chunk +var reservedCommands: Set | undefined; + +export type CommandRegistrationResult = { + ok: boolean; + error?: string; +}; + +export function validateCommandName(name: string): string | null { + const trimmed = name.trim().toLowerCase(); + + if (!trimmed) { + return "Command name cannot be empty"; + } + + // Must start with a letter, contain only letters, numbers, hyphens, underscores + // Note: trimmed is already lowercased, so no need for /i flag + if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) { + return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; + } + + reservedCommands ??= new Set([ + "help", + "commands", + "status", + "whoami", + "context", + "btw", + "stop", + "restart", + "reset", + "new", + "compact", + "config", + "debug", + "allowlist", + "activation", + "skill", + "subagents", + "kill", + "steer", + "tell", + "model", + "models", + "queue", + "send", + "bash", + "exec", + "think", + "verbose", + "reasoning", + "elevated", + "usage", + ]); + + if (reservedCommands.has(trimmed)) { + return `Command name "${trimmed}" is reserved by a built-in command`; + } + + return null; +} + +/** + * Validate a plugin command definition without registering it. + * Returns an error message if invalid, or null if valid. + * Shared by both the global registration path and snapshot (non-activating) loads. + */ +export function validatePluginCommandDefinition( + command: OpenClawPluginCommandDefinition, +): string | null { + if (typeof command.handler !== "function") { + return "Command handler must be a function"; + } + if (typeof command.name !== "string") { + return "Command name must be a string"; + } + if (typeof command.description !== "string") { + return "Command description must be a string"; + } + if (!command.description.trim()) { + return "Command description cannot be empty"; + } + const nameError = validateCommandName(command.name.trim()); + if (nameError) { + return nameError; + } + for (const [label, alias] of Object.entries(command.nativeNames ?? {})) { + if (typeof alias !== "string") { + continue; + } + const aliasError = validateCommandName(alias.trim()); + if (aliasError) { + return `Native command alias "${label}" invalid: ${aliasError}`; + } + } + return null; +} + +export function listPluginInvocationKeys(command: OpenClawPluginCommandDefinition): string[] { + const keys = new Set(); + const push = (value: string | undefined) => { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return; + } + keys.add(`/${normalized}`); + }; + + push(command.name); + push(command.nativeNames?.default); + push(command.nativeNames?.telegram); + push(command.nativeNames?.discord); + + return [...keys]; +} + +export function registerPluginCommand( + pluginId: string, + command: OpenClawPluginCommandDefinition, + opts?: { pluginName?: string; pluginRoot?: string }, +): CommandRegistrationResult { + // Prevent registration while commands are being processed + if (isPluginCommandRegistryLocked()) { + return { ok: false, error: "Cannot register commands while processing is in progress" }; + } + + const definitionError = validatePluginCommandDefinition(command); + if (definitionError) { + return { ok: false, error: definitionError }; + } + + const name = command.name.trim(); + const description = command.description.trim(); + const normalizedCommand = { + ...command, + name, + description, + }; + const invocationKeys = listPluginInvocationKeys(normalizedCommand); + const key = `/${name.toLowerCase()}`; + + // Check for duplicate registration + for (const invocationKey of invocationKeys) { + const existing = + pluginCommands.get(invocationKey) ?? + Array.from(pluginCommands.values()).find((candidate) => + listPluginInvocationKeys(candidate).includes(invocationKey), + ); + if (existing) { + return { + ok: false, + error: `Command "${invocationKey.slice(1)}" already registered by plugin "${existing.pluginId}"`, + }; + } + } + + pluginCommands.set(key, { + ...normalizedCommand, + pluginId, + pluginName: opts?.pluginName, + pluginRoot: opts?.pluginRoot, + }); + logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); + return { ok: true }; +} + +export { clearPluginCommands, clearPluginCommandsForPlugin, getPluginCommandSpecs }; +export type { RegisteredPluginCommand }; diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index dee74852eac..d22c05af54f 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -12,7 +12,12 @@ import { clearPluginCommands, clearPluginCommandsForPlugin, getPluginCommandSpecs, - isPluginCommandRegistryLocked, + listPluginInvocationKeys, + registerPluginCommand, + validateCommandName, + validatePluginCommandDefinition, +} from "./command-registration.js"; +import { pluginCommands, setPluginCommandRegistryLocked, type RegisteredPluginCommand, @@ -31,190 +36,15 @@ import type { // Maximum allowed length for command arguments (defense in depth) const MAX_ARGS_LENGTH = 4096; -/** - * Reserved command names that plugins cannot override (built-in commands). - * - * Constructed lazily inside validateCommandName to avoid TDZ errors: the - * bundler can place this module's body after call sites within the same - * output chunk, so any module-level const/let would be uninitialized when - * first accessed during plugin registration. - */ -// eslint-disable-next-line no-var -- var avoids TDZ when bundler reorders module bodies in a chunk -var reservedCommands: Set | undefined; - -/** - * Validate a command name. - * Returns an error message if invalid, or null if valid. - */ -export function validateCommandName(name: string): string | null { - const trimmed = name.trim().toLowerCase(); - - if (!trimmed) { - return "Command name cannot be empty"; - } - - // Must start with a letter, contain only letters, numbers, hyphens, underscores - // Note: trimmed is already lowercased, so no need for /i flag - if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) { - return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; - } - - reservedCommands ??= new Set([ - "help", - "commands", - "status", - "whoami", - "context", - "btw", - "stop", - "restart", - "reset", - "new", - "compact", - "config", - "debug", - "allowlist", - "activation", - "skill", - "subagents", - "kill", - "steer", - "tell", - "model", - "models", - "queue", - "send", - "bash", - "exec", - "think", - "verbose", - "reasoning", - "elevated", - "usage", - ]); - - if (reservedCommands.has(trimmed)) { - return `Command name "${trimmed}" is reserved by a built-in command`; - } - - return null; -} - -export type CommandRegistrationResult = { - ok: boolean; - error?: string; +export { + clearPluginCommands, + clearPluginCommandsForPlugin, + getPluginCommandSpecs, + registerPluginCommand, + validateCommandName, + validatePluginCommandDefinition, }; -/** - * Validate a plugin command definition without registering it. - * Returns an error message if invalid, or null if valid. - * Shared by both the global registration path and snapshot (non-activating) loads. - */ -export function validatePluginCommandDefinition( - command: OpenClawPluginCommandDefinition, -): string | null { - if (typeof command.handler !== "function") { - return "Command handler must be a function"; - } - if (typeof command.name !== "string") { - return "Command name must be a string"; - } - if (typeof command.description !== "string") { - return "Command description must be a string"; - } - if (!command.description.trim()) { - return "Command description cannot be empty"; - } - const nameError = validateCommandName(command.name.trim()); - if (nameError) { - return nameError; - } - for (const [label, alias] of Object.entries(command.nativeNames ?? {})) { - if (typeof alias !== "string") { - continue; - } - const aliasError = validateCommandName(alias.trim()); - if (aliasError) { - return `Native command alias "${label}" invalid: ${aliasError}`; - } - } - return null; -} - -function listPluginInvocationKeys(command: OpenClawPluginCommandDefinition): string[] { - const keys = new Set(); - const push = (value: string | undefined) => { - const normalized = value?.trim().toLowerCase(); - if (!normalized) { - return; - } - keys.add(`/${normalized}`); - }; - - push(command.name); - push(command.nativeNames?.default); - push(command.nativeNames?.telegram); - push(command.nativeNames?.discord); - - return [...keys]; -} - -/** - * Register a plugin command. - * Returns an error if the command name is invalid or reserved. - */ -export function registerPluginCommand( - pluginId: string, - command: OpenClawPluginCommandDefinition, - opts?: { pluginName?: string; pluginRoot?: string }, -): CommandRegistrationResult { - // Prevent registration while commands are being processed - if (isPluginCommandRegistryLocked()) { - return { ok: false, error: "Cannot register commands while processing is in progress" }; - } - - const definitionError = validatePluginCommandDefinition(command); - if (definitionError) { - return { ok: false, error: definitionError }; - } - - const name = command.name.trim(); - const description = command.description.trim(); - const normalizedCommand = { - ...command, - name, - description, - }; - const invocationKeys = listPluginInvocationKeys(normalizedCommand); - const key = `/${name.toLowerCase()}`; - - // Check for duplicate registration - for (const invocationKey of invocationKeys) { - const existing = - pluginCommands.get(invocationKey) ?? - Array.from(pluginCommands.values()).find((candidate) => - listPluginInvocationKeys(candidate).includes(invocationKey), - ); - if (existing) { - return { - ok: false, - error: `Command "${invocationKey.slice(1)}" already registered by plugin "${existing.pluginId}"`, - }; - } - } - - pluginCommands.set(key, { - ...normalizedCommand, - pluginId, - pluginName: opts?.pluginName, - pluginRoot: opts?.pluginRoot, - }); - logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); - return { ok: true }; -} - -export { clearPluginCommands, clearPluginCommandsForPlugin, getPluginCommandSpecs }; - /** * Check if a command body matches a registered plugin command. * Returns the command definition and parsed args if matched. diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 04154435d30..10f7c1f520b 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -10,7 +10,7 @@ import { registerInternalHook } from "../hooks/internal-hooks.js"; import type { HookEntry } from "../hooks/types.js"; import { registerMemoryPromptSection } from "../memory/prompt-section.js"; import { resolveUserPath } from "../utils.js"; -import { registerPluginCommand, validatePluginCommandDefinition } from "./commands.js"; +import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js"; import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { registerPluginInteractiveHandler } from "./interactive.js";