/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { glob } from 'glob'; import yaml from 'js-yaml'; import { debugLogger } from '../utils/debugLogger.js'; import { coreEvents } from '../utils/events.js'; /** * Represents the definition of an Agent Skill. */ export interface SkillDefinition { /** The unique name of the skill. */ name: string; /** A concise description of what the skill does. */ description: string; /** The absolute path to the skill's source file on disk. */ location: string; /** The core logic/instructions of the skill. */ body: string; /** Whether the skill is currently disabled. */ disabled?: boolean; /** Whether the skill is a built-in skill. */ isBuiltin?: boolean; } export const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?/; /** * Parses frontmatter content using YAML with a fallback to simple key-value parsing. * This handles cases where description contains colons that would break YAML parsing. */ function parseFrontmatter( content: string, ): { name: string; description: string } | null { try { const parsed = yaml.load(content); if (parsed && typeof parsed === 'object') { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const { name, description } = parsed as Record; if (typeof name === 'string' && typeof description === 'string') { return { name, description }; } } } catch (yamlError) { debugLogger.debug( 'YAML frontmatter parsing failed, falling back to simple parser:', yamlError, ); } return parseSimpleFrontmatter(content); } /** * Simple frontmatter parser that extracts name and description fields. * Handles cases where values contain colons that would break YAML parsing. */ function parseSimpleFrontmatter( content: string, ): { name: string; description: string } | null { const lines = content.split(/\r?\n/); let name: string | undefined; let description: string | undefined; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Match "name:" at the start of the line (optional whitespace) const nameMatch = line.match(/^\s*name:\s*(.*)$/); if (nameMatch) { name = nameMatch[1].trim(); continue; } // Match "description:" at the start of the line (optional whitespace) const descMatch = line.match(/^\s*description:\s*(.*)$/); if (descMatch) { const descLines = [descMatch[1].trim()]; // Check for multi-line description (indented continuation lines) while (i + 1 < lines.length) { const nextLine = lines[i + 1]; // If next line is indented, it's a continuation of the description if (nextLine.match(/^[ \t]+\S/)) { descLines.push(nextLine.trim()); i++; } else { break; } } description = descLines.filter(Boolean).join(' '); continue; } } if (name !== undefined && description !== undefined) { return { name, description }; } return null; } /** * Discovers and loads all skills in the provided directory. */ export async function loadSkillsFromDir( dir: string, ): Promise { const discoveredSkills: SkillDefinition[] = []; try { const absoluteSearchPath = path.resolve(dir); const stats = await fs.stat(absoluteSearchPath).catch(() => null); if (!stats || !stats.isDirectory()) { return []; } const pattern = ['SKILL.md', '*/SKILL.md']; const skillFiles = await glob(pattern, { cwd: absoluteSearchPath, absolute: true, nodir: true, ignore: ['**/node_modules/**', '**/.git/**'], }); for (const skillFile of skillFiles) { const metadata = await loadSkillFromFile(skillFile); if (metadata) { discoveredSkills.push(metadata); } } if (discoveredSkills.length === 0) { const files = await fs.readdir(absoluteSearchPath); if (files.length > 0) { debugLogger.debug( `Failed to load skills from ${absoluteSearchPath}. The directory is not empty but no valid skills were discovered. Please ensure SKILL.md files are present in subdirectories and have valid frontmatter.`, ); } } } catch (error) { coreEvents.emitFeedback( 'warning', `Error discovering skills in ${dir}:`, error, ); } return discoveredSkills; } /** * Loads a single skill from a SKILL.md file. */ export async function loadSkillFromFile( filePath: string, ): Promise { try { const content = await fs.readFile(filePath, 'utf-8'); const match = content.match(FRONTMATTER_REGEX); if (!match) { return null; } const frontmatter = parseFrontmatter(match[1]); if (!frontmatter) { return null; } // Sanitize name for use as a filename/directory name (e.g. replace ':' with '-') const sanitizedName = frontmatter.name.replace(/[:\\/<>*?"|]/g, '-'); return { name: sanitizedName, description: frontmatter.description, location: filePath, body: match[2]?.trim() ?? '', }; } catch (error) { debugLogger.log(`Error parsing skill file ${filePath}:`, error); return null; } }