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

@@ -19,6 +19,7 @@
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007. - 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. - 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. - 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. - 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. - TUI: show provider/model labels for the active session and default model.
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example. - Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.

View File

@@ -80,6 +80,8 @@ Notes:
- Use `{baseDir}` in instructions to reference the skill folder path. - Use `{baseDir}` in instructions to reference the skill folder path.
- Optional frontmatter keys: - Optional frontmatter keys:
- `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.clawdbot.homepage`). - `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) ## Gating (load-time filters)

View File

@@ -96,6 +96,7 @@ Notes:
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow. - Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`). - Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. - 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. - **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) ## Usage vs cost (what shows where)

View File

@@ -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();
});
});

View File

@@ -9,15 +9,17 @@ async function _writeSkill(params: {
name: string; name: string;
description: string; description: string;
metadata?: string; metadata?: string;
frontmatterExtra?: string;
body?: 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.mkdir(dir, { recursive: true });
await fs.writeFile( await fs.writeFile(
path.join(dir, "SKILL.md"), path.join(dir, "SKILL.md"),
`--- `---
name: ${name} name: ${name}
description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""}
${frontmatterExtra ?? ""}
--- ---
${body ?? `# ${name}\n`} ${body ?? `# ${name}\n`}
@@ -38,4 +40,31 @@ describe("buildWorkspaceSkillSnapshot", () => {
expect(snapshot.prompt).toBe(""); expect(snapshot.prompt).toBe("");
expect(snapshot.skills).toEqual([]); 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",
]);
});
}); });

View File

