mirror of
https://github.com/github/awesome-copilot.git
synced 2026-02-20 02:15:12 +00:00
Adding script to convert collections to plugins
This commit is contained in:
539
eng/collection-to-plugin.mjs
Normal file
539
eng/collection-to-plugin.mjs
Normal file
@@ -0,0 +1,539 @@
|
||||
#!/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 } 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 === "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("");
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -15,6 +15,8 @@
|
||||
"collection:create": "node ./eng/create-collection.mjs",
|
||||
"skill:validate": "node ./eng/validate-skills.mjs",
|
||||
"skill:create": "node ./eng/create-skill.mjs",
|
||||
"plugin:migrate": "node ./eng/collection-to-plugin.mjs",
|
||||
"plugin:refresh": "PLUGIN_MODE=refresh node ./eng/collection-to-plugin.mjs",
|
||||
"website:data": "node ./eng/generate-website-data.mjs",
|
||||
"website:dev": "npm run website:data && npm run --prefix website dev",
|
||||
"website:build": "npm run build && npm run website:data && npm run --prefix website build",
|
||||
|
||||
Reference in New Issue
Block a user