"use strict"; /** * Skills Scanner - Scans the Skills directory for menuEntry.json files * * menuEntry.json Schema: * { * "type": "skill" | "submenu", // Required: "skill" for launchable skill, "submenu" for folder containing skills * "title": "Display Name", // Required: Button label shown on Jibo's screen * "icon": "path/to/icon.png", // Required: Icon path (relative to skill folder or core:// URL) * "color": "blue", // Optional: Button color theme (default, blue, green, red, purple, orange, etc.) * "skillId": "@namespace/skill-id", // Required for type="skill": The skill ID to launch * "description": "Short description",// Optional: Tooltip/accessibility text * "order": 0, // Optional: Sort order (lower = first, default: 100) * "hidden": false // Optional: Hide from menu (default: false) * } */ const fs = require('fs'); const path = require('path'); // Default icon path for skills without custom icons (relative to @be/main-menu) const DEFAULT_ICON = 'resources/icons/settings.png'; const DEFAULT_SUBMENU_ICON = 'resources/icons/fun-stuff.png'; const DEFAULT_COLOR = 'default'; const DEFAULT_ORDER = 100; /** * Safely read and parse a JSON file * @param {string} filePath - Path to JSON file * @returns {object|null} Parsed JSON or null on error */ function safeReadJson(filePath) { try { const txt = fs.readFileSync(filePath, 'utf8'); if (!txt || txt.trim().length === 0) return null; return JSON.parse(txt); } catch (e) { console.warn('skills-scanner: failed to read json', filePath, e.message); return null; } } /** * Check if path is a directory * @param {string} p - Path to check * @returns {boolean} */ function isDirectory(p) { try { return fs.statSync(p).isDirectory(); } catch (e) { return false; } } /** * Scan a single skill directory and return menu entry * @param {string} skillDir - Full path to skill directory * @param {string} folderName - Name of the folder * @returns {object|null} Menu entry object or null if invalid */ function scanSkillEntry(skillDir, folderName) { const menuJsonPath = path.join(skillDir, 'menuEntry.json'); const menuJson = safeReadJson(menuJsonPath); if (!menuJson) { // No menuEntry.json - check if it's a valid skill by looking for package.json or index.html const hasPackage = fs.existsSync(path.join(skillDir, 'package.json')); const hasIndex = fs.existsSync(path.join(skillDir, 'index.html')); if (hasPackage || hasIndex) { // Auto-generate entry for legacy skills return { id: folderName, type: 'skill', title: folderName, icon: DEFAULT_ICON, color: DEFAULT_COLOR, skillId: folderName, path: skillDir, order: DEFAULT_ORDER, hidden: false }; } return null; } // Validate required fields if (menuJson.hidden === true) { return null; // Skip hidden entries } const entry = { id: folderName, type: menuJson.type || 'skill', title: menuJson.title || menuJson.label || folderName, icon: menuJson.icon || menuJson.iconSrc || (menuJson.type === 'submenu' ? DEFAULT_SUBMENU_ICON : DEFAULT_ICON), color: menuJson.color || menuJson.colors || DEFAULT_COLOR, description: menuJson.description || '', path: skillDir, order: typeof menuJson.order === 'number' ? menuJson.order : DEFAULT_ORDER, hidden: menuJson.hidden === true }; // For skills, include skillId if (entry.type === 'skill') { entry.skillId = menuJson.skillId || folderName; } // For submenus, scan child directories if (entry.type === 'submenu') { entry.submenu = scanSubmenuChildren(skillDir); // Copy submenu-specific properties if (menuJson.submenuTitle) { entry.submenuTitle = menuJson.submenuTitle; } } // Support for legacy isSubmenu flag if (menuJson.isSubmenu === true && entry.type !== 'submenu') { entry.type = 'submenu'; entry.submenu = scanSubmenuChildren(skillDir); } return entry; } /** * Scan children of a submenu directory * @param {string} submenuDir - Path to submenu directory * @returns {Array} Array of skill entries */ function scanSubmenuChildren(submenuDir) { const children = []; let items; try { items = fs.readdirSync(submenuDir); } catch (e) { return children; } items.forEach(name => { // Skip hidden folders and @be if (name.startsWith('.') || name === '@be') return; const childPath = path.join(submenuDir, name); if (!isDirectory(childPath)) return; const entry = scanSkillEntry(childPath, name); if (entry && entry.type === 'skill') { children.push(entry); } // Note: Nested submenus within submenus are not supported (single level only) }); // Sort by order, then alphabetically children.sort((a, b) => { if (a.order !== b.order) return a.order - b.order; return a.title.localeCompare(b.title); }); return children; } /** * Scan the Skills root directory for all menu entries * @param {string} rootDir - Path to Skills directory * @returns {Array} Array of menu entries */ function scanDirForMenuEntries(rootDir) { const entries = []; let children; try { children = fs.readdirSync(rootDir); } catch (e) { console.warn('skills-scanner: failed to read skills directory', rootDir, e.message); return entries; } children.forEach(name => { // Skip hidden folders and @be (it's the core brain, not a menu item) if (name.startsWith('.') || name === '@be') return; const skillDir = path.join(rootDir, name); if (!isDirectory(skillDir)) return; const entry = scanSkillEntry(skillDir, name); if (entry) { entries.push(entry); } }); // Sort by order, then alphabetically entries.sort((a, b) => { if (a.order !== b.order) return a.order - b.order; return a.title.localeCompare(b.title); }); return entries; } /** * Scan skills directory and return menu definition * @param {string} rootDir - Path to Skills directory * @returns {object} Menu definition with buttons array */ function scanSkills(rootDir) { const entries = scanDirForMenuEntries(rootDir); const menuDef = { id: 'main-menu', title: 'Apps', buttons: [] }; entries.forEach(entry => { const button = { id: entry.id, label: entry.title, icon: entry.icon, colors: entry.color, description: entry.description }; if (entry.type === 'submenu' && entry.submenu) { // For submenus, include the submenu array and metadata button.submenu = entry.submenu.map(s => ({ id: s.id, label: s.title, icon: s.icon, colors: s.color, skillId: s.skillId, description: s.description })); button.submenuTitle = entry.submenuTitle || entry.title; button.submenuPath = entry.path; } else { // Regular skill button.skillId = entry.skillId; } menuDef.buttons.push(button); }); return menuDef; } /** * Get a specific submenu's contents by path * @param {string} submenuPath - Path to the submenu directory * @returns {object} Menu definition for the submenu */ function getSubmenu(submenuPath) { const menuJsonPath = path.join(submenuPath, 'menuEntry.json'); const menuJson = safeReadJson(menuJsonPath) || {}; const folderName = path.basename(submenuPath); const entries = scanSubmenuChildren(submenuPath); return { id: folderName + '-submenu', title: menuJson.submenuTitle || menuJson.title || folderName, isSubmenu: true, parentPath: path.dirname(submenuPath), buttons: entries.map(entry => ({ id: entry.id, label: entry.title, icon: entry.icon, colors: entry.color, skillId: entry.skillId, description: entry.description })) }; } /** * Get all registered skills (flat list for skill loading) * @param {string} rootDir - Path to Skills directory * @returns {Array} Array of skill IDs */ function getAllSkillIds(rootDir) { const skillIds = []; const entries = scanDirForMenuEntries(rootDir); entries.forEach(entry => { if (entry.type === 'skill' && entry.skillId) { skillIds.push(entry.skillId); } else if (entry.type === 'submenu' && entry.submenu) { entry.submenu.forEach(s => { if (s.skillId) skillIds.push(s.skillId); }); } }); return skillIds; } module.exports = { scanSkills, scanDirForMenuEntries, getSubmenu, getAllSkillIds, DEFAULT_ICON, DEFAULT_SUBMENU_ICON, DEFAULT_COLOR };