From fa498ef96df83a05b52a6618f5f4b9efefee5d7c Mon Sep 17 00:00:00 2001 From: ekko Date: Fri, 22 May 2026 10:37:20 +0800 Subject: [PATCH] fix: sync bundled skills across profiles --- docs/openapi.json | 358 ++++++++++++------ .../server/src/controllers/hermes/profiles.ts | 23 ++ packages/server/src/index.ts | 10 +- .../src/services/hermes/skill-injector.ts | 111 +++++- tests/server/skill-injector.test.ts | 37 ++ 5 files changed, 419 insertions(+), 120 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index 4816f366..e692e240 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -36,10 +36,6 @@ "name": "Files", "description": "Hermes file browser" }, - { - "name": "Gateways", - "description": "Gateway process management" - }, { "name": "Group Chat", "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": { "get": { "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": { "post": { "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}": { "get": { "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": { "post": { "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": { "post": { "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": { "get": { "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": { "post": { "tags": [ diff --git a/packages/server/src/controllers/hermes/profiles.ts b/packages/server/src/controllers/hermes/profiles.ts index ac92d134..5657d367 100644 --- a/packages/server/src/controllers/hermes/profiles.ts +++ b/packages/server/src/controllers/hermes/profiles.ts @@ -14,6 +14,7 @@ import { logger } from '../../services/logger' import { smartCloneCleanup } from '../../services/hermes/profile-credentials' import { detectHermesRootHome } from '../../services/hermes/hermes-path' import { getActiveProfileName } from '../../services/hermes/hermes-profile' +import { HermesSkillInjector } from '../../services/hermes/skill-injector' import type { HermesProfile } from '../../services/hermes/hermes-cli' 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)) } +async function injectBundledSkillsForProfile(name: string): Promise { + 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 { if (!isForbiddenProfileName(name)) return false const base = detectHermesRootHome() @@ -353,6 +372,8 @@ export async function create(ctx: any) { } } + await injectBundledSkillsForProfile(name) + ctx.body = { success: true, message: output.trim(), @@ -625,6 +646,8 @@ export async function switchProfile(ctx: any) { logger.error(err, 'Ensure config failed') } + await injectBundledSkillsForProfile(name) + // TODO: re-enable pending session delete drain after confirming safety // const drainResult = await SessionDeleter.getInstance().drain(name) SessionDeleter.getInstance().switchProfile(name) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8e9b4ecc..bb985e18 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -92,10 +92,16 @@ export async function bootstrap() { const skillInjector = new HermesSkillInjector() const injectionResult = await skillInjector.injectMissingSkills() 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) { - 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) { logger.warn(err, '[bootstrap] failed to inject bundled skills') diff --git a/packages/server/src/services/hermes/skill-injector.ts b/packages/server/src/services/hermes/skill-injector.ts index 6052bf4c..bc094896 100644 --- a/packages/server/src/services/hermes/skill-injector.ts +++ b/packages/server/src/services/hermes/skill-injector.ts @@ -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 { 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 { + 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 diff --git a/tests/server/skill-injector.test.ts b/tests/server/skill-injector.test.ts index 32684caf..fdef5e5f 100644 --- a/tests/server/skill-injector.test.ts +++ b/tests/server/skill-injector.test.ts @@ -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', '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') + }) })