diff --git a/apps/desktop/src/main/services/skill-installer.ts b/apps/desktop/src/main/services/skill-installer.ts index 6694ee2..b5f3d78 100644 --- a/apps/desktop/src/main/services/skill-installer.ts +++ b/apps/desktop/src/main/services/skill-installer.ts @@ -498,6 +498,57 @@ export class SkillInstaller { * * Note: This method only scans SKILL.md format skills, NOT MCP configurations. */ + /** + * Discover skill directories under a scan path. + * Returns an array of directories that contain a SKILL.md file, + * supporting both flat and one-level nested structures. + */ + private static async collectSkillDirs( + scanPath: string, + ): Promise { + const result: string[] = []; + + if (!(await fileExists(scanPath))) { + return result; + } + + const entries = await fs.readdir(scanPath, { withFileTypes: true }); + const dirsToCheck: string[] = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + dirsToCheck.push(path.join(scanPath, entry.name)); + } + } + + for (const baseDir of dirsToCheck) { + const directMd = path.join(baseDir, "SKILL.md"); + if (await fileExists(directMd)) { + result.push(baseDir); + } else { + // Check subdirectories for category-nested structures (e.g., Hermes) + try { + const subEntries = await fs.readdir(baseDir, { withFileTypes: true }); + for (const sub of subEntries) { + if (sub.isDirectory()) { + const nestedDir = path.join(baseDir, sub.name); + if (await fileExists(path.join(nestedDir, "SKILL.md"))) { + result.push(nestedDir); + } + } + } + } catch (err) { + console.warn( + `Failed reading skill directory: ${baseDir}, skipping`, + err, + ); + } + } + } + + return result; + } + static async scanLocal(db: SkillDB): Promise { let count = 0; const skipped: string[] = []; @@ -511,14 +562,11 @@ export class SkillInstaller { try { console.log(`Scanning path for skills: ${scanPath}`); - const entries = await fs.readdir(scanPath, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - const skillFolderPath = path.join(scanPath, entry.name); - const skillMdPath = path.join(skillFolderPath, "SKILL.md"); - - if (await fileExists(skillMdPath)) { - let skillDisplayName = entry.name; + const skillDirs = await this.collectSkillDirs(scanPath); + + for (const skillFolderPath of skillDirs) { + const skillMdPath = path.join(skillFolderPath, "SKILL.md"); + let skillDisplayName = path.basename(skillFolderPath); try { const instructions = await fs.readFile(skillMdPath, "utf-8"); const manifest = await this.readManifest(skillFolderPath); @@ -529,15 +577,14 @@ export class SkillInstaller { const sanitized = sanitizeImportedSkillDraft( { name: parsedSkill?.frontmatter.name, - fallbackName: manifest.name || entry.name, + fallbackName: manifest.name || path.basename(skillFolderPath), description: parsedSkill?.frontmatter.description, fallbackDescription: - manifest.description || - `Local skill found in ${entry.name}`, + manifest.description || undefined, version: parsedSkill?.frontmatter.version, fallbackVersion: manifest.version, author: parsedSkill?.frontmatter.author, - fallbackAuthor: manifest.author || "Local", + fallbackAuthor: manifest.author || undefined, tags: parsedSkill?.frontmatter.tags, fallbackTags: [], instructions, @@ -548,7 +595,7 @@ export class SkillInstaller { ); const name = sanitized.name; - skillDisplayName = name || entry.name; + skillDisplayName = name || path.basename(skillFolderPath); if (!name || name.trim().length === 0) { console.warn( @@ -572,7 +619,7 @@ export class SkillInstaller { }); count++; console.log( - `Discovered local skill via SKILL.md: ${name} in ${entry.name}`, + `Discovered local skill via SKILL.md: ${name} in ${path.basename(skillFolderPath)}`, ); } catch (error: unknown) { const msg = getErrorMessage(error); @@ -592,8 +639,7 @@ export class SkillInstaller { } } } - } - } catch (e) { + } catch (e) { console.error(`Failed to scan path: ${scanPath}`, e); } } @@ -646,14 +692,11 @@ export class SkillInstaller { } try { - const entries = await fs.readdir(scanPath, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - const skillFolderPath = path.join(scanPath, entry.name); - const skillMdPath = path.join(skillFolderPath, "SKILL.md"); + const skillDirs = await SkillInstaller.collectSkillDirs(scanPath); - if (await fileExists(skillMdPath)) { - try { + for (const skillFolderPath of skillDirs) { + const skillMdPath = path.join(skillFolderPath, "SKILL.md"); + try { const instructions = await fs.readFile(skillMdPath, "utf-8"); const manifest = await this.readManifest(skillFolderPath); const parsedSkill = parseSkillMd(instructions); @@ -661,7 +704,7 @@ export class SkillInstaller { const name = parsedSkill?.frontmatter.name || manifest.name || - entry.name; + path.basename(skillFolderPath); if (!name || name.trim().length === 0) { console.warn( @@ -682,15 +725,14 @@ export class SkillInstaller { const sanitized = sanitizeImportedSkillDraft( { name: parsedSkill?.frontmatter.name, - fallbackName: manifest.name || entry.name, + fallbackName: manifest.name || path.basename(skillFolderPath), description: parsedSkill?.frontmatter.description, fallbackDescription: - manifest.description || - `Local skill found in ${entry.name}`, + manifest.description || undefined, version: parsedSkill?.frontmatter.version, fallbackVersion: manifest.version, author: parsedSkill?.frontmatter.author, - fallbackAuthor: manifest.author || "Local", + fallbackAuthor: manifest.author || undefined, tags: parsedSkill?.frontmatter.tags, fallbackTags: [], instructions, @@ -703,9 +745,9 @@ export class SkillInstaller { name: sanitized.name!, description: sanitized.description || - `Local skill found in ${entry.name}`, + manifest.description, version: sanitized.version, - author: sanitized.author || "Local", + author: sanitized.author || manifest.author, tags: sanitized.tags, instructions: sanitized.instructions || instructions, filePath: skillMdPath, @@ -723,8 +765,7 @@ export class SkillInstaller { } } } - } - } catch (e) { + } catch (e) { console.error(`Failed to scan path: ${scanPath}`, e); } }), diff --git a/apps/desktop/src/renderer/assets/platforms/hermes.svg b/apps/desktop/src/renderer/assets/platforms/hermes.svg new file mode 100644 index 0000000..3021925 --- /dev/null +++ b/apps/desktop/src/renderer/assets/platforms/hermes.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/src/renderer/components/ui/PlatformIcon.tsx b/apps/desktop/src/renderer/components/ui/PlatformIcon.tsx index e31080f..86acd69 100644 --- a/apps/desktop/src/renderer/components/ui/PlatformIcon.tsx +++ b/apps/desktop/src/renderer/components/ui/PlatformIcon.tsx @@ -30,6 +30,7 @@ import qoderIcon from "../../assets/platforms/qoder.png"; import qoderworkIcon from "../../assets/platforms/qoderwork.png"; import codebuddyLightIcon from "../../assets/platforms/codebuddy-light.svg"; import codebuddyDarkIcon from "../../assets/platforms/codebuddy-dark.svg"; +import hermesIcon from "@renderer/assets/platforms/hermes.svg"; type PlatformIconSource = string | { light: string; dark: string }; @@ -55,6 +56,7 @@ const PLATFORM_ICONS: Record = { light: codebuddyLightIcon, dark: codebuddyDarkIcon, }, + hermes: hermesIcon, }; // Fallback Lucide icons for platforms without PNG @@ -76,6 +78,7 @@ const FALLBACK_ICONS: Record = { qoder: , qoderwork: , codebuddy: , + hermes: , }; interface PlatformIconProps { diff --git a/packages/shared/constants/platforms.ts b/packages/shared/constants/platforms.ts index bcf6c06..9afa5d2 100644 --- a/packages/shared/constants/platforms.ts +++ b/packages/shared/constants/platforms.ts @@ -172,6 +172,16 @@ export const SKILL_PLATFORMS: SkillPlatform[] = [ linux: "~/.qoderwork/skills", }, }, + { + id: "hermes", + name: "Hermes Agent", + icon: "Bot", + skillsDir: { + darwin: "~/.hermes/skills", + win32: "%USERPROFILE%\\.hermes\\skills", + linux: "~/.hermes/skills", + }, + }, { id: "codebuddy", name: "CodeBuddy",