Compare commits
5 Commits
main
...
commands-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed1f230ff | ||
|
|
08d9d9f9e3 | ||
|
|
c291b7a992 | ||
|
|
f564fe5adf | ||
|
|
3ce5b642b3 |
@ -10,6 +10,7 @@
|
|||||||
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
|
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
|
||||||
- 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: add `/commands` list including text-only entries. (#497) — thanks @lc0rp
|
||||||
- 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
|
||||||
|
|||||||
@ -35,6 +35,7 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
|
|||||||
|
|
||||||
Text + native (when enabled):
|
Text + native (when enabled):
|
||||||
- `/help`
|
- `/help`
|
||||||
|
- `/commands`
|
||||||
- `/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)
|
||||||
|
|||||||
@ -40,8 +40,16 @@ 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(true);
|
||||||
|
expect(hasControlCommand("/status:")).toBe(true);
|
||||||
expect(hasControlCommand("status")).toBe(false);
|
expect(hasControlCommand("status")).toBe(false);
|
||||||
expect(hasControlCommand("usage")).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 command of listChatCommands()) {
|
||||||
for (const alias of command.textAliases) {
|
for (const alias of command.textAliases) {
|
||||||
|
|||||||
@ -22,6 +22,9 @@ describe("commands registry", () => {
|
|||||||
|
|
||||||
it("detects known text commands", () => {
|
it("detects known text commands", () => {
|
||||||
const detection = getCommandDetection();
|
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 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,27 @@ export type NativeCommandSpec = {
|
|||||||
acceptsArgs: boolean;
|
acceptsArgs: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function defineChatCommand(
|
function defineChatCommand(command: {
|
||||||
command: Omit<ChatCommandDefinition, "textAliases"> & { textAlias: string },
|
key: string;
|
||||||
): ChatCommandDefinition {
|
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 {
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,6 +68,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",
|
||||||
@ -111,6 +132,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",
|
||||||
@ -167,27 +195,6 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
|||||||
|
|
||||||
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 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:
|
let cachedDetection:
|
||||||
| {
|
| {
|
||||||
exact: Set<string>;
|
exact: Set<string>;
|
||||||
@ -204,8 +211,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),
|
||||||
}));
|
}));
|
||||||
@ -216,7 +225,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.nativeName?.toLowerCase() === normalized &&
|
||||||
|
command.scope !== "text",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,31 +239,11 @@ export function buildCommandText(commandName: string, args?: string): string {
|
|||||||
export function normalizeCommandBody(raw: string): string {
|
export function normalizeCommandBody(raw: string): string {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed.startsWith("/")) return trimmed;
|
if (!trimmed.startsWith("/")) return trimmed;
|
||||||
|
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
|
||||||
const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
|
if (!match) return trimmed;
|
||||||
const normalized = colonMatch
|
const [, command, rest] = match;
|
||||||
? (() => {
|
const normalizedRest = rest.trimStart();
|
||||||
const [, command, rest] = colonMatch;
|
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
|
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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,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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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,32 @@ 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
|
||||||
|
? `/${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");
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user