feat(commands): add /commands slash list

This commit is contained in:
LK
2026-01-08 16:02:54 +01:00
committed by Peter Steinberger
parent ee70a1d1fb
commit 3ce5b642b3
7 changed files with 112 additions and 55 deletions

View File

@@ -35,6 +35,7 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
Text + native (when enabled):
- `/help`
- `/commands`
- `/status` (show current status; includes a short usage line when available)
- `/usage` (alias: `/status`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only)

View File

@@ -40,8 +40,16 @@ describe("control command parsing", () => {
it("treats bare commands as non-control", () => {
expect(hasControlCommand("send")).toBe(false);
expect(hasControlCommand("help")).toBe(false);
expect(hasControlCommand("/commands")).toBe(true);
expect(hasControlCommand("/commands:")).toBe(true);
expect(hasControlCommand("commands")).toBe(false);
expect(hasControlCommand("/status")).toBe(true);
expect(hasControlCommand("/status:")).toBe(true);
expect(hasControlCommand("status")).toBe(false);
expect(hasControlCommand("usage")).toBe(false);
expect(hasControlCommand("/compact")).toBe(true);
expect(hasControlCommand("/compact:")).toBe(true);
expect(hasControlCommand("compact")).toBe(false);
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {

View File

@@ -18,10 +18,14 @@ 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("/help")).toBe(true);
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);

View File

@@ -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,27 @@ export type NativeCommandSpec = {
acceptsArgs: boolean;
};
function defineChatCommand(
command: Omit<ChatCommandDefinition, "textAliases"> & { textAlias: string },
): ChatCommandDefinition {
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] : []);
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,
};
}
@@ -53,6 +68,12 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
description: "Show available commands.",
textAlias: "/help",
}),
defineChatCommand({
key: "commands",
nativeName: "commands",
description: "List all slash commands.",
textAlias: "/commands",
}),
defineChatCommand({
key: "status",
nativeName: "status",
@@ -111,6 +132,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",
@@ -167,27 +195,6 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
type TextAliasSpec = {
canonical: string;
acceptsArgs: boolean;
};
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
const map = new Map<string, TextAliasSpec>();
for (const command of CHAT_COMMANDS) {
const canonical = `/${command.key}`;
const acceptsArgs = Boolean(command.acceptsArgs);
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
if (!map.has(normalized)) {
map.set(normalized, { canonical, acceptsArgs });
}
}
}
return map;
})();
let cachedDetection:
| {
exact: Set<string>;
@@ -204,8 +211,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),
}));
@@ -216,7 +225,9 @@ export function findCommandByNativeName(
): ChatCommandDefinition | undefined {
const normalized = name.trim().toLowerCase();
return CHAT_COMMANDS.find(
(command) => command.nativeName.toLowerCase() === normalized,
(command) =>
command.nativeName?.toLowerCase() === normalized &&
command.scope !== "text",
);
}
@@ -228,31 +239,11 @@ export function buildCommandText(commandName: string, args?: string): string {
export function normalizeCommandBody(raw: string): string {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) return trimmed;
const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
const normalized = colonMatch
? (() => {
const [, command, rest] = colonMatch;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
})()
: trimmed;
const lowered = normalized.toLowerCase();
const exact = TEXT_ALIAS_MAP.get(lowered);
if (exact) return exact.canonical;
const tokenMatch = normalized.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
if (!tokenMatch) return normalized;
const [, token, rest] = tokenMatch;
const tokenKey = `/${token.toLowerCase()}`;
const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey);
if (!tokenSpec) return normalized;
if (rest && !tokenSpec.acceptsArgs) return normalized;
const normalizedRest = rest?.trimStart();
return normalizedRest
? `${tokenSpec.canonical} ${normalizedRest}`
: tokenSpec.canonical;
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
if (!match) return trimmed;
const [, command, rest] = match;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
}
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {

View File

@@ -57,6 +57,7 @@ import {
} from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import {
buildCommandsMessage,
buildHelpMessage,
buildStatusMessage,
formatContextUsageShort,
@@ -592,6 +593,17 @@ export async function handleCommands(params: {
return { shouldContinue: false, reply: { text: buildHelpMessage() } };
}
const commandsRequested = command.commandBodyNormalized === "/commands";
if (allowTextCommands && commandsRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /commands from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: { text: buildCommandsMessage() } };
}
const statusRequested =
directives.hasStatusDirective ||
command.commandBodyNormalized === "/status";

View File

@@ -4,7 +4,7 @@ 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 { buildStatusMessage } from "./status.js";
import { buildCommandsMessage, buildStatusMessage } from "./status.js";
afterEach(() => {
vi.restoreAllMocks();
@@ -296,3 +296,16 @@ describe("buildStatusMessage", () => {
);
});
});
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.",
);
});
});

View File

@@ -28,6 +28,7 @@ import {
resolveModelCostConfig,
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands } from "./commands-registry.js";
import type {
ElevatedLevel,
ReasoningLevel,
@@ -358,5 +359,32 @@ export function buildHelpMessage(): string {
" Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /debug show",
"More: /commands for all slash commands",
].join("\n");
}
export function buildCommandsMessage(): string {
const lines = [" Slash commands"];
for (const command of listChatCommands()) {
const primary = command.nativeName
? `/${command.nativeName}`
: command.textAliases[0]?.trim() || `/${command.key}`;
const seen = new Set<string>();
const aliases = command.textAliases
.map((alias) => alias.trim())
.filter(Boolean)
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
.filter((alias) => {
const key = alias.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const aliasLabel = aliases.length
? ` (aliases: ${aliases.join(", ")})`
: "";
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
}
return lines.join("\n");
}