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:
Aaron Powell
2026-02-13 15:38:37 +11:00
parent de0611d0ec
commit 7a003fc75a
154 changed files with 2603 additions and 5790 deletions

View File

@@ -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();

View File

@@ -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,

View File

@@ -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
View 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();

View File

@@ -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,

View File

@@ -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}`);

View File

@@ -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
View 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);
}

View File

@@ -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,