Merge pull request #497 from lc0rp-contrib/commands-list-clean

feat(commands): add /commands to list available commands
This commit is contained in:
Peter Steinberger
2026-01-09 16:46:32 +00:00
committed by GitHub
6 changed files with 56 additions and 1 deletions

View File

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

View File

@@ -40,6 +40,9 @@ describe("control command parsing", () => {
it("treats bare commands as non-control", () => { it("treats bare commands as non-control", () => {
expect(hasControlCommand("send")).toBe(false); expect(hasControlCommand("send")).toBe(false);
expect(hasControlCommand("help")).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(false); expect(hasControlCommand("status")).toBe(false);
expect(hasControlCommand("usage")).toBe(false); expect(hasControlCommand("usage")).toBe(false);

View File

@@ -53,6 +53,12 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
description: "Show available commands.", description: "Show available commands.",
textAlias: "/help", textAlias: "/help",
}), }),
defineChatCommand({
key: "commands",
nativeName: "commands",
description: "List all slash commands.",
textAlias: "/commands",
}),
defineChatCommand({ defineChatCommand({
key: "status", key: "status",
nativeName: "status", nativeName: "status",

View File

@@ -57,6 +57,7 @@ import {
} from "../group-activation.js"; } from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js"; import { parseSendPolicyCommand } from "../send-policy.js";
import { import {
buildCommandsMessage,
buildHelpMessage, buildHelpMessage,
buildStatusMessage, buildStatusMessage,
formatContextUsageShort, formatContextUsageShort,
@@ -592,6 +593,17 @@ export async function handleCommands(params: {
return { shouldContinue: false, reply: { text: buildHelpMessage() } }; 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 = const statusRequested =
directives.hasStatusDirective || directives.hasStatusDirective ||
command.commandBodyNormalized === "/status"; 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 { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js"; import { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { buildStatusMessage } from "./status.js"; import { buildCommandsMessage, buildStatusMessage } from "./status.js";
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -296,3 +296,10 @@ describe("buildStatusMessage", () => {
); );
}); });
}); });
describe("buildCommandsMessage", () => {
it("lists commands with aliases and text-only hints", () => {
const text = buildCommandsMessage();
expect(text).toContain("/commands - List all slash commands.");
});
});

View File

@@ -28,6 +28,7 @@ import {
resolveModelCostConfig, resolveModelCostConfig,
} from "../utils/usage-format.js"; } from "../utils/usage-format.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { listChatCommands } from "./commands-registry.js";
import type { import type {
ElevatedLevel, ElevatedLevel,
ReasoningLevel, ReasoningLevel,
@@ -358,5 +359,29 @@ export function buildHelpMessage(): string {
" Help", " Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)", "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", "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"); ].join("\n");
} }
export function buildCommandsMessage(): string {
const lines = [" Slash commands"];
for (const command of listChatCommands()) {
const primary = `/${command.nativeName}`;
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(", ")})`
: "";
lines.push(`${primary}${aliasLabel} - ${command.description}`);
}
return lines.join("\n");
}