2026-01-03 16:24:36 -08:00
/ * *
* @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' ;
2026-01-04 14:45:07 -08:00
import { coreEvents } from '../utils/events.js' ;
2026-01-03 16:24:36 -08:00
/ * *
* 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 ;
2026-01-09 22:26:58 -08:00
/** Whether the skill is a built-in skill. */
isBuiltin? : boolean ;
2026-01-03 16:24:36 -08:00
}
2026-01-12 16:20:28 -08:00
export const FRONTMATTER_REGEX =
/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?/ ;
2026-01-03 16:24:36 -08:00
2026-01-15 11:10:21 +09:00
/ * *
* 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' ) {
2026-02-10 00:10:15 +00:00
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
2026-01-15 11:10:21 +09:00
const { name , description } = parsed as Record < string , unknown > ;
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 ] ;
2026-01-14 18:44:08 -08:00
// Match "name:" at the start of the line (optional whitespace)
const nameMatch = line . match ( /^\s*name:\s*(.*)$/ ) ;
if ( nameMatch ) {
name = nameMatch [ 1 ] . trim ( ) ;
2026-01-15 11:10:21 +09:00
continue ;
}
2026-01-14 18:44:08 -08:00
// Match "description:" at the start of the line (optional whitespace)
const descMatch = line . match ( /^\s*description:\s*(.*)$/ ) ;
if ( descMatch ) {
const descLines = [ descMatch [ 1 ] . trim ( ) ] ;
2026-01-15 11:10:21 +09:00
2026-01-14 18:44:08 -08:00
// Check for multi-line description (indented continuation lines)
2026-01-15 11:10:21 +09:00
while ( i + 1 < lines . length ) {
const nextLine = lines [ i + 1 ] ;
2026-01-14 18:44:08 -08:00
// If next line is indented, it's a continuation of the description
2026-01-15 11:10:21 +09:00
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 ;
}
2026-01-03 16:24:36 -08:00
/ * *
* Discovers and loads all skills in the provided directory .
* /
export async function loadSkillsFromDir (
dir : string ,
) : Promise < SkillDefinition [ ] > {
const discoveredSkills : SkillDefinition [ ] = [ ] ;
try {
const absoluteSearchPath = path . resolve ( dir ) ;
const stats = await fs . stat ( absoluteSearchPath ) . catch ( ( ) = > null ) ;
if ( ! stats || ! stats . isDirectory ( ) ) {
return [ ] ;
}
2026-02-04 14:11:01 -08:00
const pattern = [ 'SKILL.md' , '*/SKILL.md' ] ;
const skillFiles = await glob ( pattern , {
2026-01-03 16:24:36 -08:00
cwd : absoluteSearchPath ,
absolute : true ,
nodir : true ,
2026-02-04 14:11:01 -08:00
ignore : [ '**/node_modules/**' , '**/.git/**' ] ,
2026-01-03 16:24:36 -08:00
} ) ;
for ( const skillFile of skillFiles ) {
const metadata = await loadSkillFromFile ( skillFile ) ;
if ( metadata ) {
discoveredSkills . push ( metadata ) ;
}
}
2026-01-04 14:45:07 -08:00
if ( discoveredSkills . length === 0 ) {
const files = await fs . readdir ( absoluteSearchPath ) ;
if ( files . length > 0 ) {
2026-01-08 03:38:47 -08:00
debugLogger . debug (
2026-01-04 14:45:07 -08:00
` 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. ` ,
) ;
}
}
2026-01-03 16:24:36 -08:00
} catch ( error ) {
2026-01-04 14:45:07 -08:00
coreEvents . emitFeedback (
'warning' ,
` Error discovering skills in ${ dir } : ` ,
error ,
) ;
2026-01-03 16:24:36 -08:00
}
return discoveredSkills ;
}
/ * *
* Loads a single skill from a SKILL . md file .
* /
export async function loadSkillFromFile (
filePath : string ,
) : Promise < SkillDefinition | null > {
try {
const content = await fs . readFile ( filePath , 'utf-8' ) ;
const match = content . match ( FRONTMATTER_REGEX ) ;
if ( ! match ) {
return null ;
}
2026-01-15 11:10:21 +09:00
const frontmatter = parseFrontmatter ( match [ 1 ] ) ;
if ( ! frontmatter ) {
2026-01-03 16:24:36 -08:00
return null ;
}
2026-02-04 14:11:01 -08:00
// Sanitize name for use as a filename/directory name (e.g. replace ':' with '-')
const sanitizedName = frontmatter . name . replace ( /[:\\/<>*?"|]/g , '-' ) ;
2026-01-03 16:24:36 -08:00
return {
2026-02-04 14:11:01 -08:00
name : sanitizedName ,
2026-01-15 11:10:21 +09:00
description : frontmatter.description ,
2026-01-03 16:24:36 -08:00
location : filePath ,
2026-01-14 18:44:08 -08:00
body : match [ 2 ] ? . trim ( ) ? ? '' ,
2026-01-03 16:24:36 -08:00
} ;
} catch ( error ) {
debugLogger . log ( ` Error parsing skill file ${ filePath } : ` , error ) ;
return null ;
}
}