Files
Zos/Skills/@be/be/menu/skills-scanner.js

306 lines
9.3 KiB
JavaScript

"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
};