From cf1e0d743c2445de3e2217ce0a7021bd22288806 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:53:24 +0100 Subject: [PATCH] fix: harden slash command registry --- CHANGELOG.md | 1 + src/auto-reply/command-detection.test.ts | 3 + src/auto-reply/commands-registry.test.ts | 3 + src/auto-reply/commands-registry.ts | 110 +++++++++++++++++++---- src/auto-reply/status.test.ts | 6 ++ src/auto-reply/status.ts | 7 +- 6 files changed, 112 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af8e12d2..5f08ad68f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow - Commands: accept /models as an alias for /model. - Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp +- Commands: harden slash command registry and list text-only commands in `/commands`. - Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete - Debugging: add raw model stream logging flags and document gateway watch mode. - Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-detection.test.ts index e21d35d65..fc3c22973 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-detection.test.ts @@ -43,6 +43,9 @@ describe("control command parsing", () => { expect(hasControlCommand("/commands")).toBe(true); expect(hasControlCommand("/commands:")).toBe(true); expect(hasControlCommand("commands")).toBe(false); + expect(hasControlCommand("/compact")).toBe(true); + expect(hasControlCommand("/compact:")).toBe(true); + expect(hasControlCommand("compact")).toBe(false); expect(hasControlCommand("status")).toBe(false); expect(hasControlCommand("usage")).toBe(false); diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 9952af7eb..ea5f18a31 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -18,10 +18,13 @@ describe("commands registry", () => { const specs = listNativeCommandSpecs(); expect(specs.find((spec) => spec.name === "help")).toBeTruthy(); expect(specs.find((spec) => spec.name === "stop")).toBeTruthy(); + expect(specs.find((spec) => spec.name === "compact")).toBeFalsy(); }); it("detects known text commands", () => { const detection = getCommandDetection(); + expect(detection.exact.has("/commands")).toBe(true); + expect(detection.exact.has("/compact")).toBe(true); for (const command of listChatCommands()) { for (const alias of command.textAliases) { expect(detection.exact.has(alias.toLowerCase())).toBe(true); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 9565968be..6078e85c4 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -1,11 +1,14 @@ import type { ClawdbotConfig } from "../config/types.js"; +export type CommandScope = "text" | "native" | "both"; + export type ChatCommandDefinition = { key: string; - nativeName: string; + nativeName?: string; description: string; textAliases: string[]; acceptsArgs?: boolean; + scope: CommandScope; }; export type NativeCommandSpec = { @@ -14,15 +17,35 @@ export type NativeCommandSpec = { acceptsArgs: boolean; }; -function defineChatCommand( - command: Omit & { textAlias: string }, -): ChatCommandDefinition { +type TextAliasSpec = { + canonical: string; + acceptsArgs: boolean; +}; + +function defineChatCommand(command: { + key: string; + nativeName?: string; + description: string; + acceptsArgs?: boolean; + textAlias?: string; + textAliases?: string[]; + scope?: CommandScope; +}): ChatCommandDefinition { + const aliases = ( + command.textAliases ?? (command.textAlias ? [command.textAlias] : []) + ) + .map((alias) => alias.trim()) + .filter(Boolean); + const scope = + command.scope ?? + (command.nativeName ? (aliases.length ? "both" : "native") : "text"); return { key: command.key, nativeName: command.nativeName, description: command.description, acceptsArgs: command.acceptsArgs, - textAliases: [command.textAlias], + textAliases: aliases, + scope, }; } @@ -35,16 +58,64 @@ function registerAlias( if (!command) { throw new Error(`registerAlias: unknown command key: ${key}`); } - const existing = new Set(command.textAliases.map((alias) => alias.trim())); + const existing = new Set( + command.textAliases.map((alias) => alias.trim().toLowerCase()), + ); for (const alias of aliases) { const trimmed = alias.trim(); if (!trimmed) continue; - if (existing.has(trimmed)) continue; - existing.add(trimmed); + const lowered = trimmed.toLowerCase(); + if (existing.has(lowered)) continue; + existing.add(lowered); command.textAliases.push(trimmed); } } +function assertCommandRegistry(commands: ChatCommandDefinition[]): void { + const keys = new Set(); + const nativeNames = new Set(); + const textAliases = new Set(); + for (const command of commands) { + if (keys.has(command.key)) { + throw new Error(`Duplicate command key: ${command.key}`); + } + keys.add(command.key); + + const nativeName = command.nativeName?.trim(); + if (command.scope === "text") { + if (nativeName) { + throw new Error(`Text-only command has native name: ${command.key}`); + } + if (command.textAliases.length === 0) { + throw new Error(`Text-only command missing text alias: ${command.key}`); + } + } else if (!nativeName) { + throw new Error(`Native command missing native name: ${command.key}`); + } else { + const nativeKey = nativeName.toLowerCase(); + if (nativeNames.has(nativeKey)) { + throw new Error(`Duplicate native command: ${nativeName}`); + } + nativeNames.add(nativeKey); + } + + if (command.scope === "native" && command.textAliases.length > 0) { + throw new Error(`Native-only command has text aliases: ${command.key}`); + } + + for (const alias of command.textAliases) { + if (!alias.startsWith("/")) { + throw new Error(`Command alias missing leading '/': ${alias}`); + } + const aliasKey = alias.toLowerCase(); + if (textAliases.has(aliasKey)) { + throw new Error(`Duplicate command alias: ${alias}`); + } + textAliases.add(aliasKey); + } + } +} + export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { const commands: ChatCommandDefinition[] = [ defineChatCommand({ @@ -117,6 +188,13 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { description: "Start a new session.", textAlias: "/new", }), + defineChatCommand({ + key: "compact", + description: "Compact the session context.", + textAlias: "/compact", + scope: "text", + acceptsArgs: true, + }), defineChatCommand({ key: "think", nativeName: "think", @@ -168,16 +246,12 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { registerAlias(commands, "elevated", "/elev"); registerAlias(commands, "model", "/models"); + assertCommandRegistry(commands); return commands; })(); const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]); -type TextAliasSpec = { - canonical: string; - acceptsArgs: boolean; -}; - const TEXT_ALIAS_MAP: Map = (() => { const map = new Map(); for (const command of CHAT_COMMANDS) { @@ -210,8 +284,10 @@ export function listChatCommands(): ChatCommandDefinition[] { } export function listNativeCommandSpecs(): NativeCommandSpec[] { - return CHAT_COMMANDS.map((command) => ({ - name: command.nativeName, + return CHAT_COMMANDS.filter( + (command) => command.scope !== "text" && command.nativeName, + ).map((command) => ({ + name: command.nativeName ?? command.key, description: command.description, acceptsArgs: Boolean(command.acceptsArgs), })); @@ -222,7 +298,9 @@ export function findCommandByNativeName( ): ChatCommandDefinition | undefined { const normalized = name.trim().toLowerCase(); return CHAT_COMMANDS.find( - (command) => command.nativeName.toLowerCase() === normalized, + (command) => + command.scope !== "text" && + command.nativeName?.toLowerCase() === normalized, ); } diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index fb40ef69a..459dd0d3c 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -301,5 +301,11 @@ describe("buildCommandsMessage", () => { it("lists commands with aliases and text-only hints", () => { const text = buildCommandsMessage(); expect(text).toContain("/commands - List all slash commands."); + expect(text).toContain( + "/think (aliases: /thinking, /t) - Set thinking level.", + ); + expect(text).toContain( + "/compact (text-only) - Compact the session context.", + ); }); }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 989d2c0eb..5784d03f8 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -366,7 +366,9 @@ export function buildHelpMessage(): string { export function buildCommandsMessage(): string { const lines = ["ℹ️ Slash commands"]; for (const command of listChatCommands()) { - const primary = `/${command.nativeName}`; + const primary = command.nativeName + ? `/${command.nativeName}` + : command.textAliases[0]?.trim() || `/${command.key}`; const seen = new Set(); const aliases = command.textAliases .map((alias) => alias.trim()) @@ -381,7 +383,8 @@ export function buildCommandsMessage(): string { const aliasLabel = aliases.length ? ` (aliases: ${aliases.join(", ")})` : ""; - lines.push(`${primary}${aliasLabel} - ${command.description}`); + const scopeLabel = command.scope === "text" ? " (text-only)" : ""; + lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`); } return lines.join("\n"); }