feat: expand skill command registration
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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";
|
||||
|
||||
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", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
@@ -174,6 +186,43 @@ describe("directive behavior", () => {
|
||||
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 () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ModelAliasIndex } from "../../agents/model-selection.js";
|
||||
import type { SkillCommandSpec } from "../../agents/skills.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { listChatCommands, shouldHandleTextCommands } from "../commands-registry.js";
|
||||
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
@@ -24,6 +26,7 @@ export type ReplyDirectiveContinuation = {
|
||||
commandSource: string;
|
||||
command: ReturnType<typeof buildCommandContext>;
|
||||
allowTextCommands: boolean;
|
||||
skillCommands?: SkillCommandSpec[];
|
||||
directives: InlineDirectives;
|
||||
cleanedBody: string;
|
||||
messageProviderKey: string;
|
||||
@@ -65,6 +68,7 @@ export async function resolveReplyDirectives(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
workspaceDir: string;
|
||||
agentCfg: AgentDefaults;
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
@@ -83,6 +87,7 @@ export async function resolveReplyDirectives(params: {
|
||||
model: string;
|
||||
typing: TypingController;
|
||||
opts?: GetReplyOptions;
|
||||
skillFilter?: string[];
|
||||
}): Promise<ReplyDirectiveResult> {
|
||||
const {
|
||||
ctx,
|
||||
@@ -90,6 +95,7 @@ export async function resolveReplyDirectives(params: {
|
||||
agentId,
|
||||
agentCfg,
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
@@ -106,6 +112,7 @@ export async function resolveReplyDirectives(params: {
|
||||
model: initialModel,
|
||||
typing,
|
||||
opts,
|
||||
skillFilter,
|
||||
} = params;
|
||||
let provider = initialProvider;
|
||||
let model = initialModel;
|
||||
@@ -132,11 +139,23 @@ export async function resolveReplyDirectives(params: {
|
||||
surface: command.surface,
|
||||
commandSource: ctx.CommandSource,
|
||||
});
|
||||
const shouldResolveSkillCommands =
|
||||
allowTextCommands && command.commandBodyNormalized.includes("/");
|
||||
const skillCommands = shouldResolveSkillCommands
|
||||
? listSkillCommandsForWorkspace({
|
||||
workspaceDir,
|
||||
cfg,
|
||||
skillFilter,
|
||||
})
|
||||
: [];
|
||||
const reservedCommands = new Set(
|
||||
listChatCommands().flatMap((cmd) =>
|
||||
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 ?? {})
|
||||
.map((entry) => entry.alias?.trim())
|
||||
.filter((alias): alias is string => Boolean(alias))
|
||||
@@ -385,6 +404,7 @@ export async function resolveReplyDirectives(params: {
|
||||
commandSource,
|
||||
command,
|
||||
allowTextCommands,
|
||||
skillCommands,
|
||||
directives,
|
||||
cleanedBody,
|
||||
messageProviderKey,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import type { SkillCommandSpec } from "../../agents/skills.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
@@ -39,6 +40,7 @@ export async function handleInlineActions(params: {
|
||||
allowTextCommands: boolean;
|
||||
inlineStatusRequested: boolean;
|
||||
command: Parameters<typeof handleCommands>[0]["command"];
|
||||
skillCommands?: SkillCommandSpec[];
|
||||
directives: InlineDirectives;
|
||||
cleanedBody: string;
|
||||
elevatedEnabled: boolean;
|
||||
@@ -99,13 +101,16 @@ export async function handleInlineActions(params: {
|
||||
let cleanedBody = initialCleanedBody;
|
||||
|
||||
const shouldLoadSkillCommands = command.commandBodyNormalized.startsWith("/");
|
||||
const skillCommands = shouldLoadSkillCommands
|
||||
? listSkillCommandsForWorkspace({
|
||||
workspaceDir,
|
||||
cfg,
|
||||
skillFilter,
|
||||
})
|
||||
: [];
|
||||
const skillCommands =
|
||||
shouldLoadSkillCommands && params.skillCommands
|
||||
? params.skillCommands
|
||||
: shouldLoadSkillCommands
|
||||
? listSkillCommandsForWorkspace({
|
||||
workspaceDir,
|
||||
cfg,
|
||||
skillFilter,
|
||||
})
|
||||
: [];
|
||||
|
||||
const skillInvocation =
|
||||
allowTextCommands && skillCommands.length > 0
|
||||
|
||||
@@ -118,6 +118,7 @@ export async function getReplyFromConfig(
|
||||
cfg,
|
||||
agentId,
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
agentCfg,
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
@@ -136,6 +137,7 @@ export async function getReplyFromConfig(
|
||||
model,
|
||||
typing,
|
||||
opts,
|
||||
skillFilter: opts?.skillFilter,
|
||||
});
|
||||
if (directiveResult.kind === "reply") {
|
||||
return directiveResult.reply;
|
||||
@@ -145,6 +147,7 @@ export async function getReplyFromConfig(
|
||||
commandSource,
|
||||
command,
|
||||
allowTextCommands,
|
||||
skillCommands,
|
||||
directives,
|
||||
cleanedBody,
|
||||
elevatedEnabled,
|
||||
@@ -187,6 +190,7 @@ export async function getReplyFromConfig(
|
||||
allowTextCommands,
|
||||
inlineStatusRequested,
|
||||
command,
|
||||
skillCommands,
|
||||
directives,
|
||||
cleanedBody,
|
||||
elevatedEnabled,
|
||||
|
||||
@@ -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 { 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", () => {
|
||||
it("matches skill commands and parses args", () => {
|
||||
@@ -19,3 +38,44 @@ describe("resolveSkillCommandInvocation", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
|
||||
import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agents/skills.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: {
|
||||
commandBodyNormalized: string;
|
||||
skillCommands: SkillCommandSpec[];
|
||||
|
||||
Reference in New Issue
Block a user