fix: sync bundled skills across profiles (#926)
This commit is contained in:
+250
-108
@@ -36,10 +36,6 @@
|
|||||||
"name": "Files",
|
"name": "Files",
|
||||||
"description": "Hermes file browser"
|
"description": "Hermes file browser"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Gateways",
|
|
||||||
"description": "Gateway process management"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Group Chat",
|
"name": "Group Chat",
|
||||||
"description": "Group chat management"
|
"description": "Group chat management"
|
||||||
@@ -875,6 +871,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/hermes/custom-model": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"Models"
|
||||||
|
],
|
||||||
|
"summary": "Update custom-model",
|
||||||
|
"description": "PUT /api/hermes/custom-model",
|
||||||
|
"operationId": "addCustomModel",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/components/responses/BadRequest"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"Models"
|
||||||
|
],
|
||||||
|
"summary": "Delete custom-model",
|
||||||
|
"description": "DELETE /api/hermes/custom-model",
|
||||||
|
"operationId": "removeCustomModel",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/hermes/download": {
|
"/api/hermes/download": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1132,110 +1175,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/hermes/gateways": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Gateways"
|
|
||||||
],
|
|
||||||
"summary": "List gateways",
|
|
||||||
"description": "GET /api/hermes/gateways",
|
|
||||||
"operationId": "list",
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"BearerAuth": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Success"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/Unauthorized"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not found"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/hermes/gateways/{name}/health": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Gateways"
|
|
||||||
],
|
|
||||||
"summary": "Get health",
|
|
||||||
"description": "GET /api/hermes/gateways/:name/health",
|
|
||||||
"operationId": "health",
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"BearerAuth": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Success"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/Unauthorized"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not found"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/hermes/gateways/{name}/start": {
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"Gateways"
|
|
||||||
],
|
|
||||||
"summary": "Create start",
|
|
||||||
"description": "POST /api/hermes/gateways/:name/start",
|
|
||||||
"operationId": "start",
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"BearerAuth": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Success"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"$ref": "#/components/responses/BadRequest"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/Unauthorized"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/hermes/gateways/{name}/stop": {
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"Gateways"
|
|
||||||
],
|
|
||||||
"summary": "Create stop",
|
|
||||||
"description": "POST /api/hermes/gateways/:name/stop",
|
|
||||||
"operationId": "stop",
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"BearerAuth": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Success"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"$ref": "#/components/responses/BadRequest"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"$ref": "#/components/responses/Unauthorized"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/hermes/group-chat/rooms": {
|
"/api/hermes/group-chat/rooms": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2117,6 +2056,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/hermes/profiles/runtime-statuses": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Profiles"
|
||||||
|
],
|
||||||
|
"summary": "Get runtime-statuses",
|
||||||
|
"description": "GET /api/hermes/profiles/runtime-statuses",
|
||||||
|
"operationId": "runtimeStatuses",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/hermes/profiles/{name}": {
|
"/api/hermes/profiles/{name}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2164,6 +2129,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/hermes/profiles/{name}/avatar": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"Profiles"
|
||||||
|
],
|
||||||
|
"summary": "Update avatar",
|
||||||
|
"description": "PUT /api/hermes/profiles/:name/avatar",
|
||||||
|
"operationId": "updateAvatar",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/components/responses/BadRequest"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"Profiles"
|
||||||
|
],
|
||||||
|
"summary": "Delete avatar",
|
||||||
|
"description": "DELETE /api/hermes/profiles/:name/avatar",
|
||||||
|
"operationId": "deleteAvatar",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/hermes/profiles/{name}/export": {
|
"/api/hermes/profiles/{name}/export": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2190,6 +2202,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/hermes/profiles/{name}/gateway/restart": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Profiles"
|
||||||
|
],
|
||||||
|
"summary": "Create restart",
|
||||||
|
"description": "POST /api/hermes/profiles/:name/gateway/restart",
|
||||||
|
"operationId": "restartGatewayForProfile",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/components/responses/BadRequest"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/hermes/profiles/{name}/rename": {
|
"/api/hermes/profiles/{name}/rename": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2216,6 +2254,84 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/hermes/profiles/{name}/restart": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Profiles"
|
||||||
|
],
|
||||||
|
"summary": "Create restart",
|
||||||
|
"description": "POST /api/hermes/profiles/:name/restart",
|
||||||
|
"operationId": "restartProfileRuntime",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/components/responses/BadRequest"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/hermes/profiles/{name}/runtime-status": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Profiles"
|
||||||
|
],
|
||||||
|
"summary": "Get runtime-status",
|
||||||
|
"description": "GET /api/hermes/profiles/:name/runtime-status",
|
||||||
|
"operationId": "runtimeStatus",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/hermes/provider-models": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Models"
|
||||||
|
],
|
||||||
|
"summary": "Create provider-models",
|
||||||
|
"description": "POST /api/hermes/provider-models",
|
||||||
|
"operationId": "fetchProviderModelList",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/components/responses/BadRequest"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/hermes/search/sessions": {
|
"/api/hermes/search/sessions": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2575,6 +2691,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/hermes/sessions/{id}/model": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Sessions"
|
||||||
|
],
|
||||||
|
"summary": "Create model",
|
||||||
|
"description": "POST /api/hermes/sessions/:id/model",
|
||||||
|
"operationId": "setModel",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/components/responses/BadRequest"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/hermes/sessions/{id}/rename": {
|
"/api/hermes/sessions/{id}/rename": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { logger } from '../../services/logger'
|
|||||||
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
|
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
|
||||||
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
|
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
|
||||||
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
|
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
|
||||||
|
import { HermesSkillInjector } from '../../services/hermes/skill-injector'
|
||||||
import type { HermesProfile } from '../../services/hermes/hermes-cli'
|
import type { HermesProfile } from '../../services/hermes/hermes-cli'
|
||||||
|
|
||||||
const bridgeCleanupClient = () => new AgentBridgeClient({ connectRetryMs: 0, timeoutMs: 5000 })
|
const bridgeCleanupClient = () => new AgentBridgeClient({ connectRetryMs: 0, timeoutMs: 5000 })
|
||||||
@@ -89,6 +90,24 @@ function profileExistsForManualSwitch(name: string): boolean {
|
|||||||
return existsSync(join(base, 'profiles', name, 'config.yaml')) || existsSync(join(base, 'profiles', name))
|
return existsSync(join(base, 'profiles', name, 'config.yaml')) || existsSync(join(base, 'profiles', name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function injectBundledSkillsForProfile(name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const targetDir = HermesSkillInjector.resolveTargetDirForProfile(name)
|
||||||
|
const result = await new HermesSkillInjector(undefined, targetDir).injectMissingSkills()
|
||||||
|
const target = result.targets[0]
|
||||||
|
if (target && (target.injected.length > 0 || target.updated.length > 0)) {
|
||||||
|
logger.info({
|
||||||
|
profile: name,
|
||||||
|
targetDir,
|
||||||
|
injected: target.injected,
|
||||||
|
updated: target.updated,
|
||||||
|
}, '[profiles] synced bundled skills for profile')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.warn(err, '[profiles] failed to sync bundled skills for profile "%s"', name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function deleteForbiddenProfileFromDisk(name: string): boolean {
|
function deleteForbiddenProfileFromDisk(name: string): boolean {
|
||||||
if (!isForbiddenProfileName(name)) return false
|
if (!isForbiddenProfileName(name)) return false
|
||||||
const base = detectHermesRootHome()
|
const base = detectHermesRootHome()
|
||||||
@@ -353,6 +372,8 @@ export async function create(ctx: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await injectBundledSkillsForProfile(name)
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
message: output.trim(),
|
message: output.trim(),
|
||||||
@@ -625,6 +646,8 @@ export async function switchProfile(ctx: any) {
|
|||||||
logger.error(err, 'Ensure config failed')
|
logger.error(err, 'Ensure config failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await injectBundledSkillsForProfile(name)
|
||||||
|
|
||||||
// TODO: re-enable pending session delete drain after confirming safety
|
// TODO: re-enable pending session delete drain after confirming safety
|
||||||
// const drainResult = await SessionDeleter.getInstance().drain(name)
|
// const drainResult = await SessionDeleter.getInstance().drain(name)
|
||||||
SessionDeleter.getInstance().switchProfile(name)
|
SessionDeleter.getInstance().switchProfile(name)
|
||||||
|
|||||||
@@ -92,10 +92,16 @@ export async function bootstrap() {
|
|||||||
const skillInjector = new HermesSkillInjector()
|
const skillInjector = new HermesSkillInjector()
|
||||||
const injectionResult = await skillInjector.injectMissingSkills()
|
const injectionResult = await skillInjector.injectMissingSkills()
|
||||||
if (injectionResult.injected.length > 0) {
|
if (injectionResult.injected.length > 0) {
|
||||||
console.log('[bootstrap] bundled skills injected:', injectionResult.injected.join(', '))
|
logger.info({
|
||||||
|
injected: [...new Set(injectionResult.injected)],
|
||||||
|
targetCount: injectionResult.targets.length,
|
||||||
|
}, '[bootstrap] bundled skills injected')
|
||||||
}
|
}
|
||||||
if (injectionResult.updated.length > 0) {
|
if (injectionResult.updated.length > 0) {
|
||||||
console.log('[bootstrap] bundled skills updated:', injectionResult.updated.join(', '))
|
logger.info({
|
||||||
|
updated: [...new Set(injectionResult.updated)],
|
||||||
|
targetCount: injectionResult.targets.length,
|
||||||
|
}, '[bootstrap] bundled skills updated')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(err, '[bootstrap] failed to inject bundled skills')
|
logger.warn(err, '[bootstrap] failed to inject bundled skills')
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
import { copyFile, mkdir, readdir, rm, stat } from 'fs/promises'
|
import { copyFile, mkdir, readdir, rm, stat } from 'fs/promises'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync, readdirSync } from 'fs'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { getActiveProfileDir } from './hermes-profile'
|
import { detectHermesRootHome } from './hermes-path'
|
||||||
import { logger } from '../logger'
|
import { logger } from '../logger'
|
||||||
|
|
||||||
export interface SkillInjectionResult {
|
export interface SkillInjectionTargetResult {
|
||||||
sourceDir: string
|
profile?: string
|
||||||
targetDir: string
|
targetDir: string
|
||||||
injected: string[]
|
injected: string[]
|
||||||
updated: string[]
|
updated: string[]
|
||||||
skipped: string[]
|
skipped: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillInjectionResult extends SkillInjectionTargetResult {
|
||||||
|
sourceDir: string
|
||||||
|
targets: SkillInjectionTargetResult[]
|
||||||
|
}
|
||||||
|
|
||||||
export class HermesSkillInjector {
|
export class HermesSkillInjector {
|
||||||
|
private readonly targetDirs: string[]
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sourceDir = HermesSkillInjector.resolveSourceDir(),
|
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 {
|
static resolveSourceDir(env: NodeJS.ProcessEnv = process.env, baseDir = __dirname): string {
|
||||||
const override = env.HERMES_WEB_UI_SKILLS_DIR?.trim()
|
const override = env.HERMES_WEB_UI_SKILLS_DIR?.trim()
|
||||||
@@ -34,13 +44,52 @@ export class HermesSkillInjector {
|
|||||||
return candidates.find(candidate => existsSync(candidate)) || candidates[0]
|
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> {
|
async injectMissingSkills(): Promise<SkillInjectionResult> {
|
||||||
const result: SkillInjectionResult = {
|
const result: SkillInjectionResult = {
|
||||||
sourceDir: this.sourceDir,
|
sourceDir: this.sourceDir,
|
||||||
targetDir: this.targetDir,
|
targetDir: this.targetDirs[0] || '',
|
||||||
injected: [],
|
injected: [],
|
||||||
updated: [],
|
updated: [],
|
||||||
skipped: [],
|
skipped: [],
|
||||||
|
targets: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await this.isDirectory(this.sourceDir)) {
|
if (!await this.isDirectory(this.sourceDir)) {
|
||||||
@@ -48,12 +97,53 @@ export class HermesSkillInjector {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(this.targetDir, { recursive: true })
|
|
||||||
const entries = await readdir(this.sourceDir, { withFileTypes: 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) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
||||||
const sourceSkillDir = join(this.sourceDir, entry.name)
|
const sourceSkillDir = join(this.sourceDir, entry.name)
|
||||||
const targetSkillDir = join(this.targetDir, entry.name)
|
const targetSkillDir = join(targetDir, entry.name)
|
||||||
const existed = existsSync(targetSkillDir)
|
const existed = existsSync(targetSkillDir)
|
||||||
if (existsSync(targetSkillDir)) {
|
if (existsSync(targetSkillDir)) {
|
||||||
await rm(targetSkillDir, { recursive: true, force: true })
|
await rm(targetSkillDir, { recursive: true, force: true })
|
||||||
@@ -65,9 +155,10 @@ export class HermesSkillInjector {
|
|||||||
|
|
||||||
if (result.injected.length > 0 || result.updated.length > 0) {
|
if (result.injected.length > 0 || result.updated.length > 0) {
|
||||||
logger.info({
|
logger.info({
|
||||||
|
profile,
|
||||||
injected: result.injected,
|
injected: result.injected,
|
||||||
updated: result.updated,
|
updated: result.updated,
|
||||||
targetDir: this.targetDir,
|
targetDir,
|
||||||
}, '[skill-injector] synced bundled skills')
|
}, '[skill-injector] synced bundled skills')
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -61,4 +61,41 @@ describe('HermesSkillInjector', () => {
|
|||||||
await expect(readFile(join(hermesHome, 'skills', 'new-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# New Skill\n')
|
await expect(readFile(join(hermesHome, 'skills', 'new-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# New Skill\n')
|
||||||
await expect(readFile(join(hermesHome, 'skills', 'existing-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# Bundled Existing\n')
|
await expect(readFile(join(hermesHome, 'skills', 'existing-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# Bundled Existing\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('syncs bundled skills into default and named profiles only touching bundled names', async () => {
|
||||||
|
const source = await tempDir('hermes-skill-source-')
|
||||||
|
const hermesHome = await tempDir('hermes-skill-home-')
|
||||||
|
process.env.HERMES_HOME = hermesHome
|
||||||
|
|
||||||
|
await mkdir(join(source, 'webui-skill'), { recursive: true })
|
||||||
|
await writeFile(join(source, 'webui-skill', 'SKILL.md'), '# WebUI Skill\n', 'utf-8')
|
||||||
|
|
||||||
|
await mkdir(join(hermesHome, 'skills', 'webui-skill'), { recursive: true })
|
||||||
|
await writeFile(join(hermesHome, 'skills', 'webui-skill', 'SKILL.md'), '# Old WebUI Skill\n', 'utf-8')
|
||||||
|
await mkdir(join(hermesHome, 'skills', 'local-skill'), { recursive: true })
|
||||||
|
await writeFile(join(hermesHome, 'skills', 'local-skill', 'SKILL.md'), '# Local Skill\n', 'utf-8')
|
||||||
|
|
||||||
|
await mkdir(join(hermesHome, 'profiles', 'alpha', 'skills'), { recursive: true })
|
||||||
|
await mkdir(join(hermesHome, 'profiles', 'beta', 'skills', 'webui-skill'), { recursive: true })
|
||||||
|
await writeFile(join(hermesHome, 'profiles', 'beta', 'skills', 'webui-skill', 'SKILL.md'), '# Old Profile Skill\n', 'utf-8')
|
||||||
|
await mkdir(join(hermesHome, 'profiles', 'beta', 'skills', 'profile-local'), { recursive: true })
|
||||||
|
await writeFile(join(hermesHome, 'profiles', 'beta', 'skills', 'profile-local', 'SKILL.md'), '# Profile Local\n', 'utf-8')
|
||||||
|
|
||||||
|
const { HermesSkillInjector } = await import('../../packages/server/src/services/hermes/skill-injector')
|
||||||
|
const result = await new HermesSkillInjector(source).injectMissingSkills()
|
||||||
|
|
||||||
|
expect(result.targets.map(target => target.targetDir)).toEqual([
|
||||||
|
join(hermesHome, 'skills'),
|
||||||
|
join(hermesHome, 'profiles', 'alpha', 'skills'),
|
||||||
|
join(hermesHome, 'profiles', 'beta', 'skills'),
|
||||||
|
])
|
||||||
|
expect(result.injected).toEqual(['webui-skill'])
|
||||||
|
expect(result.updated).toEqual(['webui-skill', 'webui-skill'])
|
||||||
|
|
||||||
|
await expect(readFile(join(hermesHome, 'skills', 'webui-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# WebUI Skill\n')
|
||||||
|
await expect(readFile(join(hermesHome, 'profiles', 'alpha', 'skills', 'webui-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# WebUI Skill\n')
|
||||||
|
await expect(readFile(join(hermesHome, 'profiles', 'beta', 'skills', 'webui-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# WebUI Skill\n')
|
||||||
|
await expect(readFile(join(hermesHome, 'skills', 'local-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# Local Skill\n')
|
||||||
|
await expect(readFile(join(hermesHome, 'profiles', 'beta', 'skills', 'profile-local', 'SKILL.md'), 'utf-8')).resolves.toBe('# Profile Local\n')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user