From 4176923bac28d7a534567e6280e86817e863a47c Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Sun, 24 May 2026 19:47:52 +0800 Subject: [PATCH] support external skill sources (#981) --- packages/client/src/api/hermes/skills.ts | 2 +- .../components/hermes/skills/SkillList.vue | 4 + packages/client/src/i18n/locales/de.ts | 1 + packages/client/src/i18n/locales/en.ts | 1 + packages/client/src/i18n/locales/es.ts | 1 + packages/client/src/i18n/locales/fr.ts | 1 + packages/client/src/i18n/locales/ja.ts | 1 + packages/client/src/i18n/locales/ko.ts | 1 + packages/client/src/i18n/locales/pt.ts | 1 + packages/client/src/i18n/locales/zh-TW.ts | 1 + packages/client/src/i18n/locales/zh.ts | 1 + .../client/src/views/hermes/SkillsView.vue | 4 + .../server/src/controllers/hermes/skills.ts | 155 +++++++++++++++--- .../server/src/services/config-helpers.ts | 2 +- tests/client/skill-list.test.ts | 51 ++++++ tests/server/skills-controller.test.ts | 63 ++++++- 16 files changed, 261 insertions(+), 29 deletions(-) create mode 100644 tests/client/skill-list.test.ts diff --git a/packages/client/src/api/hermes/skills.ts b/packages/client/src/api/hermes/skills.ts index 7d539b1..f63713a 100644 --- a/packages/client/src/api/hermes/skills.ts +++ b/packages/client/src/api/hermes/skills.ts @@ -1,6 +1,6 @@ import { request } from '../client' -export type SkillSource = 'builtin' | 'hub' | 'local' +export type SkillSource = 'builtin' | 'hub' | 'local' | 'external' export interface SkillInfo { name: string diff --git a/packages/client/src/components/hermes/skills/SkillList.vue b/packages/client/src/components/hermes/skills/SkillList.vue index 82251ab..77f9e7c 100644 --- a/packages/client/src/components/hermes/skills/SkillList.vue +++ b/packages/client/src/components/hermes/skills/SkillList.vue @@ -289,6 +289,10 @@ async function handleToggle(category: string, skillName: string, newEnabled: boo background: #66bb6a; } +.dot-external { + background: #f59e0b; +} + .skill-info { flex: 1; min-width: 0; diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index 764568e..4f1ee73 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -423,6 +423,7 @@ jobTriggered: 'Job ausgelost', builtin: 'Integriert', hub: 'Hub', local: 'Lokal', + external: 'Extern', }, }, diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 1017acb..b2f64b9 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -518,6 +518,7 @@ export default { builtin: 'Builtin', hub: 'Hub', local: 'Local', + external: 'External', }, }, diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index 9c2b3bb..1bd5a46 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -423,6 +423,7 @@ jobTriggered: 'Job ejecutado', builtin: 'Integrado', hub: 'Hub', local: 'Local', + external: 'Externo', }, }, diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index 9f5648e..34fbab7 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -423,6 +423,7 @@ jobTriggered: 'Job declenche', builtin: 'Intégré', hub: 'Hub', local: 'Local', + external: 'Externe', }, }, diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index e7964b9..4e14c27 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -423,6 +423,7 @@ export default { builtin: '組み込み', hub: 'Hub', local: 'ローカル', + external: '外部', }, }, diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index 5956f84..944caeb 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -423,6 +423,7 @@ export default { builtin: '내장', hub: 'Hub', local: '로컬', + external: '외부', }, }, diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index f7fe68f..9fcb80f 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -423,6 +423,7 @@ jobTriggered: 'Job acionado', builtin: 'Integrado', hub: 'Hub', local: 'Local', + external: 'Externo', }, }, diff --git a/packages/client/src/i18n/locales/zh-TW.ts b/packages/client/src/i18n/locales/zh-TW.ts index 36cf1ed..662ef16 100644 --- a/packages/client/src/i18n/locales/zh-TW.ts +++ b/packages/client/src/i18n/locales/zh-TW.ts @@ -518,6 +518,7 @@ export default { builtin: '內建', hub: 'Hub 安裝', local: '本地安裝', + external: '外部目錄', }, }, diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index bd8088a..e747933 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -518,6 +518,7 @@ export default { builtin: '内置', hub: 'Hub 安装', local: '本地安装', + external: '外部目录', }, }, diff --git a/packages/client/src/views/hermes/SkillsView.vue b/packages/client/src/views/hermes/SkillsView.vue index 5240681..132bb7a 100644 --- a/packages/client/src/views/hermes/SkillsView.vue +++ b/packages/client/src/views/hermes/SkillsView.vue @@ -139,6 +139,9 @@ function handlePinToggled(name: string, pinned: boolean) { + @@ -250,6 +253,7 @@ function handlePinToggled(name: string, pinned: boolean) { .legend-dot.dot-builtin { background: #888; } .legend-dot.dot-hub { background: #4a90d9; } .legend-dot.dot-local { background: #66bb6a; } +.legend-dot.dot-external { background: #f59e0b; } .modified-icon { font-size: 11px; diff --git a/packages/server/src/controllers/hermes/skills.ts b/packages/server/src/controllers/hermes/skills.ts index 3f89275..2208457 100644 --- a/packages/server/src/controllers/hermes/skills.ts +++ b/packages/server/src/controllers/hermes/skills.ts @@ -1,10 +1,12 @@ -import { mkdir, readdir, readFile, writeFile } from 'fs/promises' +import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises' +import { homedir } from 'os' import { join, resolve } from 'path' import { createHash } from 'crypto' import { readConfigYamlForProfile, updateConfigYamlForProfile, safeReadFile, extractDescription, listFilesRecursive, } from '../../services/config-helpers' +import type { SkillSource } from '../../services/config-helpers' import { isPathWithin } from '../../services/hermes/hermes-path' import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db' @@ -21,6 +23,45 @@ function requestSkillsDir(ctx: any): string { return join(requestProfileDir(ctx), 'skills') } +function expandConfiguredPath(value: string): string { + const expandedEnv = value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => { + return process.env[braced || bare] || '' + }) + if (expandedEnv === '~') return homedir() + if (expandedEnv.startsWith('~/')) return join(homedir(), expandedEnv.slice(2)) + return expandedEnv +} + +async function resolveExternalSkillsDirs(config: Record, localSkillsDir: string): Promise { + const rawDirs = config.skills?.external_dirs + const entries = typeof rawDirs === 'string' + ? [rawDirs] + : Array.isArray(rawDirs) + ? rawDirs + : [] + const localResolved = resolve(localSkillsDir) + const seen = new Set() + const dirs: string[] = [] + + for (const rawEntry of entries) { + const entry = String(rawEntry || '').trim() + if (!entry) continue + const expanded = expandConfiguredPath(entry) + const resolved = resolve(expanded) + if (resolved === localResolved || seen.has(resolved)) continue + try { + const info = await stat(resolved) + if (!info.isDirectory()) continue + } catch { + continue + } + seen.add(resolved) + dirs.push(resolved) + } + + return dirs +} + /** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */ function readBundledManifest(manifestContent: string | null): Map { const map = new Map() @@ -75,7 +116,7 @@ function getSkillSource( dirName: string, bundledManifest: Map, hubNames: Set, -): 'builtin' | 'hub' | 'local' { +): SkillSource { if (bundledManifest.has(dirName)) return 'builtin' if (hubNames.has(dirName)) return 'hub' return 'local' @@ -122,6 +163,31 @@ async function findSkillDirByName(rootDir: string, skillName: string): Promise { + if (category === 'misc') { + const skillDir = join(rootDir, skillName) + const skillMd = await safeReadFile(join(skillDir, 'SKILL.md')) + return skillMd !== null ? skillDir : null + } + return findSkillDirByName(join(rootDir, category), skillName) +} + +async function resolveSkillDirFromConfig( + config: Record, + localSkillsDir: string, + category: string, + skillName: string, +): Promise { + const localSkillDir = await findSkillDirInRoot(localSkillsDir, category, skillName) + if (localSkillDir) return localSkillDir + + for (const externalDir of await resolveExternalSkillsDirs(config, localSkillsDir)) { + const externalSkillDir = await findSkillDirInRoot(externalDir, category, skillName) + if (externalSkillDir) return externalSkillDir + } + return null +} + /** * Scan for skills at different directory depths. * @@ -248,6 +314,59 @@ async function scanSkillsDir(skillsDir: string, bundledManifest: Map) { + return scanSkillsDir(skillsDir, new Map(), new Set(), disabledList, usageStats).then(categories => + categories.map(category => ({ + ...category, + skills: category.skills.map((skill: any) => ({ + ...skill, + source: 'external' as SkillSource, + modified: undefined, + })), + })), + ) +} + +function collectSkillNames(categories: any[]): Set { + const names = new Set() + for (const category of categories) { + for (const skill of category.skills || []) { + if (skill?.name) names.add(skill.name) + } + } + return names +} + +function mergeExternalCategories(categories: any[], externalCategories: any[]): any[] { + const byName = new Map() + for (const category of categories) { + byName.set(category.name, { ...category, skills: [...category.skills] }) + } + + const seenSkills = collectSkillNames(categories) + for (const externalCategory of externalCategories) { + const target = byName.get(externalCategory.name) || { + name: externalCategory.name, + description: externalCategory.description, + skills: [], + } + for (const skill of externalCategory.skills || []) { + if (seenSkills.has(skill.name)) continue + seenSkills.add(skill.name) + target.skills.push(skill) + } + if (target.skills.length > 0) byName.set(target.name, target) + } + + const merged = [...byName.values()] + .filter(category => category.skills.length > 0) + .sort((a, b) => a.name.localeCompare(b.name)) + for (const category of merged) { + category.skills.sort((a: any, b: any) => a.name.localeCompare(b.name)) + } + return merged +} + export async function list(ctx: any) { const skillsDir = requestSkillsDir(ctx) try { @@ -260,7 +379,11 @@ export async function list(ctx: any) { const usageStats = readUsageStats(await safeReadFile(join(skillsDir, '.usage.json'))) // Scan all skills (supports both two-level and three-level directory structures) - const categories = await scanSkillsDir(skillsDir, bundledManifest, hubNames, disabledList, usageStats) + let categories = await scanSkillsDir(skillsDir, bundledManifest, hubNames, disabledList, usageStats) + for (const externalDir of await resolveExternalSkillsDirs(config, skillsDir)) { + const externalCategories = await scanExternalSkillsDir(externalDir, disabledList, usageStats) + categories = mergeExternalCategories(categories, externalCategories) + } // Read archived skills from .archive/ const archived: any[] = [] @@ -329,24 +452,10 @@ export async function toggle(ctx: any) { export async function listFiles(ctx: any) { const { category, skill } = ctx.params - const profileDir = requestProfileDir(ctx) - const profileSkillsDir = join(profileDir, 'skills') - const skillsDir = join(profileSkillsDir, category) - if (category === 'misc') { - const skillDir = join(profileSkillsDir, skill) - try { - const allFiles = await listFilesRecursive(skillDir, '') - const files = allFiles.filter((f: any) => f.path !== 'SKILL.md') - ctx.body = { files } - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message } - } - return - } - // Recursively find the actual skill directory (supports nested sub-categories like mlops/evaluation/lm-evaluation-harness) + const profileSkillsDir = requestSkillsDir(ctx) try { - const skillDir = await findSkillDirByName(skillsDir, skill) + const config = await readConfigYamlForProfile(requestedProfile(ctx)) + const skillDir = await resolveSkillDirFromConfig(config, profileSkillsDir, category, skill) if (!skillDir) { ctx.status = 404 ctx.body = { error: 'Skill not found' } @@ -363,7 +472,7 @@ export async function listFiles(ctx: any) { export async function readFile_(ctx: any) { const filePath = (ctx.params as any).path - const profileSkillsDir = join(requestProfileDir(ctx), 'skills') + const profileSkillsDir = requestSkillsDir(ctx) // Handle 'misc' category: real skill dir is skills/, not skills/misc/ let realPath = filePath if (filePath.startsWith('misc/')) { @@ -384,8 +493,8 @@ export async function readFile_(ctx: any) { const category = parts[0] const skillName = parts[1] const restPath = parts.slice(2).join('/') - const catDir = join(profileSkillsDir, category) - const skillDir = await findSkillDirByName(catDir, skillName) + const config = await readConfigYamlForProfile(requestedProfile(ctx)) + const skillDir = await resolveSkillDirFromConfig(config, profileSkillsDir, category, skillName) if (skillDir) { const resolvedPath = resolve(join(skillDir, restPath)) if (isPathWithin(resolvedPath, skillDir)) { diff --git a/packages/server/src/services/config-helpers.ts b/packages/server/src/services/config-helpers.ts index 14e307d..b6ae6e9 100644 --- a/packages/server/src/services/config-helpers.ts +++ b/packages/server/src/services/config-helpers.ts @@ -43,7 +43,7 @@ export const PROVIDER_ENV_MAP: Record ({ + useI18n: () => ({ t: (key: string) => key }), +})) + +vi.mock('@/api/hermes/skills', () => ({ + toggleSkill: vi.fn(), +})) + +vi.mock('naive-ui', () => ({ + NSwitch: defineComponent({ + name: 'NSwitch', + props: ['value', 'loading'], + emits: ['update:value', 'click'], + template: '', + }), + useMessage: () => ({ error: vi.fn() }), +})) + +describe('SkillList', () => { + it('supports filtering skills from external sources', () => { + const wrapper = mount(SkillList, { + props: { + categories: [ + { + name: 'tools', + description: '', + skills: [ + { name: 'local-skill', description: 'Local skill', enabled: true, source: 'local' }, + { name: 'external-skill', description: 'External skill', enabled: true, source: 'external' }, + ], + }, + ], + archived: [], + selectedSkill: null, + searchQuery: '', + sourceFilter: 'external', + }, + }) + + expect(wrapper.text()).toContain('external-skill') + expect(wrapper.text()).not.toContain('local-skill') + expect(wrapper.get('.source-dot').classes()).toContain('dot-external') + expect(wrapper.get('.source-dot').attributes('title')).toBe('skills.source.external') + }) +}) diff --git a/tests/server/skills-controller.test.ts b/tests/server/skills-controller.test.ts index 173148f..0307c23 100644 --- a/tests/server/skills-controller.test.ts +++ b/tests/server/skills-controller.test.ts @@ -1,9 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' const mockGetSkillUsageStatsFromDb = vi.hoisted(() => vi.fn()) const mockGetActiveProfileName = vi.hoisted(() => vi.fn()) const mockGetProfileDir = vi.hoisted(() => vi.fn()) const mockUpdateConfigYamlForProfile = vi.hoisted(() => vi.fn()) +const mockReadConfigYamlForProfile = vi.hoisted(() => vi.fn()) +const mockSafeReadFile = vi.hoisted(() => vi.fn()) +const mockExtractDescription = vi.hoisted(() => vi.fn()) +const mockListFilesRecursive = vi.hoisted(() => vi.fn()) vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({ getSkillUsageStatsFromDb: mockGetSkillUsageStatsFromDb, @@ -15,11 +22,11 @@ vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({ })) vi.mock('../../packages/server/src/services/config-helpers', () => ({ - readConfigYamlForProfile: vi.fn(), + readConfigYamlForProfile: mockReadConfigYamlForProfile, updateConfigYamlForProfile: mockUpdateConfigYamlForProfile, - safeReadFile: vi.fn(), - extractDescription: vi.fn(), - listFilesRecursive: vi.fn(), + safeReadFile: mockSafeReadFile, + extractDescription: mockExtractDescription, + listFilesRecursive: mockListFilesRecursive, })) async function loadController() { @@ -32,6 +39,18 @@ describe('skills controller', () => { vi.clearAllMocks() mockGetActiveProfileName.mockReturnValue('default') mockGetProfileDir.mockImplementation((profile: string) => `/tmp/hermes-${profile}`) + mockReadConfigYamlForProfile.mockResolvedValue({}) + mockSafeReadFile.mockImplementation(async (path: string) => { + try { + return await readFile(path, 'utf-8') + } catch { + return null + } + }) + mockExtractDescription.mockImplementation((content: string) => { + return content.split('\n').find(line => line.trim() && !line.startsWith('#'))?.trim() || '' + }) + mockListFilesRecursive.mockResolvedValue([]) mockUpdateConfigYamlForProfile.mockImplementation(async (_profile: string, updater: (config: Record) => Record) => updater({})) mockGetSkillUsageStatsFromDb.mockResolvedValue({ period_days: 7, @@ -88,4 +107,40 @@ describe('skills controller', () => { }) expect(ctx.body).toEqual({ success: true }) }) + + it('lists configured external skill directories with external source while keeping local skills first', async () => { + const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-external-skills-')) + const profileDir = join(root, 'profile') + const localSkillDir = join(profileDir, 'skills', 'tools', 'dupe-skill') + const externalDir = join(root, 'external-skills') + const externalSkillDir = join(externalDir, 'tools', 'external-skill') + const externalDupeDir = join(externalDir, 'tools', 'dupe-skill') + + await mkdir(localSkillDir, { recursive: true }) + await mkdir(externalSkillDir, { recursive: true }) + await mkdir(externalDupeDir, { recursive: true }) + await writeFile(join(localSkillDir, 'SKILL.md'), '# Local Dupe\nlocal copy\n', 'utf-8') + await writeFile(join(externalSkillDir, 'SKILL.md'), '# External Skill\nexternal copy\n', 'utf-8') + await writeFile(join(externalDupeDir, 'SKILL.md'), '# External Dupe\nexternal duplicate\n', 'utf-8') + + mockGetProfileDir.mockReturnValue(profileDir) + mockReadConfigYamlForProfile.mockResolvedValue({ + skills: { external_dirs: [externalDir] }, + }) + + try { + const { list } = await loadController() + const ctx: any = { state: { profile: { name: 'research' } }, body: null } + + await list(ctx) + + const tools = ctx.body.categories.find((category: any) => category.name === 'tools') + expect(tools.skills).toEqual([ + expect.objectContaining({ name: 'dupe-skill', source: 'local', description: 'local copy' }), + expect.objectContaining({ name: 'external-skill', source: 'external', description: 'external copy' }), + ]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) })