support external skill sources (#981)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { request } from '../client'
|
import { request } from '../client'
|
||||||
|
|
||||||
export type SkillSource = 'builtin' | 'hub' | 'local'
|
export type SkillSource = 'builtin' | 'hub' | 'local' | 'external'
|
||||||
|
|
||||||
export interface SkillInfo {
|
export interface SkillInfo {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -289,6 +289,10 @@ async function handleToggle(category: string, skillName: string, newEnabled: boo
|
|||||||
background: #66bb6a;
|
background: #66bb6a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dot-external {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
.skill-info {
|
.skill-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ jobTriggered: 'Job ausgelost',
|
|||||||
builtin: 'Integriert',
|
builtin: 'Integriert',
|
||||||
hub: 'Hub',
|
hub: 'Hub',
|
||||||
local: 'Lokal',
|
local: 'Lokal',
|
||||||
|
external: 'Extern',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -518,6 +518,7 @@ export default {
|
|||||||
builtin: 'Builtin',
|
builtin: 'Builtin',
|
||||||
hub: 'Hub',
|
hub: 'Hub',
|
||||||
local: 'Local',
|
local: 'Local',
|
||||||
|
external: 'External',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ jobTriggered: 'Job ejecutado',
|
|||||||
builtin: 'Integrado',
|
builtin: 'Integrado',
|
||||||
hub: 'Hub',
|
hub: 'Hub',
|
||||||
local: 'Local',
|
local: 'Local',
|
||||||
|
external: 'Externo',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ jobTriggered: 'Job declenche',
|
|||||||
builtin: 'Intégré',
|
builtin: 'Intégré',
|
||||||
hub: 'Hub',
|
hub: 'Hub',
|
||||||
local: 'Local',
|
local: 'Local',
|
||||||
|
external: 'Externe',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ export default {
|
|||||||
builtin: '組み込み',
|
builtin: '組み込み',
|
||||||
hub: 'Hub',
|
hub: 'Hub',
|
||||||
local: 'ローカル',
|
local: 'ローカル',
|
||||||
|
external: '外部',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ export default {
|
|||||||
builtin: '내장',
|
builtin: '내장',
|
||||||
hub: 'Hub',
|
hub: 'Hub',
|
||||||
local: '로컬',
|
local: '로컬',
|
||||||
|
external: '외부',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ jobTriggered: 'Job acionado',
|
|||||||
builtin: 'Integrado',
|
builtin: 'Integrado',
|
||||||
hub: 'Hub',
|
hub: 'Hub',
|
||||||
local: 'Local',
|
local: 'Local',
|
||||||
|
external: 'Externo',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -518,6 +518,7 @@ export default {
|
|||||||
builtin: '內建',
|
builtin: '內建',
|
||||||
hub: 'Hub 安裝',
|
hub: 'Hub 安裝',
|
||||||
local: '本地安裝',
|
local: '本地安裝',
|
||||||
|
external: '外部目錄',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -518,6 +518,7 @@ export default {
|
|||||||
builtin: '内置',
|
builtin: '内置',
|
||||||
hub: 'Hub 安装',
|
hub: 'Hub 安装',
|
||||||
local: '本地安装',
|
local: '本地安装',
|
||||||
|
external: '外部目录',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ function handlePinToggled(name: string, pinned: boolean) {
|
|||||||
<button class="legend-item" :class="{ active: sourceFilter === 'local' }" @click="toggleFilter('local')">
|
<button class="legend-item" :class="{ active: sourceFilter === 'local' }" @click="toggleFilter('local')">
|
||||||
<span class="legend-dot dot-local" />{{ t('skills.source.local') }}
|
<span class="legend-dot dot-local" />{{ t('skills.source.local') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="legend-item" :class="{ active: sourceFilter === 'external' }" @click="toggleFilter('external')">
|
||||||
|
<span class="legend-dot dot-external" />{{ t('skills.source.external') }}
|
||||||
|
</button>
|
||||||
<button class="legend-item" :class="{ active: sourceFilter === 'modified' }" @click="toggleFilter('modified')">
|
<button class="legend-item" :class="{ active: sourceFilter === 'modified' }" @click="toggleFilter('modified')">
|
||||||
<span class="modified-icon">✎</span>{{ t('skills.modified') }}
|
<span class="modified-icon">✎</span>{{ t('skills.modified') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -250,6 +253,7 @@ function handlePinToggled(name: string, pinned: boolean) {
|
|||||||
.legend-dot.dot-builtin { background: #888; }
|
.legend-dot.dot-builtin { background: #888; }
|
||||||
.legend-dot.dot-hub { background: #4a90d9; }
|
.legend-dot.dot-hub { background: #4a90d9; }
|
||||||
.legend-dot.dot-local { background: #66bb6a; }
|
.legend-dot.dot-local { background: #66bb6a; }
|
||||||
|
.legend-dot.dot-external { background: #f59e0b; }
|
||||||
|
|
||||||
.modified-icon {
|
.modified-icon {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -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 { join, resolve } from 'path'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import {
|
import {
|
||||||
readConfigYamlForProfile, updateConfigYamlForProfile,
|
readConfigYamlForProfile, updateConfigYamlForProfile,
|
||||||
safeReadFile, extractDescription, listFilesRecursive,
|
safeReadFile, extractDescription, listFilesRecursive,
|
||||||
} from '../../services/config-helpers'
|
} from '../../services/config-helpers'
|
||||||
|
import type { SkillSource } from '../../services/config-helpers'
|
||||||
import { isPathWithin } from '../../services/hermes/hermes-path'
|
import { isPathWithin } from '../../services/hermes/hermes-path'
|
||||||
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
|
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
|
||||||
import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db'
|
import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db'
|
||||||
@@ -21,6 +23,45 @@ function requestSkillsDir(ctx: any): string {
|
|||||||
return join(requestProfileDir(ctx), 'skills')
|
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<string, any>, localSkillsDir: string): Promise<string[]> {
|
||||||
|
const rawDirs = config.skills?.external_dirs
|
||||||
|
const entries = typeof rawDirs === 'string'
|
||||||
|
? [rawDirs]
|
||||||
|
: Array.isArray(rawDirs)
|
||||||
|
? rawDirs
|
||||||
|
: []
|
||||||
|
const localResolved = resolve(localSkillsDir)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
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 */
|
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
|
||||||
function readBundledManifest(manifestContent: string | null): Map<string, string> {
|
function readBundledManifest(manifestContent: string | null): Map<string, string> {
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, string>()
|
||||||
@@ -75,7 +116,7 @@ function getSkillSource(
|
|||||||
dirName: string,
|
dirName: string,
|
||||||
bundledManifest: Map<string, string>,
|
bundledManifest: Map<string, string>,
|
||||||
hubNames: Set<string>,
|
hubNames: Set<string>,
|
||||||
): 'builtin' | 'hub' | 'local' {
|
): SkillSource {
|
||||||
if (bundledManifest.has(dirName)) return 'builtin'
|
if (bundledManifest.has(dirName)) return 'builtin'
|
||||||
if (hubNames.has(dirName)) return 'hub'
|
if (hubNames.has(dirName)) return 'hub'
|
||||||
return 'local'
|
return 'local'
|
||||||
@@ -122,6 +163,31 @@ async function findSkillDirByName(rootDir: string, skillName: string): Promise<s
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findSkillDirInRoot(rootDir: string, category: string, skillName: string): Promise<string | null> {
|
||||||
|
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<string, any>,
|
||||||
|
localSkillsDir: string,
|
||||||
|
category: string,
|
||||||
|
skillName: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
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.
|
* Scan for skills at different directory depths.
|
||||||
*
|
*
|
||||||
@@ -248,6 +314,59 @@ async function scanSkillsDir(skillsDir: string, bundledManifest: Map<string, str
|
|||||||
return categories
|
return categories
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function scanExternalSkillsDir(skillsDir: string, disabledList: string[], usageStats: Map<string, UsageStats>) {
|
||||||
|
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<string> {
|
||||||
|
const names = new Set<string>()
|
||||||
|
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<string, any>()
|
||||||
|
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) {
|
export async function list(ctx: any) {
|
||||||
const skillsDir = requestSkillsDir(ctx)
|
const skillsDir = requestSkillsDir(ctx)
|
||||||
try {
|
try {
|
||||||
@@ -260,7 +379,11 @@ export async function list(ctx: any) {
|
|||||||
const usageStats = readUsageStats(await safeReadFile(join(skillsDir, '.usage.json')))
|
const usageStats = readUsageStats(await safeReadFile(join(skillsDir, '.usage.json')))
|
||||||
|
|
||||||
// Scan all skills (supports both two-level and three-level directory structures)
|
// 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/
|
// Read archived skills from .archive/
|
||||||
const archived: any[] = []
|
const archived: any[] = []
|
||||||
@@ -329,24 +452,10 @@ export async function toggle(ctx: any) {
|
|||||||
|
|
||||||
export async function listFiles(ctx: any) {
|
export async function listFiles(ctx: any) {
|
||||||
const { category, skill } = ctx.params
|
const { category, skill } = ctx.params
|
||||||
const profileDir = requestProfileDir(ctx)
|
const profileSkillsDir = requestSkillsDir(ctx)
|
||||||
const profileSkillsDir = join(profileDir, 'skills')
|
|
||||||
const skillsDir = join(profileSkillsDir, category)
|
|
||||||
if (category === 'misc') {
|
|
||||||
const skillDir = join(profileSkillsDir, skill)
|
|
||||||
try {
|
try {
|
||||||
const allFiles = await listFilesRecursive(skillDir, '')
|
const config = await readConfigYamlForProfile(requestedProfile(ctx))
|
||||||
const files = allFiles.filter((f: any) => f.path !== 'SKILL.md')
|
const skillDir = await resolveSkillDirFromConfig(config, profileSkillsDir, category, skill)
|
||||||
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)
|
|
||||||
try {
|
|
||||||
const skillDir = await findSkillDirByName(skillsDir, skill)
|
|
||||||
if (!skillDir) {
|
if (!skillDir) {
|
||||||
ctx.status = 404
|
ctx.status = 404
|
||||||
ctx.body = { error: 'Skill not found' }
|
ctx.body = { error: 'Skill not found' }
|
||||||
@@ -363,7 +472,7 @@ export async function listFiles(ctx: any) {
|
|||||||
|
|
||||||
export async function readFile_(ctx: any) {
|
export async function readFile_(ctx: any) {
|
||||||
const filePath = (ctx.params as any).path
|
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/<skill>, not skills/misc/<skill>
|
// Handle 'misc' category: real skill dir is skills/<skill>, not skills/misc/<skill>
|
||||||
let realPath = filePath
|
let realPath = filePath
|
||||||
if (filePath.startsWith('misc/')) {
|
if (filePath.startsWith('misc/')) {
|
||||||
@@ -384,8 +493,8 @@ export async function readFile_(ctx: any) {
|
|||||||
const category = parts[0]
|
const category = parts[0]
|
||||||
const skillName = parts[1]
|
const skillName = parts[1]
|
||||||
const restPath = parts.slice(2).join('/')
|
const restPath = parts.slice(2).join('/')
|
||||||
const catDir = join(profileSkillsDir, category)
|
const config = await readConfigYamlForProfile(requestedProfile(ctx))
|
||||||
const skillDir = await findSkillDirByName(catDir, skillName)
|
const skillDir = await resolveSkillDirFromConfig(config, profileSkillsDir, category, skillName)
|
||||||
if (skillDir) {
|
if (skillDir) {
|
||||||
const resolvedPath = resolve(join(skillDir, restPath))
|
const resolvedPath = resolve(join(skillDir, restPath))
|
||||||
if (isPathWithin(resolvedPath, skillDir)) {
|
if (isPathWithin(resolvedPath, skillDir)) {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_en
|
|||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
export type SkillSource = 'builtin' | 'hub' | 'local'
|
export type SkillSource = 'builtin' | 'hub' | 'local' | 'external'
|
||||||
|
|
||||||
export interface SkillInfo {
|
export interface SkillInfo {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import SkillList from '@/components/hermes/skills/SkillList.vue'
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
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: '<button type="button" @click="$emit(\'click\')"></button>',
|
||||||
|
}),
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
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 mockGetSkillUsageStatsFromDb = vi.hoisted(() => vi.fn())
|
||||||
const mockGetActiveProfileName = vi.hoisted(() => vi.fn())
|
const mockGetActiveProfileName = vi.hoisted(() => vi.fn())
|
||||||
const mockGetProfileDir = vi.hoisted(() => vi.fn())
|
const mockGetProfileDir = vi.hoisted(() => vi.fn())
|
||||||
const mockUpdateConfigYamlForProfile = 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', () => ({
|
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
||||||
getSkillUsageStatsFromDb: mockGetSkillUsageStatsFromDb,
|
getSkillUsageStatsFromDb: mockGetSkillUsageStatsFromDb,
|
||||||
@@ -15,11 +22,11 @@ vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||||
readConfigYamlForProfile: vi.fn(),
|
readConfigYamlForProfile: mockReadConfigYamlForProfile,
|
||||||
updateConfigYamlForProfile: mockUpdateConfigYamlForProfile,
|
updateConfigYamlForProfile: mockUpdateConfigYamlForProfile,
|
||||||
safeReadFile: vi.fn(),
|
safeReadFile: mockSafeReadFile,
|
||||||
extractDescription: vi.fn(),
|
extractDescription: mockExtractDescription,
|
||||||
listFilesRecursive: vi.fn(),
|
listFilesRecursive: mockListFilesRecursive,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
async function loadController() {
|
async function loadController() {
|
||||||
@@ -32,6 +39,18 @@ describe('skills controller', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockGetActiveProfileName.mockReturnValue('default')
|
mockGetActiveProfileName.mockReturnValue('default')
|
||||||
mockGetProfileDir.mockImplementation((profile: string) => `/tmp/hermes-${profile}`)
|
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<string, any>) => Record<string, any>) => updater({}))
|
mockUpdateConfigYamlForProfile.mockImplementation(async (_profile: string, updater: (config: Record<string, any>) => Record<string, any>) => updater({}))
|
||||||
mockGetSkillUsageStatsFromDb.mockResolvedValue({
|
mockGetSkillUsageStatsFromDb.mockResolvedValue({
|
||||||
period_days: 7,
|
period_days: 7,
|
||||||
@@ -88,4 +107,40 @@ describe('skills controller', () => {
|
|||||||
})
|
})
|
||||||
expect(ctx.body).toEqual({ success: true })
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user