/** * Step 17 - Skills system * * Goal: * - load reusable workflows from SKILL.md files * - parse YAML frontmatter and markdown instructions * - expose model-visible skills in the system prompt * - let the model invoke skills through a Skill tool * - let users invoke skills with slash commands like `/review src/foo.ts` * - support conditional activation through `paths` frontmatter * * This file is a teaching version that condenses the core mechanics. */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { parse as parseYaml } from "yaml"; import ignore from "ignore"; const SKILL_FILE = "SKILL.md"; const DEFAULT_SKILL_BUDGET_CHARS = 8000; const MAX_LISTING_DESC_CHARS = 250; const SKILL_NAME_RE = /^[a-zA-Z0-9_-]+$/; // ----------------------------------------------------------------------------- // 1. Paths // ----------------------------------------------------------------------------- export function getUserSkillsDir() { return path.join(os.homedir(), ".easy-agent", "skills"); } export function getProjectSkillsDir(cwd) { return path.join(cwd, ".easy-agent", "skills"); } function posixifyPath(filePath) { return String(filePath).split(/[\\/]/).join("/"); } // ----------------------------------------------------------------------------- // 2. Frontmatter parsing // ----------------------------------------------------------------------------- const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; function asString(value) { if (typeof value === "string") { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } if (typeof value === "number" || typeof value === "boolean") { return String(value); } return undefined; } function asStringArray(value) { if (Array.isArray(value)) { return value .map((item) => (typeof item === "string" ? item.trim() : undefined)) .filter(Boolean); } if (typeof value === "string") { return value.split(",").map((item) => item.trim()).filter(Boolean); } return []; } function asBoolean(value) { if (typeof value === "boolean") return value; if (typeof value === "string") { const normalized = value.trim().toLowerCase(); return normalized === "true" || normalized === "yes" || normalized === "1"; } return false; } export function splitFrontmatter(content) { const match = String(content).match(FRONTMATTER_RE); if (!match) return { raw: {}, body: String(content) }; const [, yamlText, body] = match; try { const parsed = parseYaml(yamlText); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return { raw: parsed, body }; } return { raw: {}, body, parseError: "Frontmatter must be a YAML mapping (key: value)", }; } catch (error) { return { raw: {}, body, parseError: error.message }; } } export function extractFallbackDescription(body) { const buffer = []; for (const rawLine of String(body).split(/\r?\n/)) { const line = rawLine.trim(); if (!line) { if (buffer.length > 0) break; continue; } if (buffer.length === 0 && line.startsWith("#")) continue; buffer.push(line); } return buffer.join(" ").replace(/\s+/g, " ").trim(); } export function normalizeFrontmatter(raw) { const allowedTools = asStringArray(raw["allowed-tools"] ?? raw.allowedTools); const paths = asStringArray(raw.paths); return { name: asString(raw.name), description: asString(raw.description), whenToUse: asString(raw.when_to_use ?? raw.whenToUse), allowedTools, argumentHint: asString(raw["argument-hint"] ?? raw.argumentHint), disableModelInvocation: asBoolean( raw["disable-model-invocation"] ?? raw.disableModelInvocation, ), paths: paths.length > 0 ? paths : undefined, hasForkContext: asString(raw.context) === "fork", raw, }; } // ----------------------------------------------------------------------------- // 3. Disk loader // ----------------------------------------------------------------------------- async function loadFromOneDir(dir, source) { let dirents; try { dirents = await fs.readdir(dir, { withFileTypes: true }); } catch (error) { if (error?.code === "ENOENT") return { skills: [], warnings: [] }; return { skills: [], warnings: ["Failed to read " + dir + ": " + error.message] }; } const skills = []; const warnings = []; for (const dirent of dirents) { if (!dirent.isDirectory()) continue; const skillDir = path.join(dir, dirent.name); const filePath = path.join(skillDir, SKILL_FILE); let rawText; try { rawText = await fs.readFile(filePath, "utf8"); } catch (error) { if (error?.code !== "ENOENT") { warnings.push("[skills] Skipping " + skillDir + ": " + error.message); } continue; } const split = splitFrontmatter(rawText); if (split.parseError) { warnings.push("[skills] Skipping " + dirent.name + ": " + split.parseError); continue; } const frontmatter = normalizeFrontmatter(split.raw); const realFile = await fs.realpath(filePath).catch(() => filePath); const realDir = await fs.realpath(skillDir).catch(() => skillDir); const name = frontmatter.name ?? dirent.name; skills.push({ name, description: frontmatter.description ?? extractFallbackDescription(split.body) ?? name, whenToUse: frontmatter.whenToUse, body: split.body, filePath: realFile, baseDir: realDir, source, frontmatter, }); } return { skills, warnings }; } export async function loadAllSkills(cwd) { const [userResult, projectResult] = await Promise.all([ loadFromOneDir(getUserSkillsDir(), "user"), loadFromOneDir(getProjectSkillsDir(cwd), "project"), ]); const seenRealPaths = new Set(); const byName = new Map(); // Project skills are loaded second, so they override user skills by name. for (const skill of [...userResult.skills, ...projectResult.skills]) { if (seenRealPaths.has(skill.filePath)) continue; seenRealPaths.add(skill.filePath); byName.set(skill.name, skill); } return { skills: [...byName.values()], warnings: [...userResult.warnings, ...projectResult.warnings], }; } // ----------------------------------------------------------------------------- // 4. Registry // ----------------------------------------------------------------------------- const dynamicSkills = new Map(); const conditionalSkills = new Map(); let initialized = false; export function setSkills(skills) { dynamicSkills.clear(); conditionalSkills.clear(); for (const skill of skills) { if (skill.frontmatter.paths && skill.frontmatter.paths.length > 0) { conditionalSkills.set(skill.name, skill); } else { dynamicSkills.set(skill.name, skill); } } initialized = true; } export function isSkillsInitialized() { return initialized; } export function getModelVisibleSkills() { return [...dynamicSkills.values()].filter( (skill) => !skill.frontmatter.disableModelInvocation, ); } export function getAllUserInvocableSkills() { return [...dynamicSkills.values(), ...conditionalSkills.values()]; } export function findSkill(name) { return dynamicSkills.get(name) ?? conditionalSkills.get(name); } export function activateConditionalSkill(name) { const skill = conditionalSkills.get(name); if (!skill) return false; conditionalSkills.delete(name); dynamicSkills.set(name, skill); return true; } // ----------------------------------------------------------------------------- // 5. System prompt discovery listing // ----------------------------------------------------------------------------- function truncateDescription(description, maxChars) { if (description.length <= maxChars) return description; if (maxChars <= 1) return "..."; return description.slice(0, maxChars - 3).trimEnd() + "..."; } function buildSkillLine(skill, descMax) { const fullDescription = skill.whenToUse ? skill.description + " - " + skill.whenToUse : skill.description; return "- " + skill.name + ": " + truncateDescription( fullDescription, Math.min(descMax, MAX_LISTING_DESC_CHARS), ); } export function formatSkillsWithinBudget( skills, budget = DEFAULT_SKILL_BUDGET_CHARS, ) { if (skills.length === 0) return ""; const fullLines = skills.map((skill) => buildSkillLine(skill, MAX_LISTING_DESC_CHARS)); const fullCost = fullLines.reduce((sum, line) => sum + line.length + 1, 0); if (fullCost <= budget) return fullLines.join("\n"); const prefixCost = skills.reduce((sum, skill) => { return sum + ("- " + skill.name + ": ").length + 1; }, 0); const descBudget = budget - prefixCost; if (descBudget >= skills.length * 20) { const perDesc = Math.max(20, Math.floor(descBudget / skills.length)); return skills.map((skill) => buildSkillLine(skill, perDesc)).join("\n"); } return skills.map((skill) => "- " + skill.name).join("\n"); } export function formatSkillsSystemReminder(skills) { const listing = formatSkillsWithinBudget(skills); if (!listing) return ""; return [ "", "Available skills you can invoke via the `Skill` tool.", "Call `Skill(skill=\"\", args=\"\")` when a skill matches the user's request.", "", listing, "", ].join("\n"); } // ----------------------------------------------------------------------------- // 6. Skill tool // ----------------------------------------------------------------------------- function substituteSkillVariables(skill, args, sessionId) { return skill.body .replaceAll("${CLAUDE_SKILL_DIR}", posixifyPath(skill.baseDir)) .replaceAll("${CLAUDE_SESSION_ID}", sessionId) .replaceAll("$ARGUMENTS", args); } export function createSkillTool() { return { name: "Skill", description: "Execute a named skill. The skill's instructions are returned as text; read them and continue following them.", inputSchema: { type: "object", properties: { skill: { type: "string" }, args: { type: "string" }, }, required: ["skill"], additionalProperties: false, }, isReadOnly() { return false; }, isEnabled() { return true; }, async call(input, context) { const name = typeof input.skill === "string" ? input.skill.trim() : ""; const args = typeof input.args === "string" ? input.args : ""; if (!name || !SKILL_NAME_RE.test(name)) { return { content: "Error: invalid skill name. Use letters, digits, underscores, or dashes.", isError: true, }; } const skill = findSkill(name); if (!skill) { return { content: 'Error: skill "' + name + '" not found.', isError: true }; } if (skill.frontmatter.disableModelInvocation) { return { content: 'Error: skill "' + name + '" can only be invoked by the user.', isError: true, }; } if (skill.frontmatter.hasForkContext) { return { content: 'Error: skill "' + name + '" requires forked sub-agent context.', isError: true, }; } if (skill.frontmatter.allowedTools.length > 0 && context.addSessionAllowRules) { context.addSessionAllowRules(skill.frontmatter.allowedTools); } const sessionId = context.sessionId ?? "unknown-session"; const body = substituteSkillVariables(skill, args, sessionId); return { content: 'Loaded skill "' + skill.name + '" from ' + skill.source + ".\n" + "Base directory for this skill: " + posixifyPath(skill.baseDir) + "\n\n" + body, }; }, }; } // ----------------------------------------------------------------------------- // 7. User slash command expansion // ----------------------------------------------------------------------------- export function expandSkillSlashCommand(input, context) { const match = String(input).match(/^\/([a-zA-Z0-9_-]+)(?:\s+(.*))?$/); if (!match) return null; const [, name, rawArgs] = match; const skill = findSkill(name); if (!skill) return null; const args = rawArgs?.trim() ?? ""; const sessionId = context.sessionId ?? "unknown-session"; if (skill.frontmatter.allowedTools.length > 0 && context.addSessionAllowRules) { context.addSessionAllowRules(skill.frontmatter.allowedTools); } const markerLines = [ "" + skill.name + "", "/" + skill.name + "", ]; if (args) { markerLines.push("" + args + ""); } return { markerContent: markerLines.join("\n"), bodyText: "[skill_invocation:" + skill.name + "]\n" + 'Run skill "' + skill.name + '" with the following instructions.\n' + "Base directory for this skill: " + posixifyPath(skill.baseDir) + "\n\n" + substituteSkillVariables(skill, args, sessionId), }; } // ----------------------------------------------------------------------------- // 8. Conditional activation // ----------------------------------------------------------------------------- export function activateConditionalSkillsForPaths(filePaths, cwd) { if (!filePaths || filePaths.length === 0) return []; const relativePaths = filePaths .map((filePath) => { const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath); const relative = path.relative(cwd, absolute); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return null; return posixifyPath(relative); }) .filter(Boolean); const activated = []; for (const skill of conditionalSkills.values()) { const patterns = skill.frontmatter.paths; if (!patterns || patterns.length === 0) continue; const matcher = ignore().add(patterns); if (relativePaths.some((filePath) => matcher.ignores(filePath))) { if (activateConditionalSkill(skill.name)) { activated.push(skill.name); } } } return activated; } export function extractToolFilePaths(toolName, input) { if (toolName === "Read" || toolName === "Write" || toolName === "Edit") { return typeof input.file_path === "string" ? [input.file_path] : []; } if (toolName === "Glob") { return typeof input.path === "string" ? [input.path] : []; } return []; } // ----------------------------------------------------------------------------- // 9. Bootstrap // ----------------------------------------------------------------------------- export async function bootstrapSkills(cwd) { const { skills, warnings } = await loadAllSkills(cwd); setSkills(skills); return { skillCount: getModelVisibleSkills().length, userInvocableCount: getAllUserInvocableSkills().length, conditionalCount: skills.filter((skill) => skill.frontmatter.paths).length, warnings, }; }