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

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

View File

@@ -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 = {

View File

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