mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 07:30:52 -07:00
191 lines
5.3 KiB
TypeScript
191 lines
5.3 KiB
TypeScript
/**
|
|
* @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<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];
|
|
|
|
// 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<SkillDefinition[]> {
|
|
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<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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|