feat: expand skill command registration

This commit is contained in:
Peter Steinberger
2026-01-16 20:11:01 +00:00
parent 69761e8a51
commit 38b49aa0f6
21 changed files with 311 additions and 45 deletions

View File

@@ -20,7 +20,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. - Skills: add user-invocable skill commands (sanitized + unique), native skill registration gating, 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

@@ -26,6 +26,7 @@ They run immediately, are stripped before the model sees the message, and the re
{ {
commands: { commands: {
native: "auto", native: "auto",
nativeSkills: "auto",
text: true, text: true,
bash: false, bash: false,
bashForegroundMs: 2000, bashForegroundMs: 2000,
@@ -43,6 +44,9 @@ They run immediately, are stripped before the model sees the message, and the re
- Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support. - Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support.
- Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`). - Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`).
- `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically. - `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically.
- `commands.nativeSkills` (default `"auto"`) registers **skill** commands natively when supported.
- Auto: on for Discord/Telegram; off for Slack (Slack requires creating a slash command per skill).
- Set `channels.discord.commands.nativeSkills`, `channels.telegram.commands.nativeSkills`, or `channels.slack.commands.nativeSkills` to override per provider (bool or `"auto"`).
- `commands.bash` (default `false`) enables `! <cmd>` to run host shell commands (`/bash <cmd>` is an alias; requires `tools.elevated` allowlists). - `commands.bash` (default `false`) enables `! <cmd>` to run host shell commands (`/bash <cmd>` is an alias; requires `tools.elevated` allowlists).
- `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately). - `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately).
- `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`). - `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`).

View File

