Files
awesome-copilot/eng/yaml-parser.mjs
Harald Kirschner f548156161 Fix: Normalize path separators in skill bundled assets to forward slashes
This fixes cross-platform compatibility issues where skills created on Windows
would have backslashes in bundled asset paths, causing CI failures on Linux.

The build script now normalizes all path separators to forward slashes (/)
regardless of the platform where the skill was created.

Fixes #502
2026-01-05 09:44:52 -08:00

207 lines
6.1 KiB
JavaScript

// YAML parser for collection files and frontmatter parsing using vfile-matter
import fs from "fs";
import path from "path";
import yaml from "js-yaml";
import { VFile } from "vfile";
import { matter } from "vfile-matter";
function safeFileOperation(operation, filePath, defaultValue = null) {
try {
return operation();
} catch (error) {
console.error(`Error processing file ${filePath}: ${error.message}`);
return defaultValue;
}
}
/**
* Parse a collection YAML file (.collection.yml)
* Collections are pure YAML files without frontmatter delimiters
* @param {string} filePath - Path to the collection file
* @returns {object|null} Parsed collection object or null on error
*/
function parseCollectionYaml(filePath) {
return safeFileOperation(
() => {
const content = fs.readFileSync(filePath, "utf8");
// Collections are pure YAML files, parse directly with js-yaml
return yaml.load(content, { schema: yaml.JSON_SCHEMA });
},
filePath,
null
);
}
/**
* Parse frontmatter from a markdown file using vfile-matter
* Works with any markdown file that has YAML frontmatter (agents, prompts, instructions)
* @param {string} filePath - Path to the markdown file
* @returns {object|null} Parsed frontmatter object or null on error
*/
function parseFrontmatter(filePath) {
return safeFileOperation(
() => {
const content = fs.readFileSync(filePath, "utf8");
const file = new VFile({ path: filePath, value: content });
// Parse the frontmatter using vfile-matter
matter(file);
// The frontmatter is now available in file.data.matter
const frontmatter = file.data.matter;
// Normalize string fields that can accumulate trailing newlines/spaces
if (frontmatter) {
if (typeof frontmatter.name === "string") {
frontmatter.name = frontmatter.name.replace(/[\r\n]+$/g, "").trim();
}
if (typeof frontmatter.title === "string") {
frontmatter.title = frontmatter.title.replace(/[\r\n]+$/g, "").trim();
}
if (typeof frontmatter.description === "string") {
// Remove only trailing whitespace/newlines; preserve internal formatting
frontmatter.description = frontmatter.description.replace(
/[\s\r\n]+$/g,
""
);
}
}
return frontmatter;
},
filePath,
null
);
}
/**
* Extract agent metadata including MCP server information
* @param {string} filePath - Path to the agent file
* @returns {object|null} Agent metadata object with name, description, tools, and mcp-servers
*/
function extractAgentMetadata(filePath) {
const frontmatter = parseFrontmatter(filePath);
if (!frontmatter) {
return null;
}
return {
name: typeof frontmatter.name === "string" ? frontmatter.name : null,
description:
typeof frontmatter.description === "string"
? frontmatter.description
: null,
tools: frontmatter.tools || [],
mcpServers: frontmatter["mcp-servers"] || {},
};
}
/**
* Extract MCP server names from an agent file
* @param {string} filePath - Path to the agent file
* @returns {string[]} Array of MCP server names
*/
function extractMcpServers(filePath) {
const metadata = extractAgentMetadata(filePath);
if (!metadata || !metadata.mcpServers) {
return [];
}
return Object.keys(metadata.mcpServers);
}
/**
* Extract full MCP server configs from an agent file
* @param {string} filePath - Path to the agent file
* @returns {Array<{name:string,type?:string,command?:string,args?:string[],url?:string,headers?:object}>}
*/
function extractMcpServerConfigs(filePath) {
const metadata = extractAgentMetadata(filePath);
if (!metadata || !metadata.mcpServers) return [];
return Object.entries(metadata.mcpServers).map(([name, cfg]) => {
// Ensure we don't mutate original cfg
const copy = { ...cfg };
return {
name,
type: typeof copy.type === "string" ? copy.type : undefined,
command: typeof copy.command === "string" ? copy.command : undefined,
args: Array.isArray(copy.args) ? copy.args : undefined,
url: typeof copy.url === "string" ? copy.url : undefined,
headers:
typeof copy.headers === "object" && copy.headers !== null
? copy.headers
: undefined,
};
});
}
/**
* Parse SKILL.md frontmatter and list bundled assets in a skill folder
* @param {string} skillPath - Path to skill folder
* @returns {object|null} Skill metadata with name, description, and assets array
*/
function parseSkillMetadata(skillPath) {
return safeFileOperation(
() => {
const skillFile = path.join(skillPath, "SKILL.md");
if (!fs.existsSync(skillFile)) {
return null;
}
const frontmatter = parseFrontmatter(skillFile);
// Validate required fields
if (!frontmatter?.name || !frontmatter?.description) {
console.warn(
`Invalid skill at ${skillPath}: missing name or description in frontmatter`
);
return null;
}
// List bundled assets (all files except SKILL.md), recursing through subdirectories
const getAllFiles = (dirPath, arrayOfFiles = []) => {
const files = fs.readdirSync(dirPath);
files.forEach((file) => {
const filePath = path.join(dirPath, file);
if (fs.statSync(filePath).isDirectory()) {
arrayOfFiles = getAllFiles(filePath, arrayOfFiles);
} else {
const relativePath = path.relative(skillPath, filePath);
if (relativePath !== "SKILL.md") {
// Normalize path separators to forward slashes for cross-platform consistency
arrayOfFiles.push(relativePath.replace(/\\/g, '/'));
}
}
});
return arrayOfFiles;
};
const assets = getAllFiles(skillPath).sort();
return {
name: frontmatter.name,
description: frontmatter.description,
assets,
path: skillPath,
};
},
skillPath,
null
);
}
export {
parseCollectionYaml,
parseFrontmatter,
extractAgentMetadata,
extractMcpServers,
extractMcpServerConfigs,
parseSkillMetadata,
safeFileOperation,
};