新增只读 Hermes 插件页 (#592)
* feat: add read-only plugins page * fix: align plugins page i18n and header
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
import { request } from '../client'
|
||||||
|
|
||||||
|
export type PluginConfigStatus = 'enabled' | 'disabled' | 'not-enabled' | 'auto' | 'provider-managed'
|
||||||
|
export type PluginEffectiveStatus = 'enabled' | 'disabled' | 'inactive' | 'auto-active' | 'provider-managed'
|
||||||
|
|
||||||
|
export interface HermesPluginInfo {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
kind: string
|
||||||
|
source: string
|
||||||
|
configStatus: PluginConfigStatus | string
|
||||||
|
effectiveStatus: PluginEffectiveStatus | string
|
||||||
|
version: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
path: string
|
||||||
|
providesTools: string[]
|
||||||
|
providesHooks: string[]
|
||||||
|
requiresEnv: Array<string | Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesPluginsMetadata {
|
||||||
|
hermesAgentRoot: string
|
||||||
|
pythonExecutable: string
|
||||||
|
cwd: string
|
||||||
|
projectPluginsEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesPluginsResponse {
|
||||||
|
plugins: HermesPluginInfo[]
|
||||||
|
warnings: string[]
|
||||||
|
metadata: HermesPluginsMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlugins(): Promise<HermesPluginsResponse> {
|
||||||
|
return request<HermesPluginsResponse>('/api/hermes/plugins')
|
||||||
|
}
|
||||||
@@ -157,6 +157,13 @@ function openChangelog() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{{ t("sidebar.skills") }}</span>
|
<span>{{ t("sidebar.skills") }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" :class="{ active: selectedKey === 'hermes.plugins' }" @click="handleNav('hermes.plugins')">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l2.1-2.1a4 4 0 0 1-5.3 5.3l-7.8 7.8a2.1 2.1 0 0 1-3-3l7.8-7.8a4 4 0 0 1 5.3-5.3l-2.1 2.1z" />
|
||||||
|
<path d="M5 19l1-1" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ t("sidebar.plugins") }}</span>
|
||||||
|
</button>
|
||||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.memory' }" @click="handleNav('hermes.memory')">
|
<button class="nav-item" :class="{ active: selectedKey === 'hermes.memory' }" @click="handleNav('hermes.memory')">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M9 18h6" />
|
<path d="M9 18h6" />
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export default {
|
|||||||
jobs: 'Geplante Aufgaben',
|
jobs: 'Geplante Aufgaben',
|
||||||
models: 'Modelle',
|
models: 'Modelle',
|
||||||
profiles: 'Profile',
|
profiles: 'Profile',
|
||||||
|
plugins: 'Plugins',
|
||||||
skills: 'Fahigkeiten',
|
skills: 'Fahigkeiten',
|
||||||
memory: 'Gedachtnis',
|
memory: 'Gedachtnis',
|
||||||
logs: 'Protokolle',
|
logs: 'Protokolle',
|
||||||
@@ -277,6 +278,74 @@ jobTriggered: 'Job ausgelost',
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
plugins: {
|
||||||
|
title: 'Plugins',
|
||||||
|
refresh: 'Aktualisieren',
|
||||||
|
notice: 'Schreibgeschütztes Inventar erkennbarer Hermes-Plugin-Manifeste. Discovery-Metadaten werden gelesen, ohne Plugin-Code zu laden. Verwaltungsaktionen bleiben in v1 im CLI; Änderungen gelten für neue Hermes-Sitzungen.',
|
||||||
|
loadFailed: 'Plugins konnten nicht geladen werden',
|
||||||
|
commandCopied: 'Befehl kopiert',
|
||||||
|
searchPlaceholder: 'Key, Name, Beschreibung, Pfad suchen...',
|
||||||
|
source: 'Quelle',
|
||||||
|
kind: 'Typ',
|
||||||
|
statusTitle: 'Status',
|
||||||
|
configStatus: 'config: {status}',
|
||||||
|
notAvailable: 'n/a',
|
||||||
|
copyCommand: 'Befehl kopieren',
|
||||||
|
managedElsewhere: 'anderweitig verwaltet',
|
||||||
|
noMatch: 'Keine Plugins passen zu den aktuellen Filtern',
|
||||||
|
enabled: 'aktiviert',
|
||||||
|
disabled: 'deaktiviert',
|
||||||
|
summary: {
|
||||||
|
total: 'Gesamt',
|
||||||
|
active: 'Aktiviert / auto',
|
||||||
|
inactive: 'Inaktiv',
|
||||||
|
disabled: 'Deaktiviert',
|
||||||
|
providerManaged: 'Provider-verwaltet',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
enabled: 'Aktiviert',
|
||||||
|
'auto-active': 'Auto-aktiv',
|
||||||
|
inactive: 'Inaktiv',
|
||||||
|
disabled: 'Deaktiviert',
|
||||||
|
'provider-managed': 'Provider-verwaltet',
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
enabled: 'Per Konfiguration aktiviert',
|
||||||
|
'auto-active': 'Auto-aktiv',
|
||||||
|
inactive: 'Inaktiv',
|
||||||
|
disabled: 'Deaktiviert',
|
||||||
|
'provider-managed': 'Provider-verwaltet',
|
||||||
|
},
|
||||||
|
configStatuses: {
|
||||||
|
enabled: 'aktiviert',
|
||||||
|
disabled: 'deaktiviert',
|
||||||
|
'not-enabled': 'nicht aktiviert',
|
||||||
|
auto: 'auto',
|
||||||
|
'provider-managed': 'provider-verwaltet',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
plugin: 'Plugin',
|
||||||
|
status: 'Status',
|
||||||
|
source: 'Quelle',
|
||||||
|
kind: 'Typ',
|
||||||
|
capabilities: 'Fähigkeiten',
|
||||||
|
path: 'Pfad / Entry Point',
|
||||||
|
cli: 'CLI',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
tools: '{count} Tools',
|
||||||
|
hooks: '{count} Hooks',
|
||||||
|
env: '{count} env',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
agentRoot: 'Agent root',
|
||||||
|
python: 'Python',
|
||||||
|
scanCwd: 'Scan cwd',
|
||||||
|
projectPlugins: 'Projekt-Plugins',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Memory
|
// Memory
|
||||||
memory: {
|
memory: {
|
||||||
title: 'Gedachtnis',
|
title: 'Gedachtnis',
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default {
|
|||||||
models: 'Models',
|
models: 'Models',
|
||||||
profiles: 'Profiles',
|
profiles: 'Profiles',
|
||||||
skills: 'Skills',
|
skills: 'Skills',
|
||||||
|
plugins: 'Plugins',
|
||||||
memory: 'Memory',
|
memory: 'Memory',
|
||||||
logs: 'Logs',
|
logs: 'Logs',
|
||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
@@ -392,6 +393,74 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
plugins: {
|
||||||
|
title: 'Plugins',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
notice: 'Read-only inventory of discoverable Hermes plugin manifests. Discovery metadata is read without loading plugin code. Management actions stay in CLI for v1; changes take effect in new Hermes sessions.',
|
||||||
|
loadFailed: 'Failed to load plugins',
|
||||||
|
commandCopied: 'Command copied',
|
||||||
|
searchPlaceholder: 'Search key, name, description, path...',
|
||||||
|
source: 'Source',
|
||||||
|
kind: 'Kind',
|
||||||
|
statusTitle: 'Status',
|
||||||
|
configStatus: 'config: {status}',
|
||||||
|
notAvailable: 'n/a',
|
||||||
|
copyCommand: 'Copy command',
|
||||||
|
managedElsewhere: 'managed elsewhere',
|
||||||
|
noMatch: 'No plugins match the current filters',
|
||||||
|
enabled: 'enabled',
|
||||||
|
disabled: 'disabled',
|
||||||
|
summary: {
|
||||||
|
total: 'Total',
|
||||||
|
active: 'Enabled / auto',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
providerManaged: 'Provider-managed',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
enabled: 'Enabled',
|
||||||
|
'auto-active': 'Auto-active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
'provider-managed': 'Provider-managed',
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
enabled: 'Enabled by config',
|
||||||
|
'auto-active': 'Auto-active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
'provider-managed': 'Provider-managed',
|
||||||
|
},
|
||||||
|
configStatuses: {
|
||||||
|
enabled: 'enabled',
|
||||||
|
disabled: 'disabled',
|
||||||
|
'not-enabled': 'not enabled',
|
||||||
|
auto: 'auto',
|
||||||
|
'provider-managed': 'provider-managed',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
plugin: 'Plugin',
|
||||||
|
status: 'Status',
|
||||||
|
source: 'Source',
|
||||||
|
kind: 'Kind',
|
||||||
|
capabilities: 'Capabilities',
|
||||||
|
path: 'Path / entrypoint',
|
||||||
|
cli: 'CLI',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
tools: '{count} tools',
|
||||||
|
hooks: '{count} hooks',
|
||||||
|
env: '{count} env',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
agentRoot: 'Agent root',
|
||||||
|
python: 'Python',
|
||||||
|
scanCwd: 'Scan cwd',
|
||||||
|
projectPlugins: 'Project plugins',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Memory
|
// Memory
|
||||||
memory: {
|
memory: {
|
||||||
title: 'Memory',
|
title: 'Memory',
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export default {
|
|||||||
jobs: 'Tareas programadas',
|
jobs: 'Tareas programadas',
|
||||||
models: 'Modelos',
|
models: 'Modelos',
|
||||||
profiles: 'Perfiles',
|
profiles: 'Perfiles',
|
||||||
|
plugins: 'Plugins',
|
||||||
skills: 'Habilidades',
|
skills: 'Habilidades',
|
||||||
memory: 'Memoria',
|
memory: 'Memoria',
|
||||||
logs: 'Registros',
|
logs: 'Registros',
|
||||||
@@ -277,6 +278,74 @@ jobTriggered: 'Job ejecutado',
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
plugins: {
|
||||||
|
title: 'Plugins',
|
||||||
|
refresh: 'Actualizar',
|
||||||
|
notice: 'Inventario de solo lectura de manifests de plugins Hermes detectables. Los metadatos de descubrimiento se leen sin cargar código de plugins. En v1, la gestión permanece en CLI; los cambios se aplican en nuevas sesiones Hermes.',
|
||||||
|
loadFailed: 'No se pudieron cargar los plugins',
|
||||||
|
commandCopied: 'Comando copiado',
|
||||||
|
searchPlaceholder: 'Buscar key, nombre, descripción, ruta...',
|
||||||
|
source: 'Origen',
|
||||||
|
kind: 'Tipo',
|
||||||
|
statusTitle: 'Estado',
|
||||||
|
configStatus: 'config: {status}',
|
||||||
|
notAvailable: 'n/a',
|
||||||
|
copyCommand: 'Copiar comando',
|
||||||
|
managedElsewhere: 'gestionado en otro lugar',
|
||||||
|
noMatch: 'Ningún plugin coincide con los filtros actuales',
|
||||||
|
enabled: 'activado',
|
||||||
|
disabled: 'desactivado',
|
||||||
|
summary: {
|
||||||
|
total: 'Total',
|
||||||
|
active: 'Activado / auto',
|
||||||
|
inactive: 'Inactivo',
|
||||||
|
disabled: 'Desactivado',
|
||||||
|
providerManaged: 'Gestionado por provider',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
enabled: 'Activado',
|
||||||
|
'auto-active': 'Autoactivo',
|
||||||
|
inactive: 'Inactivo',
|
||||||
|
disabled: 'Desactivado',
|
||||||
|
'provider-managed': 'Gestionado por provider',
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
enabled: 'Activado por configuración',
|
||||||
|
'auto-active': 'Autoactivo',
|
||||||
|
inactive: 'Inactivo',
|
||||||
|
disabled: 'Desactivado',
|
||||||
|
'provider-managed': 'Gestionado por provider',
|
||||||
|
},
|
||||||
|
configStatuses: {
|
||||||
|
enabled: 'activado',
|
||||||
|
disabled: 'desactivado',
|
||||||
|
'not-enabled': 'no activado',
|
||||||
|
auto: 'auto',
|
||||||
|
'provider-managed': 'gestionado por provider',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
plugin: 'Plugin',
|
||||||
|
status: 'Estado',
|
||||||
|
source: 'Origen',
|
||||||
|
kind: 'Tipo',
|
||||||
|
capabilities: 'Capacidades',
|
||||||
|
path: 'Ruta / entrypoint',
|
||||||
|
cli: 'CLI',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
tools: '{count} herramientas',
|
||||||
|
hooks: '{count} hooks',
|
||||||
|
env: '{count} env',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
agentRoot: 'Agent root',
|
||||||
|
python: 'Python',
|
||||||
|
scanCwd: 'Scan cwd',
|
||||||
|
projectPlugins: 'Plugins del proyecto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Memory
|
// Memory
|
||||||
memory: {
|
memory: {
|
||||||
title: 'Memoria',
|
title: 'Memoria',
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export default {
|
|||||||
jobs: 'Taches planifiees',
|
jobs: 'Taches planifiees',
|
||||||
models: 'Modeles',
|
models: 'Modeles',
|
||||||
profiles: 'Profils',
|
profiles: 'Profils',
|
||||||
|
plugins: 'Plugins',
|
||||||
skills: 'Competences',
|
skills: 'Competences',
|
||||||
memory: 'Memoire',
|
memory: 'Memoire',
|
||||||
logs: 'Journaux',
|
logs: 'Journaux',
|
||||||
@@ -277,6 +278,74 @@ jobTriggered: 'Job declenche',
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
plugins: {
|
||||||
|
title: 'Plugins',
|
||||||
|
refresh: 'Actualiser',
|
||||||
|
notice: 'Inventaire en lecture seule des manifests de plugins Hermes détectables. Les métadonnées de découverte sont lues sans charger le code des plugins. En v1, la gestion reste dans le CLI; les changements prennent effet dans les nouvelles sessions Hermes.',
|
||||||
|
loadFailed: 'Échec du chargement des plugins',
|
||||||
|
commandCopied: 'Commande copiée',
|
||||||
|
searchPlaceholder: 'Rechercher key, nom, description, chemin...',
|
||||||
|
source: 'Source',
|
||||||
|
kind: 'Type',
|
||||||
|
statusTitle: 'Statut',
|
||||||
|
configStatus: 'config : {status}',
|
||||||
|
notAvailable: 'n/a',
|
||||||
|
copyCommand: 'Copier la commande',
|
||||||
|
managedElsewhere: 'géré ailleurs',
|
||||||
|
noMatch: 'Aucun plugin ne correspond aux filtres actuels',
|
||||||
|
enabled: 'activé',
|
||||||
|
disabled: 'désactivé',
|
||||||
|
summary: {
|
||||||
|
total: 'Total',
|
||||||
|
active: 'Activé / auto',
|
||||||
|
inactive: 'Inactif',
|
||||||
|
disabled: 'Désactivé',
|
||||||
|
providerManaged: 'Géré par provider',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
enabled: 'Activé',
|
||||||
|
'auto-active': 'Auto-actif',
|
||||||
|
inactive: 'Inactif',
|
||||||
|
disabled: 'Désactivé',
|
||||||
|
'provider-managed': 'Géré par provider',
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
enabled: 'Activé par configuration',
|
||||||
|
'auto-active': 'Auto-actif',
|
||||||
|
inactive: 'Inactif',
|
||||||
|
disabled: 'Désactivé',
|
||||||
|
'provider-managed': 'Géré par provider',
|
||||||
|
},
|
||||||
|
configStatuses: {
|
||||||
|
enabled: 'activé',
|
||||||
|
disabled: 'désactivé',
|
||||||
|
'not-enabled': 'non activé',
|
||||||
|
auto: 'auto',
|
||||||
|
'provider-managed': 'géré par provider',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
plugin: 'Plugin',
|
||||||
|
status: 'Statut',
|
||||||
|
source: 'Source',
|
||||||
|
kind: 'Type',
|
||||||
|
capabilities: 'Capacités',
|
||||||
|
path: 'Chemin / entrypoint',
|
||||||
|
cli: 'CLI',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
tools: '{count} outils',
|
||||||
|
hooks: '{count} hooks',
|
||||||
|
env: '{count} env',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
agentRoot: 'Agent root',
|
||||||
|
python: 'Python',
|
||||||
|
scanCwd: 'Scan cwd',
|
||||||
|
projectPlugins: 'Plugins du projet',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Memory
|
// Memory
|
||||||
memory: {
|
memory: {
|
||||||
title: 'Memoire',
|
title: 'Memoire',
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export default {
|
|||||||
jobs: 'ジョブ',
|
jobs: 'ジョブ',
|
||||||
models: 'モデル',
|
models: 'モデル',
|
||||||
profiles: 'プロファイル',
|
profiles: 'プロファイル',
|
||||||
|
plugins: 'プラグイン',
|
||||||
skills: 'スキル',
|
skills: 'スキル',
|
||||||
memory: 'メモリ',
|
memory: 'メモリ',
|
||||||
logs: 'ログ',
|
logs: 'ログ',
|
||||||
@@ -277,6 +278,74 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// プラグイン
|
||||||
|
plugins: {
|
||||||
|
title: 'プラグイン',
|
||||||
|
refresh: '更新',
|
||||||
|
notice: '検出可能な Hermes プラグイン manifest の読み取り専用インベントリです。検出メタデータはプラグインコードを読み込まずに取得します。v1 の管理操作は CLI のままで、変更は新しい Hermes セッションで有効になります。',
|
||||||
|
loadFailed: 'プラグインの読み込みに失敗しました',
|
||||||
|
commandCopied: 'コマンドをコピーしました',
|
||||||
|
searchPlaceholder: 'key、名前、説明、パスを検索...',
|
||||||
|
source: 'ソース',
|
||||||
|
kind: '種類',
|
||||||
|
statusTitle: 'ステータス',
|
||||||
|
configStatus: 'config: {status}',
|
||||||
|
notAvailable: 'n/a',
|
||||||
|
copyCommand: 'コマンドをコピー',
|
||||||
|
managedElsewhere: '他で管理されています',
|
||||||
|
noMatch: '現在のフィルターに一致するプラグインはありません',
|
||||||
|
enabled: '有効',
|
||||||
|
disabled: '無効',
|
||||||
|
summary: {
|
||||||
|
total: '合計',
|
||||||
|
active: '有効 / 自動',
|
||||||
|
inactive: '非アクティブ',
|
||||||
|
disabled: '無効',
|
||||||
|
providerManaged: 'Provider 管理',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
enabled: '有効',
|
||||||
|
'auto-active': '自動有効',
|
||||||
|
inactive: '非アクティブ',
|
||||||
|
disabled: '無効',
|
||||||
|
'provider-managed': 'Provider 管理',
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
enabled: '設定で有効',
|
||||||
|
'auto-active': '自動有効',
|
||||||
|
inactive: '非アクティブ',
|
||||||
|
disabled: '無効',
|
||||||
|
'provider-managed': 'Provider 管理',
|
||||||
|
},
|
||||||
|
configStatuses: {
|
||||||
|
enabled: '有効',
|
||||||
|
disabled: '無効',
|
||||||
|
'not-enabled': '未有効',
|
||||||
|
auto: '自動',
|
||||||
|
'provider-managed': 'Provider 管理',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
plugin: 'プラグイン',
|
||||||
|
status: 'ステータス',
|
||||||
|
source: 'ソース',
|
||||||
|
kind: '種類',
|
||||||
|
capabilities: '機能',
|
||||||
|
path: 'パス / entrypoint',
|
||||||
|
cli: 'CLI',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
tools: '{count} tools',
|
||||||
|
hooks: '{count} hooks',
|
||||||
|
env: '{count} env',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
agentRoot: 'Agent root',
|
||||||
|
python: 'Python',
|
||||||
|
scanCwd: 'Scan cwd',
|
||||||
|
projectPlugins: 'プロジェクトプラグイン',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// メモリ
|
// メモリ
|
||||||
memory: {
|
memory: {
|
||||||
title: 'メモリ',
|
title: 'メモリ',
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export default {
|
|||||||
jobs: '예약 작업',
|
jobs: '예약 작업',
|
||||||
models: '모델',
|
models: '모델',
|
||||||
profiles: '프로필',
|
profiles: '프로필',
|
||||||
|
plugins: '플러그인',
|
||||||
skills: '스킬',
|
skills: '스킬',
|
||||||
memory: '메모리',
|
memory: '메모리',
|
||||||
logs: '로그',
|
logs: '로그',
|
||||||
@@ -277,6 +278,74 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 플러그인
|
||||||
|
plugins: {
|
||||||
|
title: '플러그인',
|
||||||
|
refresh: '새로고침',
|
||||||
|
notice: '탐색 가능한 Hermes 플러그인 manifest의 읽기 전용 인벤토리입니다. 탐색 메타데이터는 플러그인 코드를 로드하지 않고 읽습니다. v1의 관리 작업은 CLI에 유지되며, 변경 사항은 새 Hermes 세션에서 적용됩니다.',
|
||||||
|
loadFailed: '플러그인을 불러오지 못했습니다',
|
||||||
|
commandCopied: '명령을 복사했습니다',
|
||||||
|
searchPlaceholder: 'key, 이름, 설명, 경로 검색...',
|
||||||
|
source: '소스',
|
||||||
|
kind: '종류',
|
||||||
|
statusTitle: '상태',
|
||||||
|
configStatus: 'config: {status}',
|
||||||
|
notAvailable: 'n/a',
|
||||||
|
copyCommand: '명령 복사',
|
||||||
|
managedElsewhere: '다른 곳에서 관리됨',
|
||||||
|
noMatch: '현재 필터와 일치하는 플러그인이 없습니다',
|
||||||
|
enabled: '활성화됨',
|
||||||
|
disabled: '비활성화됨',
|
||||||
|
summary: {
|
||||||
|
total: '전체',
|
||||||
|
active: '활성 / 자동',
|
||||||
|
inactive: '비활성',
|
||||||
|
disabled: '비활성화됨',
|
||||||
|
providerManaged: 'Provider 관리',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
enabled: '활성화됨',
|
||||||
|
'auto-active': '자동 활성',
|
||||||
|
inactive: '비활성',
|
||||||
|
disabled: '비활성화됨',
|
||||||
|
'provider-managed': 'Provider 관리',
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
enabled: '설정으로 활성화됨',
|
||||||
|
'auto-active': '자동 활성',
|
||||||
|
inactive: '비활성',
|
||||||
|
disabled: '비활성화됨',
|
||||||
|
'provider-managed': 'Provider 관리',
|
||||||
|
},
|
||||||
|
configStatuses: {
|
||||||
|
enabled: '활성화됨',
|
||||||
|
disabled: '비활성화됨',
|
||||||
|
'not-enabled': '활성화되지 않음',
|
||||||
|
auto: '자동',
|
||||||
|
'provider-managed': 'Provider 관리',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
plugin: '플러그인',
|
||||||
|
status: '상태',
|
||||||
|
source: '소스',
|
||||||
|
kind: '종류',
|
||||||
|
capabilities: '기능',
|
||||||
|
path: '경로 / entrypoint',
|
||||||
|
cli: 'CLI',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
tools: '{count} tools',
|
||||||
|
hooks: '{count} hooks',
|
||||||
|
env: '{count} env',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
agentRoot: 'Agent root',
|
||||||
|
python: 'Python',
|
||||||
|
scanCwd: 'Scan cwd',
|
||||||
|
projectPlugins: '프로젝트 플러그인',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// 메모리
|
// 메모리
|
||||||
memory: {
|
memory: {
|
||||||
title: '메모리',
|
title: '메모리',
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export default {
|
|||||||
jobs: 'Tarefas agendadas',
|
jobs: 'Tarefas agendadas',
|
||||||
models: 'Modelos',
|
models: 'Modelos',
|
||||||
profiles: 'Perfis',
|
profiles: 'Perfis',
|
||||||
|
plugins: 'Plugins',
|
||||||
skills: 'Habilidades',
|
skills: 'Habilidades',
|
||||||
memory: 'Memoria',
|
memory: 'Memoria',
|
||||||
logs: 'Logs',
|
logs: 'Logs',
|
||||||
@@ -277,6 +278,74 @@ jobTriggered: 'Job acionado',
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
plugins: {
|
||||||
|
title: 'Plugins',
|
||||||
|
refresh: 'Atualizar',
|
||||||
|
notice: 'Inventário somente leitura dos manifests de plugins Hermes detectáveis. Os metadados de descoberta são lidos sem carregar código de plugin. No v1, ações de gerenciamento ficam na CLI; mudanças entram em vigor em novas sessões Hermes.',
|
||||||
|
loadFailed: 'Falha ao carregar plugins',
|
||||||
|
commandCopied: 'Comando copiado',
|
||||||
|
searchPlaceholder: 'Buscar key, nome, descrição, caminho...',
|
||||||
|
source: 'Origem',
|
||||||
|
kind: 'Tipo',
|
||||||
|
statusTitle: 'Status',
|
||||||
|
configStatus: 'config: {status}',
|
||||||
|
notAvailable: 'n/a',
|
||||||
|
copyCommand: 'Copiar comando',
|
||||||
|
managedElsewhere: 'gerenciado em outro lugar',
|
||||||
|
noMatch: 'Nenhum plugin corresponde aos filtros atuais',
|
||||||
|
enabled: 'ativado',
|
||||||
|
disabled: 'desativado',
|
||||||
|
summary: {
|
||||||
|
total: 'Total',
|
||||||
|
active: 'Ativado / auto',
|
||||||
|
inactive: 'Inativo',
|
||||||
|
disabled: 'Desativado',
|
||||||
|
providerManaged: 'Gerenciado por provider',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
enabled: 'Ativado',
|
||||||
|
'auto-active': 'Autoativo',
|
||||||
|
inactive: 'Inativo',
|
||||||
|
disabled: 'Desativado',
|
||||||
|
'provider-managed': 'Gerenciado por provider',
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
enabled: 'Ativado por configuração',
|
||||||
|
'auto-active': 'Autoativo',
|
||||||
|
inactive: 'Inativo',
|
||||||
|
disabled: 'Desativado',
|
||||||
|
'provider-managed': 'Gerenciado por provider',
|
||||||
|
},
|
||||||
|
configStatuses: {
|
||||||
|
enabled: 'ativado',
|
||||||
|
disabled: 'desativado',
|
||||||
|
'not-enabled': 'não ativado',
|
||||||
|
auto: 'auto',
|
||||||
|
'provider-managed': 'gerenciado por provider',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
plugin: 'Plugin',
|
||||||
|
status: 'Status',
|
||||||
|
source: 'Origem',
|
||||||
|
kind: 'Tipo',
|
||||||
|
capabilities: 'Capacidades',
|
||||||
|
path: 'Caminho / entrypoint',
|
||||||
|
cli: 'CLI',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
tools: '{count} ferramentas',
|
||||||
|
hooks: '{count} hooks',
|
||||||
|
env: '{count} env',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
agentRoot: 'Agent root',
|
||||||
|
python: 'Python',
|
||||||
|
scanCwd: 'Scan cwd',
|
||||||
|
projectPlugins: 'Plugins do projeto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Memory
|
// Memory
|
||||||
memory: {
|
memory: {
|
||||||
title: 'Memoria',
|
title: 'Memoria',
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export default {
|
|||||||
kanban: '看板',
|
kanban: '看板',
|
||||||
models: '模型',
|
models: '模型',
|
||||||
profiles: '用户',
|
profiles: '用户',
|
||||||
|
plugins: '插件',
|
||||||
skills: '技能',
|
skills: '技能',
|
||||||
memory: '记忆',
|
memory: '记忆',
|
||||||
logs: '日志',
|
logs: '日志',
|
||||||
@@ -392,6 +393,74 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 插件
|
||||||
|
plugins: {
|
||||||
|
title: '插件',
|
||||||
|
refresh: '刷新',
|
||||||
|
notice: '只读展示可发现的 Hermes 插件 manifest。发现元数据读取不会加载插件代码。v1 管理动作仍保留在 CLI,新 Hermes 会话生效。',
|
||||||
|
loadFailed: '加载插件失败',
|
||||||
|
commandCopied: '命令已复制',
|
||||||
|
searchPlaceholder: '搜索 key、名称、描述、路径...',
|
||||||
|
source: '来源',
|
||||||
|
kind: '类型',
|
||||||
|
statusTitle: '状态',
|
||||||
|
configStatus: '配置:{status}',
|
||||||
|
notAvailable: '无',
|
||||||
|
copyCommand: '复制命令',
|
||||||
|
managedElsewhere: '由其他位置管理',
|
||||||
|
noMatch: '没有匹配当前筛选条件的插件',
|
||||||
|
enabled: '已启用',
|
||||||
|
disabled: '已禁用',
|
||||||
|
summary: {
|
||||||
|
total: '总数',
|
||||||
|
active: '已启用 / 自动',
|
||||||
|
inactive: '未启用',
|
||||||
|
disabled: '已禁用',
|
||||||
|
providerManaged: 'Provider 管理',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
enabled: '已启用',
|
||||||
|
'auto-active': '自动启用',
|
||||||
|
inactive: '未启用',
|
||||||
|
disabled: '已禁用',
|
||||||
|
'provider-managed': 'Provider 管理',
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
enabled: '配置启用',
|
||||||
|
'auto-active': '自动启用',
|
||||||
|
inactive: '未启用',
|
||||||
|
disabled: '已禁用',
|
||||||
|
'provider-managed': 'Provider 管理',
|
||||||
|
},
|
||||||
|
configStatuses: {
|
||||||
|
enabled: '已启用',
|
||||||
|
disabled: '已禁用',
|
||||||
|
'not-enabled': '未启用',
|
||||||
|
auto: '自动',
|
||||||
|
'provider-managed': 'Provider 管理',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
plugin: '插件',
|
||||||
|
status: '状态',
|
||||||
|
source: '来源',
|
||||||
|
kind: '类型',
|
||||||
|
capabilities: '能力',
|
||||||
|
path: '路径 / 入口',
|
||||||
|
cli: 'CLI',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
tools: '{count} 个工具',
|
||||||
|
hooks: '{count} 个 hook',
|
||||||
|
env: '{count} 个环境变量',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
agentRoot: 'Agent 根目录',
|
||||||
|
python: 'Python',
|
||||||
|
scanCwd: '扫描 cwd',
|
||||||
|
projectPlugins: '项目插件',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// 记忆
|
// 记忆
|
||||||
memory: {
|
memory: {
|
||||||
title: '记忆',
|
title: '记忆',
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ const router = createRouter({
|
|||||||
name: 'hermes.skills',
|
name: 'hermes.skills',
|
||||||
component: () => import('@/views/hermes/SkillsView.vue'),
|
component: () => import('@/views/hermes/SkillsView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/hermes/plugins',
|
||||||
|
name: 'hermes.plugins',
|
||||||
|
component: () => import('@/views/hermes/PluginsView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/hermes/memory',
|
path: '/hermes/memory',
|
||||||
name: 'hermes.memory',
|
name: 'hermes.memory',
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { NAlert, NButton, NEmpty, NInput, NSelect, NSpin, NTag, useMessage } from 'naive-ui'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { fetchPlugins, type HermesPluginInfo, type HermesPluginsMetadata } from '@/api/hermes/plugins'
|
||||||
|
|
||||||
|
const { t, te } = useI18n()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const plugins = ref<HermesPluginInfo[]>([])
|
||||||
|
const warnings = ref<string[]>([])
|
||||||
|
const metadata = ref<HermesPluginsMetadata | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const sourceFilter = ref<string | null>(null)
|
||||||
|
const kindFilter = ref<string | null>(null)
|
||||||
|
const statusFilter = ref<string | null>(null)
|
||||||
|
|
||||||
|
const statusValues = ['enabled', 'auto-active', 'inactive', 'disabled', 'provider-managed'] as const
|
||||||
|
const statusOptions = computed(() => statusValues.map(value => ({
|
||||||
|
label: t(`plugins.status.${value}`),
|
||||||
|
value,
|
||||||
|
})))
|
||||||
|
|
||||||
|
const sourceOptions = computed(() => toOptions(plugins.value.map(p => p.source)))
|
||||||
|
const kindOptions = computed(() => toOptions(plugins.value.map(p => p.kind)))
|
||||||
|
|
||||||
|
const summary = computed(() => ({
|
||||||
|
total: plugins.value.length,
|
||||||
|
active: plugins.value.filter(p => p.effectiveStatus === 'enabled' || p.effectiveStatus === 'auto-active').length,
|
||||||
|
inactive: plugins.value.filter(p => p.effectiveStatus === 'inactive').length,
|
||||||
|
disabled: plugins.value.filter(p => p.effectiveStatus === 'disabled').length,
|
||||||
|
providerManaged: plugins.value.filter(p => p.effectiveStatus === 'provider-managed').length,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const filteredPlugins = computed(() => {
|
||||||
|
const query = searchQuery.value.trim().toLowerCase()
|
||||||
|
return plugins.value.filter((plugin) => {
|
||||||
|
if (sourceFilter.value && plugin.source !== sourceFilter.value) return false
|
||||||
|
if (kindFilter.value && plugin.kind !== kindFilter.value) return false
|
||||||
|
if (statusFilter.value && plugin.effectiveStatus !== statusFilter.value) return false
|
||||||
|
if (!query) return true
|
||||||
|
return [plugin.key, plugin.name, plugin.description, plugin.path, plugin.source, plugin.kind]
|
||||||
|
.some(value => String(value || '').toLowerCase().includes(query))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function toOptions(values: string[]) {
|
||||||
|
return Array.from(new Set(values.filter(Boolean))).sort((a, b) => a.localeCompare(b)).map(value => ({
|
||||||
|
label: value,
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlugins() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const data = await fetchPlugins()
|
||||||
|
plugins.value = data.plugins ?? []
|
||||||
|
warnings.value = data.warnings ?? []
|
||||||
|
metadata.value = data.metadata ?? null
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.message || t('plugins.loadFailed')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(plugin: HermesPluginInfo) {
|
||||||
|
const key = `plugins.statusLabel.${plugin.effectiveStatus}`
|
||||||
|
return te(key) ? t(key) : plugin.effectiveStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
function configStatusLabel(plugin: HermesPluginInfo) {
|
||||||
|
const key = `plugins.configStatuses.${plugin.configStatus}`
|
||||||
|
return te(key) ? t(key) : plugin.configStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTagType(plugin: HermesPluginInfo): 'success' | 'warning' | 'error' | 'info' | 'default' {
|
||||||
|
switch (plugin.effectiveStatus) {
|
||||||
|
case 'enabled':
|
||||||
|
case 'auto-active':
|
||||||
|
return 'success'
|
||||||
|
case 'disabled':
|
||||||
|
return 'error'
|
||||||
|
case 'provider-managed':
|
||||||
|
return 'info'
|
||||||
|
default:
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginCommand(plugin: HermesPluginInfo) {
|
||||||
|
const escapedKey = plugin.key.replace(/'/g, `'\\''`)
|
||||||
|
if (plugin.effectiveStatus === 'disabled' || plugin.effectiveStatus === 'inactive') {
|
||||||
|
return `hermes plugins enable '${escapedKey}'`
|
||||||
|
}
|
||||||
|
if (plugin.effectiveStatus === 'enabled') {
|
||||||
|
return `hermes plugins disable '${escapedKey}'`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCommand(plugin: HermesPluginInfo) {
|
||||||
|
const command = pluginCommand(plugin)
|
||||||
|
if (!command) return
|
||||||
|
await navigator.clipboard.writeText(command)
|
||||||
|
message.success(t('plugins.commandCopied'))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadPlugins)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="plugins-view">
|
||||||
|
<header class="page-header">
|
||||||
|
<h2 class="header-title">{{ t('plugins.title') }}</h2>
|
||||||
|
<NButton size="small" quaternary :loading="loading" @click="loadPlugins">
|
||||||
|
{{ t('plugins.refresh') }}
|
||||||
|
</NButton>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="plugins-content">
|
||||||
|
<NAlert type="info" :bordered="false" class="plugins-notice">
|
||||||
|
{{ t('plugins.notice') }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NAlert v-if="error" type="error" class="plugins-notice">
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NAlert v-for="warning in warnings" :key="warning" type="warning" class="plugins-notice">
|
||||||
|
{{ warning }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-card">
|
||||||
|
<span class="summary-label">{{ t('plugins.summary.total') }}</span>
|
||||||
|
<strong>{{ summary.total }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card success">
|
||||||
|
<span class="summary-label">{{ t('plugins.summary.active') }}</span>
|
||||||
|
<strong>{{ summary.active }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card warning">
|
||||||
|
<span class="summary-label">{{ t('plugins.summary.inactive') }}</span>
|
||||||
|
<strong>{{ summary.inactive }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card error">
|
||||||
|
<span class="summary-label">{{ t('plugins.summary.disabled') }}</span>
|
||||||
|
<strong>{{ summary.disabled }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card info">
|
||||||
|
<span class="summary-label">{{ t('plugins.summary.providerManaged') }}</span>
|
||||||
|
<strong>{{ summary.providerManaged }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-row">
|
||||||
|
<NInput v-model:value="searchQuery" :placeholder="t('plugins.searchPlaceholder')" clearable />
|
||||||
|
<NSelect v-model:value="sourceFilter" :options="sourceOptions" :placeholder="t('plugins.source')" clearable />
|
||||||
|
<NSelect v-model:value="kindFilter" :options="kindOptions" :placeholder="t('plugins.kind')" clearable />
|
||||||
|
<NSelect v-model:value="statusFilter" :options="statusOptions" :placeholder="t('plugins.statusTitle')" clearable />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NSpin :show="loading && plugins.length === 0">
|
||||||
|
<div v-if="filteredPlugins.length" class="plugins-table-wrap">
|
||||||
|
<table class="plugins-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('plugins.table.plugin') }}</th>
|
||||||
|
<th>{{ t('plugins.table.status') }}</th>
|
||||||
|
<th>{{ t('plugins.table.source') }}</th>
|
||||||
|
<th>{{ t('plugins.table.kind') }}</th>
|
||||||
|
<th>{{ t('plugins.table.capabilities') }}</th>
|
||||||
|
<th>{{ t('plugins.table.path') }}</th>
|
||||||
|
<th>{{ t('plugins.table.cli') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="plugin in filteredPlugins" :key="plugin.key">
|
||||||
|
<td>
|
||||||
|
<div class="plugin-name">
|
||||||
|
<strong>{{ plugin.key }}</strong>
|
||||||
|
<span v-if="plugin.name !== plugin.key">{{ plugin.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="plugin.description" class="description">{{ plugin.description }}</div>
|
||||||
|
<div v-if="plugin.version || plugin.author" class="meta-line">
|
||||||
|
<span v-if="plugin.version">v{{ plugin.version }}</span>
|
||||||
|
<span v-if="plugin.author">{{ plugin.author }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<NTag size="small" :type="statusTagType(plugin)">{{ statusLabel(plugin) }}</NTag>
|
||||||
|
<div class="config-status">{{ t('plugins.configStatus', { status: configStatusLabel(plugin) }) }}</div>
|
||||||
|
</td>
|
||||||
|
<td><NTag size="small" round>{{ plugin.source }}</NTag></td>
|
||||||
|
<td><NTag size="small" round>{{ plugin.kind }}</NTag></td>
|
||||||
|
<td>
|
||||||
|
<div class="capability-list">
|
||||||
|
<span>{{ t('plugins.capabilities.tools', { count: plugin.providesTools.length }) }}</span>
|
||||||
|
<span>{{ t('plugins.capabilities.hooks', { count: plugin.providesHooks.length }) }}</span>
|
||||||
|
<span>{{ t('plugins.capabilities.env', { count: plugin.requiresEnv.length }) }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><code class="path-cell">{{ plugin.path || t('plugins.notAvailable') }}</code></td>
|
||||||
|
<td>
|
||||||
|
<NButton v-if="pluginCommand(plugin)" size="tiny" secondary @click="copyCommand(plugin)">
|
||||||
|
{{ t('plugins.copyCommand') }}
|
||||||
|
</NButton>
|
||||||
|
<span v-else class="muted">{{ t('plugins.managedElsewhere') }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<NEmpty v-else-if="!loading" :description="t('plugins.noMatch')" />
|
||||||
|
</NSpin>
|
||||||
|
|
||||||
|
<div v-if="metadata" class="metadata-panel">
|
||||||
|
<span>{{ t('plugins.metadata.agentRoot') }}: <code>{{ metadata.hermesAgentRoot }}</code></span>
|
||||||
|
<span>{{ t('plugins.metadata.python') }}: <code>{{ metadata.pythonExecutable }}</code></span>
|
||||||
|
<span>{{ t('plugins.metadata.scanCwd') }}: <code>{{ metadata.cwd }}</code></span>
|
||||||
|
<span>{{ t('plugins.metadata.projectPlugins') }}: <code>{{ metadata.projectPluginsEnabled ? t('plugins.enabled') : t('plugins.disabled') }}</code></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/styles/variables' as *;
|
||||||
|
|
||||||
|
.plugins-view {
|
||||||
|
height: calc(100 * var(--vh));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-notice {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $bg-secondary;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success strong { color: $success; }
|
||||||
|
&.warning strong { color: $warning; }
|
||||||
|
&.error strong { color: $error; }
|
||||||
|
&.info strong { color: $accent-primary; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $text-muted;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 1fr) repeat(3, minmax(140px, 180px));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $bg-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 980px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: rgba(var(--accent-primary-rgb), 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: $text-secondary;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-line,
|
||||||
|
.config-status,
|
||||||
|
.muted {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
color: $text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-cell {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 320px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: $text-muted;
|
||||||
|
background: rgba(var(--accent-primary-rgb), 0.06);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-panel {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 16px;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: $text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.summary-grid,
|
||||||
|
.filter-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { listHermesPlugins } from '../../services/hermes/plugins'
|
||||||
|
|
||||||
|
export async function list(ctx: any) {
|
||||||
|
try {
|
||||||
|
ctx.body = await listHermesPlugins()
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 500
|
||||||
|
ctx.body = { error: err.message || 'Failed to discover Hermes plugins' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Router from '@koa/router'
|
||||||
|
import * as ctrl from '../../controllers/hermes/plugins'
|
||||||
|
|
||||||
|
export const pluginRoutes = new Router()
|
||||||
|
|
||||||
|
pluginRoutes.get('/api/hermes/plugins', ctrl.list)
|
||||||
@@ -11,6 +11,7 @@ import { authPublicRoutes, authProtectedRoutes } from './auth'
|
|||||||
import { sessionRoutes } from './hermes/sessions'
|
import { sessionRoutes } from './hermes/sessions'
|
||||||
import { profileRoutes } from './hermes/profiles'
|
import { profileRoutes } from './hermes/profiles'
|
||||||
import { skillRoutes } from './hermes/skills'
|
import { skillRoutes } from './hermes/skills'
|
||||||
|
import { pluginRoutes } from './hermes/plugins'
|
||||||
import { memoryRoutes } from './hermes/memory'
|
import { memoryRoutes } from './hermes/memory'
|
||||||
import { modelRoutes } from './hermes/models'
|
import { modelRoutes } from './hermes/models'
|
||||||
import { providerRoutes } from './hermes/providers'
|
import { providerRoutes } from './hermes/providers'
|
||||||
@@ -51,6 +52,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
|
|||||||
app.use(sessionRoutes.routes())
|
app.use(sessionRoutes.routes())
|
||||||
app.use(profileRoutes.routes())
|
app.use(profileRoutes.routes())
|
||||||
app.use(skillRoutes.routes())
|
app.use(skillRoutes.routes())
|
||||||
|
app.use(pluginRoutes.routes())
|
||||||
app.use(memoryRoutes.routes())
|
app.use(memoryRoutes.routes())
|
||||||
app.use(modelRoutes.routes())
|
app.use(modelRoutes.routes())
|
||||||
app.use(providerRoutes.routes())
|
app.use(providerRoutes.routes())
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { execFile } from 'child_process'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { dirname, join, resolve } from 'path'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
export type HermesPluginSource = 'bundled' | 'user' | 'project' | 'entrypoint'
|
||||||
|
export type HermesPluginKind = 'standalone' | 'backend' | 'exclusive' | 'platform' | 'model-provider'
|
||||||
|
export type HermesPluginConfigStatus = 'enabled' | 'disabled' | 'not-enabled' | 'auto' | 'provider-managed'
|
||||||
|
export type HermesPluginEffectiveStatus = 'enabled' | 'disabled' | 'inactive' | 'auto-active' | 'provider-managed'
|
||||||
|
|
||||||
|
export interface HermesPluginInfo {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
kind: HermesPluginKind | string
|
||||||
|
source: HermesPluginSource | string
|
||||||
|
configStatus: HermesPluginConfigStatus
|
||||||
|
effectiveStatus: HermesPluginEffectiveStatus
|
||||||
|
version: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
path: string
|
||||||
|
providesTools: string[]
|
||||||
|
providesHooks: string[]
|
||||||
|
requiresEnv: Array<string | Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesPluginsMetadata {
|
||||||
|
hermesAgentRoot: string
|
||||||
|
pythonExecutable: string
|
||||||
|
cwd: string
|
||||||
|
projectPluginsEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesPluginsResponse {
|
||||||
|
plugins: HermesPluginInfo[]
|
||||||
|
warnings: string[]
|
||||||
|
metadata: HermesPluginsMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
const PYTHON_BRIDGE = String.raw`
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
warnings = []
|
||||||
|
agent_root = os.environ.get("HERMES_AGENT_ROOT_RESOLVED", "")
|
||||||
|
|
||||||
|
# python -c normally prepends the process cwd to sys.path. Remove it before any
|
||||||
|
# Hermes imports so an arbitrary WUI launch directory cannot shadow modules like
|
||||||
|
# hermes_cli, hermes_constants, utils, or yaml. The process cwd is still preserved
|
||||||
|
# separately for optional project-plugin scanning below.
|
||||||
|
sys.path = [entry for entry in sys.path if entry not in ("", os.getcwd())]
|
||||||
|
if agent_root:
|
||||||
|
sys.path.insert(0, agent_root)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_cli.plugins import (
|
||||||
|
PluginManager,
|
||||||
|
get_bundled_plugins_dir,
|
||||||
|
_get_disabled_plugins,
|
||||||
|
_get_enabled_plugins,
|
||||||
|
)
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
except Exception as exc:
|
||||||
|
print(json.dumps({
|
||||||
|
"error": "Failed to import Hermes Agent plugin modules",
|
||||||
|
"detail": str(exc),
|
||||||
|
"traceback": traceback.format_exc(),
|
||||||
|
}))
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def env_enabled(name):
|
||||||
|
return os.getenv(name, "").strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_scan(label, fn):
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except Exception as exc:
|
||||||
|
warnings.append(f"{label}: {exc}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_list(value):
|
||||||
|
return value if isinstance(value, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def read_manifest_list(plugin_path, *keys):
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
plugin_dir = Path(plugin_path)
|
||||||
|
manifest_file = plugin_dir / "plugin.yaml"
|
||||||
|
if not manifest_file.exists():
|
||||||
|
manifest_file = plugin_dir / "plugin.yml"
|
||||||
|
if not manifest_file.exists():
|
||||||
|
return []
|
||||||
|
data = yaml.safe_load(manifest_file.read_text(encoding="utf-8")) or {}
|
||||||
|
for key in keys:
|
||||||
|
value = data.get(key)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
return []
|
||||||
|
except Exception as exc:
|
||||||
|
warnings.append(f"manifest metadata at {plugin_path}: {exc}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_list(manifest, attr, *manifest_keys):
|
||||||
|
value = coerce_list(getattr(manifest, attr, []))
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return read_manifest_list(getattr(manifest, "path", ""), *manifest_keys)
|
||||||
|
|
||||||
|
manager = PluginManager()
|
||||||
|
manifests = []
|
||||||
|
|
||||||
|
bundled_root = get_bundled_plugins_dir()
|
||||||
|
manifests.extend(safe_scan(
|
||||||
|
f"bundled plugins at {bundled_root}",
|
||||||
|
lambda: manager._scan_directory(
|
||||||
|
bundled_root,
|
||||||
|
source="bundled",
|
||||||
|
skip_names={"platforms"},
|
||||||
|
),
|
||||||
|
))
|
||||||
|
manifests.extend(safe_scan(
|
||||||
|
f"bundled platform plugins at {bundled_root / 'platforms'}",
|
||||||
|
lambda: manager._scan_directory(bundled_root / "platforms", source="bundled"),
|
||||||
|
))
|
||||||
|
|
||||||
|
user_dir = get_hermes_home() / "plugins"
|
||||||
|
manifests.extend(safe_scan(
|
||||||
|
f"user plugins at {user_dir}",
|
||||||
|
lambda: manager._scan_directory(user_dir, source="user"),
|
||||||
|
))
|
||||||
|
|
||||||
|
project_plugins_enabled = env_enabled("HERMES_ENABLE_PROJECT_PLUGINS")
|
||||||
|
if project_plugins_enabled:
|
||||||
|
project_dir = Path.cwd() / ".hermes" / "plugins"
|
||||||
|
manifests.extend(safe_scan(
|
||||||
|
f"project plugins at {project_dir}",
|
||||||
|
lambda: manager._scan_directory(project_dir, source="project"),
|
||||||
|
))
|
||||||
|
|
||||||
|
manifests.extend(safe_scan(
|
||||||
|
"pip entry-point plugins",
|
||||||
|
lambda: manager._scan_entry_points(),
|
||||||
|
))
|
||||||
|
|
||||||
|
winners = {}
|
||||||
|
for manifest in manifests:
|
||||||
|
key = manifest.key or manifest.name
|
||||||
|
winners[key] = manifest
|
||||||
|
|
||||||
|
disabled = _get_disabled_plugins()
|
||||||
|
enabled = _get_enabled_plugins()
|
||||||
|
enabled_set = enabled if enabled is not None else set()
|
||||||
|
|
||||||
|
plugins = []
|
||||||
|
for key, manifest in sorted(winners.items(), key=lambda item: item[0].lower()):
|
||||||
|
disabled_match = key in disabled or manifest.name in disabled
|
||||||
|
enabled_match = key in enabled_set or manifest.name in enabled_set
|
||||||
|
|
||||||
|
if disabled_match:
|
||||||
|
config_status = "disabled"
|
||||||
|
effective_status = "disabled"
|
||||||
|
elif manifest.kind == "exclusive":
|
||||||
|
config_status = "provider-managed"
|
||||||
|
effective_status = "provider-managed"
|
||||||
|
elif manifest.kind == "model-provider":
|
||||||
|
config_status = "provider-managed"
|
||||||
|
effective_status = "provider-managed"
|
||||||
|
elif manifest.source == "bundled" and manifest.kind in ("backend", "platform"):
|
||||||
|
config_status = "auto"
|
||||||
|
effective_status = "auto-active"
|
||||||
|
elif enabled_match:
|
||||||
|
config_status = "enabled"
|
||||||
|
effective_status = "enabled"
|
||||||
|
else:
|
||||||
|
config_status = "not-enabled"
|
||||||
|
effective_status = "inactive"
|
||||||
|
|
||||||
|
plugins.append({
|
||||||
|
"key": key,
|
||||||
|
"name": manifest.name,
|
||||||
|
"kind": manifest.kind,
|
||||||
|
"source": manifest.source,
|
||||||
|
"configStatus": config_status,
|
||||||
|
"effectiveStatus": effective_status,
|
||||||
|
"version": manifest.version or "",
|
||||||
|
"description": manifest.description or "",
|
||||||
|
"author": manifest.author or "",
|
||||||
|
"path": manifest.path or "",
|
||||||
|
"providesTools": manifest_list(manifest, "provides_tools", "provides_tools", "tools"),
|
||||||
|
"providesHooks": manifest_list(manifest, "provides_hooks", "provides_hooks", "hooks"),
|
||||||
|
"requiresEnv": manifest_list(manifest, "requires_env", "requires_env"),
|
||||||
|
})
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"plugins": plugins,
|
||||||
|
"warnings": warnings,
|
||||||
|
"metadata": {
|
||||||
|
"hermesAgentRoot": os.environ.get("HERMES_AGENT_ROOT_RESOLVED", ""),
|
||||||
|
"pythonExecutable": sys.executable,
|
||||||
|
"cwd": str(Path.cwd()),
|
||||||
|
"projectPluginsEnabled": project_plugins_enabled,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
`
|
||||||
|
|
||||||
|
function hasHermesPluginModule(root: string): boolean {
|
||||||
|
return existsSync(join(root, 'hermes_cli', 'plugins.py'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRootFromHermesBin(): string[] {
|
||||||
|
const hermesBin = process.env.HERMES_BIN?.trim()
|
||||||
|
if (!hermesBin || hermesBin.includes('\n')) return []
|
||||||
|
|
||||||
|
const resolvedBin = resolve(hermesBin)
|
||||||
|
const candidates = [
|
||||||
|
dirname(dirname(dirname(resolvedBin))), // /opt/hermes/.venv/bin/hermes -> /opt/hermes
|
||||||
|
dirname(dirname(resolvedBin)),
|
||||||
|
dirname(resolvedBin),
|
||||||
|
]
|
||||||
|
return candidates.filter((candidate, index) => candidates.indexOf(candidate) === index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHermesAgentRoot(): string {
|
||||||
|
const candidates = [
|
||||||
|
process.env.HERMES_AGENT_ROOT?.trim(),
|
||||||
|
...maybeRootFromHermesBin(),
|
||||||
|
'/opt/hermes',
|
||||||
|
join(process.env.HOME || '', '.hermes', 'hermes-agent'),
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
|
return candidates.find(hasHermesPluginModule) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function pythonCandidates(agentRoot: string): string[] {
|
||||||
|
const hermesBin = process.env.HERMES_BIN?.trim()
|
||||||
|
const hermesBinPython = hermesBin && hermesBin.includes('/bin/') ? join(dirname(hermesBin), 'python') : undefined
|
||||||
|
const rootPythons = agentRoot
|
||||||
|
? [join(agentRoot, '.venv', 'bin', 'python'), join(agentRoot, 'venv', 'bin', 'python')]
|
||||||
|
: []
|
||||||
|
const candidates = [
|
||||||
|
process.env.HERMES_PYTHON?.trim(),
|
||||||
|
hermesBinPython,
|
||||||
|
...rootPythons,
|
||||||
|
'python3',
|
||||||
|
'python',
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
|
return candidates.filter((candidate) => {
|
||||||
|
if (candidate.includes('/') || candidate.includes('\\')) return existsSync(candidate)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractError(err: any): string {
|
||||||
|
const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : ''
|
||||||
|
const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : ''
|
||||||
|
return [err?.message, stdout, stderr].filter(Boolean).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listHermesPlugins(): Promise<HermesPluginsResponse> {
|
||||||
|
const agentRoot = resolveHermesAgentRoot()
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
HERMES_AGENT_ROOT_RESOLVED: agentRoot,
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = []
|
||||||
|
for (const python of pythonCandidates(agentRoot)) {
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execFileAsync(python, ['-I', '-c', PYTHON_BRIDGE], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env,
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: 15000,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
})
|
||||||
|
const parsed = JSON.parse(stdout) as HermesPluginsResponse & { error?: string; detail?: string }
|
||||||
|
if ((parsed as any).error) {
|
||||||
|
throw new Error(`${(parsed as any).error}: ${(parsed as any).detail || 'unknown error'}`)
|
||||||
|
}
|
||||||
|
if (stderr?.trim()) {
|
||||||
|
parsed.warnings = [...(parsed.warnings || []), stderr.trim()]
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`${python}: ${extractError(err)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to discover Hermes plugins.\n${errors.join('\n')}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const listMock = vi.fn(async (ctx: any) => {
|
||||||
|
ctx.body = { plugins: [], warnings: [], metadata: {} }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/controllers/hermes/plugins', () => ({
|
||||||
|
list: listMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('plugin routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
listMock.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers the plugins inventory route', async () => {
|
||||||
|
const { pluginRoutes } = await import('../../packages/server/src/routes/hermes/plugins')
|
||||||
|
const paths = pluginRoutes.stack.map((entry: any) => entry.path)
|
||||||
|
|
||||||
|
expect(paths).toEqual(expect.arrayContaining(['/api/hermes/plugins']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delegates plugin listing to the controller', async () => {
|
||||||
|
const { pluginRoutes } = await import('../../packages/server/src/routes/hermes/plugins')
|
||||||
|
const layer = pluginRoutes.stack.find((entry: any) => entry.path === '/api/hermes/plugins')
|
||||||
|
const ctx: any = { body: null, params: {}, query: {} }
|
||||||
|
|
||||||
|
await layer.stack[0](ctx)
|
||||||
|
|
||||||
|
expect(listMock).toHaveBeenCalledWith(ctx)
|
||||||
|
expect(ctx.body).toEqual({ plugins: [], warnings: [], metadata: {} })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user