mirror of
https://github.com/github/awesome-copilot.git
synced 2026-02-20 02:15:12 +00:00
Deprecate Collections in favour of Plugins
Replace Collections with Plugins as first-class citizens in the repo. With the Copilot CLI v0.409 release making plugins an on-by-default marketplace, collections are redundant overhead. ## What changed ### Plugin Infrastructure - Created eng/validate-plugins.mjs (replaces validate-collections.mjs) - Created eng/create-plugin.mjs (replaces create-collection.mjs) - Enhanced all 42 plugin.json files with tags, featured, display, and items metadata from their corresponding collection.yml files ### Build & Website - Updated eng/update-readme.mjs to generate plugin docs - Updated eng/generate-website-data.mjs to emit plugins.json with full items array for modal rendering - Renamed website collections page to plugins (/plugins/) - Fixed plugin modal to use <div> instead of <pre> for proper styling - Updated README.md featured section from Collections to Plugins ### Documentation & CI - Updated CONTRIBUTING.md, AGENTS.md, copilot-instructions.md, PR template - Updated CI workflows to validate plugins instead of collections - Replaced docs/README.collections.md with docs/README.plugins.md ### Cleanup - Removed eng/validate-collections.mjs, eng/create-collection.mjs, eng/collection-to-plugin.mjs - Removed entire collections/ directory (41 .collection.yml + .md files) - Removed parseCollectionYaml from yaml-parser.mjs - Removed COLLECTIONS_DIR from constants.mjs Closes #711
This commit is contained in:
@@ -1,570 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import readline from "readline";
|
||||
import { COLLECTIONS_DIR, ROOT_FOLDER } from "./constants.mjs";
|
||||
import {
|
||||
parseCollectionYaml,
|
||||
parseFrontmatter,
|
||||
parseHookMetadata,
|
||||
} from "./yaml-parser.mjs";
|
||||
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
function prompt(question) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const out = { collection: undefined, mode: "migrate", all: false };
|
||||
|
||||
// Check for mode from environment variable (set by npm scripts)
|
||||
if (process.env.PLUGIN_MODE === "refresh") {
|
||||
out.mode = "refresh";
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--collection" || a === "-c") {
|
||||
out.collection = args[i + 1];
|
||||
i++;
|
||||
} else if (a.startsWith("--collection=")) {
|
||||
out.collection = a.split("=")[1];
|
||||
} else if (a === "--refresh" || a === "-r") {
|
||||
out.mode = "refresh";
|
||||
} else if (a === "--migrate" || a === "-m") {
|
||||
out.mode = "migrate";
|
||||
} else if (a === "--all" || a === "-a") {
|
||||
out.all = true;
|
||||
} else if (!a.startsWith("-") && !out.collection) {
|
||||
out.collection = a;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* List available collections
|
||||
*/
|
||||
function listCollections() {
|
||||
if (!fs.existsSync(COLLECTIONS_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(COLLECTIONS_DIR)
|
||||
.filter((file) => file.endsWith(".collection.yml"))
|
||||
.map((file) => file.replace(".collection.yml", ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* List existing plugins that have a corresponding collection
|
||||
*/
|
||||
function listExistingPlugins() {
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const collections = listCollections();
|
||||
const plugins = fs
|
||||
.readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
|
||||
// Return only plugins that have a matching collection
|
||||
return plugins.filter((plugin) => collections.includes(plugin));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a symlink from destPath pointing to srcPath
|
||||
* Uses relative paths for portability
|
||||
*/
|
||||
function createSymlink(srcPath, destPath) {
|
||||
const destDir = path.dirname(destPath);
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Calculate relative path from dest to src
|
||||
const relativePath = path.relative(destDir, srcPath);
|
||||
|
||||
// Remove existing file/symlink if present
|
||||
try {
|
||||
const stats = fs.lstatSync(destPath);
|
||||
if (stats) {
|
||||
fs.unlinkSync(destPath);
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, which is fine
|
||||
}
|
||||
|
||||
fs.symlinkSync(relativePath, destPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a symlink to a directory
|
||||
*/
|
||||
function symlinkDirectory(srcDir, destDir) {
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(destDir);
|
||||
if (!fs.existsSync(parentDir)) {
|
||||
fs.mkdirSync(parentDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Calculate relative path from dest to src
|
||||
const relativePath = path.relative(parentDir, srcDir);
|
||||
|
||||
// Remove existing directory/symlink if present
|
||||
if (fs.existsSync(destDir)) {
|
||||
fs.rmSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.symlinkSync(relativePath, destDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate plugin.json content
|
||||
*/
|
||||
function generatePluginJson(collection) {
|
||||
return {
|
||||
name: collection.id,
|
||||
description: collection.description,
|
||||
version: "1.0.0",
|
||||
author: {
|
||||
name: "Awesome Copilot Community",
|
||||
},
|
||||
repository: "https://github.com/github/awesome-copilot",
|
||||
license: "MIT",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base name without extension for display
|
||||
*/
|
||||
function getDisplayName(filePath, kind) {
|
||||
const basename = path.basename(filePath);
|
||||
if (kind === "prompt") {
|
||||
return basename.replace(".prompt.md", "");
|
||||
} else if (kind === "agent") {
|
||||
return basename.replace(".agent.md", "");
|
||||
} else if (kind === "instruction") {
|
||||
return basename.replace(".instructions.md", "");
|
||||
} else if (kind === "hook") {
|
||||
// For folder-based hooks like hooks/<hook>/README.md, use the folder name.
|
||||
if (basename.toLowerCase() === "readme.md") {
|
||||
return path.basename(path.dirname(filePath));
|
||||
}
|
||||
return basename.replace(".hook.md", "");
|
||||
} else if (kind === "skill") {
|
||||
return path.basename(filePath);
|
||||
}
|
||||
return basename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate README.md content for the plugin
|
||||
*/
|
||||
function generateReadme(collection, items) {
|
||||
const lines = [];
|
||||
|
||||
// Title from collection name
|
||||
const title = collection.name || collection.id;
|
||||
lines.push(`# ${title} Plugin`);
|
||||
lines.push("");
|
||||
lines.push(collection.description);
|
||||
lines.push("");
|
||||
|
||||
// Installation section
|
||||
lines.push("## Installation");
|
||||
lines.push("");
|
||||
lines.push("```bash");
|
||||
lines.push("# Using Copilot CLI");
|
||||
lines.push(`copilot plugin install ${collection.id}@awesome-copilot`);
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
|
||||
lines.push("## What's Included");
|
||||
lines.push("");
|
||||
|
||||
// Commands (prompts)
|
||||
const prompts = items.filter((item) => item.kind === "prompt");
|
||||
if (prompts.length > 0) {
|
||||
lines.push("### Commands (Slash Commands)");
|
||||
lines.push("");
|
||||
lines.push("| Command | Description |");
|
||||
lines.push("|---------|-------------|");
|
||||
for (const item of prompts) {
|
||||
const name = getDisplayName(item.path, "prompt");
|
||||
const description =
|
||||
item.frontmatter?.description || item.frontmatter?.title || name;
|
||||
lines.push(`| \`/${collection.id}:${name}\` | ${description} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Agents
|
||||
const agents = items.filter((item) => item.kind === "agent");
|
||||
if (agents.length > 0) {
|
||||
lines.push("### Agents");
|
||||
lines.push("");
|
||||
lines.push("| Agent | Description |");
|
||||
lines.push("|-------|-------------|");
|
||||
for (const item of agents) {
|
||||
const name = getDisplayName(item.path, "agent");
|
||||
const description =
|
||||
item.frontmatter?.description || item.frontmatter?.name || name;
|
||||
lines.push(`| \`${name}\` | ${description} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Hooks
|
||||
const hooks = items.filter((item) => item.kind === "hook");
|
||||
if (hooks.length > 0) {
|
||||
lines.push("### Hooks");
|
||||
lines.push("");
|
||||
lines.push("| Hook | Description | Event |");
|
||||
lines.push("|------|-------------|-------|");
|
||||
for (const item of hooks) {
|
||||
const name = getDisplayName(item.path, "hook");
|
||||
const description =
|
||||
item.frontmatter?.description || item.frontmatter?.name || name;
|
||||
// Extract events from hooks.json rather than frontmatter
|
||||
const hookFolderPath = path.join(ROOT_FOLDER, path.dirname(item.path));
|
||||
const hookMeta = parseHookMetadata(hookFolderPath);
|
||||
const event =
|
||||
hookMeta?.hooks?.length > 0 ? hookMeta.hooks.join(", ") : "N/A";
|
||||
lines.push(`| \`${name}\` | ${description} | ${event} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Skills
|
||||
const skills = items.filter((item) => item.kind === "skill");
|
||||
if (skills.length > 0) {
|
||||
lines.push("### Skills");
|
||||
lines.push("");
|
||||
lines.push("| Skill | Description |");
|
||||
lines.push("|-------|-------------|");
|
||||
for (const item of skills) {
|
||||
const name = getDisplayName(item.path, "skill");
|
||||
const description = item.frontmatter?.description || name;
|
||||
lines.push(`| \`${name}\` | ${description} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Source
|
||||
lines.push("## Source");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot), a community-driven collection of GitHub Copilot extensions."
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("## License");
|
||||
lines.push("");
|
||||
lines.push("MIT");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a collection to a plugin
|
||||
* @param {string} collectionId - The collection ID
|
||||
* @param {string} mode - "migrate" for first-time creation, "refresh" for updating existing
|
||||
* @param {boolean} silent - If true, return false instead of exiting on errors (for batch mode)
|
||||
* @returns {boolean} - True if successful
|
||||
*/
|
||||
function convertCollectionToPlugin(
|
||||
collectionId,
|
||||
mode = "migrate",
|
||||
silent = false
|
||||
) {
|
||||
const collectionFile = path.join(
|
||||
COLLECTIONS_DIR,
|
||||
`${collectionId}.collection.yml`
|
||||
);
|
||||
|
||||
if (!fs.existsSync(collectionFile)) {
|
||||
if (silent) {
|
||||
console.warn(`⚠️ Collection file not found: ${collectionId}`);
|
||||
return false;
|
||||
}
|
||||
console.error(`❌ Collection file not found: ${collectionFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const collection = parseCollectionYaml(collectionFile);
|
||||
if (!collection) {
|
||||
if (silent) {
|
||||
console.warn(`⚠️ Failed to parse collection: ${collectionId}`);
|
||||
return false;
|
||||
}
|
||||
console.error(`❌ Failed to parse collection: ${collectionFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pluginDir = path.join(PLUGINS_DIR, collectionId);
|
||||
const pluginExists = fs.existsSync(pluginDir);
|
||||
|
||||
if (mode === "migrate") {
|
||||
// Migrate mode: fail if plugin already exists
|
||||
if (pluginExists) {
|
||||
if (silent) {
|
||||
console.warn(`⚠️ Plugin already exists: ${collectionId}`);
|
||||
return false;
|
||||
}
|
||||
console.error(`❌ Plugin already exists: ${pluginDir}`);
|
||||
console.log(
|
||||
"💡 Use 'npm run plugin:refresh' to update an existing plugin."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n📦 Migrating collection "${collectionId}" to plugin...`);
|
||||
} else {
|
||||
// Refresh mode: fail if plugin doesn't exist
|
||||
if (!pluginExists) {
|
||||
if (silent) {
|
||||
console.warn(`⚠️ Plugin does not exist: ${collectionId}`);
|
||||
return false;
|
||||
}
|
||||
console.error(`❌ Plugin does not exist: ${pluginDir}`);
|
||||
console.log(
|
||||
"💡 Use 'npm run plugin:migrate' to create a new plugin first."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n🔄 Refreshing plugin "${collectionId}" from collection...`);
|
||||
// Remove existing plugin directory for refresh
|
||||
fs.rmSync(pluginDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create plugin directory structure
|
||||
fs.mkdirSync(path.join(pluginDir, ".github", "plugin"), { recursive: true });
|
||||
|
||||
// Process items and collect metadata
|
||||
const processedItems = [];
|
||||
const stats = { prompts: 0, agents: 0, instructions: 0, skills: 0 };
|
||||
|
||||
for (const item of collection.items || []) {
|
||||
const srcPath = path.join(ROOT_FOLDER, item.path);
|
||||
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
console.warn(`⚠️ Source file not found, skipping: ${item.path}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let destPath;
|
||||
let frontmatter = null;
|
||||
|
||||
switch (item.kind) {
|
||||
case "prompt":
|
||||
// Prompts go to commands/ with .md extension
|
||||
const promptName = path
|
||||
.basename(item.path)
|
||||
.replace(".prompt.md", ".md");
|
||||
destPath = path.join(pluginDir, "commands", promptName);
|
||||
frontmatter = parseFrontmatter(srcPath);
|
||||
stats.prompts++;
|
||||
break;
|
||||
|
||||
case "agent":
|
||||
// Agents go to agents/ with .md extension
|
||||
const agentName = path.basename(item.path).replace(".agent.md", ".md");
|
||||
destPath = path.join(pluginDir, "agents", agentName);
|
||||
frontmatter = parseFrontmatter(srcPath);
|
||||
stats.agents++;
|
||||
break;
|
||||
|
||||
case "instruction":
|
||||
// Instructions are not supported in plugins - track for summary
|
||||
stats.instructions++;
|
||||
continue;
|
||||
|
||||
case "skill":
|
||||
// Skills are folders - path can be either the folder or the SKILL.md file
|
||||
let skillSrcDir = srcPath;
|
||||
let skillMdPath;
|
||||
|
||||
// If path points to SKILL.md, use parent directory as the skill folder
|
||||
if (item.path.endsWith("SKILL.md")) {
|
||||
skillSrcDir = path.dirname(srcPath);
|
||||
skillMdPath = srcPath;
|
||||
} else {
|
||||
skillMdPath = path.join(srcPath, "SKILL.md");
|
||||
}
|
||||
|
||||
const skillName = path.basename(skillSrcDir);
|
||||
destPath = path.join(pluginDir, "skills", skillName);
|
||||
|
||||
// Verify the source is a directory
|
||||
if (!fs.statSync(skillSrcDir).isDirectory()) {
|
||||
console.warn(
|
||||
`⚠️ Skill path is not a directory, skipping: ${item.path}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
symlinkDirectory(skillSrcDir, destPath);
|
||||
|
||||
// Try to get SKILL.md frontmatter
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
frontmatter = parseFrontmatter(skillMdPath);
|
||||
}
|
||||
stats.skills++;
|
||||
processedItems.push({ ...item, frontmatter });
|
||||
continue; // Already linked
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
`⚠️ Unknown item kind "${item.kind}", skipping: ${item.path}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create symlink to the source file
|
||||
createSymlink(srcPath, destPath);
|
||||
processedItems.push({ ...item, frontmatter });
|
||||
}
|
||||
|
||||
// Generate plugin.json
|
||||
const pluginJson = generatePluginJson(collection);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, ".github", "plugin", "plugin.json"),
|
||||
JSON.stringify(pluginJson, null, 2) + "\n"
|
||||
);
|
||||
|
||||
// Generate README.md
|
||||
const readme = generateReadme(collection, processedItems);
|
||||
fs.writeFileSync(path.join(pluginDir, "README.md"), readme + "\n");
|
||||
|
||||
// Print summary
|
||||
console.log(`\n✅ Plugin created: ${pluginDir}`);
|
||||
console.log("\n📊 Summary:");
|
||||
if (stats.prompts > 0)
|
||||
console.log(` - Commands (prompts): ${stats.prompts}`);
|
||||
if (stats.agents > 0) console.log(` - Agents: ${stats.agents}`);
|
||||
if (stats.skills > 0) console.log(` - Skills: ${stats.skills}`);
|
||||
|
||||
console.log("\n📁 Generated files:");
|
||||
console.log(
|
||||
` - ${path.join(pluginDir, ".github", "plugin", "plugin.json")}`
|
||||
);
|
||||
console.log(` - ${path.join(pluginDir, "README.md")}`);
|
||||
if (stats.prompts > 0)
|
||||
console.log(` - ${path.join(pluginDir, "commands", "*.md")}`);
|
||||
if (stats.agents > 0)
|
||||
console.log(` - ${path.join(pluginDir, "agents", "*.md")}`);
|
||||
if (stats.skills > 0)
|
||||
console.log(` - ${path.join(pluginDir, "skills", "*")}`);
|
||||
|
||||
// Note about excluded instructions
|
||||
if (stats.instructions > 0) {
|
||||
console.log(
|
||||
`\n📋 Note: ${stats.instructions} instruction${
|
||||
stats.instructions > 1 ? "s" : ""
|
||||
} excluded (not supported in plugins)`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const parsed = parseArgs();
|
||||
const isRefresh = parsed.mode === "refresh";
|
||||
|
||||
console.log(isRefresh ? "🔄 Plugin Refresh" : "📦 Plugin Migration");
|
||||
console.log(
|
||||
isRefresh
|
||||
? "This tool refreshes an existing plugin from its collection.\n"
|
||||
: "This tool migrates a collection to a new plugin.\n"
|
||||
);
|
||||
|
||||
// Handle --all flag (only valid for refresh mode)
|
||||
if (parsed.all) {
|
||||
if (!isRefresh) {
|
||||
console.error("❌ The --all flag is only valid with plugin:refresh");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existingPlugins = listExistingPlugins();
|
||||
if (existingPlugins.length === 0) {
|
||||
console.log("No existing plugins with matching collections found.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${existingPlugins.length} plugins to refresh:\n`);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const pluginId of existingPlugins) {
|
||||
const success = convertCollectionToPlugin(pluginId, "refresh", true);
|
||||
if (success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${"=".repeat(50)}`);
|
||||
console.log(`✅ Refreshed: ${successCount} plugins`);
|
||||
if (failCount > 0) {
|
||||
console.log(`⚠️ Failed: ${failCount} plugins`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let collectionId = parsed.collection;
|
||||
if (!collectionId) {
|
||||
// List available collections
|
||||
const collections = listCollections();
|
||||
if (collections.length === 0) {
|
||||
console.error("❌ No collections found in collections directory");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Available collections:");
|
||||
collections.forEach((c, i) => console.log(` ${i + 1}. ${c}`));
|
||||
console.log("");
|
||||
|
||||
collectionId = await prompt(
|
||||
"Enter collection ID (or number from list): "
|
||||
);
|
||||
|
||||
// Check if user entered a number
|
||||
const num = parseInt(collectionId, 10);
|
||||
if (!isNaN(num) && num >= 1 && num <= collections.length) {
|
||||
collectionId = collections[num - 1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!collectionId) {
|
||||
console.error("❌ Collection ID is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
convertCollectionToPlugin(collectionId, parsed.mode);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -57,6 +57,27 @@ Curated collections of related prompts, instructions, and agents organized aroun
|
||||
|
||||
Discover our curated collections of prompts, instructions, and agents organized around specific themes and workflows.`,
|
||||
|
||||
pluginsSection: `## 🔌 Plugins
|
||||
|
||||
Curated plugins of related prompts, agents, and skills organized around specific themes, workflows, or use cases. Plugins can be installed directly via GitHub Copilot CLI.`,
|
||||
|
||||
pluginsUsage: `### How to Use Plugins
|
||||
|
||||
**Browse Plugins:**
|
||||
- ⭐ Featured plugins are highlighted and appear at the top of the list
|
||||
- Explore themed plugins that group related customizations
|
||||
- Each plugin includes prompts, agents, and skills for specific workflows
|
||||
- Plugins make it easy to adopt comprehensive toolkits for particular scenarios
|
||||
|
||||
**Install Plugins:**
|
||||
- Use \\\`copilot plugin install <plugin-name>@awesome-copilot\\\` to install a plugin
|
||||
- Or browse to the individual files to copy content manually
|
||||
- Plugins help you discover related customizations you might have missed`,
|
||||
|
||||
featuredPluginsSection: `## 🌟 Featured Plugins
|
||||
|
||||
Discover our curated plugins of prompts, agents, and skills organized around specific themes and workflows.`,
|
||||
|
||||
agentsSection: `## 🤖 Custom Agents
|
||||
|
||||
Custom agents for GitHub Copilot, making it easy for users and organizations to "specialize" their Copilot coding agent (CCA) through simple file-based configuration.`,
|
||||
@@ -151,9 +172,9 @@ const PROMPTS_DIR = path.join(ROOT_FOLDER, "prompts");
|
||||
const AGENTS_DIR = path.join(ROOT_FOLDER, "agents");
|
||||
const SKILLS_DIR = path.join(ROOT_FOLDER, "skills");
|
||||
const HOOKS_DIR = path.join(ROOT_FOLDER, "hooks");
|
||||
const COLLECTIONS_DIR = path.join(ROOT_FOLDER, "collections");
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
const COOKBOOK_DIR = path.join(ROOT_FOLDER, "cookbook");
|
||||
const MAX_COLLECTION_ITEMS = 50;
|
||||
const MAX_PLUGIN_ITEMS = 50;
|
||||
|
||||
// Agent Skills validation constants
|
||||
const SKILL_NAME_MIN_LENGTH = 1;
|
||||
@@ -166,12 +187,12 @@ const DOCS_DIR = path.join(ROOT_FOLDER, "docs");
|
||||
export {
|
||||
AGENTS_DIR,
|
||||
AKA_INSTALL_URLS,
|
||||
COLLECTIONS_DIR,
|
||||
PLUGINS_DIR,
|
||||
COOKBOOK_DIR,
|
||||
DOCS_DIR,
|
||||
HOOKS_DIR,
|
||||
INSTRUCTIONS_DIR,
|
||||
MAX_COLLECTION_ITEMS,
|
||||
MAX_PLUGIN_ITEMS,
|
||||
PROMPTS_DIR,
|
||||
repoBaseUrl,
|
||||
ROOT_FOLDER,
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import readline from "readline";
|
||||
import { COLLECTIONS_DIR } from "./constants.mjs";
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
function prompt(question) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const out = { id: undefined, tags: undefined };
|
||||
|
||||
// simple long/short option parsing
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--id" || a === "-i") {
|
||||
out.id = args[i + 1];
|
||||
i++;
|
||||
} else if (a.startsWith("--id=")) {
|
||||
out.id = a.split("=")[1];
|
||||
} else if (a === "--tags" || a === "-t") {
|
||||
out.tags = args[i + 1];
|
||||
i++;
|
||||
} else if (a.startsWith("--tags=")) {
|
||||
out.tags = a.split("=")[1];
|
||||
} else if (!a.startsWith("-") && !out.id) {
|
||||
// first positional -> id
|
||||
out.id = a;
|
||||
} else if (!a.startsWith("-") && out.id && !out.tags) {
|
||||
// second positional -> tags
|
||||
out.tags = a;
|
||||
}
|
||||
}
|
||||
|
||||
// normalize tags to string (comma separated) or undefined
|
||||
if (Array.isArray(out.tags)) {
|
||||
out.tags = out.tags.join(",");
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function createCollectionTemplate() {
|
||||
try {
|
||||
console.log("🎯 Collection Creator");
|
||||
console.log("This tool will help you create a new collection manifest.\n");
|
||||
|
||||
// Parse CLI args and fall back to interactive prompts when missing
|
||||
const parsed = parseArgs();
|
||||
// Get collection ID
|
||||
let collectionId = parsed.id;
|
||||
if (!collectionId) {
|
||||
collectionId = await prompt("Collection ID (lowercase, hyphens only): ");
|
||||
}
|
||||
|
||||
// Validate collection ID format
|
||||
if (!collectionId) {
|
||||
console.error("❌ Collection ID is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(collectionId)) {
|
||||
console.error(
|
||||
"❌ Collection ID must contain only lowercase letters, numbers, and hyphens"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filePath = path.join(
|
||||
COLLECTIONS_DIR,
|
||||
`${collectionId}.collection.yml`
|
||||
);
|
||||
|
||||
// Check if file already exists
|
||||
if (fs.existsSync(filePath)) {
|
||||
console.log(
|
||||
`⚠️ Collection ${collectionId} already exists at ${filePath}`
|
||||
);
|
||||
console.log("💡 Please edit that file instead or choose a different ID.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure collections directory exists
|
||||
if (!fs.existsSync(COLLECTIONS_DIR)) {
|
||||
fs.mkdirSync(COLLECTIONS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Get collection name
|
||||
const defaultName = collectionId
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
|
||||
let collectionName = await prompt(
|
||||
`Collection name (default: ${defaultName}): `
|
||||
);
|
||||
if (!collectionName.trim()) {
|
||||
collectionName = defaultName;
|
||||
}
|
||||
|
||||
// Get description
|
||||
const defaultDescription = `A collection of related prompts, instructions, and agents for ${collectionName.toLowerCase()}.`;
|
||||
let description = await prompt(
|
||||
`Description (default: ${defaultDescription}): `
|
||||
);
|
||||
if (!description.trim()) {
|
||||
description = defaultDescription;
|
||||
}
|
||||
|
||||
// Get tags (from CLI or prompt)
|
||||
let tags = [];
|
||||
let tagInput = parsed.tags;
|
||||
if (!tagInput) {
|
||||
tagInput = await prompt(
|
||||
"Tags (comma-separated, or press Enter for defaults): "
|
||||
);
|
||||
}
|
||||
|
||||
if (tagInput && tagInput.toString().trim()) {
|
||||
tags = tagInput
|
||||
.toString()
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag);
|
||||
} else {
|
||||
// Generate some default tags from the collection ID
|
||||
tags = collectionId.split("-").slice(0, 3);
|
||||
}
|
||||
|
||||
// Template content
|
||||
const template = `id: ${collectionId}
|
||||
name: ${collectionName}
|
||||
description: ${description}
|
||||
tags: [${tags.join(", ")}]
|
||||
items:
|
||||
# Add your collection items here
|
||||
# Example:
|
||||
# - path: prompts/example.prompt.md
|
||||
# kind: prompt
|
||||
# - path: instructions/example.instructions.md
|
||||
# kind: instruction
|
||||
# - path: agents/example.agent.md
|
||||
# kind: agent
|
||||
# - path: agents/example.agent.md
|
||||
# kind: agent
|
||||
# usage: |
|
||||
# This agent requires the example MCP server to be installed.
|
||||
# Configure any required environment variables (e.g., EXAMPLE_API_KEY).
|
||||
display:
|
||||
ordering: alpha # or "manual" to preserve the order above
|
||||
show_badge: false # set to true to show collection badge on items
|
||||
`;
|
||||
|
||||
fs.writeFileSync(filePath, template);
|
||||
console.log(`✅ Created collection template: ${filePath}`);
|
||||
console.log("\n📝 Next steps:");
|
||||
console.log("1. Edit the collection manifest to add your items");
|
||||
console.log("2. Update the name, description, and tags as needed");
|
||||
console.log("3. Run 'npm run collection:validate' to validate");
|
||||
console.log("4. Run 'npm start' to generate documentation");
|
||||
console.log("\n📄 Collection template contents:");
|
||||
console.log(template);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error creating collection template: ${error.message}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the interactive creation process
|
||||
createCollectionTemplate();
|
||||
191
eng/create-plugin.mjs
Executable file
191
eng/create-plugin.mjs
Executable file
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import readline from "readline";
|
||||
import { ROOT_FOLDER } from "./constants.mjs";
|
||||
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
function prompt(question) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const out = { name: undefined, tags: undefined };
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--name" || a === "-n") {
|
||||
out.name = args[i + 1];
|
||||
i++;
|
||||
} else if (a.startsWith("--name=")) {
|
||||
out.name = a.split("=")[1];
|
||||
} else if (a === "--tags" || a === "-t") {
|
||||
out.tags = args[i + 1];
|
||||
i++;
|
||||
} else if (a.startsWith("--tags=")) {
|
||||
out.tags = a.split("=")[1];
|
||||
} else if (!a.startsWith("-") && !out.name) {
|
||||
// first positional -> name
|
||||
out.name = a;
|
||||
} else if (!a.startsWith("-") && out.name && !out.tags) {
|
||||
// second positional -> tags
|
||||
out.tags = a;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(out.tags)) {
|
||||
out.tags = out.tags.join(",");
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function createPlugin() {
|
||||
try {
|
||||
console.log("🔌 Plugin Creator");
|
||||
console.log("This tool will help you create a new plugin.\n");
|
||||
|
||||
const parsed = parseArgs();
|
||||
|
||||
// Get plugin ID
|
||||
let pluginId = parsed.name;
|
||||
if (!pluginId) {
|
||||
pluginId = await prompt("Plugin ID (lowercase, hyphens only): ");
|
||||
}
|
||||
|
||||
if (!pluginId) {
|
||||
console.error("❌ Plugin ID is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(pluginId)) {
|
||||
console.error(
|
||||
"❌ Plugin ID must contain only lowercase letters, numbers, and hyphens"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pluginDir = path.join(PLUGINS_DIR, pluginId);
|
||||
|
||||
// Check if plugin already exists
|
||||
if (fs.existsSync(pluginDir)) {
|
||||
console.log(
|
||||
`⚠️ Plugin ${pluginId} already exists at ${pluginDir}`
|
||||
);
|
||||
console.log("💡 Please edit that plugin instead or choose a different ID.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get display name
|
||||
const defaultDisplayName = pluginId
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
|
||||
let displayName = await prompt(
|
||||
`Display name (default: ${defaultDisplayName}): `
|
||||
);
|
||||
if (!displayName.trim()) {
|
||||
displayName = defaultDisplayName;
|
||||
}
|
||||
|
||||
// Get description
|
||||
const defaultDescription = `A plugin for ${displayName.toLowerCase()}.`;
|
||||
let description = await prompt(
|
||||
`Description (default: ${defaultDescription}): `
|
||||
);
|
||||
if (!description.trim()) {
|
||||
description = defaultDescription;
|
||||
}
|
||||
|
||||
// Get tags
|
||||
let tags = [];
|
||||
let tagInput = parsed.tags;
|
||||
if (!tagInput) {
|
||||
tagInput = await prompt(
|
||||
"Tags (comma-separated, or press Enter for defaults): "
|
||||
);
|
||||
}
|
||||
|
||||
if (tagInput && tagInput.toString().trim()) {
|
||||
tags = tagInput
|
||||
.toString()
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag);
|
||||
} else {
|
||||
tags = pluginId.split("-").slice(0, 3);
|
||||
}
|
||||
|
||||
// Create directory structure
|
||||
const githubPluginDir = path.join(pluginDir, ".github", "plugin");
|
||||
fs.mkdirSync(githubPluginDir, { recursive: true });
|
||||
|
||||
// Generate plugin.json
|
||||
const pluginJson = {
|
||||
name: pluginId,
|
||||
description,
|
||||
version: "1.0.0",
|
||||
author: { name: "Awesome Copilot Community" },
|
||||
repository: "https://github.com/github/awesome-copilot",
|
||||
license: "MIT",
|
||||
tags,
|
||||
items: [],
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(githubPluginDir, "plugin.json"),
|
||||
JSON.stringify(pluginJson, null, 2) + "\n"
|
||||
);
|
||||
|
||||
// Generate README.md
|
||||
const readmeContent = `# ${displayName} Plugin
|
||||
|
||||
${description}
|
||||
|
||||
## Installation
|
||||
|
||||
\`\`\`bash
|
||||
copilot plugin install ${pluginId}@awesome-copilot
|
||||
\`\`\`
|
||||
|
||||
## What's Included
|
||||
|
||||
_Add your plugin contents here._
|
||||
|
||||
## Source
|
||||
|
||||
This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(pluginDir, "README.md"), readmeContent);
|
||||
|
||||
console.log(`\n✅ Created plugin: ${pluginDir}`);
|
||||
console.log("\n📝 Next steps:");
|
||||
console.log(`1. Add agents, prompts, or instructions to plugins/${pluginId}/`);
|
||||
console.log(`2. Update plugins/${pluginId}/.github/plugin/plugin.json to list your items`);
|
||||
console.log(`3. Edit plugins/${pluginId}/README.md to describe your plugin`);
|
||||
console.log("4. Run 'npm run build' to regenerate documentation");
|
||||
} catch (error) {
|
||||
console.error(`❌ Error creating plugin: ${error.message}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
createPlugin();
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* Generate JSON metadata files for the GitHub Pages website.
|
||||
* This script extracts metadata from agents, prompts, instructions, skills, and collections
|
||||
* This script extracts metadata from agents, prompts, instructions, skills, and plugins
|
||||
* and writes them to website/data/ for client-side search and display.
|
||||
*/
|
||||
|
||||
@@ -11,17 +11,16 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import {
|
||||
AGENTS_DIR,
|
||||
COLLECTIONS_DIR,
|
||||
COOKBOOK_DIR,
|
||||
HOOKS_DIR,
|
||||
INSTRUCTIONS_DIR,
|
||||
PLUGINS_DIR,
|
||||
PROMPTS_DIR,
|
||||
ROOT_FOLDER,
|
||||
SKILLS_DIR
|
||||
} from "./constants.mjs";
|
||||
import { getGitFileDates } from "./utils/git-dates.mjs";
|
||||
import {
|
||||
parseCollectionYaml,
|
||||
parseFrontmatter,
|
||||
parseSkillMetadata,
|
||||
parseHookMetadata,
|
||||
@@ -483,66 +482,58 @@ function getSkillFiles(skillPath, relativePath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate collections metadata
|
||||
* Generate plugins metadata
|
||||
*/
|
||||
function generateCollectionsData(gitDates) {
|
||||
const collections = [];
|
||||
function generatePluginsData(gitDates) {
|
||||
const plugins = [];
|
||||
|
||||
if (!fs.existsSync(COLLECTIONS_DIR)) {
|
||||
return collections;
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
return plugins;
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(COLLECTIONS_DIR)
|
||||
.filter((f) => f.endsWith(".collection.yml"));
|
||||
const pluginDirs = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory());
|
||||
|
||||
// Track all unique tags
|
||||
const allTags = new Set();
|
||||
for (const dir of pluginDirs) {
|
||||
const pluginDir = path.join(PLUGINS_DIR, dir.name);
|
||||
const jsonPath = path.join(pluginDir, ".github", "plugin", "plugin.json");
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(COLLECTIONS_DIR, file);
|
||||
const data = parseCollectionYaml(filePath);
|
||||
const relativePath = path
|
||||
.relative(ROOT_FOLDER, filePath)
|
||||
.replace(/\\/g, "/");
|
||||
if (!fs.existsSync(jsonPath)) continue;
|
||||
|
||||
if (data) {
|
||||
const tags = data.tags || [];
|
||||
tags.forEach((t) => allTags.add(t));
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
||||
const relPath = `plugins/${dir.name}`;
|
||||
const dates = gitDates[relPath] || gitDates[`${relPath}/`] || {};
|
||||
|
||||
// featured can be at top level or nested under display
|
||||
const featured = data.featured || data.display?.featured || false;
|
||||
|
||||
collections.push({
|
||||
id: file.replace(".collection.yml", ""),
|
||||
name: data.name || file.replace(".collection.yml", ""),
|
||||
plugins.push({
|
||||
id: dir.name,
|
||||
name: data.name || dir.name,
|
||||
description: data.description || "",
|
||||
tags: tags,
|
||||
featured: featured,
|
||||
items: (data.items || []).map((item) => ({
|
||||
path: item.path,
|
||||
kind: item.kind,
|
||||
usage: item.usage || null,
|
||||
})),
|
||||
path: relativePath,
|
||||
filename: file,
|
||||
lastUpdated: gitDates.get(relativePath) || null,
|
||||
path: relPath,
|
||||
tags: data.tags || [],
|
||||
featured: data.featured || false,
|
||||
itemCount: data.items ? data.items.length : 0,
|
||||
items: data.items || [],
|
||||
lastUpdated: dates.lastModified || null,
|
||||
searchText: `${data.name || dir.name} ${data.description || ""} ${(data.tags || []).join(" ")}`.toLowerCase(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Failed to parse plugin: ${dir.name}`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort with featured first, then alphabetically
|
||||
const sortedCollections = collections.sort((a, b) => {
|
||||
// Collect all unique tags
|
||||
const allTags = [...new Set(plugins.flatMap(p => p.tags))].sort();
|
||||
|
||||
const sortedPlugins = plugins.sort((a, b) => {
|
||||
if (a.featured && !b.featured) return -1;
|
||||
if (!a.featured && b.featured) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return {
|
||||
items: sortedCollections,
|
||||
filters: {
|
||||
tags: Array.from(allTags).sort(),
|
||||
},
|
||||
items: sortedPlugins,
|
||||
filters: { tags: allTags }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -612,7 +603,7 @@ function generateSearchIndex(
|
||||
instructions,
|
||||
hooks,
|
||||
skills,
|
||||
collections
|
||||
plugins
|
||||
) {
|
||||
const index = [];
|
||||
|
||||
@@ -682,18 +673,16 @@ function generateSearchIndex(
|
||||
});
|
||||
}
|
||||
|
||||
for (const collection of collections) {
|
||||
for (const plugin of plugins) {
|
||||
index.push({
|
||||
type: "collection",
|
||||
id: collection.id,
|
||||
title: collection.name,
|
||||
description: collection.description,
|
||||
path: collection.path,
|
||||
tags: collection.tags,
|
||||
lastUpdated: collection.lastUpdated,
|
||||
searchText: `${collection.name} ${
|
||||
collection.description
|
||||
} ${collection.tags.join(" ")}`.toLowerCase(),
|
||||
type: "plugin",
|
||||
id: plugin.id,
|
||||
title: plugin.name,
|
||||
description: plugin.description,
|
||||
path: plugin.path,
|
||||
tags: plugin.tags,
|
||||
lastUpdated: plugin.lastUpdated,
|
||||
searchText: plugin.searchText,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -806,7 +795,7 @@ async function main() {
|
||||
// Load git dates for all resource files (single efficient git command)
|
||||
console.log("Loading git history for last updated dates...");
|
||||
const gitDates = getGitFileDates(
|
||||
["agents/", "prompts/", "instructions/", "hooks/", "skills/", "collections/"],
|
||||
["agents/", "prompts/", "instructions/", "hooks/", "skills/", "plugins/"],
|
||||
ROOT_FOLDER
|
||||
);
|
||||
console.log(`✓ Loaded dates for ${gitDates.size} files\n`);
|
||||
@@ -842,10 +831,10 @@ async function main() {
|
||||
`✓ Generated ${skills.length} skills (${skillsData.filters.categories.length} categories)`
|
||||
);
|
||||
|
||||
const collectionsData = generateCollectionsData(gitDates);
|
||||
const collections = collectionsData.items;
|
||||
const pluginsData = generatePluginsData(gitDates);
|
||||
const plugins = pluginsData.items;
|
||||
console.log(
|
||||
`✓ Generated ${collections.length} collections (${collectionsData.filters.tags.length} tags)`
|
||||
`✓ Generated ${plugins.length} plugins (${pluginsData.filters.tags.length} tags)`
|
||||
);
|
||||
|
||||
const toolsData = generateToolsData();
|
||||
@@ -865,7 +854,7 @@ async function main() {
|
||||
instructions,
|
||||
hooks,
|
||||
skills,
|
||||
collections
|
||||
plugins
|
||||
);
|
||||
console.log(`✓ Generated search index with ${searchIndex.length} items`);
|
||||
|
||||
@@ -896,8 +885,8 @@ async function main() {
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(WEBSITE_DATA_DIR, "collections.json"),
|
||||
JSON.stringify(collectionsData, null, 2)
|
||||
path.join(WEBSITE_DATA_DIR, "plugins.json"),
|
||||
JSON.stringify(pluginsData, null, 2)
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
@@ -924,7 +913,7 @@ async function main() {
|
||||
instructions: instructions.length,
|
||||
skills: skills.length,
|
||||
hooks: hooks.length,
|
||||
collections: collections.length,
|
||||
plugins: plugins.length,
|
||||
tools: tools.length,
|
||||
samples: samplesData.totalRecipes,
|
||||
total: searchIndex.length,
|
||||
|
||||
@@ -6,10 +6,10 @@ import { fileURLToPath } from "url";
|
||||
import {
|
||||
AGENTS_DIR,
|
||||
AKA_INSTALL_URLS,
|
||||
COLLECTIONS_DIR,
|
||||
DOCS_DIR,
|
||||
HOOKS_DIR,
|
||||
INSTRUCTIONS_DIR,
|
||||
PLUGINS_DIR,
|
||||
PROMPTS_DIR,
|
||||
repoBaseUrl,
|
||||
ROOT_FOLDER,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "./constants.mjs";
|
||||
import {
|
||||
extractMcpServerConfigs,
|
||||
parseCollectionYaml,
|
||||
parseFrontmatter,
|
||||
parseSkillMetadata,
|
||||
parseHookMetadata,
|
||||
@@ -708,143 +707,151 @@ function generateUnifiedModeSection(cfg) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the collections section with a table of all collections
|
||||
* Read and parse a plugin.json file from a plugin directory.
|
||||
*/
|
||||
function generateCollectionsSection(collectionsDir) {
|
||||
// Check if collections directory exists, create it if it doesn't
|
||||
if (!fs.existsSync(collectionsDir)) {
|
||||
console.log("Collections directory does not exist, creating it...");
|
||||
fs.mkdirSync(collectionsDir, { recursive: true });
|
||||
function readPluginJson(pluginDir) {
|
||||
const jsonPath = path.join(pluginDir, ".github", "plugin", "plugin.json");
|
||||
if (!fs.existsSync(jsonPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the plugins section with a table of all plugins
|
||||
*/
|
||||
function generatePluginsSection(pluginsDir) {
|
||||
// Check if plugins directory exists, create it if it doesn't
|
||||
if (!fs.existsSync(pluginsDir)) {
|
||||
console.log("Plugins directory does not exist, creating it...");
|
||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Get all collection files
|
||||
const collectionFiles = fs
|
||||
.readdirSync(collectionsDir)
|
||||
.filter((file) => file.endsWith(".collection.yml"));
|
||||
// Get all plugin directories
|
||||
const pluginDirs = fs
|
||||
.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
|
||||
// Map collection files to objects with name for sorting
|
||||
const collectionEntries = collectionFiles
|
||||
.map((file) => {
|
||||
const filePath = path.join(collectionsDir, file);
|
||||
const collection = parseCollectionYaml(filePath);
|
||||
// Map plugin dirs to objects with name for sorting
|
||||
const pluginEntries = pluginDirs
|
||||
.map((dir) => {
|
||||
const pluginDir = path.join(pluginsDir, dir);
|
||||
const plugin = readPluginJson(pluginDir);
|
||||
|
||||
if (!collection) {
|
||||
console.warn(`Failed to parse collection: ${file}`);
|
||||
if (!plugin) {
|
||||
console.warn(`Failed to parse plugin: ${dir}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const collectionId =
|
||||
collection.id || path.basename(file, ".collection.yml");
|
||||
const name = collection.name || collectionId;
|
||||
const isFeatured = collection.display?.featured === true;
|
||||
return { file, filePath, collection, collectionId, name, isFeatured };
|
||||
const pluginId = plugin.name || dir;
|
||||
const name = plugin.name || dir;
|
||||
const isFeatured = plugin.featured === true;
|
||||
return { dir, pluginDir, plugin, pluginId, name, isFeatured };
|
||||
})
|
||||
.filter((entry) => entry !== null); // Remove failed parses
|
||||
.filter((entry) => entry !== null);
|
||||
|
||||
// Separate featured and regular collections
|
||||
const featuredCollections = collectionEntries.filter(
|
||||
(entry) => entry.isFeatured
|
||||
);
|
||||
const regularCollections = collectionEntries.filter(
|
||||
(entry) => !entry.isFeatured
|
||||
);
|
||||
// Separate featured and regular plugins
|
||||
const featuredPlugins = pluginEntries.filter((entry) => entry.isFeatured);
|
||||
const regularPlugins = pluginEntries.filter((entry) => !entry.isFeatured);
|
||||
|
||||
// Sort each group alphabetically by name
|
||||
featuredCollections.sort((a, b) => a.name.localeCompare(b.name));
|
||||
regularCollections.sort((a, b) => a.name.localeCompare(b.name));
|
||||
featuredPlugins.sort((a, b) => a.name.localeCompare(b.name));
|
||||
regularPlugins.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Combine: featured first, then regular
|
||||
const sortedEntries = [...featuredCollections, ...regularCollections];
|
||||
const sortedEntries = [...featuredPlugins, ...regularPlugins];
|
||||
|
||||
console.log(
|
||||
`Found ${collectionEntries.length} collection files (${featuredCollections.length} featured)`
|
||||
`Found ${pluginEntries.length} plugins (${featuredPlugins.length} featured)`
|
||||
);
|
||||
|
||||
// If no collections, return empty string
|
||||
// If no plugins, return empty string
|
||||
if (sortedEntries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Create table header
|
||||
let collectionsContent =
|
||||
let pluginsContent =
|
||||
"| Name | Description | Items | Tags |\n| ---- | ----------- | ----- | ---- |\n";
|
||||
|
||||
// Generate table rows for each collection file
|
||||
// Generate table rows for each plugin
|
||||
for (const entry of sortedEntries) {
|
||||
const { collection, collectionId, name, isFeatured } = entry;
|
||||
const { plugin, dir, name, isFeatured } = entry;
|
||||
const description = formatTableCell(
|
||||
collection.description || "No description"
|
||||
plugin.description || "No description"
|
||||
);
|
||||
const itemCount = collection.items ? collection.items.length : 0;
|
||||
const tags = collection.tags ? collection.tags.join(", ") : "";
|
||||
const itemCount = plugin.items ? plugin.items.length : 0;
|
||||
const tags = plugin.tags ? plugin.tags.join(", ") : "";
|
||||
|
||||
const link = `../collections/${collectionId}.md`;
|
||||
const link = `../plugins/${dir}/README.md`;
|
||||
const displayName = isFeatured ? `⭐ ${name}` : name;
|
||||
|
||||
collectionsContent += `| [${displayName}](${link}) | ${description} | ${itemCount} items | ${tags} |\n`;
|
||||
pluginsContent += `| [${displayName}](${link}) | ${description} | ${itemCount} items | ${tags} |\n`;
|
||||
}
|
||||
|
||||
return `${TEMPLATES.collectionsSection}\n${TEMPLATES.collectionsUsage}\n\n${collectionsContent}`;
|
||||
return `${TEMPLATES.pluginsSection}\n${TEMPLATES.pluginsUsage}\n\n${pluginsContent}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the featured collections section for the main README
|
||||
* Generate the featured plugins section for the main README
|
||||
*/
|
||||
function generateFeaturedCollectionsSection(collectionsDir) {
|
||||
// Check if collections directory exists
|
||||
if (!fs.existsSync(collectionsDir)) {
|
||||
function generateFeaturedPluginsSection(pluginsDir) {
|
||||
// Check if plugins directory exists
|
||||
if (!fs.existsSync(pluginsDir)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Get all collection files
|
||||
const collectionFiles = fs
|
||||
.readdirSync(collectionsDir)
|
||||
.filter((file) => file.endsWith(".collection.yml"));
|
||||
// Get all plugin directories
|
||||
const pluginDirs = fs
|
||||
.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
|
||||
// Map collection files to objects with name for sorting, filter for featured
|
||||
const featuredCollections = collectionFiles
|
||||
.map((file) => {
|
||||
const filePath = path.join(collectionsDir, file);
|
||||
// Map plugin dirs to objects, filter for featured
|
||||
const featuredPlugins = pluginDirs
|
||||
.map((dir) => {
|
||||
const pluginDir = path.join(pluginsDir, dir);
|
||||
return safeFileOperation(
|
||||
() => {
|
||||
const collection = parseCollectionYaml(filePath);
|
||||
if (!collection) return null;
|
||||
const plugin = readPluginJson(pluginDir);
|
||||
if (!plugin) return null;
|
||||
|
||||
// Only include collections with featured: true
|
||||
if (!collection.display?.featured) return null;
|
||||
// Only include plugins with featured: true
|
||||
if (!plugin.featured) return null;
|
||||
|
||||
const collectionId =
|
||||
collection.id || path.basename(file, ".collection.yml");
|
||||
const name = collection.name || collectionId;
|
||||
const name = plugin.name || dir;
|
||||
const description = formatTableCell(
|
||||
collection.description || "No description"
|
||||
plugin.description || "No description"
|
||||
);
|
||||
const tags = collection.tags ? collection.tags.join(", ") : "";
|
||||
const itemCount = collection.items ? collection.items.length : 0;
|
||||
const tags = plugin.tags ? plugin.tags.join(", ") : "";
|
||||
const itemCount = plugin.items ? plugin.items.length : 0;
|
||||
|
||||
return {
|
||||
file,
|
||||
collection,
|
||||
collectionId,
|
||||
dir,
|
||||
plugin,
|
||||
pluginId: name,
|
||||
name,
|
||||
description,
|
||||
tags,
|
||||
itemCount,
|
||||
};
|
||||
},
|
||||
filePath,
|
||||
pluginDir,
|
||||
null
|
||||
);
|
||||
})
|
||||
.filter((entry) => entry !== null); // Remove non-featured and failed parses
|
||||
.filter((entry) => entry !== null);
|
||||
|
||||
// Sort by name alphabetically
|
||||
featuredCollections.sort((a, b) => a.name.localeCompare(b.name));
|
||||
featuredPlugins.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
console.log(`Found ${featuredCollections.length} featured collection(s)`);
|
||||
console.log(`Found ${featuredPlugins.length} featured plugin(s)`);
|
||||
|
||||
// If no featured collections, return empty string
|
||||
if (featuredCollections.length === 0) {
|
||||
// If no featured plugins, return empty string
|
||||
if (featuredPlugins.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -852,167 +859,15 @@ function generateFeaturedCollectionsSection(collectionsDir) {
|
||||
let featuredContent =
|
||||
"| Name | Description | Items | Tags |\n| ---- | ----------- | ----- | ---- |\n";
|
||||
|
||||
// Generate table rows for each featured collection
|
||||
for (const entry of featuredCollections) {
|
||||
const { collectionId, name, description, tags, itemCount } = entry;
|
||||
const readmeLink = `collections/${collectionId}.md`;
|
||||
// Generate table rows for each featured plugin
|
||||
for (const entry of featuredPlugins) {
|
||||
const { dir, name, description, tags, itemCount } = entry;
|
||||
const readmeLink = `plugins/${dir}/README.md`;
|
||||
|
||||
featuredContent += `| [${name}](${readmeLink}) | ${description} | ${itemCount} items | ${tags} |\n`;
|
||||
}
|
||||
|
||||
return `${TEMPLATES.featuredCollectionsSection}\n\n${featuredContent}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate individual collection README file
|
||||
* @param {Object} collection - Collection object
|
||||
* @param {string} collectionId - Collection ID
|
||||
* @param {{ name: string, displayName: string }[]} registryNames - Pre-loaded MCP registry names
|
||||
*/
|
||||
function generateCollectionReadme(
|
||||
collection,
|
||||
collectionId,
|
||||
registryNames = []
|
||||
) {
|
||||
if (!collection || !collection.items) {
|
||||
return `# ${collectionId}\n\nCollection not found or invalid.`;
|
||||
}
|
||||
|
||||
const name = collection.name || collectionId;
|
||||
const description = collection.description || "No description provided.";
|
||||
const tags = collection.tags ? collection.tags.join(", ") : "None";
|
||||
|
||||
let content = `# ${name}\n\n${description}\n\n`;
|
||||
|
||||
if (collection.tags && collection.tags.length > 0) {
|
||||
content += `**Tags:** ${tags}\n\n`;
|
||||
}
|
||||
|
||||
content += `## Items in this Collection\n\n`;
|
||||
|
||||
// Check if collection has any agents to determine table structure (future: chatmodes may migrate)
|
||||
const hasAgents = collection.items.some((item) => item.kind === "agent");
|
||||
|
||||
// Generate appropriate table header
|
||||
if (hasAgents) {
|
||||
content += `| Title | Type | Description | MCP Servers |\n| ----- | ---- | ----------- | ----------- |\n`;
|
||||
} else {
|
||||
content += `| Title | Type | Description |\n| ----- | ---- | ----------- |\n`;
|
||||
}
|
||||
|
||||
let collectionUsageHeader = "## Collection Usage\n\n";
|
||||
let collectionUsageContent = [];
|
||||
|
||||
// Sort items based on display.ordering setting
|
||||
const items = [...collection.items];
|
||||
if (collection.display?.ordering === "alpha") {
|
||||
items.sort((a, b) => {
|
||||
const titleA = extractTitle(path.join(ROOT_FOLDER, a.path));
|
||||
const titleB = extractTitle(path.join(ROOT_FOLDER, b.path));
|
||||
return titleA.localeCompare(titleB);
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const filePath = path.join(ROOT_FOLDER, item.path);
|
||||
const title = extractTitle(filePath);
|
||||
const description = extractDescription(filePath) || "No description";
|
||||
|
||||
const typeDisplay =
|
||||
item.kind === "instruction"
|
||||
? "Instruction"
|
||||
: item.kind === "agent"
|
||||
? "Agent"
|
||||
: item.kind === "skill"
|
||||
? "Skill"
|
||||
: "Prompt";
|
||||
const link = `../${item.path}`;
|
||||
|
||||
// Create install badges for each item (skills don't use chat install badges)
|
||||
const badgeType =
|
||||
item.kind === "instruction"
|
||||
? "instructions"
|
||||
: item.kind === "agent"
|
||||
? "agent"
|
||||
: item.kind === "skill"
|
||||
? null
|
||||
: "prompt";
|
||||
const badges = badgeType ? makeBadges(item.path, badgeType) : "";
|
||||
|
||||
const usageDescription = item.usage
|
||||
? `${description} [see usage](#${title
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase()})`
|
||||
: description;
|
||||
|
||||
// Generate MCP server column if collection has agents
|
||||
content += buildCollectionRow({
|
||||
hasAgents,
|
||||
title,
|
||||
link,
|
||||
badges,
|
||||
typeDisplay,
|
||||
usageDescription,
|
||||
filePath,
|
||||
kind: item.kind,
|
||||
registryNames,
|
||||
});
|
||||
// Generate Usage section for each collection
|
||||
if (item.usage && item.usage.trim()) {
|
||||
collectionUsageContent.push(
|
||||
`### ${title}\n\n${item.usage.trim()}\n\n---\n\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Append the usage section if any items had usage defined
|
||||
if (collectionUsageContent.length > 0) {
|
||||
content += `\n${collectionUsageHeader}${collectionUsageContent.join("")}`;
|
||||
} else if (collection.display?.show_badge) {
|
||||
content += "\n---\n";
|
||||
}
|
||||
|
||||
// Optional badge note at the end if show_badge is true
|
||||
if (collection.display?.show_badge) {
|
||||
content += `*This collection includes ${items.length} curated items for **${name}**.*`;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single markdown table row for a collection item.
|
||||
* Handles optional MCP server column when agents are present.
|
||||
*/
|
||||
function buildCollectionRow({
|
||||
hasAgents,
|
||||
title,
|
||||
link,
|
||||
badges,
|
||||
typeDisplay,
|
||||
usageDescription,
|
||||
filePath,
|
||||
kind,
|
||||
registryNames = [],
|
||||
}) {
|
||||
const titleCell = badges
|
||||
? `[${title}](${link})<br />${badges}`
|
||||
: `[${title}](${link})`;
|
||||
|
||||
// Ensure description is table-safe
|
||||
const safeUsage = formatTableCell(usageDescription);
|
||||
|
||||
if (hasAgents) {
|
||||
// Only agents currently have MCP servers;
|
||||
const mcpServers =
|
||||
kind === "agent" ? extractMcpServerConfigs(filePath) : [];
|
||||
const mcpServerCell =
|
||||
mcpServers.length > 0
|
||||
? generateMcpServerLinks(mcpServers, registryNames)
|
||||
: "";
|
||||
return `| ${titleCell} | ${typeDisplay} | ${safeUsage} | ${mcpServerCell} |\n`;
|
||||
}
|
||||
return `| ${titleCell} | ${typeDisplay} | ${safeUsage} |\n`;
|
||||
return `${TEMPLATES.featuredPluginsSection}\n\n${featuredContent}`;
|
||||
}
|
||||
|
||||
// Utility: write file only if content changed
|
||||
@@ -1067,7 +922,7 @@ async function main() {
|
||||
const agentsHeader = TEMPLATES.agentsSection.replace(/^##\s/m, "# ");
|
||||
const hooksHeader = TEMPLATES.hooksSection.replace(/^##\s/m, "# ");
|
||||
const skillsHeader = TEMPLATES.skillsSection.replace(/^##\s/m, "# ");
|
||||
const collectionsHeader = TEMPLATES.collectionsSection.replace(
|
||||
const pluginsHeader = TEMPLATES.pluginsSection.replace(
|
||||
/^##\s/m,
|
||||
"# "
|
||||
);
|
||||
@@ -1113,12 +968,12 @@ async function main() {
|
||||
registryNames
|
||||
);
|
||||
|
||||
// Generate collections README
|
||||
const collectionsReadme = buildCategoryReadme(
|
||||
generateCollectionsSection,
|
||||
COLLECTIONS_DIR,
|
||||
collectionsHeader,
|
||||
TEMPLATES.collectionsUsage,
|
||||
// Generate plugins README
|
||||
const pluginsReadme = buildCategoryReadme(
|
||||
generatePluginsSection,
|
||||
PLUGINS_DIR,
|
||||
pluginsHeader,
|
||||
TEMPLATES.pluginsUsage,
|
||||
registryNames
|
||||
);
|
||||
|
||||
@@ -1137,39 +992,15 @@ async function main() {
|
||||
writeFileIfChanged(path.join(DOCS_DIR, "README.hooks.md"), hooksReadme);
|
||||
writeFileIfChanged(path.join(DOCS_DIR, "README.skills.md"), skillsReadme);
|
||||
writeFileIfChanged(
|
||||
path.join(DOCS_DIR, "README.collections.md"),
|
||||
collectionsReadme
|
||||
path.join(DOCS_DIR, "README.plugins.md"),
|
||||
pluginsReadme
|
||||
);
|
||||
|
||||
// Generate individual collection README files
|
||||
if (fs.existsSync(COLLECTIONS_DIR)) {
|
||||
console.log("Generating individual collection README files...");
|
||||
// Plugin READMEs are authoritative (already exist in each plugin folder)
|
||||
|
||||
const collectionFiles = fs
|
||||
.readdirSync(COLLECTIONS_DIR)
|
||||
.filter((file) => file.endsWith(".collection.yml"));
|
||||
|
||||
for (const file of collectionFiles) {
|
||||
const filePath = path.join(COLLECTIONS_DIR, file);
|
||||
const collection = parseCollectionYaml(filePath);
|
||||
|
||||
if (collection) {
|
||||
const collectionId =
|
||||
collection.id || path.basename(file, ".collection.yml");
|
||||
const readmeContent = generateCollectionReadme(
|
||||
collection,
|
||||
collectionId,
|
||||
registryNames
|
||||
);
|
||||
const readmeFile = path.join(COLLECTIONS_DIR, `${collectionId}.md`);
|
||||
writeFileIfChanged(readmeFile, readmeContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate featured collections section and update main README.md
|
||||
console.log("Updating main README.md with featured collections...");
|
||||
const featuredSection = generateFeaturedCollectionsSection(COLLECTIONS_DIR);
|
||||
// Generate featured plugins section and update main README.md
|
||||
console.log("Updating main README.md with featured plugins...");
|
||||
const featuredSection = generateFeaturedPluginsSection(PLUGINS_DIR);
|
||||
|
||||
if (featuredSection) {
|
||||
const mainReadmePath = path.join(ROOT_FOLDER, "README.md");
|
||||
@@ -1177,8 +1008,8 @@ async function main() {
|
||||
if (fs.existsSync(mainReadmePath)) {
|
||||
let readmeContent = fs.readFileSync(mainReadmePath, "utf8");
|
||||
|
||||
// Define markers to identify where to insert the featured collections
|
||||
const startMarker = "## 🌟 Featured Collections";
|
||||
// Define markers to identify where to insert the featured plugins
|
||||
const startMarker = "## 🌟 Featured Plugins";
|
||||
const endMarker = "## MCP Server";
|
||||
|
||||
// Check if the section already exists
|
||||
@@ -1205,14 +1036,14 @@ async function main() {
|
||||
}
|
||||
|
||||
writeFileIfChanged(mainReadmePath, readmeContent);
|
||||
console.log("Main README.md updated with featured collections");
|
||||
console.log("Main README.md updated with featured plugins");
|
||||
} else {
|
||||
console.warn(
|
||||
"README.md not found, skipping featured collections update"
|
||||
"README.md not found, skipping featured plugins update"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log("No featured collections found to add to README.md");
|
||||
console.log("No featured plugins found to add to README.md");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error generating category README files: ${error.message}`);
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
COLLECTIONS_DIR,
|
||||
MAX_COLLECTION_ITEMS,
|
||||
ROOT_FOLDER,
|
||||
} from "./constants.mjs";
|
||||
import { parseCollectionYaml, parseFrontmatter } from "./yaml-parser.mjs";
|
||||
|
||||
// Validation functions
|
||||
function validateCollectionId(id) {
|
||||
if (!id || typeof id !== "string") {
|
||||
return "ID is required and must be a string";
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(id)) {
|
||||
return "ID must contain only lowercase letters, numbers, and hyphens";
|
||||
}
|
||||
if (id.length < 1 || id.length > 50) {
|
||||
return "ID must be between 1 and 50 characters";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCollectionName(name) {
|
||||
if (!name || typeof name !== "string") {
|
||||
return "Name is required and must be a string";
|
||||
}
|
||||
if (name.length < 1 || name.length > 100) {
|
||||
return "Name must be between 1 and 100 characters";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCollectionDescription(description) {
|
||||
if (!description || typeof description !== "string") {
|
||||
return "Description is required and must be a string";
|
||||
}
|
||||
if (description.length < 1 || description.length > 500) {
|
||||
return "Description must be between 1 and 500 characters";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCollectionTags(tags) {
|
||||
if (tags && !Array.isArray(tags)) {
|
||||
return "Tags must be an array";
|
||||
}
|
||||
if (tags && tags.length > 10) {
|
||||
return "Maximum 10 tags allowed";
|
||||
}
|
||||
if (tags) {
|
||||
for (const tag of tags) {
|
||||
if (typeof tag !== "string") {
|
||||
return "All tags must be strings";
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(tag)) {
|
||||
return `Tag "${tag}" must contain only lowercase letters, numbers, and hyphens`;
|
||||
}
|
||||
if (tag.length < 1 || tag.length > 30) {
|
||||
return `Tag "${tag}" must be between 1 and 30 characters`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateAgentFile(filePath) {
|
||||
try {
|
||||
const agent = parseFrontmatter(filePath);
|
||||
|
||||
if (!agent) {
|
||||
return `Item ${filePath} agent file could not be parsed`;
|
||||
}
|
||||
|
||||
// Validate name field
|
||||
if (!agent.name || typeof agent.name !== "string") {
|
||||
return `Item ${filePath} agent must have a 'name' field`;
|
||||
}
|
||||
if (agent.name.length < 1 || agent.name.length > 50) {
|
||||
return `Item ${filePath} agent name must be between 1 and 50 characters`;
|
||||
}
|
||||
|
||||
// Validate description field
|
||||
if (!agent.description || typeof agent.description !== "string") {
|
||||
return `Item ${filePath} agent must have a 'description' field`;
|
||||
}
|
||||
if (agent.description.length < 1 || agent.description.length > 500) {
|
||||
return `Item ${filePath} agent description must be between 1 and 500 characters`;
|
||||
}
|
||||
|
||||
// Validate tools field (optional)
|
||||
if (agent.tools !== undefined && !Array.isArray(agent.tools)) {
|
||||
return `Item ${filePath} agent 'tools' must be an array`;
|
||||
}
|
||||
|
||||
// Validate mcp-servers field (optional)
|
||||
if (agent["mcp-servers"]) {
|
||||
if (
|
||||
typeof agent["mcp-servers"] !== "object" ||
|
||||
Array.isArray(agent["mcp-servers"])
|
||||
) {
|
||||
return `Item ${filePath} agent 'mcp-servers' must be an object`;
|
||||
}
|
||||
|
||||
// Validate each MCP server configuration
|
||||
for (const [serverName, serverConfig] of Object.entries(
|
||||
agent["mcp-servers"]
|
||||
)) {
|
||||
if (!serverConfig || typeof serverConfig !== "object") {
|
||||
return `Item ${filePath} agent MCP server '${serverName}' must be an object`;
|
||||
}
|
||||
|
||||
if (!serverConfig.type || typeof serverConfig.type !== "string") {
|
||||
return `Item ${filePath} agent MCP server '${serverName}' must have a 'type' field`;
|
||||
}
|
||||
|
||||
// For local type servers, command is required
|
||||
if (serverConfig.type === "local" && !serverConfig.command) {
|
||||
return `Item ${filePath} agent MCP server '${serverName}' with type 'local' must have a 'command' field`;
|
||||
}
|
||||
|
||||
// Validate args if present
|
||||
if (
|
||||
serverConfig.args !== undefined &&
|
||||
!Array.isArray(serverConfig.args)
|
||||
) {
|
||||
return `Item ${filePath} agent MCP server '${serverName}' 'args' must be an array`;
|
||||
}
|
||||
|
||||
// Validate tools if present
|
||||
if (
|
||||
serverConfig.tools !== undefined &&
|
||||
!Array.isArray(serverConfig.tools)
|
||||
) {
|
||||
return `Item ${filePath} agent MCP server '${serverName}' 'tools' must be an array`;
|
||||
}
|
||||
|
||||
// Validate env if present
|
||||
if (serverConfig.env !== undefined) {
|
||||
if (
|
||||
typeof serverConfig.env !== "object" ||
|
||||
Array.isArray(serverConfig.env)
|
||||
) {
|
||||
return `Item ${filePath} agent MCP server '${serverName}' 'env' must be an object`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null; // All validations passed
|
||||
} catch (error) {
|
||||
return `Item ${filePath} agent file validation failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function validateHookFile(filePath) {
|
||||
try {
|
||||
const hook = parseFrontmatter(filePath);
|
||||
|
||||
if (!hook) {
|
||||
return `Item ${filePath} hook file could not be parsed`;
|
||||
}
|
||||
|
||||
// Validate name field
|
||||
if (!hook.name || typeof hook.name !== "string") {
|
||||
return `Item ${filePath} hook must have a 'name' field`;
|
||||
}
|
||||
if (hook.name.length < 1 || hook.name.length > 50) {
|
||||
return `Item ${filePath} hook name must be between 1 and 50 characters`;
|
||||
}
|
||||
|
||||
// Validate description field
|
||||
if (!hook.description || typeof hook.description !== "string") {
|
||||
return `Item ${filePath} hook must have a 'description' field`;
|
||||
}
|
||||
if (hook.description.length < 1 || hook.description.length > 500) {
|
||||
return `Item ${filePath} hook description must be between 1 and 500 characters`;
|
||||
}
|
||||
|
||||
// Validate event field (optional but recommended)
|
||||
if (hook.event !== undefined && typeof hook.event !== "string") {
|
||||
return `Item ${filePath} hook 'event' must be a string`;
|
||||
}
|
||||
|
||||
return null; // All validations passed
|
||||
} catch (error) {
|
||||
return `Item ${filePath} hook file validation failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCollectionItems(items) {
|
||||
if (!items || !Array.isArray(items)) {
|
||||
return "Items is required and must be an array";
|
||||
}
|
||||
if (items.length < 1) {
|
||||
return "At least one item is required";
|
||||
}
|
||||
if (items.length > MAX_COLLECTION_ITEMS) {
|
||||
return `Maximum ${MAX_COLLECTION_ITEMS} items allowed`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (!item || typeof item !== "object") {
|
||||
return `Item ${i + 1} must be an object`;
|
||||
}
|
||||
if (!item.path || typeof item.path !== "string") {
|
||||
return `Item ${i + 1} must have a path string`;
|
||||
}
|
||||
if (!item.kind || typeof item.kind !== "string") {
|
||||
return `Item ${i + 1} must have a kind string`;
|
||||
}
|
||||
if (!["prompt", "instruction", "agent", "skill", "hook"].includes(item.kind)) {
|
||||
return `Item ${
|
||||
i + 1
|
||||
} kind must be one of: prompt, instruction, agent, skill, hook`;
|
||||
}
|
||||
|
||||
// Validate file path exists
|
||||
const filePath = path.join(ROOT_FOLDER, item.path);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return `Item ${i + 1} file does not exist: ${item.path}`;
|
||||
}
|
||||
|
||||
// Validate path pattern matches kind
|
||||
if (item.kind === "prompt" && !item.path.endsWith(".prompt.md")) {
|
||||
return `Item ${
|
||||
i + 1
|
||||
} kind is "prompt" but path doesn't end with .prompt.md`;
|
||||
}
|
||||
if (
|
||||
item.kind === "instruction" &&
|
||||
!item.path.endsWith(".instructions.md")
|
||||
) {
|
||||
return `Item ${
|
||||
i + 1
|
||||
} kind is "instruction" but path doesn't end with .instructions.md`;
|
||||
}
|
||||
if (item.kind === "agent" && !item.path.endsWith(".agent.md")) {
|
||||
return `Item ${
|
||||
i + 1
|
||||
} kind is "agent" but path doesn't end with .agent.md`;
|
||||
}
|
||||
if (item.kind === "hook") {
|
||||
const isValidHookPath =
|
||||
item.path.startsWith("hooks/") && item.path.endsWith("/README.md");
|
||||
if (!isValidHookPath) {
|
||||
return `Item ${
|
||||
i + 1
|
||||
} kind is "hook" but path must be hooks/<hook>/README.md`;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate agent-specific frontmatter
|
||||
if (item.kind === "agent") {
|
||||
const agentValidation = validateAgentFile(filePath, i + 1);
|
||||
if (agentValidation) {
|
||||
return agentValidation;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate hook-specific frontmatter
|
||||
if (item.kind === "hook") {
|
||||
const hookValidation = validateHookFile(filePath);
|
||||
if (hookValidation) {
|
||||
return hookValidation;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCollectionDisplay(display) {
|
||||
if (display && typeof display !== "object") {
|
||||
return "Display must be an object";
|
||||
}
|
||||
if (display) {
|
||||
// Normalize ordering and show_badge in case the YAML parser left inline comments
|
||||
const normalize = (val) => {
|
||||
if (typeof val !== "string") return val;
|
||||
// Strip any inline comment starting with '#'
|
||||
const hashIndex = val.indexOf("#");
|
||||
if (hashIndex !== -1) {
|
||||
val = val.substring(0, hashIndex).trim();
|
||||
}
|
||||
// Also strip surrounding quotes if present
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.substring(1, val.length - 1);
|
||||
}
|
||||
return val.trim();
|
||||
};
|
||||
|
||||
if (display.ordering) {
|
||||
const normalizedOrdering = normalize(display.ordering);
|
||||
if (!["manual", "alpha"].includes(normalizedOrdering)) {
|
||||
return "Display ordering must be 'manual' or 'alpha'";
|
||||
}
|
||||
}
|
||||
|
||||
if (display.show_badge !== undefined) {
|
||||
const raw = display.show_badge;
|
||||
const normalizedBadge = normalize(raw);
|
||||
// Accept boolean or string boolean values
|
||||
if (typeof normalizedBadge === "string") {
|
||||
if (!["true", "false"].includes(normalizedBadge.toLowerCase())) {
|
||||
return "Display show_badge must be boolean";
|
||||
}
|
||||
} else if (typeof normalizedBadge !== "boolean") {
|
||||
return "Display show_badge must be boolean";
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCollectionManifest(collection, filePath) {
|
||||
const errors = [];
|
||||
|
||||
const idError = validateCollectionId(collection.id);
|
||||
if (idError) errors.push(`ID: ${idError}`);
|
||||
|
||||
const nameError = validateCollectionName(collection.name);
|
||||
if (nameError) errors.push(`Name: ${nameError}`);
|
||||
|
||||
const descError = validateCollectionDescription(collection.description);
|
||||
if (descError) errors.push(`Description: ${descError}`);
|
||||
|
||||
const tagsError = validateCollectionTags(collection.tags);
|
||||
if (tagsError) errors.push(`Tags: ${tagsError}`);
|
||||
|
||||
const itemsError = validateCollectionItems(collection.items);
|
||||
if (itemsError) errors.push(`Items: ${itemsError}`);
|
||||
|
||||
const displayError = validateCollectionDisplay(collection.display);
|
||||
if (displayError) errors.push(`Display: ${displayError}`);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Main validation function
|
||||
function validateCollections() {
|
||||
if (!fs.existsSync(COLLECTIONS_DIR)) {
|
||||
console.log("No collections directory found - validation skipped");
|
||||
return true;
|
||||
}
|
||||
|
||||
const collectionFiles = fs
|
||||
.readdirSync(COLLECTIONS_DIR)
|
||||
.filter((file) => file.endsWith(".collection.yml"));
|
||||
|
||||
if (collectionFiles.length === 0) {
|
||||
console.log("No collection files found - validation skipped");
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`Validating ${collectionFiles.length} collection files...`);
|
||||
|
||||
let hasErrors = false;
|
||||
const usedIds = new Set();
|
||||
|
||||
for (const file of collectionFiles) {
|
||||
const filePath = path.join(COLLECTIONS_DIR, file);
|
||||
console.log(`\nValidating ${file}...`);
|
||||
|
||||
const collection = parseCollectionYaml(filePath);
|
||||
if (!collection) {
|
||||
console.error(`❌ Failed to parse ${file}`);
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate the collection structure
|
||||
const errors = validateCollectionManifest(collection, filePath);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`❌ Validation errors in ${file}:`);
|
||||
errors.forEach((error) => console.error(` - ${error}`));
|
||||
hasErrors = true;
|
||||
} else {
|
||||
console.log(`✅ ${file} is valid`);
|
||||
}
|
||||
|
||||
// Check for duplicate IDs
|
||||
if (collection.id) {
|
||||
if (usedIds.has(collection.id)) {
|
||||
console.error(
|
||||
`❌ Duplicate collection ID "${collection.id}" found in ${file}`
|
||||
);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
usedIds.add(collection.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasErrors) {
|
||||
console.log(`\n✅ All ${collectionFiles.length} collections are valid`);
|
||||
}
|
||||
|
||||
return !hasErrors;
|
||||
}
|
||||
|
||||
// Run validation
|
||||
try {
|
||||
const isValid = validateCollections();
|
||||
if (!isValid) {
|
||||
console.error("\n❌ Collection validation failed");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\n🎉 Collection validation passed");
|
||||
} catch (error) {
|
||||
console.error(`Error during validation: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
247
eng/validate-plugins.mjs
Executable file
247
eng/validate-plugins.mjs
Executable file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ROOT_FOLDER } from "./constants.mjs";
|
||||
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
|
||||
const VALID_ITEM_KINDS = ["prompt", "agent", "instruction", "skill", "hook"];
|
||||
|
||||
// Validation functions
|
||||
function validateName(name, folderName) {
|
||||
const errors = [];
|
||||
if (!name || typeof name !== "string") {
|
||||
errors.push("name is required and must be a string");
|
||||
return errors;
|
||||
}
|
||||
if (name.length < 1 || name.length > 50) {
|
||||
errors.push("name must be between 1 and 50 characters");
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
errors.push("name must contain only lowercase letters, numbers, and hyphens");
|
||||
}
|
||||
if (name !== folderName) {
|
||||
errors.push(`name "${name}" must match folder name "${folderName}"`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateDescription(description) {
|
||||
if (!description || typeof description !== "string") {
|
||||
return "description is required and must be a string";
|
||||
}
|
||||
if (description.length < 1 || description.length > 500) {
|
||||
return "description must be between 1 and 500 characters";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateVersion(version) {
|
||||
if (!version || typeof version !== "string") {
|
||||
return "version is required and must be a string";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateTags(tags) {
|
||||
if (tags === undefined) return null;
|
||||
if (!Array.isArray(tags)) {
|
||||
return "tags must be an array";
|
||||
}
|
||||
if (tags.length > 10) {
|
||||
return "maximum 10 tags allowed";
|
||||
}
|
||||
for (const tag of tags) {
|
||||
if (typeof tag !== "string") {
|
||||
return "all tags must be strings";
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(tag)) {
|
||||
return `tag "${tag}" must contain only lowercase letters, numbers, and hyphens`;
|
||||
}
|
||||
if (tag.length < 1 || tag.length > 30) {
|
||||
return `tag "${tag}" must be between 1 and 30 characters`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateFeatured(featured) {
|
||||
if (featured === undefined) return null;
|
||||
if (typeof featured !== "boolean") {
|
||||
return "featured must be a boolean";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateDisplay(display) {
|
||||
if (display === undefined) return null;
|
||||
if (typeof display !== "object" || Array.isArray(display) || display === null) {
|
||||
return "display must be an object";
|
||||
}
|
||||
if (display.ordering !== undefined) {
|
||||
if (!["manual", "alpha"].includes(display.ordering)) {
|
||||
return "display.ordering must be 'manual' or 'alpha'";
|
||||
}
|
||||
}
|
||||
if (display.show_badge !== undefined) {
|
||||
if (typeof display.show_badge !== "boolean") {
|
||||
return "display.show_badge must be a boolean";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateItems(items) {
|
||||
if (items === undefined) return [];
|
||||
const errors = [];
|
||||
if (!Array.isArray(items)) {
|
||||
errors.push("items must be an array");
|
||||
return errors;
|
||||
}
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (!item || typeof item !== "object") {
|
||||
errors.push(`items[${i}] must be an object`);
|
||||
continue;
|
||||
}
|
||||
if (!item.path || typeof item.path !== "string") {
|
||||
errors.push(`items[${i}] must have a path string`);
|
||||
}
|
||||
if (!item.kind || typeof item.kind !== "string") {
|
||||
errors.push(`items[${i}] must have a kind string`);
|
||||
} else if (!VALID_ITEM_KINDS.includes(item.kind)) {
|
||||
errors.push(
|
||||
`items[${i}] kind must be one of: ${VALID_ITEM_KINDS.join(", ")}`
|
||||
);
|
||||
}
|
||||
// Validate referenced path exists relative to repo root
|
||||
if (item.path && typeof item.path === "string") {
|
||||
const filePath = path.join(ROOT_FOLDER, item.path);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
errors.push(`items[${i}] file does not exist: ${item.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validatePlugin(folderName) {
|
||||
const pluginDir = path.join(PLUGINS_DIR, folderName);
|
||||
const errors = [];
|
||||
|
||||
// Rule 1: Must have .github/plugin/plugin.json
|
||||
const pluginJsonPath = path.join(pluginDir, ".github", "plugin", "plugin.json");
|
||||
if (!fs.existsSync(pluginJsonPath)) {
|
||||
errors.push("missing required file: .github/plugin/plugin.json");
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Rule 2: Must have README.md
|
||||
const readmePath = path.join(pluginDir, "README.md");
|
||||
if (!fs.existsSync(readmePath)) {
|
||||
errors.push("missing required file: README.md");
|
||||
}
|
||||
|
||||
// Parse plugin.json
|
||||
let plugin;
|
||||
try {
|
||||
const raw = fs.readFileSync(pluginJsonPath, "utf-8");
|
||||
plugin = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
errors.push(`failed to parse plugin.json: ${err.message}`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Rule 3 & 4: name, description, version
|
||||
const nameErrors = validateName(plugin.name, folderName);
|
||||
errors.push(...nameErrors);
|
||||
|
||||
const descError = validateDescription(plugin.description);
|
||||
if (descError) errors.push(descError);
|
||||
|
||||
const versionError = validateVersion(plugin.version);
|
||||
if (versionError) errors.push(versionError);
|
||||
|
||||
// Rule 5: tags
|
||||
const tagsError = validateTags(plugin.tags);
|
||||
if (tagsError) errors.push(tagsError);
|
||||
|
||||
// Rule 8: featured
|
||||
const featuredError = validateFeatured(plugin.featured);
|
||||
if (featuredError) errors.push(featuredError);
|
||||
|
||||
// Rule 9: display
|
||||
const displayError = validateDisplay(plugin.display);
|
||||
if (displayError) errors.push(displayError);
|
||||
|
||||
// Rule 6 & 7: items
|
||||
const itemErrors = validateItems(plugin.items);
|
||||
errors.push(...itemErrors);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Main validation function
|
||||
function validatePlugins() {
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
console.log("No plugins directory found - validation skipped");
|
||||
return true;
|
||||
}
|
||||
|
||||
const pluginDirs = fs
|
||||
.readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
|
||||
if (pluginDirs.length === 0) {
|
||||
console.log("No plugin directories found - validation skipped");
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`Validating ${pluginDirs.length} plugins...\n`);
|
||||
|
||||
let hasErrors = false;
|
||||
const seenNames = new Set();
|
||||
|
||||
for (const dir of pluginDirs) {
|
||||
console.log(`Validating ${dir}...`);
|
||||
|
||||
const errors = validatePlugin(dir);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`❌ ${dir}:`);
|
||||
errors.forEach((e) => console.error(` - ${e}`));
|
||||
hasErrors = true;
|
||||
} else {
|
||||
console.log(`✅ ${dir} is valid`);
|
||||
}
|
||||
|
||||
// Rule 10: duplicate names
|
||||
if (seenNames.has(dir)) {
|
||||
console.error(`❌ Duplicate plugin name "${dir}"`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
seenNames.add(dir);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasErrors) {
|
||||
console.log(`\n✅ All ${pluginDirs.length} plugins are valid`);
|
||||
}
|
||||
|
||||
return !hasErrors;
|
||||
}
|
||||
|
||||
// Run validation
|
||||
try {
|
||||
const isValid = validatePlugins();
|
||||
if (!isValid) {
|
||||
console.error("\n❌ Plugin validation failed");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\n🎉 Plugin validation passed");
|
||||
} catch (error) {
|
||||
console.error(`Error during validation: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// YAML parser for collection files and frontmatter parsing using vfile-matter
|
||||
// YAML parser for frontmatter parsing using vfile-matter
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import path from "path";
|
||||
@@ -14,25 +14,6 @@ function safeFileOperation(operation, filePath, defaultValue = null) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -292,7 +273,6 @@ export {
|
||||
extractAgentMetadata,
|
||||
extractMcpServerConfigs,
|
||||
extractMcpServers,
|
||||
parseCollectionYaml,
|
||||
parseFrontmatter,
|
||||
parseSkillMetadata,
|
||||
parseHookMetadata,
|
||||
|
||||
Reference in New Issue
Block a user