#!/usr/bin/env node import fs from "fs"; import path, { dirname } from "path"; import { fileURLToPath } from "url"; import { AGENTS_DIR, AKA_INSTALL_URLS, COLLECTIONS_DIR, DOCS_DIR, INSTRUCTIONS_DIR, PROMPTS_DIR, repoBaseUrl, ROOT_FOLDER, SKILLS_DIR, TEMPLATES, vscodeInsidersInstallImage, vscodeInstallImage, } from "./constants.mjs"; import { extractMcpServerConfigs, parseCollectionYaml, parseFrontmatter, parseSkillMetadata, } from "./yaml-parser.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Cache of MCP registry server names (lower-cased) fetched from the API let MCP_REGISTRY_SET = null; /** * Loads and caches the set of MCP registry server names from the GitHub MCP registry API. * * Behavior: * - If a cached set already exists (MCP_REGISTRY_SET), it is returned immediately. * - Fetches all pages from https://api.mcp.github.com/v0.1/servers/ using cursor-based pagination * - Safely handles network errors or malformed JSON by returning an empty array. * - Extracts server names from: data[].server.name * - Normalizes names to lowercase for case-insensitive matching * - Only hits the API once per README build run (cached for subsequent calls) * * Side Effects: * - Mutates the module-scoped variable MCP_REGISTRY_SET. * - Logs a warning to console if fetching or parsing the registry fails. * * @returns {Promise<{ name: string, displayName: string }[]>} Array of server entries with name and lowercase displayName. May be empty if * the API is unreachable or returns malformed data. * * @throws {none} All errors are caught internally; failures result in an empty array. */ async function loadMcpRegistryNames() { if (MCP_REGISTRY_SET) return MCP_REGISTRY_SET; try { console.log("Fetching MCP registry from API..."); const allServers = []; let cursor = null; const apiUrl = "https://api.mcp.github.com/v0.1/servers/"; // Fetch all pages using cursor-based pagination do { const url = cursor ? `${apiUrl}?cursor=${encodeURIComponent(cursor)}` : apiUrl; const response = await fetch(url); if (!response.ok) { throw new Error(`API returned status ${response.status}`); } const json = await response.json(); const servers = json?.servers || []; // Extract server names and displayNames from the response for (const entry of servers) { const serverName = entry?.server?.name; if (serverName) { // Try to get displayName from GitHub metadata, fall back to server name const displayName = entry?.server?._meta?.[ "io.modelcontextprotocol.registry/publisher-provided" ]?.github?.displayName || serverName; allServers.push({ name: serverName, displayName: displayName.toLowerCase(), // Also store the original full name for matching fullName: serverName.toLowerCase(), }); } } // Get next cursor for pagination cursor = json?.metadata?.nextCursor || null; } while (cursor); console.log(`Loaded ${allServers.length} servers from MCP registry`); MCP_REGISTRY_SET = allServers; } catch (e) { console.warn(`Failed to load MCP registry from API: ${e.message}`); MCP_REGISTRY_SET = []; } return MCP_REGISTRY_SET; } // Add error handling utility /** * Safe file operation wrapper */ function safeFileOperation(operation, filePath, defaultValue = null) { try { return operation(); } catch (error) { console.error(`Error processing file ${filePath}: ${error.message}`); return defaultValue; } } function extractTitle(filePath) { return safeFileOperation( () => { const content = fs.readFileSync(filePath, "utf8"); const lines = content.split("\n"); // Step 1: Try to get title from frontmatter using vfile-matter const frontmatter = parseFrontmatter(filePath); if (frontmatter) { // Check for title field if (frontmatter.title && typeof frontmatter.title === "string") { return frontmatter.title; } // Check for name field and convert to title case if (frontmatter.name && typeof frontmatter.name === "string") { return frontmatter.name .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } } // Step 2: For prompt/agent/instructions files, look for heading after frontmatter if ( filePath.includes(".prompt.md") || filePath.includes(".agent.md") || filePath.includes(".instructions.md") ) { // Look for first heading after frontmatter let inFrontmatter = false; let frontmatterEnded = false; let inCodeBlock = false; for (const line of lines) { if (line.trim() === "---") { if (!inFrontmatter) { inFrontmatter = true; } else if (inFrontmatter && !frontmatterEnded) { frontmatterEnded = true; } continue; } // Only look for headings after frontmatter ends if (frontmatterEnded || !inFrontmatter) { // Track code blocks to ignore headings inside them if ( line.trim().startsWith("```") || line.trim().startsWith("````") ) { inCodeBlock = !inCodeBlock; continue; } if (!inCodeBlock && line.startsWith("# ")) { return line.substring(2).trim(); } } } // Step 3: Format filename for prompt/chatmode/instructions files if no heading found const basename = path.basename( filePath, filePath.includes(".prompt.md") ? ".prompt.md" : filePath.includes(".agent.md") ? ".agent.md" : ".instructions.md" ); return basename .replace(/[-_]/g, " ") .replace(/\b\w/g, (l) => l.toUpperCase()); } // Step 4: For other files, look for the first heading (but not in code blocks) let inCodeBlock = false; for (const line of lines) { if (line.trim().startsWith("```") || line.trim().startsWith("````")) { inCodeBlock = !inCodeBlock; continue; } if (!inCodeBlock && line.startsWith("# ")) { return line.substring(2).trim(); } } // Step 5: Fallback to filename const basename = path.basename(filePath, path.extname(filePath)); return basename .replace(/[-_]/g, " ") .replace(/\b\w/g, (l) => l.toUpperCase()); }, filePath, path .basename(filePath, path.extname(filePath)) .replace(/[-_]/g, " ") .replace(/\b\w/g, (l) => l.toUpperCase()) ); } function extractDescription(filePath) { return safeFileOperation( () => { // Use vfile-matter to parse frontmatter for all file types const frontmatter = parseFrontmatter(filePath); if (frontmatter && frontmatter.description) { return frontmatter.description; } return null; }, filePath, null ); } /** * Format arbitrary multiline text for safe rendering inside a markdown table cell. * - Preserves line breaks by converting to
* - Escapes pipe characters (|) to avoid breaking table columns * - Trims leading/trailing whitespace on each line * - Collapses multiple consecutive blank lines * This should be applied to descriptions across all file types when used in tables. * * @param {string|null|undefined} text * @returns {string} table-safe content */ function formatTableCell(text) { if (text === null || text === undefined) return ""; let s = String(text); // Normalize line endings s = s.replace(/\r\n/g, "\n"); // Split lines, trim, drop empty groups while preserving intentional breaks const lines = s .split("\n") .map((l) => l.trim()) .filter((_, idx, arr) => { // Keep single blank lines, drop consecutive blanks if (arr[idx] !== "") return true; return arr[idx - 1] !== ""; // allow one blank, remove duplicates }); s = lines.join("\n"); // Escape table pipes s = s.replace(/\|/g, "|"); // Convert remaining newlines to
for a single-cell rendering s = s.replace(/\n/g, "
"); return s.trim(); } function makeBadges(link, type) { const aka = AKA_INSTALL_URLS[type] || AKA_INSTALL_URLS.instructions; const vscodeUrl = `${aka}?url=${encodeURIComponent( `vscode:chat-${type}/install?url=${repoBaseUrl}/${link}` )}`; const insidersUrl = `${aka}?url=${encodeURIComponent( `vscode-insiders:chat-${type}/install?url=${repoBaseUrl}/${link}` )}`; return `[![Install in VS Code](${vscodeInstallImage})](${vscodeUrl})
[![Install in VS Code Insiders](${vscodeInsidersInstallImage})](${insidersUrl})`; } /** * Generate the instructions section with a table of all instructions */ function generateInstructionsSection(instructionsDir) { // Check if directory exists if (!fs.existsSync(instructionsDir)) { return ""; } // Get all instruction files const instructionFiles = fs .readdirSync(instructionsDir) .filter((file) => file.endsWith(".instructions.md")); // Map instruction files to objects with title for sorting const instructionEntries = instructionFiles.map((file) => { const filePath = path.join(instructionsDir, file); const title = extractTitle(filePath); return { file, filePath, title }; }); // Sort by title alphabetically instructionEntries.sort((a, b) => a.title.localeCompare(b.title)); console.log(`Found ${instructionEntries.length} instruction files`); // Return empty string if no files found if (instructionEntries.length === 0) { return ""; } // Create table header let instructionsContent = "| Title | Description |\n| ----- | ----------- |\n"; // Generate table rows for each instruction file for (const entry of instructionEntries) { const { file, filePath, title } = entry; const link = encodeURI(`instructions/${file}`); // Check if there's a description in the frontmatter const customDescription = extractDescription(filePath); // Create badges for installation links const badges = makeBadges(link, "instructions"); if (customDescription && customDescription !== "null") { // Use the description from frontmatter, table-safe instructionsContent += `| [${title}](../${link})
${badges} | ${formatTableCell( customDescription )} |\n`; } else { // Fallback to the default approach - use last word of title for description, removing trailing 's' if present const topic = title.split(" ").pop().replace(/s$/, ""); instructionsContent += `| [${title}](../${link})
${badges} | ${topic} specific coding standards and best practices |\n`; } } return `${TEMPLATES.instructionsSection}\n${TEMPLATES.instructionsUsage}\n\n${instructionsContent}`; } /** * Generate the prompts section with a table of all prompts */ function generatePromptsSection(promptsDir) { // Check if directory exists if (!fs.existsSync(promptsDir)) { return ""; } // Get all prompt files const promptFiles = fs .readdirSync(promptsDir) .filter((file) => file.endsWith(".prompt.md")); // Map prompt files to objects with title for sorting const promptEntries = promptFiles.map((file) => { const filePath = path.join(promptsDir, file); const title = extractTitle(filePath); return { file, filePath, title }; }); // Sort by title alphabetically promptEntries.sort((a, b) => a.title.localeCompare(b.title)); console.log(`Found ${promptEntries.length} prompt files`); // Return empty string if no files found if (promptEntries.length === 0) { return ""; } // Create table header let promptsContent = "| Title | Description |\n| ----- | ----------- |\n"; // Generate table rows for each prompt file for (const entry of promptEntries) { const { file, filePath, title } = entry; const link = encodeURI(`prompts/${file}`); // Check if there's a description in the frontmatter const customDescription = extractDescription(filePath); // Create badges for installation links const badges = makeBadges(link, "prompt"); if (customDescription && customDescription !== "null") { promptsContent += `| [${title}](../${link})
${badges} | ${formatTableCell( customDescription )} |\n`; } else { promptsContent += `| [${title}](../${link})
${badges} | | |\n`; } } return `${TEMPLATES.promptsSection}\n${TEMPLATES.promptsUsage}\n\n${promptsContent}`; } /** * Generate MCP server links for an agent * @param {string[]} servers - Array of MCP server names * @param {{ name: string, displayName: string }[]} registryNames - Pre-loaded registry names to avoid async calls * @returns {string} - Formatted MCP server links with badges */ function generateMcpServerLinks(servers, registryNames) { if (!servers || servers.length === 0) { return ""; } const badges = [ { type: "vscode", url: "https://img.shields.io/badge/Install-VS_Code-0098FF?style=flat-square", badgeUrl: (serverName) => `https://aka.ms/awesome-copilot/install/mcp-vscode?vscode:mcp/by-name/${serverName}/mcp-server`, }, { type: "insiders", url: "https://img.shields.io/badge/Install-VS_Code_Insiders-24bfa5?style=flat-square", badgeUrl: (serverName) => `https://aka.ms/awesome-copilot/install/mcp-vscode?vscode-insiders:mcp/by-name/${serverName}/mcp-server`, }, { type: "visualstudio", url: "https://img.shields.io/badge/Install-Visual_Studio-C16FDE?style=flat-square", badgeUrl: (serverName) => `https://aka.ms/awesome-copilot/install/mcp-visualstudio?vscode:mcp/by-name/${serverName}/mcp-server`, }, ]; return servers .map((entry) => { // Support either a string name or an object with config const serverObj = typeof entry === "string" ? { name: entry } : entry; const serverName = String(serverObj.name).trim(); // Build config-only JSON (no name/type for stdio; just command+args+env) let configPayload = {}; if (serverObj.type && serverObj.type.toLowerCase() === "http") { // HTTP: url + headers configPayload = { url: serverObj.url || "", headers: serverObj.headers || {}, }; } else { // Local/stdio: command + args + env configPayload = { command: serverObj.command || "", args: Array.isArray(serverObj.args) ? serverObj.args.map(encodeURIComponent) : [], env: serverObj.env || {}, }; } const encodedConfig = encodeURIComponent(JSON.stringify(configPayload)); const installBadgeUrls = [ `[![Install MCP](${badges[0].url})](https://aka.ms/awesome-copilot/install/mcp-vscode?name=${serverName}&config=${encodedConfig})`, `[![Install MCP](${badges[1].url})](https://aka.ms/awesome-copilot/install/mcp-vscodeinsiders?name=${serverName}&config=${encodedConfig})`, `[![Install MCP](${badges[2].url})](https://aka.ms/awesome-copilot/install/mcp-visualstudio/mcp-install?${encodedConfig})`, ].join("
"); // Match against both displayName and full name (case-insensitive) const serverNameLower = serverName.toLowerCase(); const registryEntry = registryNames.find((entry) => { // Exact match on displayName or fullName if ( entry.displayName === serverNameLower || entry.fullName === serverNameLower ) { return true; } // Check if the serverName matches a part of the full name after a slash // e.g., "apify" matches "com.apify/apify-mcp-server" const nameParts = entry.fullName.split("/"); if (nameParts.length > 1 && nameParts[1]) { // Check if it matches the second part (after the slash) const secondPart = nameParts[1] .replace("-mcp-server", "") .replace("-mcp", ""); if (secondPart === serverNameLower) { return true; } } // Check if serverName matches the displayName ignoring case return entry.displayName === serverNameLower; }); const serverLabel = registryEntry ? `[${serverName}](${`https://github.com/mcp/${registryEntry.name}`})` : serverName; return `${serverLabel}
${installBadgeUrls}`; }) .join("
"); } /** * Generate the agents section with a table of all agents * @param {string} agentsDir - Directory path * @param {{ name: string, displayName: string }[]} registryNames - Pre-loaded MCP registry names */ function generateAgentsSection(agentsDir, registryNames = []) { return generateUnifiedModeSection({ dir: agentsDir, extension: ".agent.md", linkPrefix: "agents", badgeType: "agent", includeMcpServers: true, sectionTemplate: TEMPLATES.agentsSection, usageTemplate: TEMPLATES.agentsUsage, registryNames, }); } /** * Generate the skills section with a table of all skills */ function generateSkillsSection(skillsDir) { if (!fs.existsSync(skillsDir)) { console.log(`Skills directory does not exist: ${skillsDir}`); return ""; } // Get all skill folders (directories) const skillFolders = fs.readdirSync(skillsDir).filter((file) => { const filePath = path.join(skillsDir, file); return fs.statSync(filePath).isDirectory(); }); // Parse each skill folder const skillEntries = skillFolders .map((folder) => { const skillPath = path.join(skillsDir, folder); const metadata = parseSkillMetadata(skillPath); if (!metadata) return null; return { folder, name: metadata.name, description: metadata.description, assets: metadata.assets, }; }) .filter((entry) => entry !== null) .sort((a, b) => a.name.localeCompare(b.name)); console.log(`Found ${skillEntries.length} skill(s)`); if (skillEntries.length === 0) { return ""; } // Create table header let content = "| Name | Description | Bundled Assets |\n| ---- | ----------- | -------------- |\n"; // Generate table rows for each skill for (const skill of skillEntries) { const link = `../skills/${skill.folder}/SKILL.md`; const assetsList = skill.assets.length > 0 ? skill.assets.map((a) => `\`${a}\``).join("
") : "None"; content += `| [${skill.name}](${link}) | ${formatTableCell( skill.description )} | ${assetsList} |\n`; } return `${TEMPLATES.skillsSection}\n${TEMPLATES.skillsUsage}\n\n${content}`; } /** * Unified generator for agents (future consolidation) * @param {Object} cfg * @param {string} cfg.dir - Directory path * @param {string} cfg.extension - File extension to match (e.g. .agent.md, .agent.md) * @param {string} cfg.linkPrefix - Link prefix folder name * @param {string} cfg.badgeType - Badge key (mode, agent) * @param {boolean} cfg.includeMcpServers - Whether to include MCP server column * @param {string} cfg.sectionTemplate - Section heading template * @param {string} cfg.usageTemplate - Usage subheading template * @param {{ name: string, displayName: string }[]} cfg.registryNames - Pre-loaded MCP registry names */ function generateUnifiedModeSection(cfg) { const { dir, extension, linkPrefix, badgeType, includeMcpServers, sectionTemplate, usageTemplate, registryNames = [], } = cfg; if (!fs.existsSync(dir)) { console.log(`Directory missing for unified mode section: ${dir}`); return ""; } const files = fs.readdirSync(dir).filter((f) => f.endsWith(extension)); const entries = files.map((file) => { const filePath = path.join(dir, file); return { file, filePath, title: extractTitle(filePath) }; }); entries.sort((a, b) => a.title.localeCompare(b.title)); console.log( `Unified mode generator: ${entries.length} files for extension ${extension}` ); if (entries.length === 0) return ""; let header = "| Title | Description |"; if (includeMcpServers) header += " MCP Servers |"; let separator = "| ----- | ----------- |"; if (includeMcpServers) separator += " ----------- |"; let content = `${header}\n${separator}\n`; for (const { file, filePath, title } of entries) { const link = encodeURI(`${linkPrefix}/${file}`); const description = extractDescription(filePath); const badges = makeBadges(link, badgeType); let mcpServerCell = ""; if (includeMcpServers) { const servers = extractMcpServerConfigs(filePath); mcpServerCell = generateMcpServerLinks(servers, registryNames); } const descCell = description && description !== "null" ? formatTableCell(description) : ""; if (includeMcpServers) { content += `| [${title}](../${link})
${badges} | ${descCell} | ${mcpServerCell} |\n`; } else { content += `| [${title}](../${link})
${badges} | ${descCell} |\n`; } } return `${sectionTemplate}\n${usageTemplate}\n\n${content}`; } /** * Generate the collections section with a table of all collections */ 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 }); } // Get all collection files const collectionFiles = fs .readdirSync(collectionsDir) .filter((file) => file.endsWith(".collection.yml")); // Map collection files to objects with name for sorting const collectionEntries = collectionFiles .map((file) => { const filePath = path.join(collectionsDir, file); const collection = parseCollectionYaml(filePath); if (!collection) { console.warn(`Failed to parse collection: ${file}`); 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 }; }) .filter((entry) => entry !== null); // Remove failed parses // Separate featured and regular collections const featuredCollections = collectionEntries.filter( (entry) => entry.isFeatured ); const regularCollections = collectionEntries.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)); // Combine: featured first, then regular const sortedEntries = [...featuredCollections, ...regularCollections]; console.log( `Found ${collectionEntries.length} collection files (${featuredCollections.length} featured)` ); // If no collections, return empty string if (sortedEntries.length === 0) { return ""; } // Create table header let collectionsContent = "| Name | Description | Items | Tags |\n| ---- | ----------- | ----- | ---- |\n"; // Generate table rows for each collection file for (const entry of sortedEntries) { const { collection, collectionId, name, isFeatured } = entry; const description = formatTableCell( collection.description || "No description" ); const itemCount = collection.items ? collection.items.length : 0; const tags = collection.tags ? collection.tags.join(", ") : ""; const link = `../collections/${collectionId}.md`; const displayName = isFeatured ? `⭐ ${name}` : name; collectionsContent += `| [${displayName}](${link}) | ${description} | ${itemCount} items | ${tags} |\n`; } return `${TEMPLATES.collectionsSection}\n${TEMPLATES.collectionsUsage}\n\n${collectionsContent}`; } /** * Generate the featured collections section for the main README */ function generateFeaturedCollectionsSection(collectionsDir) { // Check if collections directory exists if (!fs.existsSync(collectionsDir)) { return ""; } // Get all collection files const collectionFiles = fs .readdirSync(collectionsDir) .filter((file) => file.endsWith(".collection.yml")); // Map collection files to objects with name for sorting, filter for featured const featuredCollections = collectionFiles .map((file) => { const filePath = path.join(collectionsDir, file); return safeFileOperation( () => { const collection = parseCollectionYaml(filePath); if (!collection) return null; // Only include collections with featured: true if (!collection.display?.featured) return null; const collectionId = collection.id || path.basename(file, ".collection.yml"); const name = collection.name || collectionId; const description = formatTableCell( collection.description || "No description" ); const tags = collection.tags ? collection.tags.join(", ") : ""; const itemCount = collection.items ? collection.items.length : 0; return { file, collection, collectionId, name, description, tags, itemCount, }; }, filePath, null ); }) .filter((entry) => entry !== null); // Remove non-featured and failed parses // Sort by name alphabetically featuredCollections.sort((a, b) => a.name.localeCompare(b.name)); console.log(`Found ${featuredCollections.length} featured collection(s)`); // If no featured collections, return empty string if (featuredCollections.length === 0) { return ""; } // Create table header 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`; 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})
${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`; } // Utility: write file only if content changed function writeFileIfChanged(filePath, content) { const exists = fs.existsSync(filePath); if (exists) { const original = fs.readFileSync(filePath, "utf8"); if (original === content) { console.log( `${path.basename(filePath)} is already up to date. No changes needed.` ); return; } } fs.writeFileSync(filePath, content); console.log( `${path.basename(filePath)} ${exists ? "updated" : "created"} successfully!` ); } // Build per-category README content using existing generators, upgrading headings to H1 function buildCategoryReadme( sectionBuilder, dirPath, headerLine, usageLine, registryNames = [] ) { const section = sectionBuilder(dirPath, registryNames); if (section && section.trim()) { // Upgrade the first markdown heading level from ## to # for standalone README files return section.replace(/^##\s/m, "# "); } // Fallback content when no entries are found return `${headerLine}\n\n${usageLine}\n\n_No entries found yet._`; } // Main execution wrapped in async function async function main() { try { console.log("Generating category README files..."); // Load MCP registry names once at the beginning const registryNames = await loadMcpRegistryNames(); // Compose headers for standalone files by converting section headers to H1 const instructionsHeader = TEMPLATES.instructionsSection.replace( /^##\s/m, "# " ); const promptsHeader = TEMPLATES.promptsSection.replace(/^##\s/m, "# "); const agentsHeader = TEMPLATES.agentsSection.replace(/^##\s/m, "# "); const skillsHeader = TEMPLATES.skillsSection.replace(/^##\s/m, "# "); const collectionsHeader = TEMPLATES.collectionsSection.replace( /^##\s/m, "# " ); const instructionsReadme = buildCategoryReadme( generateInstructionsSection, INSTRUCTIONS_DIR, instructionsHeader, TEMPLATES.instructionsUsage, registryNames ); const promptsReadme = buildCategoryReadme( generatePromptsSection, PROMPTS_DIR, promptsHeader, TEMPLATES.promptsUsage, registryNames ); // Generate agents README const agentsReadme = buildCategoryReadme( generateAgentsSection, AGENTS_DIR, agentsHeader, TEMPLATES.agentsUsage, registryNames ); // Generate skills README const skillsReadme = buildCategoryReadme( generateSkillsSection, SKILLS_DIR, skillsHeader, TEMPLATES.skillsUsage, registryNames ); // Generate collections README const collectionsReadme = buildCategoryReadme( generateCollectionsSection, COLLECTIONS_DIR, collectionsHeader, TEMPLATES.collectionsUsage, registryNames ); // Ensure docs directory exists for category outputs if (!fs.existsSync(DOCS_DIR)) { fs.mkdirSync(DOCS_DIR, { recursive: true }); } // Write category outputs into docs folder writeFileIfChanged( path.join(DOCS_DIR, "README.instructions.md"), instructionsReadme ); writeFileIfChanged(path.join(DOCS_DIR, "README.prompts.md"), promptsReadme); writeFileIfChanged(path.join(DOCS_DIR, "README.agents.md"), agentsReadme); writeFileIfChanged(path.join(DOCS_DIR, "README.skills.md"), skillsReadme); writeFileIfChanged( path.join(DOCS_DIR, "README.collections.md"), collectionsReadme ); // Generate individual collection README files if (fs.existsSync(COLLECTIONS_DIR)) { console.log("Generating individual collection README files..."); 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); if (featuredSection) { const mainReadmePath = path.join(ROOT_FOLDER, "README.md"); if (fs.existsSync(mainReadmePath)) { let readmeContent = fs.readFileSync(mainReadmePath, "utf8"); // Define markers to identify where to insert the featured collections const startMarker = "## 🌟 Featured Collections"; const endMarker = "## MCP Server"; // Check if the section already exists const startIndex = readmeContent.indexOf(startMarker); if (startIndex !== -1) { // Section exists, replace it const endIndex = readmeContent.indexOf(endMarker, startIndex); if (endIndex !== -1) { // Replace the existing section const beforeSection = readmeContent.substring(0, startIndex); const afterSection = readmeContent.substring(endIndex); readmeContent = beforeSection + featuredSection + "\n\n" + afterSection; } } else { // Section doesn't exist, insert it before "## MCP Server" const mcpIndex = readmeContent.indexOf(endMarker); if (mcpIndex !== -1) { const beforeMcp = readmeContent.substring(0, mcpIndex); const afterMcp = readmeContent.substring(mcpIndex); readmeContent = beforeMcp + featuredSection + "\n\n" + afterMcp; } } writeFileIfChanged(mainReadmePath, readmeContent); console.log("Main README.md updated with featured collections"); } else { console.warn( "README.md not found, skipping featured collections update" ); } } else { console.log("No featured collections found to add to README.md"); } } catch (error) { console.error(`Error generating category README files: ${error.message}`); console.error(error.stack); process.exit(1); } } // Run the main function main().catch((error) => { console.error(`Fatal error: ${error.message}`); console.error(error.stack); process.exit(1); });