feat: add user-invocable skill commands

This commit is contained in:
Peter Steinberger
2026-01-16 12:10:20 +00:00
parent eda9410bce
commit 0d6af15d1c
23 changed files with 514 additions and 50 deletions

View File

@@ -45,6 +45,29 @@ describe("commands registry", () => {
expect(nativeDisabled.find((spec) => spec.name === "debug")).toBeFalsy();
});
it("appends skill commands when provided", () => {
const skillCommands = [
{
name: "demo_skill",
skillName: "demo-skill",
description: "Demo skill",
},
];
const commands = listChatCommandsForConfig(
{
commands: { config: false, debug: false },
},
{ skillCommands },
);
expect(commands.find((spec) => spec.nativeName === "demo_skill")).toBeTruthy();
const native = listNativeCommandSpecsForConfig(
{ commands: { config: false, debug: false, native: true } },
{ skillCommands },
);
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);

View File

@@ -1,4 +1,5 @@
import type { ClawdbotConfig } from "../config/types.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import { CHAT_COMMANDS, getNativeCommandSurfaces } from "./commands-registry.data.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
@@ -61,8 +62,24 @@ function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function listChatCommands(): ChatCommandDefinition[] {
return [...CHAT_COMMANDS];
function buildSkillCommandDefinitions(
skillCommands?: SkillCommandSpec[],
): ChatCommandDefinition[] {
if (!skillCommands || skillCommands.length === 0) return [];
return skillCommands.map((spec) => ({
key: `skill:${spec.skillName}`,
nativeName: spec.name,
description: spec.description,
textAliases: [`/${spec.name}`],
acceptsArgs: true,
argsParsing: "none",
scope: "both",
}));
}
export function listChatCommands(params?: { skillCommands?: SkillCommandSpec[] }): ChatCommandDefinition[] {
if (!params?.skillCommands?.length) return [...CHAT_COMMANDS];
return [...CHAT_COMMANDS, ...buildSkillCommandDefinitions(params.skillCommands)];
}
export function isCommandEnabled(cfg: ClawdbotConfig, commandKey: string): boolean {
@@ -72,23 +89,31 @@ export function isCommandEnabled(cfg: ClawdbotConfig, commandKey: string): boole
return true;
}
export function listChatCommandsForConfig(cfg: ClawdbotConfig): ChatCommandDefinition[] {
return CHAT_COMMANDS.filter((command) => isCommandEnabled(cfg, command.key));
export function listChatCommandsForConfig(
cfg: ClawdbotConfig,
params?: { skillCommands?: SkillCommandSpec[] },
): ChatCommandDefinition[] {
const base = CHAT_COMMANDS.filter((command) => isCommandEnabled(cfg, command.key));
if (!params?.skillCommands?.length) return base;
return [...base, ...buildSkillCommandDefinitions(params.skillCommands)];
}
export function listNativeCommandSpecs(): NativeCommandSpec[] {
return CHAT_COMMANDS.filter((command) => command.scope !== "text" && command.nativeName).map(
(command) => ({
export function listNativeCommandSpecs(params?: { skillCommands?: SkillCommandSpec[] }): NativeCommandSpec[] {
return listChatCommands({ skillCommands: params?.skillCommands })
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
}),
);
}));
}
export function listNativeCommandSpecsForConfig(cfg: ClawdbotConfig): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg)
export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig,
params?: { skillCommands?: SkillCommandSpec[] },
): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg, params)
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,

View File

@@ -1,4 +1,5 @@
import { logVerbose } from "../../globals.js";
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
import { buildCommandsMessage, buildHelpMessage } from "../status.js";
import { buildStatusReply } from "./commands-status.js";
import { buildContextReply } from "./commands-context-report.js";
@@ -28,9 +29,15 @@ export const handleCommandsListCommand: CommandHandler = async (params, allowTex
);
return { shouldContinue: false };
}
const skillCommands =
params.skillCommands ??
listSkillCommandsForWorkspace({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
});
return {
shouldContinue: false,
reply: { text: buildCommandsMessage(params.cfg) },
reply: { text: buildCommandsMessage(params.cfg, skillCommands) },
};
};

