306 lines
9.3 KiB
JavaScript
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
|
|
};
|