@@ -16,6 +16,7 @@ export {
export type { export type {
ClawdbotSkillMetadata, ClawdbotSkillMetadata,
SkillEligibilityContext, SkillEligibilityContext,
SkillCommandSpec,
SkillEntry, SkillEntry,
SkillInstallSpec, SkillInstallSpec,
SkillSnapshot, SkillSnapshot,
@@ -24,6 +25,7 @@ export type {
export { export {
buildWorkspaceSkillSnapshot, buildWorkspaceSkillSnapshot,
buildWorkspaceSkillsPrompt, buildWorkspaceSkillsPrompt,
buildWorkspaceSkillCommandSpecs,
filterWorkspaceSkillEntries, filterWorkspaceSkillEntries,
loadWorkspaceSkillEntries, loadWorkspaceSkillEntries,
resolveSkillsPromptForRun, resolveSkillsPromptForRun,

View File

@@ -5,6 +5,7 @@ import type {
ParsedSkillFrontmatter, ParsedSkillFrontmatter,
SkillEntry, SkillEntry,
SkillInstallSpec, SkillInstallSpec,
SkillInvocationPolicy,
} from "./types.js"; } from "./types.js";
function stripQuotes(value: string): string { function stripQuotes(value: string): string {
@@ -79,6 +80,24 @@ function getFrontmatterValue(frontmatter: ParsedSkillFrontmatter, key: string):
return typeof raw === "string" ? raw : undefined; 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( export function resolveClawdbotMetadata(
frontmatter: ParsedSkillFrontmatter, frontmatter: ParsedSkillFrontmatter,
): ClawdbotSkillMetadata | undefined { ): 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 { export function resolveSkillKey(skill: Skill, entry?: SkillEntry): string {
return entry?.clawdbot?.skillKey ?? skill.name; return entry?.clawdbot?.skillKey ?? skill.name;
} }

View File

@@ -26,6 +26,17 @@ export type ClawdbotSkillMetadata = {
install?: SkillInstallSpec[]; install?: SkillInstallSpec[];
}; };
export type SkillInvocationPolicy = {
userInvocable: boolean;
disableModelInvocation: boolean;
};
export type SkillCommandSpec = {
name: string;
skillName: string;
description: string;
};
export type SkillsInstallPreferences = { export type SkillsInstallPreferences = {
preferBrew: boolean; preferBrew: boolean;
nodeManager: "npm" | "pnpm" | "yarn" | "bun"; nodeManager: "npm" | "pnpm" | "yarn" | "bun";
@@ -37,6 +48,7 @@ export type SkillEntry = {
skill: Skill; skill: Skill;
frontmatter: ParsedSkillFrontmatter; frontmatter: ParsedSkillFrontmatter;
clawdbot?: ClawdbotSkillMetadata; clawdbot?: ClawdbotSkillMetadata;
invocation?: SkillInvocationPolicy;
}; };
export type SkillEligibilityContext = { export type SkillEligibilityContext = {

View File

@@ -11,11 +11,16 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import { resolveBundledSkillsDir } from "./bundled-dir.js"; import { resolveBundledSkillsDir } from "./bundled-dir.js";
import { shouldIncludeSkill } from "./config.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 { serializeByKey } from "./serialize.js";
import type { import type {
ParsedSkillFrontmatter, ParsedSkillFrontmatter,
SkillEligibilityContext, SkillEligibilityContext,
SkillCommandSpec,
SkillEntry, SkillEntry,
SkillSnapshot, SkillSnapshot,
} from "./types.js"; } from "./types.js";
@@ -43,6 +48,34 @@ function filterSkillEntries(
return filtered; 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>): 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( function loadSkillEntries(
workspaceDir: string, workspaceDir: string,
opts?: { opts?: {
@@ -114,6 +147,7 @@ function loadSkillEntries(
skill, skill,
frontmatter, frontmatter,
clawdbot: resolveClawdbotMetadata(frontmatter), clawdbot: resolveClawdbotMetadata(frontmatter),
invocation: resolveSkillInvocationPolicy(frontmatter),
}; };
}); });
return skillEntries; return skillEntries;
@@ -139,7 +173,10 @@ export function buildWorkspaceSkillSnapshot(
opts?.skillFilter, opts?.skillFilter,
opts?.eligibility, 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 remoteNote = opts?.eligibility?.remote?.note?.trim();
const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n"); const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n");
return { return {
@@ -172,8 +209,11 @@ export function buildWorkspaceSkillsPrompt(
opts?.skillFilter, opts?.skillFilter,
opts?.eligibility, opts?.eligibility,
); );
const promptEntries = eligible.filter(
(entry) => entry.invocation?.disableModelInvocation !== true,
);
const remoteNote = opts?.eligibility?.remote?.note?.trim(); 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) .filter(Boolean)
.join("\n"); .join("\n");
} }
@@ -251,3 +291,44 @@ export function filterWorkspaceSkillEntries(
): SkillEntry[] { ): SkillEntry[] {
return filterSkillEntries(entries, config); return filterSkillEntries(entries, config);
} }
export function buildWorkspaceSkillCommandSpecs(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
skillFilter?: string[];
eligibility?: SkillEligibilityContext;
reservedNames?: Set<string>;
},
): 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<string>();
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;
}

View File

@@ -45,6 +45,29 @@ describe("commands registry", () => {
expect(nativeDisabled.find((spec) => spec.name === "debug")).toBeFalsy(); 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", () => { it("detects known text commands", () => {
const detection = getCommandDetection(); const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true); expect(detection.exact.has("/commands")).toBe(true);

View File

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

View File

@@ -1,4 +1,5 @@
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
import { buildCommandsMessage, buildHelpMessage } from "../status.js"; import { buildCommandsMessage, buildHelpMessage } from "../status.js";
import { buildStatusReply } from "./commands-status.js"; import { buildStatusReply } from "./commands-status.js";
import { buildContextReply } from "./commands-context-report.js"; import { buildContextReply } from "./commands-context-report.js";
@@ -28,9 +29,15 @@ export const handleCommandsListCommand: CommandHandler = async (params, allowTex
); );
return { shouldContinue: false }; return { shouldContinue: false };
} }
const skillCommands =
params.skillCommands ??
listSkillCommandsForWorkspace({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
});
return { return {
shouldContinue: false, 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 { ChannelId } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js"; import type { SessionEntry, SessionScope } from "../../config/sessions.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js"; import type { ReplyPayload } from "../types.js";
@@ -47,6 +48,7 @@ export type HandleCommandsParams = {
model: string; model: string;
contextTokens: number; contextTokens: number;
isGroup: boolean; isGroup: boolean;
skillCommands?: SkillCommandSpec[];
}; };
export type CommandHandlerResult = { export type CommandHandlerResult = {

View File

@@ -11,6 +11,8 @@ import { isDirectiveOnly } from "./directive-handling.js";
import type { createModelSelectionState } from "./model-selection.js"; import type { createModelSelectionState } from "./model-selection.js";
import { extractInlineSimpleCommand } from "./reply-inline.js"; import { extractInlineSimpleCommand } from "./reply-inline.js";
import type { TypingController } from "./typing.js"; import type { TypingController } from "./typing.js";
import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js";
import { logVerbose } from "../../globals.js";
export type InlineActionResult = export type InlineActionResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined } | { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
@@ -55,6 +57,7 @@ export async function handleInlineActions(params: {
contextTokens: number; contextTokens: number;
directiveAck?: ReplyPayload; directiveAck?: ReplyPayload;
abortedLastRun: boolean; abortedLastRun: boolean;
skillFilter?: string[];
}): Promise<InlineActionResult> { }): Promise<InlineActionResult> {
const { const {
ctx, ctx,
@@ -89,11 +92,47 @@ export async function handleInlineActions(params: {
contextTokens, contextTokens,
directiveAck, directiveAck,
abortedLastRun: initialAbortedLastRun, abortedLastRun: initialAbortedLastRun,
skillFilter,
} = params; } = params;
let directives = initialDirectives; let directives = initialDirectives;
let cleanedBody = initialCleanedBody; 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) => { const sendInlineReply = async (reply?: ReplyPayload) => {
if (!reply) return; if (!reply) return;
if (!opts?.onBlockReply) return; if (!opts?.onBlockReply) return;
@@ -148,33 +187,34 @@ export async function handleInlineActions(params: {
commandBodyNormalized: inlineCommand.command, commandBodyNormalized: inlineCommand.command,
}; };
const inlineResult = await handleCommands({ const inlineResult = await handleCommands({
ctx, ctx,
cfg, cfg,
command: inlineCommandContext, command: inlineCommandContext,
agentId, agentId,
directives, directives,
elevated: { elevated: {
enabled: elevatedEnabled, enabled: elevatedEnabled,
allowed: elevatedAllowed, allowed: elevatedAllowed,
failures: elevatedFailures, failures: elevatedFailures,
}, },
sessionEntry, sessionEntry,
sessionStore, sessionStore,
sessionKey, sessionKey,
storePath, storePath,
sessionScope, sessionScope,
workspaceDir, workspaceDir,
defaultGroupActivation: defaultActivation, defaultGroupActivation: defaultActivation,
resolvedThinkLevel, resolvedThinkLevel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off", resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
resolvedReasoningLevel, resolvedReasoningLevel,
resolvedElevatedLevel, resolvedElevatedLevel,
resolveDefaultThinkingLevel, resolveDefaultThinkingLevel,
provider, provider,
model, model,
contextTokens, contextTokens,
isGroup, isGroup,
}); skillCommands,
});
if (inlineResult.reply) { if (inlineResult.reply) {
if (!inlineCommand.cleaned) { if (!inlineCommand.cleaned) {
typing.cleanup(); typing.cleanup();
@@ -235,6 +275,7 @@ export async function handleInlineActions(params: {
model, model,
contextTokens, contextTokens,
isGroup, isGroup,
skillCommands,
}); });
if (!commandResult.shouldContinue) { if (!commandResult.shouldContinue) {
typing.cleanup(); typing.cleanup();

View File

@@ -203,6 +203,7 @@ export async function getReplyFromConfig(
contextTokens, contextTokens,
directiveAck, directiveAck,
abortedLastRun, abortedLastRun,
skillFilter: opts?.skillFilter,
}); });
if (inlineActionResult.kind === "reply") { if (inlineActionResult.kind === "reply") {
return inlineActionResult.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("/config");
expect(text).not.toContain("/debug"); 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", () => { describe("buildHelpMessage", () => {

View File

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

View File

@@ -3,6 +3,8 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
import { Routes } from "discord-api-types/v10"; import { Routes } from "discord-api-types/v10";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.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 type { HistoryEntry } from "../../auto-reply/reply/history.js";
import { import {
isNativeCommandsExplicitlyDisabled, isNativeCommandsExplicitlyDisabled,
@@ -116,7 +118,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
throw new Error("Failed to resolve Discord application id"); 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) => const commands = commandSpecs.map((spec) =>
createDiscordNativeCommand({ createDiscordNativeCommand({
command: spec, command: spec,

View File

@@ -8,6 +8,8 @@ import {
parseCommandArgs, parseCommandArgs,
resolveCommandArgMenu, resolveCommandArgMenu,
} from "../../auto-reply/commands-registry.js"; } 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 { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { resolveNativeCommandsEnabled } from "../../config/commands.js"; import { resolveNativeCommandsEnabled } from "../../config/commands.js";
import { danger, logVerbose } from "../../globals.js"; import { danger, logVerbose } from "../../globals.js";
@@ -403,7 +405,15 @@ export function registerSlackMonitorSlashCommands(params: {
providerSetting: account.config.commands?.native, providerSetting: account.config.commands?.native,
globalSetting: cfg.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) { if (nativeCommands.length > 0) {
for (const command of nativeCommands) { for (const command of nativeCommands) {
ctx.app.command( ctx.app.command(

View File

@@ -9,6 +9,8 @@ import {
parseCommandArgs, parseCommandArgs,
resolveCommandArgMenu, resolveCommandArgMenu,
} from "../auto-reply/commands-registry.js"; } 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 type { CommandArgs } from "../auto-reply/commands-registry.js";
import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js"; import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
@@ -43,10 +45,21 @@ export const registerTelegramNativeCommands = ({
shouldSkipUpdate, shouldSkipUpdate,
opts, 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( const reservedCommands = new Set(
listNativeCommandSpecs().map((command) => command.name.toLowerCase()), listNativeCommandSpecs().map((command) => command.name.toLowerCase()),
); );
for (const command of skillCommands) {
reservedCommands.add(command.name.toLowerCase());
}
const customResolution = resolveTelegramCustomCommands({ const customResolution = resolveTelegramCustomCommands({
commands: telegramCfg.customCommands, commands: telegramCfg.customCommands,
reservedCommands, reservedCommands,

View File

@@ -6,11 +6,20 @@ import {
listNativeCommandSpecs, listNativeCommandSpecs,
listNativeCommandSpecsForConfig, listNativeCommandSpecsForConfig,
} from "../auto-reply/commands-registry.js"; } 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 { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import * as replyModule from "../auto-reply/reply.js"; import * as replyModule from "../auto-reply/reply.js";
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
import { resolveTelegramFetch } from "./fetch.js"; import { resolveTelegramFetch } from "./fetch.js";
function resolveSkillCommands(config: Parameters<typeof listNativeCommandSpecsForConfig>[0]) {
return listSkillCommandsForWorkspace({
workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)),
cfg: config,
});
}
const { loadWebMedia } = vi.hoisted(() => ({ const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(), loadWebMedia: vi.fn(),
})); }));
@@ -187,7 +196,8 @@ describe("createTelegramBot", () => {
command: string; command: string;
description: string; description: string;
}>; }>;
const native = listNativeCommandSpecsForConfig(config).map((command) => ({ const skillCommands = resolveSkillCommands(config);
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
command: command.name, command: command.name,
description: command.description, description: command.description,
})); }));
@@ -227,7 +237,8 @@ describe("createTelegramBot", () => {
command: string; command: string;
description: string; description: string;
}>; }>;
const native = listNativeCommandSpecsForConfig(config).map((command) => ({ const skillCommands = resolveSkillCommands(config);
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
command: command.name, command: command.name,
description: command.description, description: command.description,
})); }));