View File

@@ -1,6 +1,7 @@
import type { ChannelId } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import type { MsgContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
@@ -47,6 +48,7 @@ export type HandleCommandsParams = {
model: string;
contextTokens: number;
isGroup: boolean;
skillCommands?: SkillCommandSpec[];
};
export type CommandHandlerResult = {

View File

@@ -11,6 +11,8 @@ import { isDirectiveOnly } from "./directive-handling.js";
import type { createModelSelectionState } from "./model-selection.js";
import { extractInlineSimpleCommand } from "./reply-inline.js";
import type { TypingController } from "./typing.js";
import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js";
import { logVerbose } from "../../globals.js";
export type InlineActionResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
@@ -55,6 +57,7 @@ export async function handleInlineActions(params: {
contextTokens: number;
directiveAck?: ReplyPayload;
abortedLastRun: boolean;
skillFilter?: string[];
}): Promise<InlineActionResult> {
const {
ctx,
@@ -89,11 +92,47 @@ export async function handleInlineActions(params: {
contextTokens,
directiveAck,
abortedLastRun: initialAbortedLastRun,
skillFilter,
} = params;
let directives = initialDirectives;
let cleanedBody = initialCleanedBody;
const shouldLoadSkillCommands = command.commandBodyNormalized.startsWith("/");
const skillCommands = shouldLoadSkillCommands
? listSkillCommandsForWorkspace({
workspaceDir,
cfg,
skillFilter,
})
: [];
const skillInvocation =
allowTextCommands && skillCommands.length > 0
? resolveSkillCommandInvocation({
commandBodyNormalized: command.commandBodyNormalized,
skillCommands,
})
: null;
if (skillInvocation) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /${skillInvocation.command.name} from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
typing.cleanup();
return { kind: "reply", reply: undefined };
}
const promptParts = [
`Use the "${skillInvocation.command.skillName}" skill for this request.`,
skillInvocation.args ? `User input:\n${skillInvocation.args}` : null,
].filter((entry): entry is string => Boolean(entry));
const rewrittenBody = promptParts.join("\n\n");
ctx.Body = rewrittenBody;
sessionCtx.Body = rewrittenBody;
sessionCtx.BodyStripped = rewrittenBody;
cleanedBody = rewrittenBody;
}
const sendInlineReply = async (reply?: ReplyPayload) => {
if (!reply) return;
if (!opts?.onBlockReply) return;
@@ -148,33 +187,34 @@ export async function handleInlineActions(params: {
commandBodyNormalized: inlineCommand.command,
};
const inlineResult = await handleCommands({
ctx,
cfg,
command: inlineCommandContext,
agentId,
directives,
elevated: {
enabled: elevatedEnabled,
allowed: elevatedAllowed,
failures: elevatedFailures,
},
sessionEntry,
sessionStore,
sessionKey,
storePath,
sessionScope,
workspaceDir,
defaultGroupActivation: defaultActivation,
resolvedThinkLevel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup,
});
ctx,
cfg,
command: inlineCommandContext,
agentId,
directives,
elevated: {
enabled: elevatedEnabled,
allowed: elevatedAllowed,
failures: elevatedFailures,
},
sessionEntry,
sessionStore,
sessionKey,
storePath,
sessionScope,
workspaceDir,
defaultGroupActivation: defaultActivation,
resolvedThinkLevel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup,
skillCommands,
});
if (inlineResult.reply) {
if (!inlineCommand.cleaned) {
typing.cleanup();
@@ -235,6 +275,7 @@ export async function handleInlineActions(params: {
model,
contextTokens,
isGroup,
skillCommands,
});
if (!commandResult.shouldContinue) {
typing.cleanup();

View File

@@ -203,6 +203,7 @@ export async function getReplyFromConfig(
contextTokens,
directiveAck,
abortedLastRun,
skillFilter: opts?.skillFilter,
});
if (inlineActionResult.kind === "reply") {
return inlineActionResult.reply;

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { resolveSkillCommandInvocation } from "./skill-commands.js";
describe("resolveSkillCommandInvocation", () => {
it("matches skill commands and parses args", () => {
const invocation = resolveSkillCommandInvocation({
commandBodyNormalized: "/demo_skill do the thing",
skillCommands: [
{ name: "demo_skill", skillName: "demo-skill", description: "Demo" },
],
});
expect(invocation?.command.skillName).toBe("demo-skill");
expect(invocation?.args).toBe("do the thing");
});
it("returns null for unknown commands", () => {
const invocation = resolveSkillCommandInvocation({
commandBodyNormalized: "/unknown arg",
skillCommands: [
{ name: "demo_skill", skillName: "demo-skill", description: "Demo" },
],
});
expect(invocation).toBeNull();
});
});

View File

@@ -0,0 +1,51 @@
import type { ClawdbotConfig } from "../config/config.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import {
buildWorkspaceSkillCommandSpecs,
type SkillCommandSpec,
} from "../agents/skills.js";
import { listChatCommands } from "./commands-registry.js";
function resolveReservedCommandNames(): Set<string> {
const reserved = new Set<string>();
for (const command of listChatCommands()) {
if (command.nativeName) reserved.add(command.nativeName.toLowerCase());
for (const alias of command.textAliases) {
const trimmed = alias.trim();
if (!trimmed.startsWith("/")) continue;
reserved.add(trimmed.slice(1).toLowerCase());
}
}
return reserved;
}
export function listSkillCommandsForWorkspace(params: {
workspaceDir: string;
cfg: ClawdbotConfig;
skillFilter?: string[];
}): SkillCommandSpec[] {
return buildWorkspaceSkillCommandSpecs(params.workspaceDir, {
config: params.cfg,
skillFilter: params.skillFilter,
eligibility: { remote: getRemoteSkillEligibility() },
reservedNames: resolveReservedCommandNames(),
});
}
export function resolveSkillCommandInvocation(params: {
commandBodyNormalized: string;
skillCommands: SkillCommandSpec[];
}): { command: SkillCommandSpec; args?: string } | null {
const trimmed = params.commandBodyNormalized.trim();
if (!trimmed.startsWith("/")) return null;
const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
if (!match) return null;
const commandName = match[1]?.trim().toLowerCase();
if (!commandName) return null;
const command = params.skillCommands.find(
(entry) => entry.name.toLowerCase() === commandName,
);
if (!command) return null;
const args = match[2]?.trim();
return { command, args: args || undefined };
}

View File

@@ -318,6 +318,22 @@ describe("buildCommandsMessage", () => {
expect(text).not.toContain("/config");
expect(text).not.toContain("/debug");
});
it("includes skill commands when provided", () => {
const text = buildCommandsMessage(
{
commands: { config: false, debug: false },
} as ClawdbotConfig,
[
{
name: "demo_skill",
skillName: "demo-skill",
description: "Demo skill",
},
],
);
expect(text).toContain("/demo_skill - Demo skill");
});
});
describe("buildHelpMessage", () => {

View File

@@ -22,6 +22,7 @@ import {
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
type AgentConfig = Partial<NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>>;
@@ -352,9 +353,14 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
].join("\n");
}
export function buildCommandsMessage(cfg?: ClawdbotConfig): string {
export function buildCommandsMessage(
cfg?: ClawdbotConfig,
skillCommands?: SkillCommandSpec[],
): string {
const lines = [" Slash commands"];
const commands = cfg ? listChatCommandsForConfig(cfg) : listChatCommands();
const commands = cfg
? listChatCommandsForConfig(cfg, { skillCommands })
: listChatCommands({ skillCommands });
for (const command of commands) {
const primary = command.nativeName
? `/${command.nativeName}`