Files
gemini-cli/packages/core/src/skills/skillLoader.ts
T

179 lines
4.6 KiB
TypeScript
Raw Normal View History

/**
* @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;
2026-01-09 22:26:58 -08:00
/** 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') {
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];
if (line.startsWith('name:')) {
name = line.substring(5).trim();
continue;
}
if (line.startsWith('description:')) {
const descLines = [line.substring(12).trim()];
while (i + 1 < lines.length) {
const nextLine = lines[i + 1];
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<SkillDefinition[]> {
const discoveredSkills: SkillDefinition[] = [];
try {
const absoluteSearchPath = path.resolve(dir);
const stats = await fs.stat(absoluteSearchPath).catch(() => null);
if (!stats || !stats.isDirectory()) {
return [];
}
const skillFiles = await glob(['SKILL.md', '*/SKILL.md'], {
cwd: absoluteSearchPath,
absolute: true,
nodir: true,
});
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<SkillDefinition | null> {
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;
}
return {
name: frontmatter.name,
description: frontmatter.description,
location: filePath,
body: match[2].trim(),
};
} catch (error) {
debugLogger.log(`Error parsing skill file ${filePath}:`, error);
return null;
}
}