Skip to main content
Glama
loader.ts19.6 kB
/** * WP Navigator Cookbook Loader * * Discovers and loads cookbook files from multiple sources: * 1. Bundled cookbooks (package defaults) * 2. Project cookbooks (./cookbooks/) * * Unlike roles, cookbooks do NOT merge - project cookbooks * completely override bundled ones for the same plugin. * * For compiled binaries (Bun compile), bundled cookbooks are loaded from * embedded assets instead of filesystem. * * @package WP_Navigator_MCP * @since 2.1.0 */ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { parse as yamlParse } from 'yaml'; import { validateCookbook, CookbookValidationError, CookbookSchemaVersionError, } from './validation.js'; import { parseSkillMd, extractPluginSlug, SkillParseError } from './skill-parser.js'; import { COOKBOOK_SCHEMA_VERSION } from './types.js'; import type { Cookbook, LoadedCookbook, CookbookLoadResult, CookbookRegistry, CookbookRegistryEntry, } from './types.js'; import type { SkillCookbook } from './skill-types.js'; // Re-export error classes export { CookbookValidationError, CookbookSchemaVersionError, SkillParseError }; // ============================================================================= // Embedded Asset Support (for Bun-compiled binaries) // ============================================================================= /** * Cached embedded cookbooks (loaded lazily on first access). * null = not yet checked, undefined = checked but not available */ let embeddedCookbooksCache: Record<string, string> | null | undefined = null; /** * Get embedded cookbooks if available. * Uses lazy loading to avoid top-level await issues. * Returns null if not in binary mode. */ function getEmbeddedCookbooks(): Record<string, string> | null { // Return cached result if already checked (undefined means checked but not found) if (embeddedCookbooksCache !== null) { return embeddedCookbooksCache === undefined ? null : embeddedCookbooksCache; } // Try to load embedded assets synchronously // This works because embedded-assets.ts exports pure data (no async) try { // Use require for synchronous loading // eslint-disable-next-line @typescript-eslint/no-require-imports const embedded = require('../embedded-assets.js'); if (embedded.IS_EMBEDDED && embedded.EMBEDDED_COOKBOOKS) { const cookbooks: Record<string, string> = embedded.EMBEDDED_COOKBOOKS; embeddedCookbooksCache = cookbooks; return cookbooks; } } catch { // Not in binary mode - filesystem fallback will be used } embeddedCookbooksCache = undefined; return null; } // ============================================================================= // YAML Parser // ============================================================================= /** * Parse a YAML string into an object. */ function parseYaml(content: string): Record<string, unknown> { const result = yamlParse(content); return result ?? {}; } // ============================================================================= // SKILL.md to Cookbook Conversion // ============================================================================= /** * Convert a SKILL.md cookbook to the standard Cookbook format. * * SKILL.md is a markdown-first format with minimal structured data. * We create a minimal Cookbook that satisfies the schema while * preserving the markdown body for AI consumption. * * @param skill - Parsed SKILL.md cookbook * @returns Cookbook compatible with the validation schema */ function skillToCookbook(skill: SkillCookbook): Cookbook { const fm = skill.frontmatter; const pluginSlug = extractPluginSlug(fm.name); return { schema_version: COOKBOOK_SCHEMA_VERSION, cookbook_version: fm.version || '1.0.0', plugin: { slug: pluginSlug, name: pluginSlug.charAt(0).toUpperCase() + pluginSlug.slice(1).replace(/-/g, ' '), min_version: fm['min-plugin-version'], max_version: fm['max-plugin-version'], }, capabilities: { // SKILL.md doesn't define structured capabilities // The markdown body contains the guidance }, // Store the markdown body in documentation_url for now // Future: Add a dedicated field for skill body documentation_url: undefined, last_updated: new Date().toISOString().split('T')[0], author: 'WP Navigator', }; } /** * Extended LoadedCookbook with SKILL.md body */ export interface LoadedSkillCookbook extends LoadedCookbook { /** SKILL.md markdown body (guidance content) */ skillBody?: string; /** Original SKILL.md frontmatter */ skillFrontmatter?: SkillCookbook['frontmatter']; /** Allowed tools from SKILL.md */ allowedTools?: string[]; } // ============================================================================= // Path Utilities // ============================================================================= /** * Get the path to bundled cookbooks directory. * Handles both development (src/) and installed (dist/) contexts. */ function getBundledCookbooksPath(): string { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // In dist: dist/cookbook/loader.js // Bundled cookbooks are at: src/cookbook/bundled/ return path.join(__dirname, '..', '..', 'src', 'cookbook', 'bundled'); } // ============================================================================= // Registry Loading (Fast Enumeration) // ============================================================================= /** * Load the bundled cookbook registry for fast enumeration. * Returns null if registry doesn't exist (triggers fallback to directory scan). */ function loadBundledRegistry(): CookbookRegistry | null { const bundledPath = getBundledCookbooksPath(); const registryPath = path.join(bundledPath, 'registry.json'); if (!fs.existsSync(registryPath)) { return null; } try { const content = fs.readFileSync(registryPath, 'utf8'); return JSON.parse(content) as CookbookRegistry; } catch { return null; } } /** * List bundled cookbook slugs using registry (fast path). * Returns null if registry unavailable (triggers fallback). */ export function listBundledFromRegistry(): string[] | null { const registry = loadBundledRegistry(); if (!registry) return null; return registry.cookbooks.map((c) => c.slug); } /** * Get registry entry for a specific cookbook. * Returns null if not in registry. * * @param slug - Plugin slug to look up * @returns CookbookRegistryEntry if found, null otherwise */ export function getRegistryEntry(slug: string): CookbookRegistryEntry | null { const registry = loadBundledRegistry(); if (!registry) return null; return registry.cookbooks.find((c) => c.slug === slug) || null; } /** * Get the full bundled registry. * Returns null if registry doesn't exist. */ export function getBundledRegistry(): CookbookRegistry | null { return loadBundledRegistry(); } // ============================================================================= // Single File Loading // ============================================================================= /** * Load a cookbook from a file path. * * Supports: * - .json, .jsonc - Structured JSON cookbook * - .yaml, .yml - Structured YAML cookbook * - .md - SKILL.md format (YAML frontmatter + Markdown body) * * @param filePath - Absolute path to the cookbook file * @param source - Source type ('bundled' or 'project') * @returns CookbookLoadResult with success status and cookbook or error */ export function loadCookbook(filePath: string, source: 'bundled' | 'project'): CookbookLoadResult { const ext = path.extname(filePath).toLowerCase(); // Read file let content: string; try { content = fs.readFileSync(filePath, 'utf8'); } catch (error) { return { success: false, error: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`, path: filePath, }; } // Handle SKILL.md format if (ext === '.md') { const result = parseSkillMd(content); if (!result.success) { return { success: false, error: result.error || 'Failed to parse SKILL.md', path: filePath, }; } const skill = result.cookbook!; const cookbook = skillToCookbook(skill); const allowedToolsStr = skill.frontmatter['allowed-tools']; const allowedTools = allowedToolsStr ? allowedToolsStr .split(',') .map((t) => t.trim()) .filter((t) => t.length > 0) : []; const loadedCookbook: LoadedSkillCookbook = { ...cookbook, source, sourcePath: filePath, skillBody: skill.body, skillFrontmatter: skill.frontmatter, allowedTools, }; return { success: true, cookbook: loadedCookbook, path: filePath, }; } // Parse based on extension (JSON/YAML) let parsed: unknown; try { if (ext === '.json' || ext === '.jsonc') { // Strip JSONC comments before parsing const jsonContent = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); parsed = JSON.parse(jsonContent); } else if (ext === '.yaml' || ext === '.yml') { parsed = parseYaml(content); } else { return { success: false, error: `Unsupported file extension: ${ext} (use .yaml, .yml, .json, .jsonc, or .md)`, path: filePath, }; } } catch (error) { return { success: false, error: `Failed to parse ${ext}: ${error instanceof Error ? error.message : String(error)}`, path: filePath, }; } // Validate try { const cookbook = validateCookbook(parsed, filePath); const loadedCookbook: LoadedCookbook = { ...cookbook, source, sourcePath: filePath, }; return { success: true, cookbook: loadedCookbook, path: filePath, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), path: filePath, }; } } /** * Load a cookbook from embedded string content. * Used for binary distribution where cookbooks are embedded at build time. * * @param filename - Original filename (e.g., "gutenberg-SKILL.md") * @param content - File content (YAML/JSON/Markdown string) * @param source - Source type (always 'bundled' for embedded) * @returns CookbookLoadResult with success status and cookbook or error */ export function loadCookbookFromContent( filename: string, content: string, source: 'bundled' | 'project' = 'bundled' ): CookbookLoadResult { const ext = path.extname(filename).toLowerCase(); const virtualPath = `[embedded]/${filename}`; // Handle SKILL.md format if (ext === '.md') { const result = parseSkillMd(content); if (!result.success) { return { success: false, error: result.error || 'Failed to parse SKILL.md', path: virtualPath, }; } const skill = result.cookbook!; const cookbook = skillToCookbook(skill); const allowedToolsStr = skill.frontmatter['allowed-tools']; const allowedTools = allowedToolsStr ? allowedToolsStr .split(',') .map((t) => t.trim()) .filter((t) => t.length > 0) : []; const loadedCookbook: LoadedSkillCookbook = { ...cookbook, source, sourcePath: virtualPath, skillBody: skill.body, skillFrontmatter: skill.frontmatter, allowedTools, }; return { success: true, cookbook: loadedCookbook, path: virtualPath, }; } // Parse based on extension (JSON/YAML) let parsed: unknown; try { if (ext === '.json' || ext === '.jsonc') { // Strip JSONC comments before parsing const jsonContent = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); parsed = JSON.parse(jsonContent); } else if (ext === '.yaml' || ext === '.yml') { parsed = parseYaml(content); } else { return { success: false, error: `Unsupported file extension: ${ext} (use .yaml, .yml, .json, .jsonc, or .md)`, path: virtualPath, }; } } catch (error) { return { success: false, error: `Failed to parse ${ext}: ${error instanceof Error ? error.message : String(error)}`, path: virtualPath, }; } // Validate try { const cookbook = validateCookbook(parsed, virtualPath); const loadedCookbook: LoadedCookbook = { ...cookbook, source, sourcePath: virtualPath, }; return { success: true, cookbook: loadedCookbook, path: virtualPath, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), path: virtualPath, }; } } // ============================================================================= // Directory Loading // ============================================================================= /** * Load all cookbooks from a directory. * * @param directory - Path to directory containing cookbook files * @param source - Source type for all cookbooks in this directory * @returns Array of CookbookLoadResult (success or failure for each file) */ export function loadCookbooksFromDirectory( directory: string, source: 'bundled' | 'project' ): CookbookLoadResult[] { if (!fs.existsSync(directory)) { return []; } let entries: string[]; try { entries = fs.readdirSync(directory); } catch { return []; } const cookbookFiles = entries.filter((f) => /\.(yaml|yml|json|jsonc|md)$/i.test(f)); return cookbookFiles.map((file) => loadCookbook(path.join(directory, file), source)); } // ============================================================================= // Cookbook Discovery // ============================================================================= /** * Options for cookbook discovery. */ export interface CookbookDiscoveryOptions { /** Project directory to search for cookbooks/ folder. Defaults to cwd. */ projectDir?: string; /** Include bundled cookbooks. Defaults to true. */ includeBundled?: boolean; } /** * Result of cookbook discovery. */ export interface DiscoveredCookbooks { /** Map of plugin slug to cookbook definition */ cookbooks: Map<string, LoadedCookbook>; /** Which plugin slugs came from each source */ sources: { project: string[]; bundled: string[]; }; /** Failed cookbook loads */ errors: CookbookLoadResult[]; } /** * Discover cookbooks from all sources. * * Loading order: * 1. Bundled cookbooks (package defaults) * 2. Project cookbooks (./cookbooks/) - completely override bundled * * Unlike roles, cookbooks do NOT merge. Project cookbooks replace * bundled ones entirely for the same plugin. * * @param options - Discovery options * @returns DiscoveredCookbooks with cookbooks and source tracking */ export function discoverCookbooks(options: CookbookDiscoveryOptions = {}): DiscoveredCookbooks { const { projectDir = process.cwd(), includeBundled = true } = options; const cookbooks = new Map<string, LoadedCookbook>(); const sources: DiscoveredCookbooks['sources'] = { project: [], bundled: [] }; const errors: CookbookLoadResult[] = []; // Load bundled first (lower priority) // Use embedded assets if available (binary mode), otherwise filesystem if (includeBundled) { const embeddedCookbooks = getEmbeddedCookbooks(); if (embeddedCookbooks && Object.keys(embeddedCookbooks).length > 0) { // Binary mode: load from embedded assets // Filter out registry.json - it's metadata, not a cookbook const cookbookFiles = Object.entries(embeddedCookbooks).filter( ([filename]) => filename !== 'registry.json' ); for (const [filename, content] of cookbookFiles) { const result = loadCookbookFromContent(filename, content, 'bundled'); if (result.success && result.cookbook) { cookbooks.set(result.cookbook.plugin.slug, result.cookbook); sources.bundled.push(result.cookbook.plugin.slug); } else { errors.push(result); } } } else { // Normal mode: load from filesystem const bundledPath = getBundledCookbooksPath(); const registry = loadBundledRegistry(); if (registry) { // Fast path: use registry to load only listed files for (const entry of registry.cookbooks) { const filePath = path.join(bundledPath, entry.file); const result = loadCookbook(filePath, 'bundled'); if (result.success && result.cookbook) { cookbooks.set(result.cookbook.plugin.slug, result.cookbook); sources.bundled.push(result.cookbook.plugin.slug); } else { errors.push(result); } } } else { // Fallback: scan directory (backwards compatible) const bundledResults = loadCookbooksFromDirectory(bundledPath, 'bundled'); for (const result of bundledResults) { if (result.success && result.cookbook) { cookbooks.set(result.cookbook.plugin.slug, result.cookbook); sources.bundled.push(result.cookbook.plugin.slug); } else { errors.push(result); } } } } } // Load project (higher priority - replaces bundled) const projectCookbooksPath = path.join(projectDir, 'cookbooks'); const projectResults = loadCookbooksFromDirectory(projectCookbooksPath, 'project'); for (const result of projectResults) { if (result.success && result.cookbook) { const slug = result.cookbook.plugin.slug; // Project completely replaces bundled (no merge) cookbooks.set(slug, result.cookbook); sources.project.push(slug); // Remove from bundled list if it was there const bundledIndex = sources.bundled.indexOf(slug); if (bundledIndex !== -1) { sources.bundled.splice(bundledIndex, 1); } } else { errors.push(result); } } return { cookbooks, sources, errors }; } // ============================================================================= // Convenience Functions // ============================================================================= /** * List all available cookbook plugin slugs (sorted alphabetically). * * @param options - Discovery options * @returns Sorted array of plugin slugs */ export function listAvailableCookbooks(options?: CookbookDiscoveryOptions): string[] { const { cookbooks } = discoverCookbooks(options); return Array.from(cookbooks.keys()).sort(); } /** * Get a specific cookbook by plugin slug. * * @param pluginSlug - Plugin slug to retrieve cookbook for * @param options - Discovery options * @returns LoadedCookbook if found, null otherwise */ export function getCookbook( pluginSlug: string, options?: CookbookDiscoveryOptions ): LoadedCookbook | null { const { cookbooks } = discoverCookbooks(options); return cookbooks.get(pluginSlug) || null; } /** * Get the path to the bundled cookbooks directory. * Exported for testing purposes. */ export function getBundledPath(): string { return getBundledCookbooksPath(); } /** * Check if a cookbook exists for a specific plugin. * * @param pluginSlug - Plugin slug to check * @param options - Discovery options * @returns true if cookbook exists */ export function hasCookbook(pluginSlug: string, options?: CookbookDiscoveryOptions): boolean { return getCookbook(pluginSlug, options) !== null; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/littlebearapps/wp-navigator-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server