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 })
+ }
+ })
})