feat: add user-invocable skill commands
This commit is contained in:
64
src/agents/skills.buildworkspaceskillcommands.test.ts
Normal file
64
src/agents/skills.buildworkspaceskillcommands.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>): 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<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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user