@@ -38,6 +38,20 @@ function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object")); return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
} }
export function listAgentIds(cfg: ClawdbotConfig): string[] {
const agents = listAgents(cfg);
if (agents.length === 0) return [DEFAULT_AGENT_ID];
const seen = new Set<string>();
const ids: string[] = [];
for (const entry of agents) {
const id = normalizeAgentId(entry?.id);
if (seen.has(id)) continue;
seen.add(id);
ids.push(id);
}
return ids.length > 0 ? ids : [DEFAULT_AGENT_ID];
}
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string { export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
const agents = listAgents(cfg); const agents = listAgents(cfg);
if (agents.length === 0) return DEFAULT_AGENT_ID; if (agents.length === 0) return DEFAULT_AGENT_ID;

View File

@@ -321,17 +321,26 @@ export function buildWorkspaceSkillCommandSpecs(
const specs: SkillCommandSpec[] = []; const specs: SkillCommandSpec[] = [];
for (const entry of userInvocable) { for (const entry of userInvocable) {
const base = sanitizeSkillCommandName(entry.skill.name); const rawName = entry.skill.name;
const base = sanitizeSkillCommandName(rawName);
if (base !== rawName) {
console.warn(`[skills] Sanitized skill command name "${rawName}" to "/${base}".`);
}
const unique = resolveUniqueSkillCommandName(base, used); const unique = resolveUniqueSkillCommandName(base, used);
if (unique !== base) {
console.warn(
`[skills] De-duplicated skill command name for "${rawName}" to "/${unique}".`,
);
}
used.add(unique.toLowerCase()); used.add(unique.toLowerCase());
const rawDescription = entry.skill.description?.trim() || entry.skill.name; const rawDescription = entry.skill.description?.trim() || rawName;
const description = const description =
rawDescription.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH rawDescription.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH
? rawDescription.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…" ? rawDescription.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…"
: rawDescription; : rawDescription;
specs.push({ specs.push({
name: unique, name: unique,
skillName: entry.skill.name, skillName: rawName,
description, description,
}); });
} }

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
@@ -8,6 +9,17 @@ import { getReplyFromConfig } from "./reply.js";
const MAIN_SESSION_KEY = "agent:main:main"; const MAIN_SESSION_KEY = "agent:main:main";
async function writeSkill(params: { workspaceDir: string; name: string; description: string }) {
const { workspaceDir, name, description } = params;
const skillDir = path.join(workspaceDir, "skills", name);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`,
"utf-8",
);
}
vi.mock("../agents/pi-embedded.js", () => ({ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false), abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(), runEmbeddedPiAgent: vi.fn(),
@@ -174,6 +186,43 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
it("treats skill commands as reserved for model aliases", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const workspace = path.join(home, "clawd");
await writeSkill({
workspaceDir: workspace,
name: "demo-skill",
description: "Demo skill",
});
await getReplyFromConfig(
{
Body: "/demo_skill",
From: "+1222",
To: "+1222",
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace,
models: {
"anthropic/claude-opus-4-5": { alias: "demo_skill" },
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalled();
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain('Use the "demo-skill" skill');
});
});
it("errors on invalid queue options", async () => { it("errors on invalid queue options", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();

View File

@@ -1,8 +1,10 @@
import type { ModelAliasIndex } from "../../agents/model-selection.js"; import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js"; import type { SessionEntry } from "../../config/sessions.js";
import { listChatCommands, shouldHandleTextCommands } from "../commands-registry.js"; import { listChatCommands, shouldHandleTextCommands } from "../commands-registry.js";
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
import type { MsgContext, TemplateContext } from "../templating.js"; import type { MsgContext, TemplateContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -24,6 +26,7 @@ export type ReplyDirectiveContinuation = {
commandSource: string; commandSource: string;
command: ReturnType<typeof buildCommandContext>; command: ReturnType<typeof buildCommandContext>;
allowTextCommands: boolean; allowTextCommands: boolean;
skillCommands?: SkillCommandSpec[];
directives: InlineDirectives; directives: InlineDirectives;
cleanedBody: string; cleanedBody: string;
messageProviderKey: string; messageProviderKey: string;
@@ -65,6 +68,7 @@ export async function resolveReplyDirectives(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
agentId: string; agentId: string;
agentDir: string; agentDir: string;
workspaceDir: string;
agentCfg: AgentDefaults; agentCfg: AgentDefaults;
sessionCtx: TemplateContext; sessionCtx: TemplateContext;
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
@@ -83,6 +87,7 @@ export async function resolveReplyDirectives(params: {
model: string; model: string;
typing: TypingController; typing: TypingController;
opts?: GetReplyOptions; opts?: GetReplyOptions;
skillFilter?: string[];
}): Promise<ReplyDirectiveResult> { }): Promise<ReplyDirectiveResult> {
const { const {
ctx, ctx,
@@ -90,6 +95,7 @@ export async function resolveReplyDirectives(params: {
agentId, agentId,
agentCfg, agentCfg,
agentDir, agentDir,
workspaceDir,
sessionCtx, sessionCtx,
sessionEntry, sessionEntry,
sessionStore, sessionStore,
@@ -106,6 +112,7 @@ export async function resolveReplyDirectives(params: {
model: initialModel, model: initialModel,
typing, typing,
opts, opts,
skillFilter,
} = params; } = params;
let provider = initialProvider; let provider = initialProvider;
let model = initialModel; let model = initialModel;
@@ -132,11 +139,23 @@ export async function resolveReplyDirectives(params: {
surface: command.surface, surface: command.surface,
commandSource: ctx.CommandSource, commandSource: ctx.CommandSource,
}); });
const shouldResolveSkillCommands =
allowTextCommands && command.commandBodyNormalized.includes("/");
const skillCommands = shouldResolveSkillCommands
? listSkillCommandsForWorkspace({
workspaceDir,
cfg,
skillFilter,
})
: [];
const reservedCommands = new Set( const reservedCommands = new Set(
listChatCommands().flatMap((cmd) => listChatCommands().flatMap((cmd) =>
cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()), cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()),
), ),
); );
for (const command of skillCommands) {
reservedCommands.add(command.name.toLowerCase());
}
const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {}) const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {})
.map((entry) => entry.alias?.trim()) .map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias)) .filter((alias): alias is string => Boolean(alias))
@@ -385,6 +404,7 @@ export async function resolveReplyDirectives(params: {
commandSource, commandSource,
command, command,
allowTextCommands, allowTextCommands,
skillCommands,
directives, directives,
cleanedBody, cleanedBody,
messageProviderKey, messageProviderKey,

View File

@@ -1,4 +1,5 @@
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js"; import type { SessionEntry } from "../../config/sessions.js";
import type { MsgContext, TemplateContext } from "../templating.js"; import type { MsgContext, TemplateContext } from "../templating.js";
@@ -39,6 +40,7 @@ export async function handleInlineActions(params: {
allowTextCommands: boolean; allowTextCommands: boolean;
inlineStatusRequested: boolean; inlineStatusRequested: boolean;
command: Parameters<typeof handleCommands>[0]["command"]; command: Parameters<typeof handleCommands>[0]["command"];
skillCommands?: SkillCommandSpec[];
directives: InlineDirectives; directives: InlineDirectives;
cleanedBody: string; cleanedBody: string;
elevatedEnabled: boolean; elevatedEnabled: boolean;
@@ -99,13 +101,16 @@ export async function handleInlineActions(params: {
let cleanedBody = initialCleanedBody; let cleanedBody = initialCleanedBody;
const shouldLoadSkillCommands = command.commandBodyNormalized.startsWith("/"); const shouldLoadSkillCommands = command.commandBodyNormalized.startsWith("/");
const skillCommands = shouldLoadSkillCommands const skillCommands =
? listSkillCommandsForWorkspace({ shouldLoadSkillCommands && params.skillCommands
workspaceDir, ? params.skillCommands
cfg, : shouldLoadSkillCommands
skillFilter, ? listSkillCommandsForWorkspace({
}) workspaceDir,
: []; cfg,
skillFilter,
})
: [];
const skillInvocation = const skillInvocation =
allowTextCommands && skillCommands.length > 0 allowTextCommands && skillCommands.length > 0

View File

@@ -118,6 +118,7 @@ export async function getReplyFromConfig(
cfg, cfg,
agentId, agentId,
agentDir, agentDir,
workspaceDir,
agentCfg, agentCfg,
sessionCtx, sessionCtx,
sessionEntry, sessionEntry,
@@ -136,6 +137,7 @@ export async function getReplyFromConfig(
model, model,
typing, typing,
opts, opts,
skillFilter: opts?.skillFilter,
}); });
if (directiveResult.kind === "reply") { if (directiveResult.kind === "reply") {
return directiveResult.reply; return directiveResult.reply;
@@ -145,6 +147,7 @@ export async function getReplyFromConfig(
commandSource, commandSource,
command, command,
allowTextCommands, allowTextCommands,
skillCommands,
directives, directives,
cleanedBody, cleanedBody,
elevatedEnabled, elevatedEnabled,
@@ -187,6 +190,7 @@ export async function getReplyFromConfig(
allowTextCommands, allowTextCommands,
inlineStatusRequested, inlineStatusRequested,
command, command,
skillCommands,
directives, directives,
cleanedBody, cleanedBody,
elevatedEnabled, elevatedEnabled,

View File

@@ -1,5 +1,24 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveSkillCommandInvocation } from "./skill-commands.js"; import { listSkillCommandsForAgents, resolveSkillCommandInvocation } from "./skill-commands.js";
async function writeSkill(params: {
workspaceDir: string;
dirName: string;
name: string;
description: string;
}) {
const { workspaceDir, dirName, name, description } = params;
const skillDir = path.join(workspaceDir, "skills", dirName);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`,
"utf-8",
);
}
describe("resolveSkillCommandInvocation", () => { describe("resolveSkillCommandInvocation", () => {
it("matches skill commands and parses args", () => { it("matches skill commands and parses args", () => {
@@ -19,3 +38,44 @@ describe("resolveSkillCommandInvocation", () => {
expect(invocation).toBeNull(); expect(invocation).toBeNull();
}); });
}); });
describe("listSkillCommandsForAgents", () => {
it("merges command names across agents and de-duplicates", async () => {
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-skills-"));
const mainWorkspace = path.join(baseDir, "main");
const researchWorkspace = path.join(baseDir, "research");
await writeSkill({
workspaceDir: mainWorkspace,
dirName: "demo",
name: "demo-skill",
description: "Demo skill",
});
await writeSkill({
workspaceDir: researchWorkspace,
dirName: "demo2",
name: "demo-skill",
description: "Demo skill 2",
});
await writeSkill({
workspaceDir: researchWorkspace,
dirName: "extra",
name: "extra-skill",
description: "Extra skill",
});
const commands = listSkillCommandsForAgents({
cfg: {
agents: {
list: [
{ id: "main", workspace: mainWorkspace },
{ id: "research", workspace: researchWorkspace },
],
},
},
});
const names = commands.map((entry) => entry.name);
expect(names).toContain("demo_skill");
expect(names).toContain("demo_skill_2");
expect(names).toContain("extra_skill");
});
});

View File

@@ -1,4 +1,7 @@
import fs from "node:fs";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agents/skills.js"; import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agents/skills.js";
import { listChatCommands } from "./commands-registry.js"; import { listChatCommands } from "./commands-registry.js";
@@ -29,6 +32,29 @@ export function listSkillCommandsForWorkspace(params: {
}); });
} }
export function listSkillCommandsForAgents(params: {
cfg: ClawdbotConfig;
agentIds?: string[];
}): SkillCommandSpec[] {
const used = resolveReservedCommandNames();
const entries: SkillCommandSpec[] = [];
const agentIds = params.agentIds ?? listAgentIds(params.cfg);
for (const agentId of agentIds) {
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId);
if (!fs.existsSync(workspaceDir)) continue;
const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, {
config: params.cfg,
eligibility: { remote: getRemoteSkillEligibility() },
reservedNames: used,
});
for (const command of commands) {
used.add(command.name.toLowerCase());
entries.push(command);
}
}
return entries;
}
export function resolveSkillCommandInvocation(params: { export function resolveSkillCommandInvocation(params: {
commandBodyNormalized: string; commandBodyNormalized: string;
skillCommands: SkillCommandSpec[]; skillCommands: SkillCommandSpec[];

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { resolveNativeSkillsEnabled } from "./commands.js";
describe("resolveNativeSkillsEnabled", () => {
it("uses provider defaults for auto", () => {
expect(
resolveNativeSkillsEnabled({
providerId: "discord",
globalSetting: "auto",
}),
).toBe(true);
expect(
resolveNativeSkillsEnabled({
providerId: "telegram",
globalSetting: "auto",
}),
).toBe(true);
expect(
resolveNativeSkillsEnabled({
providerId: "slack",
globalSetting: "auto",
}),
).toBe(false);
expect(
resolveNativeSkillsEnabled({
providerId: "whatsapp",
globalSetting: "auto",
}),
).toBe(false);
});
it("honors explicit provider settings", () => {
expect(
resolveNativeSkillsEnabled({
providerId: "slack",
providerSetting: true,
globalSetting: "auto",
}),
).toBe(true);
expect(
resolveNativeSkillsEnabled({
providerId: "discord",
providerSetting: false,
globalSetting: true,
}),
).toBe(false);
});
});

View File

@@ -10,6 +10,18 @@ function resolveAutoDefault(providerId?: ChannelId): boolean {
return false; return false;
} }
export function resolveNativeSkillsEnabled(params: {
providerId: ChannelId;
providerSetting?: NativeCommandsSetting;
globalSetting?: NativeCommandsSetting;
}): boolean {
const { providerId, providerSetting, globalSetting } = params;
const setting = providerSetting === undefined ? globalSetting : providerSetting;
if (setting === true) return true;
if (setting === false) return false;
return resolveAutoDefault(providerId);
}
export function resolveNativeCommandsEnabled(params: { export function resolveNativeCommandsEnabled(params: {
providerId: ChannelId; providerId: ChannelId;
providerSetting?: NativeCommandsSetting; providerSetting?: NativeCommandsSetting;

View File

@@ -158,6 +158,7 @@ const FIELD_LABELS: Record<string, string> = {
"agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)",
"agents.defaults.cliBackends": "CLI Backends", "agents.defaults.cliBackends": "CLI Backends",
"commands.native": "Native Commands", "commands.native": "Native Commands",
"commands.nativeSkills": "Native Skill Commands",
"commands.text": "Text Commands", "commands.text": "Text Commands",
"commands.bash": "Allow Bash Chat Command", "commands.bash": "Allow Bash Chat Command",
"commands.bashForegroundMs": "Bash Foreground Window (ms)", "commands.bashForegroundMs": "Bash Foreground Window (ms)",
@@ -323,6 +324,8 @@ const FIELD_HELP: Record<string, string> = {
"agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).",
"commands.native": "commands.native":
"Register native commands with channels that support it (Discord/Slack/Telegram).", "Register native commands with channels that support it (Discord/Slack/Telegram).",
"commands.nativeSkills":
"Register native skill commands (user-invocable skills) with channels that support it.",
"commands.text": "Allow text command parsing (slash commands only).", "commands.text": "Allow text command parsing (slash commands only).",
"commands.bash": "commands.bash":
"Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).",
@@ -349,8 +352,14 @@ const FIELD_HELP: Record<string, string> = {
"channels.msteams.configWrites": "channels.msteams.configWrites":
"Allow Microsoft Teams to write config in response to channel events/commands (default: true).", "Allow Microsoft Teams to write config in response to channel events/commands (default: true).",
"channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").', "channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").',
"channels.discord.commands.nativeSkills":
'Override native skill commands for Discord (bool or "auto").',
"channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").', "channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").',
"channels.telegram.commands.nativeSkills":
'Override native skill commands for Telegram (bool or "auto").',
"channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").', "channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").',
"channels.slack.commands.nativeSkills":
'Override native skill commands for Slack (bool or "auto").',
"session.agentToAgent.maxPingPongTurns": "session.agentToAgent.maxPingPongTurns":
"Max reply-back turns between requester and target (05).", "Max reply-back turns between requester and target (05).",
"channels.telegram.customCommands": "channels.telegram.customCommands":

View File

@@ -95,6 +95,8 @@ export type NativeCommandsSetting = boolean | "auto";
export type CommandsConfig = { export type CommandsConfig = {
/** Enable native command registration when supported (default: "auto"). */ /** Enable native command registration when supported (default: "auto"). */
native?: NativeCommandsSetting; native?: NativeCommandsSetting;
/** Enable native skill command registration when supported (default: "auto"). */
nativeSkills?: NativeCommandsSetting;
/** Enable text command parsing (default: true). */ /** Enable text command parsing (default: true). */
text?: boolean; text?: boolean;
/** Allow bash chat command (`!`; `/bash` alias) (default: false). */ /** Allow bash chat command (`!`; `/bash` alias) (default: false). */
@@ -114,4 +116,6 @@ export type CommandsConfig = {
export type ProviderCommandsConfig = { export type ProviderCommandsConfig = {
/** Override native command registration for this provider (bool or "auto"). */ /** Override native command registration for this provider (bool or "auto"). */
native?: NativeCommandsSetting; native?: NativeCommandsSetting;
/** Override native skill command registration for this provider (bool or "auto"). */
nativeSkills?: NativeCommandsSetting;
}; };

View File

@@ -252,5 +252,6 @@ export const NativeCommandsSettingSchema = z.union([z.boolean(), z.literal("auto
export const ProviderCommandsSchema = z export const ProviderCommandsSchema = z
.object({ .object({
native: NativeCommandsSettingSchema.optional(), native: NativeCommandsSettingSchema.optional(),
nativeSkills: NativeCommandsSettingSchema.optional(),
}) })
.optional(); .optional();

View File

@@ -72,6 +72,7 @@ export const MessagesSchema = z
export const CommandsSchema = z export const CommandsSchema = z
.object({ .object({
native: NativeCommandsSettingSchema.optional().default("auto"), native: NativeCommandsSettingSchema.optional().default("auto"),
nativeSkills: NativeCommandsSettingSchema.optional().default("auto"),
text: z.boolean().optional(), text: z.boolean().optional(),
bash: z.boolean().optional(), bash: z.boolean().optional(),
bashForegroundMs: z.number().int().min(0).max(30_000).optional(), bashForegroundMs: z.number().int().min(0).max(30_000).optional(),
@@ -81,4 +82,4 @@ export const CommandsSchema = z
useAccessGroups: z.boolean().optional(), useAccessGroups: z.boolean().optional(),
}) })
.optional() .optional()
.default({ native: "auto" }); .default({ native: "auto", nativeSkills: "auto" });

View File

@@ -3,12 +3,12 @@ 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 { listSkillCommandsForAgents } 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,
resolveNativeCommandsEnabled, resolveNativeCommandsEnabled,
resolveNativeSkillsEnabled,
} from "../../config/commands.js"; } from "../../config/commands.js";
import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js"; import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
@@ -99,6 +99,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
providerSetting: discordCfg.commands?.native, providerSetting: discordCfg.commands?.native,
globalSetting: cfg.commands?.native, globalSetting: cfg.commands?.native,
}); });
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "discord",
providerSetting: discordCfg.commands?.nativeSkills,
globalSetting: cfg.commands?.nativeSkills,
});
const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({ const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({
providerSetting: discordCfg.commands?.native, providerSetting: discordCfg.commands?.native,
globalSetting: cfg.commands?.native, globalSetting: cfg.commands?.native,
@@ -109,7 +114,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
if (shouldLogVerbose()) { if (shouldLogVerbose()) {
logVerbose( logVerbose(
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`, `discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`,
); );
} }
@@ -118,12 +123,8 @@ 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 skillCommands = nativeEnabled const skillCommands =
? listSkillCommandsForWorkspace({ nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)),
cfg,
})
: [];
const commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : []; const commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : [];
const commands = commandSpecs.map((spec) => const commands = commandSpecs.map((spec) =>
createDiscordNativeCommand({ createDiscordNativeCommand({

View File

@@ -8,10 +8,9 @@ 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 { listSkillCommandsForAgents } 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, resolveNativeSkillsEnabled } from "../../config/commands.js";
import { danger, logVerbose } from "../../globals.js"; import { danger, logVerbose } from "../../globals.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js";
import { import {
@@ -405,12 +404,13 @@ export function registerSlackMonitorSlashCommands(params: {
providerSetting: account.config.commands?.native, providerSetting: account.config.commands?.native,
globalSetting: cfg.commands?.native, globalSetting: cfg.commands?.native,
}); });
const skillCommands = nativeEnabled const nativeSkillsEnabled = resolveNativeSkillsEnabled({
? listSkillCommandsForWorkspace({ providerId: "slack",
workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), providerSetting: account.config.commands?.nativeSkills,
cfg, globalSetting: cfg.commands?.nativeSkills,
}) });
: []; const skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
const nativeCommands = nativeEnabled const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands }) ? listNativeCommandSpecsForConfig(cfg, { skillCommands })
: []; : [];

View File

@@ -9,8 +9,7 @@ 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 { listSkillCommandsForAgents } 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";
@@ -39,18 +38,15 @@ export const registerTelegramNativeCommands = ({
textLimit, textLimit,
useAccessGroups, useAccessGroups,
nativeEnabled, nativeEnabled,
nativeSkillsEnabled,
nativeDisabledExplicit, nativeDisabledExplicit,
resolveGroupPolicy, resolveGroupPolicy,
resolveTelegramGroupConfig, resolveTelegramGroupConfig,
shouldSkipUpdate, shouldSkipUpdate,
opts, opts,
}) => { }) => {
const skillCommands = nativeEnabled const skillCommands =
? listSkillCommandsForWorkspace({ nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)),
cfg,
})
: [];
const nativeCommands = nativeEnabled const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands }) ? listNativeCommandSpecsForConfig(cfg, { skillCommands })
: []; : [];

View File

@@ -6,18 +6,14 @@ 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 { listSkillCommandsForAgents } 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]) { function resolveSkillCommands(config: Parameters<typeof listNativeCommandSpecsForConfig>[0]) {
return listSkillCommandsForWorkspace({ return listSkillCommandsForAgents({ cfg: config });
workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)),
cfg: config,
});
} }
const { loadWebMedia } = vi.hoisted(() => ({ const { loadWebMedia } = vi.hoisted(() => ({

View File

@@ -10,6 +10,7 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/re
import { import {
isNativeCommandsExplicitlyDisabled, isNativeCommandsExplicitlyDisabled,
resolveNativeCommandsEnabled, resolveNativeCommandsEnabled,
resolveNativeSkillsEnabled,
} from "../config/commands.js"; } from "../config/commands.js";
import type { ClawdbotConfig, ReplyToMode } from "../config/config.js"; import type { ClawdbotConfig, ReplyToMode } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
@@ -190,6 +191,11 @@ export function createTelegramBot(opts: TelegramBotOptions) {
providerSetting: telegramCfg.commands?.native, providerSetting: telegramCfg.commands?.native,
globalSetting: cfg.commands?.native, globalSetting: cfg.commands?.native,
}); });
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "telegram",
providerSetting: telegramCfg.commands?.nativeSkills,
globalSetting: cfg.commands?.nativeSkills,
});
const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({ const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({
providerSetting: telegramCfg.commands?.native, providerSetting: telegramCfg.commands?.native,
globalSetting: cfg.commands?.native, globalSetting: cfg.commands?.native,
@@ -297,6 +303,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
textLimit, textLimit,
useAccessGroups, useAccessGroups,
nativeEnabled, nativeEnabled,
nativeSkillsEnabled,
nativeDisabledExplicit, nativeDisabledExplicit,
resolveGroupPolicy, resolveGroupPolicy,
resolveTelegramGroupConfig, resolveTelegramGroupConfig,