support external skill sources (#981)
This commit is contained in:
@@ -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<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 */
|
||||
function readBundledManifest(manifestContent: string | null): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
@@ -75,7 +116,7 @@ function getSkillSource(
|
||||
dirName: string,
|
||||
bundledManifest: Map<string, string>,
|
||||
hubNames: Set<string>,
|
||||
): '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<s
|
||||
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.
|
||||
*
|
||||
@@ -248,6 +314,59 @@ async function scanSkillsDir(skillsDir: string, bundledManifest: Map<string, str
|
||||
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) {
|
||||
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/<skill>, not skills/misc/<skill>
|
||||
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)) {
|
||||
|
||||
@@ -43,7 +43,7 @@ export const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_en
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type SkillSource = 'builtin' | 'hub' | 'local'
|
||||
export type SkillSource = 'builtin' | 'hub' | 'local' | 'external'
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user