Merge pull request #589 from clawdbot/chore/commands-registry-guards
fix: harden slash command registry
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
|
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
|
||||||
- Commands: accept /models as an alias for /model.
|
- Commands: accept /models as an alias for /model.
|
||||||
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
|
- 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
|
- 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.
|
- 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
|
- Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ describe("control command parsing", () => {
|
|||||||
expect(hasControlCommand("/commands")).toBe(true);
|
expect(hasControlCommand("/commands")).toBe(true);
|
||||||
expect(hasControlCommand("/commands:")).toBe(true);
|
expect(hasControlCommand("/commands:")).toBe(true);
|
||||||
expect(hasControlCommand("commands")).toBe(false);
|
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("status")).toBe(false);
|
||||||
expect(hasControlCommand("usage")).toBe(false);
|
expect(hasControlCommand("usage")).toBe(false);
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ describe("commands registry", () => {
|
|||||||
const specs = listNativeCommandSpecs();
|
const specs = listNativeCommandSpecs();
|
||||||
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
|
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
|
||||||
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
|
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
|
||||||
|
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects known text commands", () => {
|
it("detects known text commands", () => {
|
||||||
const detection = getCommandDetection();
|
const detection = getCommandDetection();
|
||||||
|
expect(detection.exact.has("/commands")).toBe(true);
|
||||||
|
expect(detection.exact.has("/compact")).toBe(true);
|
||||||
for (const command of listChatCommands()) {
|
for (const command of listChatCommands()) {
|
||||||
for (const alias of command.textAliases) {
|
for (const alias of command.textAliases) {
|
||||||
expect(detection.exact.has(alias.toLowerCase())).toBe(true);
|
expect(detection.exact.has(alias.toLowerCase())).toBe(true);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { ClawdbotConfig } from "../config/types.js";
|
import type { ClawdbotConfig } from "../config/types.js";
|
||||||
|
|
||||||
|
export type CommandScope = "text" | "native" | "both";
|
||||||
|
|
||||||
export type ChatCommandDefinition = {
|
export type ChatCommandDefinition = {
|
||||||
key: string;
|
key: string;
|
||||||
nativeName: string;
|
nativeName?: string;
|
||||||
description: string;
|
description: string;
|
||||||
textAliases: string[];
|
textAliases: string[];
|
||||||
acceptsArgs?: boolean;
|
acceptsArgs?: boolean;
|
||||||
|
scope: CommandScope;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NativeCommandSpec = {
|
export type NativeCommandSpec = {
|
||||||
@@ -14,15 +17,35 @@ export type NativeCommandSpec = {
|
|||||||
acceptsArgs: boolean;
|
acceptsArgs: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function defineChatCommand(
|
type TextAliasSpec = {
|
||||||
command: Omit<ChatCommandDefinition, "textAliases"> & { textAlias: string },
|
canonical: string;
|
||||||
): ChatCommandDefinition {
|
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 {
|
return {
|
||||||
key: command.key,
|
key: command.key,
|
||||||
nativeName: command.nativeName,
|
nativeName: command.nativeName,
|
||||||
description: command.description,
|
description: command.description,
|
||||||
acceptsArgs: command.acceptsArgs,
|
acceptsArgs: command.acceptsArgs,
|
||||||
textAliases: [command.textAlias],
|
textAliases: aliases,
|
||||||
|
scope,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,16 +58,64 @@ function registerAlias(
|
|||||||
if (!command) {
|
if (!command) {
|
||||||
throw new Error(`registerAlias: unknown command key: ${key}`);
|
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) {
|
for (const alias of aliases) {
|
||||||
const trimmed = alias.trim();
|
const trimmed = alias.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
if (existing.has(trimmed)) continue;
|
const lowered = trimmed.toLowerCase();
|
||||||
existing.add(trimmed);
|
if (existing.has(lowered)) continue;
|
||||||
|
existing.add(lowered);
|
||||||
command.textAliases.push(trimmed);
|
command.textAliases.push(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertCommandRegistry(commands: ChatCommandDefinition[]): void {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const nativeNames = new Set<string>();
|
||||||
|
const textAliases = new Set<string>();
|
||||||
|
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[] = (() => {
|
export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
||||||
const commands: ChatCommandDefinition[] = [
|
const commands: ChatCommandDefinition[] = [
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
@@ -117,6 +188,13 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
|||||||
description: "Start a new session.",
|
description: "Start a new session.",
|
||||||
textAlias: "/new",
|
textAlias: "/new",
|
||||||
}),
|
}),
|
||||||
|
defineChatCommand({
|
||||||
|
key: "compact",
|
||||||
|
description: "Compact the session context.",
|
||||||
|
textAlias: "/compact",
|
||||||
|
scope: "text",
|
||||||
|
acceptsArgs: true,
|
||||||
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "think",
|
key: "think",
|
||||||
nativeName: "think",
|
nativeName: "think",
|
||||||
@@ -168,16 +246,12 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
|||||||
registerAlias(commands, "elevated", "/elev");
|
registerAlias(commands, "elevated", "/elev");
|
||||||
registerAlias(commands, "model", "/models");
|
registerAlias(commands, "model", "/models");
|
||||||
|
|
||||||
|
assertCommandRegistry(commands);
|
||||||
return commands;
|
return commands;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
|
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
|
||||||
|
|
||||||
type TextAliasSpec = {
|
|
||||||
canonical: string;
|
|
||||||
acceptsArgs: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
|
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
|
||||||
const map = new Map<string, TextAliasSpec>();
|
const map = new Map<string, TextAliasSpec>();
|
||||||
for (const command of CHAT_COMMANDS) {
|
for (const command of CHAT_COMMANDS) {
|
||||||
@@ -210,8 +284,10 @@ export function listChatCommands(): ChatCommandDefinition[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listNativeCommandSpecs(): NativeCommandSpec[] {
|
export function listNativeCommandSpecs(): NativeCommandSpec[] {
|
||||||
return CHAT_COMMANDS.map((command) => ({
|
return CHAT_COMMANDS.filter(
|
||||||
name: command.nativeName,
|
(command) => command.scope !== "text" && command.nativeName,
|
||||||
|
).map((command) => ({
|
||||||
|
name: command.nativeName ?? command.key,
|
||||||
description: command.description,
|
description: command.description,
|
||||||
acceptsArgs: Boolean(command.acceptsArgs),
|
acceptsArgs: Boolean(command.acceptsArgs),
|
||||||
}));
|
}));
|
||||||
@@ -222,7 +298,9 @@ export function findCommandByNativeName(
|
|||||||
): ChatCommandDefinition | undefined {
|
): ChatCommandDefinition | undefined {
|
||||||
const normalized = name.trim().toLowerCase();
|
const normalized = name.trim().toLowerCase();
|
||||||
return CHAT_COMMANDS.find(
|
return CHAT_COMMANDS.find(
|
||||||
(command) => command.nativeName.toLowerCase() === normalized,
|
(command) =>
|
||||||
|
command.scope !== "text" &&
|
||||||
|
command.nativeName?.toLowerCase() === normalized,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -301,5 +301,11 @@ describe("buildCommandsMessage", () => {
|
|||||||
it("lists commands with aliases and text-only hints", () => {
|
it("lists commands with aliases and text-only hints", () => {
|
||||||
const text = buildCommandsMessage();
|
const text = buildCommandsMessage();
|
||||||
expect(text).toContain("/commands - List all slash commands.");
|
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.",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -366,7 +366,9 @@ export function buildHelpMessage(): string {
|
|||||||
export function buildCommandsMessage(): string {
|
export function buildCommandsMessage(): string {
|
||||||
const lines = ["ℹ️ Slash commands"];
|
const lines = ["ℹ️ Slash commands"];
|
||||||
for (const command of listChatCommands()) {
|
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<string>();
|
const seen = new Set<string>();
|
||||||
const aliases = command.textAliases
|
const aliases = command.textAliases
|
||||||
.map((alias) => alias.trim())
|
.map((alias) => alias.trim())
|
||||||
@@ -381,7 +383,8 @@ export function buildCommandsMessage(): string {
|
|||||||
const aliasLabel = aliases.length
|
const aliasLabel = aliases.length
|
||||||
? ` (aliases: ${aliases.join(", ")})`
|
? ` (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");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user