From ef409242156c7bccee474bd6b960c2ecb73eb7c5 Mon Sep 17 00:00:00 2001 From: Ariel AI Date: Thu, 7 May 2026 10:44:52 +0800 Subject: [PATCH] feat: support two-level skills directory structure with misc category (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added scanSkillsDir() function that scans both three-level (skills///SKILL.md) and two-level (skills//SKILL.md) directory structures. - Flat skills (at two-level) are grouped under a new 'misc' (雜項) category, displayed with Chinese name '雜項'. - Updated listFiles() and readFile_() to handle 'misc' category path mapping correctly. - All tests pass (347 passed, 3 pre-existing failures unrelated to this change). --- .../server/src/controllers/hermes/skills.ts | 174 +++++++++++++----- 1 file changed, 126 insertions(+), 48 deletions(-) diff --git a/packages/server/src/controllers/hermes/skills.ts b/packages/server/src/controllers/hermes/skills.ts index f738fd17..c8c97670 100644 --- a/packages/server/src/controllers/hermes/skills.ts +++ b/packages/server/src/controllers/hermes/skills.ts @@ -82,6 +82,120 @@ function readUsageStats(usageContent: string | null): Map { return map } +/** + * Scan for skills at different directory depths. + * + * Supports both: + * - Three-level: skills///SKILL.md (category is a container) + * - Two-level: skills//SKILL.md (flat skill under "misc" category) + * + * Categories are identified by having a DESCRIPTION.md at the category level + * or by containing subdirectories with SKILL.md (three-level pattern). + * Skills without a parent category (flat skills) are grouped under the "misc" category. + */ +async function scanSkillsDir(skillsDir: string, bundledManifest: Map, hubNames: Set, disabledList: string[], usageStats: Map) { + const allEntries = await readdir(skillsDir, { withFileTypes: true }) + const dirNames = allEntries + .filter(e => e.isDirectory() && !e.name.startsWith('.')) + .map(e => e.name) + + // Classify directories: categories vs. flat skills + const categoryDirs: { name: string; description: string }[] = [] + const flatSkills: { name: string; skillMd: string; source: string }[] = [] + + for (const dirName of dirNames) { + const catDir = join(skillsDir, dirName) + const hasDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md')) + const hasSkillMd = await safeReadFile(join(catDir, 'SKILL.md')) + const subEntries = await readdir(catDir, { withFileTypes: true }) + const subDirs = subEntries.filter(se => se.isDirectory()) + + // Priority: SKILL.md at top level → flat skill + // DESCRIPTION.md or subdirs (without SKILL.md) → category + if (hasSkillMd) { + // Flat skill: has SKILL.md at the top level (two-level pattern) + // Could also have subdirectories (references/, scripts/, etc.) + flatSkills.push({ + name: dirName, + skillMd: hasSkillMd, + source: getSkillSource(dirName, bundledManifest, hubNames), + }) + } else if (!!hasDesc || subDirs.length > 0) { + // True category: has DESCRIPTION.md or subdirs, but no SKILL.md at top level + const catDescription = hasDesc ? hasDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : '' + categoryDirs.push({ name: dirName, description: catDescription }) + } + } + + // Build categories with their nested skills + const categories: any[] = [] + + for (const cat of categoryDirs) { + const catDir = join(skillsDir, cat.name) + const subEntries = await readdir(catDir, { withFileTypes: true }) + const skills: any[] = [] + for (const se of subEntries) { + if (!se.isDirectory()) continue + const skillMd = await safeReadFile(join(catDir, se.name, 'SKILL.md')) + if (skillMd) { + const source = getSkillSource(se.name, bundledManifest, hubNames) + let modified = false + if (source === 'builtin') { + const manifestHash = bundledManifest.get(se.name) + if (manifestHash) { + const currentHash = await dirHash(join(catDir, se.name)) + modified = currentHash !== manifestHash + } + } + const usage = usageStats.get(se.name) + skills.push({ + name: se.name, + description: extractDescription(skillMd), + enabled: !disabledList.includes(se.name), + source, + modified: modified || undefined, + patchCount: usage?.patch_count, + useCount: usage?.use_count, + viewCount: usage?.view_count, + pinned: usage?.pinned || undefined, + }) + } + } + if (skills.length > 0) { + categories.push({ name: cat.name, description: cat.description, skills }) + } + } + + // Group flat skills into a "misc" (雜項) category + if (flatSkills.length > 0) { + const miscSkills: any[] = [] + for (const fs of flatSkills) { + const usage = usageStats.get(fs.name) + miscSkills.push({ + name: fs.name, + description: extractDescription(fs.skillMd), + enabled: !disabledList.includes(fs.name), + source: fs.source, + modified: undefined, + patchCount: usage?.patch_count, + useCount: usage?.use_count, + viewCount: usage?.view_count, + pinned: usage?.pinned || undefined, + }) + } + miscSkills.sort((a: any, b: any) => a.name.localeCompare(b.name)) + categories.push({ + name: 'misc', + description: '雜項', + skills: miscSkills, + }) + } + + categories.sort((a, b) => a.name.localeCompare(b.name)) + for (const cat of categories) { cat.skills.sort((a: any, b: any) => a.name.localeCompare(b.name)) } + return categories +} + export async function list(ctx: any) { const skillsDir = join(getHermesDir(), 'skills') try { @@ -93,52 +207,8 @@ export async function list(ctx: any) { const hubNames = readHubInstalledNames(await safeReadFile(join(skillsDir, '.hub', 'lock.json'))) const usageStats = readUsageStats(await safeReadFile(join(skillsDir, '.usage.json'))) - const entries = await readdir(skillsDir, { withFileTypes: true }) - const categories: any[] = [] - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue - const catDir = join(skillsDir, entry.name) - const catDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md')) - const catDescription = catDesc ? catDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : '' - const skillEntries = await readdir(catDir, { withFileTypes: true }) - const skills: any[] = [] - for (const se of skillEntries) { - if (!se.isDirectory()) continue - const skillMd = await safeReadFile(join(catDir, se.name, 'SKILL.md')) - if (skillMd) { - const source = getSkillSource(se.name, bundledManifest, hubNames) - - // Check if builtin skill has been user-modified - let modified = false - if (source === 'builtin') { - const manifestHash = bundledManifest.get(se.name) - if (manifestHash) { - const currentHash = await dirHash(join(catDir, se.name)) - modified = currentHash !== manifestHash - } - } - - const usage = usageStats.get(se.name) - - skills.push({ - name: se.name, - description: extractDescription(skillMd), - enabled: !disabledList.includes(se.name), - source, - modified: modified || undefined, - patchCount: usage?.patch_count, - useCount: usage?.use_count, - viewCount: usage?.view_count, - pinned: usage?.pinned || undefined, - }) - } - } - if (skills.length > 0) { - categories.push({ name: entry.name, description: catDescription, skills }) - } - } - categories.sort((a, b) => a.name.localeCompare(b.name)) - for (const cat of categories) { cat.skills.sort((a: any, b: any) => a.name.localeCompare(b.name)) } + // Scan all skills (supports both two-level and three-level directory structures) + const categories = await scanSkillsDir(skillsDir, bundledManifest, hubNames, disabledList, usageStats) // Read archived skills from .archive/ const archived: any[] = [] @@ -194,7 +264,10 @@ export async function toggle(ctx: any) { export async function listFiles(ctx: any) { const { category, skill } = ctx.params - const skillDir = join(getHermesDir(), 'skills', category, skill) + const hd = getHermesDir() + // Handle "misc" category: real skill dir is skills/, not skills/misc/ + const realDir = category === 'misc' ? skill : join(category, skill) + const skillDir = join(hd, 'skills', realDir) try { const allFiles = await listFilesRecursive(skillDir, '') const files = allFiles.filter(f => f.path !== 'SKILL.md') @@ -208,7 +281,12 @@ export async function listFiles(ctx: any) { export async function readFile_(ctx: any) { const filePath = (ctx.params as any).path const hd = getHermesDir() - const fullPath = resolve(join(hd, 'skills', filePath)) + // Handle "misc" category: real skill dir is skills/, not skills/misc/ + let realPath = filePath + if (filePath.startsWith('misc/')) { + realPath = filePath.slice(5) + } + const fullPath = resolve(join(hd, 'skills', realPath)) if (!fullPath.startsWith(join(hd, 'skills'))) { ctx.status = 403 ctx.body = { error: 'Access denied' }