feat: add user-invocable skill commands
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) },
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -203,6 +203,7 @@ export async function getReplyFromConfig(
|
||||
contextTokens,
|
||||
directiveAck,
|
||||
abortedLastRun,
|
||||
skillFilter: opts?.skillFilter,
|
||||
});
|
||||
if (inlineActionResult.kind === "reply") {
|
||||
return inlineActionResult.reply;
|
||||
|
||||
25
src/auto-reply/skill-commands.test.ts
Normal file
25
src/auto-reply/skill-commands.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
51
src/auto-reply/skill-commands.ts
Normal file
51
src/auto-reply/skill-commands.ts
Normal 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 };
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user