fix: sync bundled skills across profiles (#926)

This commit is contained in:
ekko
2026-05-22 10:41:14 +08:00
committed by GitHub
parent 4b759c4d8a
commit f90e79fd2f
5 changed files with 419 additions and 120 deletions
@@ -1,22 +1,32 @@
import { copyFile, mkdir, readdir, rm, stat } from 'fs/promises'
import { existsSync } from 'fs'
import { existsSync, readdirSync } from 'fs'
import { join, resolve } from 'path'
import { getActiveProfileDir } from './hermes-profile'
import { detectHermesRootHome } from './hermes-path'
import { logger } from '../logger'
export interface SkillInjectionResult {
sourceDir: string
export interface SkillInjectionTargetResult {
profile?: string
targetDir: string
injected: string[]
updated: string[]
skipped: string[]
}
export interface SkillInjectionResult extends SkillInjectionTargetResult {
sourceDir: string
targets: SkillInjectionTargetResult[]
}
export class HermesSkillInjector {
private readonly targetDirs: string[]
constructor(
private readonly sourceDir = HermesSkillInjector.resolveSourceDir(),
private readonly targetDir = join(getActiveProfileDir(), 'skills'),
) {}
targetDirOrDirs: string | string[] = HermesSkillInjector.resolveTargetDirs(),
) {
const targetDirs = Array.isArray(targetDirOrDirs) ? targetDirOrDirs : [targetDirOrDirs]
this.targetDirs = [...new Set(targetDirs.map(targetDir => resolve(targetDir)))]
}
static resolveSourceDir(env: NodeJS.ProcessEnv = process.env, baseDir = __dirname): string {
const override = env.HERMES_WEB_UI_SKILLS_DIR?.trim()
@@ -34,13 +44,52 @@ export class HermesSkillInjector {
return candidates.find(candidate => existsSync(candidate)) || candidates[0]
}
static resolveTargetDirs(rootDir = detectHermesRootHome()): string[] {
const root = resolve(rootDir)
const targetDirs = [join(root, 'skills')]
const profilesDir = join(root, 'profiles')
try {
const entries = readdirSync(profilesDir, { withFileTypes: true })
.sort((a, b) => a.name.localeCompare(b.name))
for (const entry of entries) {
if (entry.isDirectory() && entry.name.trim() && !entry.name.startsWith('.')) {
targetDirs.push(join(profilesDir, entry.name, 'skills'))
}
}
} catch { /* no named profiles */ }
return [...new Set(targetDirs.map(targetDir => resolve(targetDir)))]
}
static resolveTargetDirForProfile(profile: string, rootDir = detectHermesRootHome()): string {
const name = String(profile || '').trim()
const root = resolve(rootDir)
if (!name || name === 'default') return join(root, 'skills')
return join(root, 'profiles', name, 'skills')
}
private static profileForTargetDir(targetDir: string, rootDir = detectHermesRootHome()): string {
const root = resolve(rootDir)
const target = resolve(targetDir)
if (target === resolve(join(root, 'skills'))) return 'default'
const profilesRoot = resolve(join(root, 'profiles'))
const relativeToProfiles = target.startsWith(profilesRoot)
? target.slice(profilesRoot.length).replace(/^[/\\]+/, '')
: ''
const [profileName, skillsSegment] = relativeToProfiles.split(/[/\\]+/)
return profileName && skillsSegment === 'skills' ? profileName : 'unknown'
}
async injectMissingSkills(): Promise<SkillInjectionResult> {
const result: SkillInjectionResult = {
sourceDir: this.sourceDir,
targetDir: this.targetDir,
targetDir: this.targetDirs[0] || '',
injected: [],
updated: [],
skipped: [],
targets: [],
}
if (!await this.isDirectory(this.sourceDir)) {
@@ -48,12 +97,53 @@ export class HermesSkillInjector {
return result
}
await mkdir(this.targetDir, { recursive: true })
const entries = await readdir(this.sourceDir, { withFileTypes: true })
const bundledSkillNames = entries
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
.map(entry => entry.name)
logger.info({
sourceDir: this.sourceDir,
targetDirs: this.targetDirs,
targetCount: this.targetDirs.length,
bundledSkillNames,
}, '[skill-injector] syncing bundled skills across profiles')
for (const targetDir of this.targetDirs) {
const targetResult = await this.injectIntoTarget(targetDir, entries)
result.targets.push(targetResult)
result.injected.push(...targetResult.injected)
result.updated.push(...targetResult.updated)
result.skipped.push(...targetResult.skipped)
}
logger.info({
sourceDir: this.sourceDir,
targetCount: result.targets.length,
injected: [...new Set(result.injected)],
updated: [...new Set(result.updated)],
skipped: [...new Set(result.skipped)],
targets: result.targets,
}, '[skill-injector] completed bundled skills sync')
return result
}
private async injectIntoTarget(targetDir: string, entries: import('fs').Dirent[]): Promise<SkillInjectionTargetResult> {
const profile = HermesSkillInjector.profileForTargetDir(targetDir)
const result: SkillInjectionTargetResult = {
profile,
targetDir,
injected: [],
updated: [],
skipped: [],
}
await mkdir(targetDir, { recursive: true })
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
const sourceSkillDir = join(this.sourceDir, entry.name)
const targetSkillDir = join(this.targetDir, entry.name)
const targetSkillDir = join(targetDir, entry.name)
const existed = existsSync(targetSkillDir)
if (existsSync(targetSkillDir)) {
await rm(targetSkillDir, { recursive: true, force: true })
@@ -65,9 +155,10 @@ export class HermesSkillInjector {
if (result.injected.length > 0 || result.updated.length > 0) {
logger.info({
profile,
injected: result.injected,
updated: result.updated,
targetDir: this.targetDir,
targetDir,
}, '[skill-injector] synced bundled skills')
}
return result