From 0d6af15d1cb8417124c1501d209a2f0d4cf2bb78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 12:10:20 +0000 Subject: [PATCH] feat: add user-invocable skill commands --- CHANGELOG.md | 1 + docs/tools/skills.md | 2 + docs/tools/slash-commands.md | 1 + ...skills.buildworkspaceskillcommands.test.ts | 64 +++++++++++++ ...skills.buildworkspaceskillsnapshot.test.ts | 31 +++++- src/agents/skills.ts | 2 + src/agents/skills/frontmatter.ts | 31 ++++++ src/agents/skills/types.ts | 12 +++ src/agents/skills/workspace.ts | 87 ++++++++++++++++- src/auto-reply/commands-registry.test.ts | 23 +++++ src/auto-reply/commands-registry.ts | 47 ++++++--- src/auto-reply/reply/commands-info.ts | 9 +- src/auto-reply/reply/commands-types.ts | 2 + .../reply/get-reply-inline-actions.ts | 95 +++++++++++++------ src/auto-reply/reply/get-reply.ts | 1 + src/auto-reply/skill-commands.test.ts | 25 +++++ src/auto-reply/skill-commands.ts | 51 ++++++++++ src/auto-reply/status.test.ts | 16 ++++ src/auto-reply/status.ts | 10 +- src/discord/monitor/provider.ts | 12 ++- src/slack/monitor/slash.ts | 12 ++- src/telegram/bot-native-commands.ts | 15 ++- src/telegram/bot.test.ts | 15 ++- 23 files changed, 514 insertions(+), 50 deletions(-) create mode 100644 src/agents/skills.buildworkspaceskillcommands.test.ts create mode 100644 src/auto-reply/skill-commands.test.ts create mode 100644 src/auto-reply/skill-commands.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b1acfca3..79c5cc486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007. - Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee. - Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows. +- Skills: add user-invocable skill commands with sanitized names and an opt-out for model invocation. - Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker. - TUI: show provider/model labels for the active session and default model. - Heartbeat: add per-agent heartbeat configuration and multi-agent docs example. diff --git a/docs/tools/skills.md b/docs/tools/skills.md index a7aeb43f7..feec2c4d2 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -80,6 +80,8 @@ Notes: - Use `{baseDir}` in instructions to reference the skill folder path. - Optional frontmatter keys: - `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.clawdbot.homepage`). + - `user-invocable` — `true|false` (default: `true`). When `true`, the skill is exposed as a user slash command. + - `disable-model-invocation` — `true|false` (default: `false`). When `true`, the skill is excluded from the model prompt (still available via user invocation). ## Gating (load-time filters) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index e6c4aa3ed..f2f814be3 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -96,6 +96,7 @@ Notes: - Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow. - Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`). - Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. +- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`). - **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg. ## Usage vs cost (what shows where) diff --git a/src/agents/skills.buildworkspaceskillcommands.test.ts b/src/agents/skills.buildworkspaceskillcommands.test.ts new file mode 100644 index 000000000..ed5d53fbb --- /dev/null +++ b/src/agents/skills.buildworkspaceskillcommands.test.ts @@ -0,0 +1,64 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { buildWorkspaceSkillCommandSpecs } from "./skills.js"; + +async function writeSkill(params: { + dir: string; + name: string; + description: string; + frontmatterExtra?: string; +}) { + const { dir, name, description, frontmatterExtra } = params; + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, "SKILL.md"), + `--- +name: ${name} +description: ${description} +${frontmatterExtra ?? ""} +--- + +# ${name} +`, + "utf-8", + ); +} + +describe("buildWorkspaceSkillCommandSpecs", () => { + it("sanitizes and de-duplicates command names", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-")); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "hello-world"), + name: "hello-world", + description: "Hello world skill", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "hello_world"), + name: "hello_world", + description: "Hello underscore skill", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "help"), + name: "help", + description: "Help skill", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "hidden"), + name: "hidden-skill", + description: "Hidden skill", + frontmatterExtra: "user-invocable: false", + }); + + const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + bundledSkillsDir: path.join(workspaceDir, ".bundled"), + reservedNames: new Set(["help"]), + }); + + const names = commands.map((entry) => entry.name).sort(); + expect(names).toEqual(["hello_world", "hello_world_2", "help_2"]); + expect(commands.find((entry) => entry.skillName === "hidden-skill")).toBeUndefined(); + }); +}); diff --git a/src/agents/skills.buildworkspaceskillsnapshot.test.ts b/src/agents/skills.buildworkspaceskillsnapshot.test.ts index 2c524b179..dd5a9c564 100644 --- a/src/agents/skills.buildworkspaceskillsnapshot.test.ts +++ b/src/agents/skills.buildworkspaceskillsnapshot.test.ts @@ -9,15 +9,17 @@ async function _writeSkill(params: { name: string; description: string; metadata?: string; + frontmatterExtra?: string; body?: string; }) { - const { dir, name, description, metadata, body } = params; + const { dir, name, description, metadata, frontmatterExtra, body } = params; await fs.mkdir(dir, { recursive: true }); await fs.writeFile( path.join(dir, "SKILL.md"), `--- name: ${name} description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} +${frontmatterExtra ?? ""} --- ${body ?? `# ${name}\n`} @@ -38,4 +40,31 @@ describe("buildWorkspaceSkillSnapshot", () => { expect(snapshot.prompt).toBe(""); expect(snapshot.skills).toEqual([]); }); + + it("omits disable-model-invocation skills from the prompt", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-")); + await _writeSkill({ + dir: path.join(workspaceDir, "skills", "visible-skill"), + name: "visible-skill", + description: "Visible skill", + }); + await _writeSkill({ + dir: path.join(workspaceDir, "skills", "hidden-skill"), + name: "hidden-skill", + description: "Hidden skill", + frontmatterExtra: "disable-model-invocation: true", + }); + + const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + bundledSkillsDir: path.join(workspaceDir, ".bundled"), + }); + + expect(snapshot.prompt).toContain("visible-skill"); + expect(snapshot.prompt).not.toContain("hidden-skill"); + expect(snapshot.skills.map((skill) => skill.name).sort()).toEqual([ + "hidden-skill", + "visible-skill", + ]); + }); }); diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 034f70865..f285a0312 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -16,6 +16,7 @@ export { export type { ClawdbotSkillMetadata, SkillEligibilityContext, + SkillCommandSpec, SkillEntry, SkillInstallSpec, SkillSnapshot, @@ -24,6 +25,7 @@ export type { export { buildWorkspaceSkillSnapshot, buildWorkspaceSkillsPrompt, + buildWorkspaceSkillCommandSpecs, filterWorkspaceSkillEntries, loadWorkspaceSkillEntries, resolveSkillsPromptForRun, diff --git a/src/agents/skills/frontmatter.ts b/src/agents/skills/frontmatter.ts index c145fad8b..cb01c1ab4 100644 --- a/src/agents/skills/frontmatter.ts +++ b/src/agents/skills/frontmatter.ts @@ -5,6 +5,7 @@ import type { ParsedSkillFrontmatter, SkillEntry, SkillInstallSpec, + SkillInvocationPolicy, } from "./types.js"; function stripQuotes(value: string): string { @@ -79,6 +80,24 @@ function getFrontmatterValue(frontmatter: ParsedSkillFrontmatter, key: string): return typeof raw === "string" ? raw : undefined; } +function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean { + if (!value) return fallback; + const normalized = value.trim().toLowerCase(); + if (!normalized) return fallback; + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if ( + normalized === "false" || + normalized === "0" || + normalized === "no" || + normalized === "off" + ) { + return false; + } + return fallback; +} + export function resolveClawdbotMetadata( frontmatter: ParsedSkillFrontmatter, ): ClawdbotSkillMetadata | undefined { @@ -121,6 +140,18 @@ export function resolveClawdbotMetadata( } } +export function resolveSkillInvocationPolicy( + frontmatter: ParsedSkillFrontmatter, +): SkillInvocationPolicy { + return { + userInvocable: parseFrontmatterBool(getFrontmatterValue(frontmatter, "user-invocable"), true), + disableModelInvocation: parseFrontmatterBool( + getFrontmatterValue(frontmatter, "disable-model-invocation"), + false, + ), + }; +} + export function resolveSkillKey(skill: Skill, entry?: SkillEntry): string { return entry?.clawdbot?.skillKey ?? skill.name; } diff --git a/src/agents/skills/types.ts b/src/agents/skills/types.ts index d3914f940..f8d64f7f4 100644 --- a/src/agents/skills/types.ts +++ b/src/agents/skills/types.ts @@ -26,6 +26,17 @@ export type ClawdbotSkillMetadata = { install?: SkillInstallSpec[]; }; +export type SkillInvocationPolicy = { + userInvocable: boolean; + disableModelInvocation: boolean; +}; + +export type SkillCommandSpec = { + name: string; + skillName: string; + description: string; +}; + export type SkillsInstallPreferences = { preferBrew: boolean; nodeManager: "npm" | "pnpm" | "yarn" | "bun"; @@ -37,6 +48,7 @@ export type SkillEntry = { skill: Skill; frontmatter: ParsedSkillFrontmatter; clawdbot?: ClawdbotSkillMetadata; + invocation?: SkillInvocationPolicy; }; export type SkillEligibilityContext = { diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index c6573b769..66d999e3a 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -11,11 +11,16 @@ import type { ClawdbotConfig } from "../../config/config.js"; import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; import { resolveBundledSkillsDir } from "./bundled-dir.js"; import { shouldIncludeSkill } from "./config.js"; -import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js"; +import { + parseFrontmatter, + resolveClawdbotMetadata, + resolveSkillInvocationPolicy, +} from "./frontmatter.js"; import { serializeByKey } from "./serialize.js"; import type { ParsedSkillFrontmatter, SkillEligibilityContext, + SkillCommandSpec, SkillEntry, SkillSnapshot, } from "./types.js"; @@ -43,6 +48,34 @@ function filterSkillEntries( return filtered; } +const SKILL_COMMAND_MAX_LENGTH = 32; +const SKILL_COMMAND_FALLBACK = "skill"; + +function sanitizeSkillCommandName(raw: string): string { + const normalized = raw + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + const trimmed = normalized.slice(0, SKILL_COMMAND_MAX_LENGTH); + return trimmed || SKILL_COMMAND_FALLBACK; +} + +function resolveUniqueSkillCommandName(base: string, used: Set): string { + const normalizedBase = base.toLowerCase(); + if (!used.has(normalizedBase)) return base; + for (let index = 2; index < 1000; index += 1) { + const suffix = `_${index}`; + const maxBaseLength = Math.max(1, SKILL_COMMAND_MAX_LENGTH - suffix.length); + const trimmedBase = base.slice(0, maxBaseLength); + const candidate = `${trimmedBase}${suffix}`; + const candidateKey = candidate.toLowerCase(); + if (!used.has(candidateKey)) return candidate; + } + const fallback = `${base.slice(0, Math.max(1, SKILL_COMMAND_MAX_LENGTH - 2))}_x`; + return fallback; +} + function loadSkillEntries( workspaceDir: string, opts?: { @@ -114,6 +147,7 @@ function loadSkillEntries( skill, frontmatter, clawdbot: resolveClawdbotMetadata(frontmatter), + invocation: resolveSkillInvocationPolicy(frontmatter), }; }); return skillEntries; @@ -139,7 +173,10 @@ export function buildWorkspaceSkillSnapshot( opts?.skillFilter, opts?.eligibility, ); - const resolvedSkills = eligible.map((entry) => entry.skill); + const promptEntries = eligible.filter( + (entry) => entry.invocation?.disableModelInvocation !== true, + ); + const resolvedSkills = promptEntries.map((entry) => entry.skill); const remoteNote = opts?.eligibility?.remote?.note?.trim(); const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n"); return { @@ -172,8 +209,11 @@ export function buildWorkspaceSkillsPrompt( opts?.skillFilter, opts?.eligibility, ); + const promptEntries = eligible.filter( + (entry) => entry.invocation?.disableModelInvocation !== true, + ); const remoteNote = opts?.eligibility?.remote?.note?.trim(); - return [remoteNote, formatSkillsForPrompt(eligible.map((entry) => entry.skill))] + return [remoteNote, formatSkillsForPrompt(promptEntries.map((entry) => entry.skill))] .filter(Boolean) .join("\n"); } @@ -251,3 +291,44 @@ export function filterWorkspaceSkillEntries( ): SkillEntry[] { return filterSkillEntries(entries, config); } + +export function buildWorkspaceSkillCommandSpecs( + workspaceDir: string, + opts?: { + config?: ClawdbotConfig; + managedSkillsDir?: string; + bundledSkillsDir?: string; + entries?: SkillEntry[]; + skillFilter?: string[]; + eligibility?: SkillEligibilityContext; + reservedNames?: Set; + }, +): SkillCommandSpec[] { + const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); + const eligible = filterSkillEntries( + skillEntries, + opts?.config, + opts?.skillFilter, + opts?.eligibility, + ); + const userInvocable = eligible.filter( + (entry) => entry.invocation?.userInvocable !== false, + ); + const used = new Set(); + for (const reserved of opts?.reservedNames ?? []) { + used.add(reserved.toLowerCase()); + } + + const specs: SkillCommandSpec[] = []; + for (const entry of userInvocable) { + const base = sanitizeSkillCommandName(entry.skill.name); + const unique = resolveUniqueSkillCommandName(base, used); + used.add(unique.toLowerCase()); + specs.push({ + name: unique, + skillName: entry.skill.name, + description: entry.skill.description?.trim() || entry.skill.name, + }); + } + return specs; +} diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index bbc20b784..f1484bad7 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -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); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 759b575a7..c4d07393f 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -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, diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index c65cde77f..69c80e812 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -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) }, }; }; diff --git a/src/auto-reply/reply/commands-types.ts b/src/auto-reply/reply/commands-types.ts index 15f3f0b5e..8fb0b0133 100644 --- a/src/auto-reply/reply/commands-types.ts +++ b/src/auto-reply/reply/commands-types.ts @@ -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 = { diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 6c9d72c24..5f2438927 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -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 { 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 || ""}`, + ); + 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(); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 292d4a628..b696a4231 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -203,6 +203,7 @@ export async function getReplyFromConfig( contextTokens, directiveAck, abortedLastRun, + skillFilter: opts?.skillFilter, }); if (inlineActionResult.kind === "reply") { return inlineActionResult.reply; diff --git a/src/auto-reply/skill-commands.test.ts b/src/auto-reply/skill-commands.test.ts new file mode 100644 index 000000000..b0c2fd7a4 --- /dev/null +++ b/src/auto-reply/skill-commands.test.ts @@ -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(); + }); +}); diff --git a/src/auto-reply/skill-commands.ts b/src/auto-reply/skill-commands.ts new file mode 100644 index 000000000..e1790a084 --- /dev/null +++ b/src/auto-reply/skill-commands.ts @@ -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 { + const reserved = new Set(); + 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 }; +} diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 9020fa309..0ce0d5d30 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -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", () => { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index f53300db7..aef421f32 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -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["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}` diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index f2c3e8997..4f7fd0ed8 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -3,6 +3,8 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; import { Routes } from "discord-api-types/v10"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js"; +import { listSkillCommandsForWorkspace } from "../../auto-reply/skill-commands.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { HistoryEntry } from "../../auto-reply/reply/history.js"; import { isNativeCommandsExplicitlyDisabled, @@ -116,7 +118,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { throw new Error("Failed to resolve Discord application id"); } - const commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg) : []; + const skillCommands = nativeEnabled + ? listSkillCommandsForWorkspace({ + workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), + cfg, + }) + : []; + const commandSpecs = nativeEnabled + ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) + : []; const commands = commandSpecs.map((spec) => createDiscordNativeCommand({ command: spec, diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 03db28eb9..7bf03bc9e 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -8,6 +8,8 @@ import { parseCommandArgs, resolveCommandArgMenu, } from "../../auto-reply/commands-registry.js"; +import { listSkillCommandsForWorkspace } from "../../auto-reply/skill-commands.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { resolveNativeCommandsEnabled } from "../../config/commands.js"; import { danger, logVerbose } from "../../globals.js"; @@ -403,7 +405,15 @@ export function registerSlackMonitorSlashCommands(params: { providerSetting: account.config.commands?.native, globalSetting: cfg.commands?.native, }); - const nativeCommands = nativeEnabled ? listNativeCommandSpecsForConfig(cfg) : []; + const skillCommands = nativeEnabled + ? listSkillCommandsForWorkspace({ + workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), + cfg, + }) + : []; + const nativeCommands = nativeEnabled + ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) + : []; if (nativeCommands.length > 0) { for (const command of nativeCommands) { ctx.app.command( diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 84871e727..74f04e21b 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -9,6 +9,8 @@ import { parseCommandArgs, resolveCommandArgMenu, } from "../auto-reply/commands-registry.js"; +import { listSkillCommandsForWorkspace } from "../auto-reply/skill-commands.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { CommandArgs } from "../auto-reply/commands-registry.js"; import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; @@ -43,10 +45,21 @@ export const registerTelegramNativeCommands = ({ shouldSkipUpdate, opts, }) => { - const nativeCommands = nativeEnabled ? listNativeCommandSpecsForConfig(cfg) : []; + const skillCommands = nativeEnabled + ? listSkillCommandsForWorkspace({ + workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), + cfg, + }) + : []; + const nativeCommands = nativeEnabled + ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) + : []; const reservedCommands = new Set( listNativeCommandSpecs().map((command) => command.name.toLowerCase()), ); + for (const command of skillCommands) { + reservedCommands.add(command.name.toLowerCase()); + } const customResolution = resolveTelegramCustomCommands({ commands: telegramCfg.customCommands, reservedCommands, diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index fb7f3332b..bf72c800b 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -6,11 +6,20 @@ import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, } from "../auto-reply/commands-registry.js"; +import { listSkillCommandsForWorkspace } from "../auto-reply/skill-commands.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import * as replyModule from "../auto-reply/reply.js"; import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; +function resolveSkillCommands(config: Parameters[0]) { + return listSkillCommandsForWorkspace({ + workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)), + cfg: config, + }); +} + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -187,7 +196,8 @@ describe("createTelegramBot", () => { command: string; description: string; }>; - const native = listNativeCommandSpecsForConfig(config).map((command) => ({ + const skillCommands = resolveSkillCommands(config); + const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ command: command.name, description: command.description, })); @@ -227,7 +237,8 @@ describe("createTelegramBot", () => { command: string; description: string; }>; - const native = listNativeCommandSpecsForConfig(config).map((command) => ({ + const skillCommands = resolveSkillCommands(config); + const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ command: command.name, description: command.description, }));