Merge pull request #69446 from NousResearch/feat/plugin-catalog

feat: plugin catalog — curated SHA-pinned plugin index (CLI, admission CI, docs, dashboard)
This commit is contained in:
Teknium
2026-09-09 09:22:21 -07:00
committed by GitHub
68 changed files with 6178 additions and 1501 deletions
@@ -0,0 +1,56 @@
name: Hermes Plugin Validate
description: >-
Validate a Hermes Agent plugin (plugin.yaml manifest schema AND
declared-vs-actually-registered capabilities) using
`hermes plugins validate`. Drop this into your plugin repo's CI:
- uses: actions/checkout@<sha>
- uses: NousResearch/hermes-agent/.github/actions/plugin-validate@main
with:
path: .
The caller's job owns checkout; this action installs Python + hermes-agent
(git install — a supported CI-context install route) and runs the
validator against your plugin directory.
inputs:
path:
description: Path to the plugin directory (containing plugin.yaml).
default: "."
hermes-ref:
description: hermes-agent git ref (branch/tag/sha) to install and validate with.
default: "main"
runs:
using: composite
steps:
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install hermes-agent
shell: bash
env:
_HERMES_REF: ${{ inputs.hermes-ref }}
run: |
set -euo pipefail
# CI-context install from git; the ref lets plugin authors validate
# against a pinned hermes release instead of main.
pip install "git+https://github.com/NousResearch/hermes-agent@${_HERMES_REF}"
- name: Validate plugin
shell: bash
env:
_PLUGIN_PATH: ${{ inputs.path }}
run: |
set -uo pipefail
# `hermes plugins validate` checks the plugin.yaml manifest schema
# and loads the plugin in a scratch subprocess to verify that the
# capabilities it DECLARES match what it actually registers.
if hermes plugins validate "$_PLUGIN_PATH"; then
echo "✅ PASS: plugin at '$_PLUGIN_PATH' validated cleanly"
else
echo "❌ FAIL: plugin at '$_PLUGIN_PATH' failed validation (see output above)"
exit 1
fi
+6
View File
@@ -9,6 +9,9 @@ on:
- 'website/**'
- 'skills/**'
- 'optional-skills/**'
# Catalog entry/removal merges must republish /docs/api/plugin-catalog.json —
# installed clients fetch it for live catalog refresh.
- 'plugin-catalog/**'
- '.github/workflows/deploy-site.yml'
workflow_dispatch:
inputs:
@@ -161,6 +164,9 @@ jobs:
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
- name: Extract plugin catalog for the Plugins page
run: python3 website/scripts/extract-plugins.py
- name: Regenerate per-skill docs pages + catalogs
run: python3 website/scripts/generate-skill-docs.py
+142
View File
@@ -0,0 +1,142 @@
name: Plugin Catalog CI
# Admission gate for plugin-catalog entries. Fires ONLY on PRs touching
# plugin-catalog/** so it can never go red on unrelated PRs.
#
# Two gates:
# structural — cheap schema check, no hermes install needed
# pinned-source-validate — supply-chain gate: the pinned sha MUST be
# reachable in the entry's repo, and the plugin
# at that exact commit must pass
# `hermes plugins validate`.
on:
pull_request:
paths:
- "plugin-catalog/**"
permissions:
contents: read
jobs:
structural:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install PyYAML
uses: ./.github/actions/retry
with:
command: pip install pyyaml==6.0.2
- name: Validate catalog files (structural)
run: |
set -euo pipefail
# Validating the whole directory is simpler than diffing and keeps
# the invariant that EVERYTHING in plugin-catalog/ stays valid.
python3 scripts/validate_plugin_catalog.py plugin-catalog/
pinned-source-validate:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # need the merge-base to diff changed catalog files
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Find changed catalog entries
id: changed
run: |
set -euo pipefail
MERGE_BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
# Added + modified entry files only; deletions and removed.yaml
# have nothing to clone.
CHANGED=$(git diff --name-only --diff-filter=AM "$MERGE_BASE"...HEAD \
-- 'plugin-catalog/*.yaml' 'plugin-catalog/*.yml' \
| grep -v '/removed\.yaml$' || true)
echo "Changed catalog entries:"
echo "${CHANGED:-<none>}"
{
echo 'files<<__EOF__'
echo "$CHANGED"
echo '__EOF__'
} >> "$GITHUB_OUTPUT"
- name: Install hermes-agent from the PR's own checkout
if: steps.changed.outputs.files != ''
uses: ./.github/actions/retry
with:
command: pip install -e .
- name: Clone each entry at its pinned sha and validate
if: steps.changed.outputs.files != ''
env:
CHANGED_FILES: ${{ steps.changed.outputs.files }}
run: |
set -euo pipefail
FAILED=0
while IFS= read -r entry; do
[ -z "$entry" ] && continue
echo "::group::validate $entry"
# Parse repo / sha / subdir from the entry yaml.
eval "$(python3 - "$entry" <<'PYEOF'
import shlex
import sys
import yaml
with open(sys.argv[1], encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
print(f"REPO={shlex.quote(str(data.get('repo', '')))}")
print(f"SHA={shlex.quote(str(data.get('sha', '')))}")
print(f"SUBDIR={shlex.quote(str(data.get('subdir', '') or ''))}")
PYEOF
)"
echo "repo=$REPO sha=$SHA subdir=$SUBDIR"
CLONE_DIR=$(mktemp -d)
# Full clone (no --depth 1): the pinned sha may not be the branch tip.
if ! git clone "$REPO" "$CLONE_DIR"; then
echo "::error file=$entry::clone failed for $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
# SUPPLY-CHAIN GATE: the pinned sha must be reachable in the repo.
if ! git -C "$CLONE_DIR" checkout --detach "$SHA"; then
echo "::error file=$entry::pinned sha $SHA is not reachable in $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
PLUGIN_DIR="$CLONE_DIR${SUBDIR:+/$SUBDIR}"
# Native plugin.yaml OR portable Agent Plugins v1 plugin.json
# (#81196; native manifest wins when both exist).
if [ ! -f "$PLUGIN_DIR/plugin.yaml" ] && [ ! -f "$PLUGIN_DIR/plugin.yml" ] && [ ! -f "$PLUGIN_DIR/plugin.json" ]; then
echo "::error file=$entry::no plugin.yaml or plugin.json at subdir '$SUBDIR' of $REPO@$SHA"
FAILED=1; echo "::endgroup::"; continue
fi
# Manifest schema + declared-vs-registered capability check.
if hermes plugins validate "$PLUGIN_DIR"; then
echo "✅ PASS: $entry"
else
echo "::error file=$entry::hermes plugins validate failed"
FAILED=1
fi
echo "::endgroup::"
done <<< "$CHANGED_FILES"
if [ "$FAILED" -ne 0 ]; then
echo "❌ FAIL: one or more catalog entries failed pinned-source validation"
exit 1
fi
echo "✅ PASS: all changed catalog entries validated at their pinned shas"
+5 -1
View File
@@ -35,7 +35,6 @@ data/
# Bundled community plugin index seed (shipped as package data) — the bare
# `data/` pattern above would otherwise swallow it.
!hermes_cli/data/
!hermes_cli/data/plugin_index.json
.pytest_cache/
test_durations.json
.pytest-cache/
@@ -134,6 +133,11 @@ website/static/api/skills-index.json
# every build).
website/static/api/skills.json
website/static/api/skills-meta.json
# plugins.json + plugins-meta.json are build artifacts emitted by
# website/scripts/extract-plugins.py during prebuild (Plugin Catalog page).
website/static/api/plugins.json
website/static/api/plugin-catalog.json
website/static/api/plugins-meta.json
# automation-blueprints-index.json is a build artifact emitted by
# website/scripts/extract-automation-blueprints.py during prebuild.
website/static/api/automation-blueprints-index.json
@@ -32,6 +32,7 @@ import {
} from '@/store/plugin-install-request'
import { $activeGatewayProfile, $profileScope } from '@/store/profile'
import { $connection } from '@/store/session'
import { runGatewayRestart } from '@/store/system-actions'
type ProbeResult = Awaited<ReturnType<NonNullable<NonNullable<Window['hermesDesktop']>['probePluginRepo']>>>
@@ -91,6 +92,8 @@ export function PluginInstallModal() {
setPhase('probing')
setProbe(null)
setInstallError(null)
// Reviewed catalog picks streamline the ceremony: enable defaults ON
// (installing a reviewed entry to not use it is the rare case).
setEnableAgent(payload.enable ?? true)
setForceReinstall(payload.force ?? false)
@@ -151,7 +154,7 @@ export function PluginInstallModal() {
}
}, [request, resetState, runProbe])
const profileLabel = activeProfile || profileScope || 'default'
const profileLabel = request?.profile || activeProfile || profileScope || 'default'
const agentTargetHint =
connection?.mode === 'remote' ? m.agentTargetRemote(profileLabel) : m.agentTargetLocal(profileLabel)
@@ -183,22 +186,34 @@ export function PluginInstallModal() {
const errors: string[] = []
const successes: string[] = []
let agentInstalled = false
try {
if (installAgent && probe.agent) {
const result = await installAgentPlugin(requestGateway, {
identifier: request.repo,
force: forceReinstall,
enable: enableAgent
enable: enableAgent,
catalogName: request.catalogName,
profile: request.profile
})
if (result.ok) {
successes.push(m.agentSuccess(result.pluginName ?? request.repo))
agentInstalled = true
if (result.missingEnv?.length) {
const firstVar = result.missingEnv[0]
notify({
kind: 'warning',
message: m.missingEnv(result.missingEnv.join(', '))
message: m.missingEnv(result.missingEnv.join(', ')),
// Deep-link straight to the credential card instead of leaving
// the user to hunt through Settings → Tools & Keys by hand.
action: {
label: m.missingEnvAction,
onClick: () => navigate(`/settings?tab=keys&key=${encodeURIComponent(firstVar)}`)
}
})
}
@@ -234,8 +249,19 @@ export function PluginInstallModal() {
notify({ kind: 'success', message })
}
// An enabled agent plugin only takes effect after a gateway restart —
// offer the restart right here instead of a dim hint to run later.
if (agentInstalled && enableAgent) {
notify({
kind: 'success',
message: m.restartToApply,
action: { label: m.restartNow, onClick: () => void runGatewayRestart() }
})
}
closePluginInstallRequest()
navigate('/settings?tab=plugins')
// Catalog picks come from Capabilities → Plugins; land back there.
navigate(request.catalogName ? '/skills?tab=plugins' : '/settings?tab=plugins')
return
}
@@ -305,12 +331,21 @@ export function PluginInstallModal() {
<div className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2 font-mono text-[length:var(--conversation-caption-font-size)] break-all text-foreground">
{request.repo}
</div>
{request.catalogName && (
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{m.catalogPinned(request.catalogName, request.sha?.slice(0, 8) ?? '')}
</p>
)}
</div>
<div className="space-y-3 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2.5">
<div className="space-y-2 text-[length:var(--conversation-caption-font-size)]">
<div className="font-medium text-foreground">{m.securityHeading}</div>
<p className="text-(--ui-text-secondary)">{m.securityIntro}</p>
<div className="font-medium text-foreground">
{request.catalogName ? m.reviewedHeading : m.securityHeading}
</div>
<p className="text-(--ui-text-secondary)">
{request.catalogName ? m.reviewedIntro : m.securityIntro}
</p>
</div>
{sourceLinks && (
@@ -414,12 +449,14 @@ export function PluginInstallModal() {
</label>
)}
<label className="flex items-center justify-between gap-3">
<span className="text-[length:var(--conversation-caption-font-size)] text-foreground">
{m.forceReinstall}
</span>
<Switch checked={forceReinstall} disabled={busy} onCheckedChange={setForceReinstall} />
</label>
{!request.catalogName && (
<label className="flex items-center justify-between gap-3">
<span className="text-[length:var(--conversation-caption-font-size)] text-foreground">
{m.forceReinstall}
</span>
<Switch checked={forceReinstall} disabled={busy} onCheckedChange={setForceReinstall} />
</label>
)}
</div>
)}
@@ -1,65 +1,34 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { requestGateway, getProfiles } = vi.hoisted(() => ({
requestGateway: vi.fn(),
getProfiles: vi.fn<() => Promise<{ profiles: { name: string; is_default: boolean }[] }>>(async () => ({
profiles: []
}))
const { requestGateway } = vi.hoisted(() => ({
requestGateway: vi.fn(async () => ({ plugins: [] }))
}))
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway })
}))
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
getProfiles
}))
import { $pluginRecords } from '@/contrib/plugins-store'
import { queryClient } from '@/lib/query-client'
import {
$agentPluginBusy,
$agentPlugins,
$agentPluginsError,
$agentPluginsStatus,
type AgentPluginRow
} from '@/store/agent-plugins'
import { $activeGatewayProfile } from '@/store/profile'
import { $connection, $gatewayState } from '@/store/session'
import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins'
import { $gatewayState } from '@/store/session'
import { PluginsSettings } from './plugins-settings'
const legacyRow = {
name: 'Legacy plugin',
version: '0.20.0',
description: 'Returned by a pre-key backend',
source: 'user',
status: 'disabled'
} satisfies AgentPluginRow
const renderSettings = () =>
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<PluginsSettings />
</QueryClientProvider>
</MemoryRouter>
)
beforeEach(() => {
requestGateway.mockReset()
getProfiles.mockReset()
getProfiles.mockResolvedValue({ profiles: [] })
queryClient.clear()
requestGateway.mockClear()
$pluginRecords.set({})
$agentPlugins.set([legacyRow])
$agentPlugins.set([])
$agentPluginsStatus.set('ready')
$agentPluginsError.set(null)
$agentPluginBusy.set(null)
$gatewayState.set('idle')
$connection.set(null)
$activeGatewayProfile.set('default')
})
afterEach(() => {
@@ -68,166 +37,93 @@ afterEach(() => {
})
describe('PluginsSettings', () => {
it('renders and searches plugin rows returned without a canonical key', () => {
renderSettings()
expect(screen.getByText('Legacy plugin')).toBeTruthy()
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'pre-key' } })
expect(screen.getByText('Legacy plugin')).toBeTruthy()
})
it('renders keyless rows read-only instead of falling back to name-addressed toggles', () => {
// Name-addressed toggles flip every same-named plugin across category
// dirs (image_gen/fal vs video_gen/fal) — the reason toggles moved to
// canonical keys. A pre-contract-v6 row must never reach the RPC.
renderSettings()
const toggle = screen.getByRole('switch', { name: 'Enable Legacy plugin' })
expect(toggle.hasAttribute('disabled') || toggle.getAttribute('aria-disabled') === 'true').toBe(true)
fireEvent.click(toggle)
expect(requestGateway).not.toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'toggle' }))
})
it('keeps duplicate-named keyless rows distinct (no React key collision)', () => {
const sibling = {
...legacyRow,
description: 'A second plugin category with the same legacy name'
}
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
$agentPlugins.set([legacyRow, sibling])
renderSettings()
expect(screen.getAllByRole('switch', { name: 'Enable Legacy plugin' })).toHaveLength(2)
expect(screen.getByText(sibling.description)).toBeTruthy()
expect(consoleError.mock.calls.flat().join(' ')).not.toContain('same key')
})
it('keeps using the canonical key when the backend provides one', async () => {
const keyedRow = { ...legacyRow, key: 'image_gen/legacy' }
$agentPlugins.set([keyedRow])
requestGateway.mockResolvedValue({ ok: true, plugin: { ...keyedRow, status: 'enabled' } })
renderSettings()
fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' }))
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', {
action: 'toggle',
key: 'image_gen/legacy',
enable: true
})
)
})
it('hides repo-bundled built-ins and keeps the count pill in sync', () => {
// The Agent plugins section is the control panel for plugins the USER
// installed — built-ins (browser backends, cron providers, model
// providers…) ship enabled-by-default and are configured elsewhere.
it('points agent-plugin management at Capabilities instead of duplicating the list', () => {
// Agent plugins are profile-scoped and managed in Capabilities → Plugins;
// Settings keeps desktop plugins only, plus a pointer.
$agentPlugins.set([
legacyRow,
{ ...legacyRow, name: 'browserbase', key: 'browser/browserbase', source: 'bundled' },
{ ...legacyRow, name: 'chronos', key: 'cron_providers/chronos', source: 'bundled' },
{ ...legacyRow, name: 'deepinfra', key: 'model-providers/deepinfra', source: 'bundled' }
{
description: 'Should NOT be listed here anymore',
key: 'demo-plugin',
name: 'demo-plugin',
source: 'git',
status: 'enabled',
version: '1.0.0'
}
])
renderSettings()
expect(screen.getByText('Legacy plugin')).toBeTruthy()
expect(screen.queryByText('browserbase')).toBeNull()
expect(screen.queryByText('chronos')).toBeNull()
expect(screen.queryByText('deepinfra')).toBeNull()
// Count pill reflects the filtered list, not the raw RPC row count.
expect(screen.getByText('1 installed', { exact: false })).toBeTruthy()
expect(screen.queryByText('demo-plugin')).toBeNull()
expect(screen.getByText(/managed per profile in Capabilities/)).toBeTruthy()
expect(screen.getByRole('link', { name: /Capabilities/ }).getAttribute('href')).toContain('/skills?tab=plugins')
})
it('hides legacy other-surface categories even when the backend omits source', () => {
// Older backends may not report source reliably — the key-prefix
// fallback still hides categories other surfaces own.
$agentPlugins.set([{ ...legacyRow, name: 'deepinfra', key: 'model-providers/deepinfra', source: 'user' }])
renderSettings()
expect(screen.queryByText('deepinfra')).toBeNull()
})
it('shows no profile selector with a single profile', async () => {
getProfiles.mockResolvedValue({ profiles: [{ name: 'default', is_default: true }] })
renderSettings()
await waitFor(() => expect(getProfiles).toHaveBeenCalled())
expect(screen.queryByText('Applies to:')).toBeNull()
})
it('lists the active profile scope without a profile param and reloads scoped on change', async () => {
getProfiles.mockResolvedValue({
profiles: [
{ name: 'default', is_default: true },
{ name: 'work', is_default: false }
]
})
requestGateway.mockResolvedValue({ plugins: [legacyRow] })
$gatewayState.set('open')
renderSettings()
// Active profile scope: no profile param — older backends unchanged.
await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { action: 'list' }))
await waitFor(() => expect(screen.getByText('Applies to:')).toBeTruthy())
})
it('sends toggles through the selected profile scope', async () => {
// jsdom's scrollIntoView is missing/non-functional; Radix Select calls it
// when the dropdown opens.
Element.prototype.scrollIntoView = vi.fn()
const keyedRow = { ...legacyRow, key: 'image_gen/legacy' }
getProfiles.mockResolvedValue({
profiles: [
{ name: 'default', is_default: true },
{ name: 'work', is_default: false }
]
})
requestGateway.mockImplementation(async (method: string, params?: Record<string, unknown>) => {
if (params?.action === 'list') {
return { plugins: [keyedRow] }
it('flags a unified-root desktop half whose agent half is missing on this backend', () => {
$pluginRecords.set({
'pixel-overlay': {
id: 'pixel-overlay',
name: 'Pixel Overlay',
kind: 'disk',
status: 'loaded',
file: '/home/user/.hermes/plugins/pixel-overlay/desktop/plugin.js'
}
return { ok: true, plugin: { ...keyedRow, status: 'enabled' } }
})
$agentPlugins.set([]) // connected backend has no agent half
$agentPluginsStatus.set('ready')
renderSettings()
expect(screen.getByText('agent half missing here')).toBeTruthy()
})
it('does not flag when the agent half exists on the connected backend', () => {
$pluginRecords.set({
'pixel-overlay': {
id: 'pixel-overlay',
name: 'Pixel Overlay',
kind: 'disk',
status: 'loaded',
file: '/home/user/.hermes/plugins/pixel-overlay/desktop/plugin.js'
}
})
$agentPlugins.set([
{
description: '',
key: 'pixel-overlay',
name: 'pixel-overlay',
source: 'user',
status: 'enabled',
version: '1.0.0'
}
])
renderSettings()
expect(screen.queryByText('agent half missing here')).toBeNull()
})
it('does not flag standalone desktop plugins (not from the unified root)', () => {
$pluginRecords.set({
standalone: {
id: 'standalone',
name: 'Standalone Theme',
kind: 'disk',
status: 'loaded',
file: '/home/user/.config/hermes-desktop/desktop-plugins/standalone/plugin.js'
}
})
$agentPlugins.set([])
renderSettings()
expect(screen.queryByText('agent half missing here')).toBeNull()
})
it('loads the connected backend plugin list once the gateway opens (badge data)', () => {
$gatewayState.set('open')
renderSettings()
await waitFor(() => expect(screen.getByText('Applies to:')).toBeTruthy())
// Select the non-active profile scope.
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(await screen.findByText('work'))
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { action: 'list', profile: 'work' })
)
fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' }))
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', {
action: 'toggle',
key: 'image_gen/legacy',
enable: true,
profile: 'work'
})
)
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'list' }))
})
})
+103 -229
View File
@@ -1,47 +1,27 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { type ReactNode, useEffect, useState } from 'react'
import { type ReactNode, useEffect } from 'react'
import { Link } from 'react-router'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { getProfiles } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { FolderOpen, Monitor, Package, RefreshCw } from '@/lib/icons'
import { normalize } from '@/lib/text'
import {
$agentPluginBusy,
$agentPlugins,
$agentPluginsError,
$agentPluginsStatus,
type AgentPluginRow,
type GatewayRequest,
isDesktopRelevantPlugin,
loadAgentPlugins,
toggleAgentPlugin
} from '@/store/agent-plugins'
import { $agentPlugins, $agentPluginsStatus, loadAgentPlugins } from '@/store/agent-plugins'
import { notifyError } from '@/store/notifications'
import { openPluginInstallRequest } from '@/store/plugin-install-request'
import { $activeGatewayProfile } from '@/store/profile'
import { $connection, $gatewayState } from '@/store/session'
import { $gatewayState } from '@/store/session'
import { EmptyState, ListRowSkeleton, Pill, SettingsContent, SettingsSection } from './primitives'
import { EmptyState, Pill, SettingsContent, SettingsSection } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
const KIND_ORDER: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
// User-installed plugins first — mirrors `hermes plugins list --user`.
const SOURCE_ORDER: Record<string, number> = { user: 0, git: 0, project: 1, entrypoint: 2 }
const agentPluginRowKey = (row: AgentPluginRow) =>
row.key ?? [row.name, row.source, row.version, row.description].join('\0')
/** Deep-link anchor for a plugin row (`?tab=plugins&plugin=<id>`). */
export const pluginElementId = (target: string) => `plugin-${target}`
@@ -73,31 +53,6 @@ async function revealPluginsDir() {
}
}
// Agent plugins live under the BACKEND's hermes home (profile-aware), so the
// path comes from the gateway — not from the renderer's local HERMES_HOME.
// Callers gate on a local connection: openDir mkdir-creates the path, which
// must never happen for a directory that belongs to a remote box.
async function revealAgentPluginsDir(request: GatewayRequest) {
try {
const result = await request<{ home?: string }>('config.get', { key: 'profile' })
const home = (result?.home ?? '').trim()
if (!home) {
notifyError('The backend did not report its home directory', 'Could not open the plugins folder')
return
}
const opened = await window.hermesDesktop?.openDir?.(`${home}/plugins`)
if (opened && !opened.ok) {
notifyError(opened.error ?? 'unknown error', 'Could not open the plugins folder')
}
} catch (err) {
notifyError(err, 'Could not open the plugins folder')
}
}
// Compact row: name + pills and a wrapping description on the left, controls
// pinned top-right. Same type scale as ListRow, without its wide control grid.
function PluginLine({
@@ -128,190 +83,61 @@ function PluginLine({
)
}
function AgentPluginRowView({ row, profile }: { row: AgentPluginRow; profile: string | null }) {
const { t } = useI18n()
const p = t.settings.plugins
const { requestGateway } = useGatewayRequest()
const busy = useStore($agentPluginBusy)
const key = row.key
/** Folder name when a desktop plugin entry lives in the UNIFIED agent-plugins
* root (`~/.hermes/plugins/<name>/desktop/plugin.js`) — i.e. it is the
* desktop half of a bundled agent+desktop package. Null for standalone
* desktop plugins. */
function unifiedPackageName(file?: string): null | string {
if (!file) {
return null
}
// Pre-contract-v6 backends return rows without a canonical key. Name-addressed
// toggles silently flip every same-named plugin across category dirs
// (image_gen/fal vs video_gen/fal), so keyless rows are read-only — the
// backend-contract skew toast tells the user to update.
const toggle = (
<Switch
aria-label={`${row.status === 'enabled' ? p.disable : p.enable} ${row.name}`}
checked={row.status === 'enabled'}
disabled={!key || busy === key}
onCheckedChange={on => {
if (!key) {
return
}
const match = /[\\/]plugins[\\/]([^\\/]+)[\\/]desktop[\\/]plugin\.js$/.exec(file)
triggerHaptic('selection')
void toggleAgentPlugin(requestGateway, key, on, p.agent.toggleFailed(row.name), profile)
}}
/>
)
return match ? match[1] : null
}
return (
<PluginLine
controls={key ? toggle : <Tip label={p.agent.updateBackendToManage}>{toggle}</Tip>}
description={row.description || (row.version ? `v${row.version}` : undefined)}
id={pluginElementId(key ?? row.name)}
title={
<>
<span>{row.name}</span>
<Pill>{p.agent.sources[row.source] ?? row.source}</Pill>
{row.portable && <Pill tone="primary">{p.agent.portable}</Pill>}
</>
/** Open the dual-target install modal pre-filled to install ONLY the agent
* half of a bundled package (drift repair). Provenance comes from the
* package's catalog sidecar when present; otherwise the git remote of the
* plugin folder is unknown and we fall back to asking the user via the
* standard flow with the folder name as identifier hint. */
async function repairAgentHalf(record: PluginRecord, packageName: string) {
let repo = ''
let catalogName: string | undefined
let sha: string | undefined
try {
const pluginDir = record.file?.replace(/[\\/]desktop[\\/]plugin\.js$/, '')
const raw = pluginDir
? await window.hermesDesktop?.readFileText?.(`${pluginDir}/.hermes-catalog.json`)
: null
if (raw) {
const sidecar = JSON.parse(typeof raw === 'string' ? raw : (raw as { content?: string }).content ?? '') as {
catalog_name?: string
repo?: string
sha?: string
}
/>
)
}
function AgentPluginsSection() {
const { t } = useI18n()
const p = t.settings.plugins
const { requestGateway } = useGatewayRequest()
const gatewayState = useStore($gatewayState)
const connection = useStore($connection)
const rows = useStore($agentPlugins)
const status = useStore($agentPluginsStatus)
const error = useStore($agentPluginsError)
const [query, setQuery] = useState('')
// 'Applies to' profile scope: which profile's plugins we list/toggle.
// Defaults to the app-wide active profile; overriding it here lets the user
// manage ANY profile's plugins without switching the whole app (same
// pattern as the Capabilities scope selector in app/skills). null = the
// active profile — the RPC is sent without a profile param so older
// backends keep working unchanged.
const activeProfile = useStore($activeGatewayProfile)
const [scopeOverride, setScopeOverride] = useState<null | string>(null)
const scopeProfile = scopeOverride ?? activeProfile ?? null
// The param we actually send: omit it for the active profile.
const requestProfile = scopeOverride && scopeOverride !== activeProfile ? scopeOverride : null
const { data: profilesData } = useQuery({
queryKey: ['agent-plugins-profiles'],
queryFn: getProfiles,
staleTime: 60_000
})
const profiles = profilesData?.profiles ?? []
// An app-wide profile switch retargets the default scope — drop the
// override so the list reloads for the profile the user just switched to.
useEffect(() => {
setScopeOverride(null)
}, [activeProfile])
useEffect(() => {
if (gatewayState !== 'open') {
return
repo = sidecar.repo ?? ''
catalogName = sidecar.catalog_name
sha = sidecar.sha
}
} catch {
// No sidecar (raw-git bundled install) — fall through to the name hint.
}
void loadAgentPlugins(requestGateway, requestProfile)
}, [gatewayState, requestGateway, requestProfile])
const needle = normalize(query)
const sorted = rows
.filter(isDesktopRelevantPlugin)
.filter(
row =>
!needle ||
row.name.toLowerCase().includes(needle) ||
(row.key ?? '').toLowerCase().includes(needle) ||
row.description.toLowerCase().includes(needle)
)
.sort((a, b) => (SOURCE_ORDER[a.source] ?? 9) - (SOURCE_ORDER[b.source] ?? 9) || a.name.localeCompare(b.name))
return (
<SettingsSection
icon={Package}
meta={status === 'ready' ? p.count(sorted.length) : undefined}
title={p.agent.title}
>
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.agent.blurb}
</p>
{profiles.length > 1 && (
<div className="mb-2 flex items-center gap-2">
<span className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
{p.agent.appliesTo}
</span>
<Select
onValueChange={name => setScopeOverride(name === activeProfile ? null : name)}
value={scopeProfile ?? ''}
>
<SelectTrigger className="h-7 w-56 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.is_default ? 'Hermes (default)' : profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{connection?.mode !== 'remote' && !requestProfile && (
<div className="mb-2 flex items-center gap-3">
<Button
onClick={() => void revealAgentPluginsDir(requestGateway)}
size="sm"
type="button"
variant="textStrong"
>
<FolderOpen className="size-3.5" />
<span>{p.openFolder}</span>
</Button>
</div>
)}
<input
className="mb-2 w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
onChange={event => setQuery(event.target.value)}
placeholder={p.agent.search}
spellCheck={false}
value={query}
/>
{status === 'loading' || status === 'idle' ? (
<div>
<ListRowSkeleton />
<ListRowSkeleton />
<ListRowSkeleton />
</div>
) : status === 'error' ? (
<EmptyState description={error ?? undefined} title={p.agent.loadFailed} />
) : sorted.length === 0 ? (
needle ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.agent.noMatches}
</p>
) : (
<EmptyState title={p.agent.empty} />
)
) : (
<div>
{sorted.map(row => (
<AgentPluginRowView key={agentPluginRowKey(row)} profile={requestProfile} row={row} />
))}
</div>
)}
</SettingsSection>
)
openPluginInstallRequest({
catalogName,
legacyHint: 'agent',
repo: repo || packageName,
sha
})
}
function PluginRow({ record }: { record: PluginRecord }) {
function PluginRow({ record, agentHalfMissing }: { record: PluginRecord; agentHalfMissing?: boolean }) {
const { t } = useI18n()
const p = t.settings.plugins
@@ -349,6 +175,18 @@ function PluginRow({ record }: { record: PluginRecord }) {
<span>{record.name}</span>
<Pill>{p.kinds[record.kind]}</Pill>
{record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>}
{agentHalfMissing && (
<Tip label={p.agentHalfMissingTip}>
<Button
className="h-5 px-1.5 text-[0.65rem]"
onClick={() => void repairAgentHalf(record, unifiedPackageName(record.file) ?? record.name)}
size="xs"
variant="outline"
>
{p.agentHalfMissing}
</Button>
</Tip>
)}
</>
}
/>
@@ -359,6 +197,25 @@ export function PluginsSettings() {
const { t } = useI18n()
const p = t.settings.plugins
const records = useStore($pluginRecords)
const { requestGateway } = useGatewayRequest()
const gatewayState = useStore($gatewayState)
// The agent-plugin list for the CURRENTLY connected backend's active
// profile — used only to flag bundled packages whose desktop half is local
// but whose agent half is not installed where the app is now pointing (one
// desktop app, N agents: switching gateway/profile makes this drift visible
// instead of silent). Management of agent plugins lives in Capabilities →
// Plugins; this page keeps just the badge.
const agentRows = useStore($agentPlugins)
const agentStatus = useStore($agentPluginsStatus)
const agentNames = new Set(agentRows.flatMap(row => [row.name, row.key ?? row.name]))
useEffect(() => {
if (gatewayState !== 'open') {
return
}
void loadAgentPlugins(requestGateway)
}, [gatewayState, requestGateway])
// Deep-link from settings search (?plugin=<id or key>): rows render as soon
// as their store hydrates, so "ready" is simply target-present; the polling
@@ -406,14 +263,31 @@ export function PluginsSettings() {
<EmptyState title={p.empty} />
) : (
<div>
{rows.map(record => (
<PluginRow key={record.id} record={record} />
))}
{rows.map(record => {
const packageName = unifiedPackageName(record.file)
return (
<PluginRow
agentHalfMissing={
packageName !== null && agentStatus === 'ready' && !agentNames.has(packageName)
}
key={record.id}
record={record}
/>
)
})}
</div>
)}
</SettingsSection>
<AgentPluginsSection />
<SettingsSection icon={Package} title={p.agent.title}>
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.agent.movedToCapabilities}{' '}
<Link className="text-(--ui-text-link,var(--ui-accent))" to="/skills?tab=plugins">
{p.agent.openCapabilities}
</Link>
</p>
</SettingsSection>
</SettingsContent>
)
}
+11 -4
View File
@@ -68,12 +68,13 @@ import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { EmbeddedHubPicker } from './embedded-hub-picker'
import { McpTab } from './mcp-tab'
import { PluginsTab } from './plugins-tab'
import { $skillsSortDesc, $toolsetsSortDesc } from './store'
// 'hub' is gone as a top-level tab — the Skills Hub browser lives inside the
// Skills tab now (EmbeddedHubPicker below the installed list). Legacy
// `?tab=hub` links fall back to 'skills' via useRouteEnumParam.
const SKILLS_MODES = ['skills', 'toolsets', 'mcp'] as const
const SKILLS_MODES = ['skills', 'toolsets', 'mcp', 'plugins'] as const
// Skills + toolsets live in the RQ cache so switching tabs/pages paints the
// cached lists instantly (no reload flash) and mount only fires a deduped
@@ -847,14 +848,15 @@ export function SkillsView({
onTabChange={id => setMode(id as (typeof SKILLS_MODES)[number])}
// MCP manages a handful of entries with the editor right there —
// searching it is noise.
searchHidden={mode === 'mcp'}
searchHidden={mode === 'mcp' || mode === 'plugins'}
searchHints={searchHints}
searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets}
searchValue={query}
tabs={[
{ id: 'skills', label: t.skills.tabSkills, meta: skills?.length ?? null },
{ id: 'toolsets', label: t.skills.tabToolsets, meta: toolsets ? visibleToolsetCount(toolsets) : null },
{ id: 'mcp', label: t.skills.tabMcp }
{ id: 'mcp', label: t.skills.tabMcp },
{ id: 'plugins', label: t.skills.tabPlugins }
]}
>
{/* One shared column: the scope selector sits above whichever tab is
@@ -864,7 +866,12 @@ export function SkillsView({
{profileScopeSelector}
<div className="flex min-h-0 flex-1 flex-col">
<div className={mode === 'skills' ? 'min-h-40 flex-1 overflow-hidden' : 'min-h-0 flex-1'}>
{mode === 'mcp' ? (
{mode === 'plugins' ? (
// Agent plugins for the scoped profile: installed list on top,
// the live catalog picker underneath (same shape as Skills).
// Keyed on scope so a profile/connection switch reloads the list.
<PluginsTab key={`plugins-${scopeKey}`} profile={scopeProfile} />
) : mode === 'mcp' ? (
// The gateway instance backs ONLY the live `reload.mcp` RPC, and
// it is the ACTIVE gateway's socket — for a scope pinned to a
// different backend that RPC would hot-reload the wrong
@@ -0,0 +1,318 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins'
import { $pluginInstallRequest, closePluginInstallRequest } from '@/store/plugin-install-request'
import { PluginsTab } from './plugins-tab'
const requestGateway = vi.fn(async () => ({ plugins: [] }))
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway })
}))
describe('PluginsTab', () => {
beforeEach(() => {
$agentPlugins.set([])
$agentPluginsStatus.set('ready')
closePluginInstallRequest()
requestGateway.mockClear()
})
afterEach(cleanup)
it('lists the scoped profile agent plugins with toggles', () => {
$agentPlugins.set([
{
description: 'A test plugin',
key: 'demo-plugin',
name: 'demo-plugin',
source: 'git',
status: 'enabled',
version: '1.0.0'
}
])
render(<PluginsTab profile="workbot" />)
expect(screen.getByText('demo-plugin')).toBeTruthy()
expect(screen.getByRole('switch', { name: 'demo-plugin' }).getAttribute('aria-checked')).toBe('true')
})
it('hides bundled plugins (managed from their own surfaces)', () => {
$agentPlugins.set([
{
description: '',
key: 'image_gen/fal',
name: 'fal',
source: 'bundled',
status: 'enabled',
version: ''
}
])
render(<PluginsTab profile={null} />)
expect(screen.queryByText('fal')).toBeNull()
expect(screen.getByText(/No agent plugins installed/)).toBeTruthy()
})
it('loads the plugin list scoped to the selected profile', () => {
render(<PluginsTab profile="workbot" />)
expect(requestGateway).toHaveBeenCalledWith(
'plugins.manage',
expect.objectContaining({ action: 'list', profile: 'workbot' })
)
})
it('opens the dual-target install modal from a catalog pick message', async () => {
render(<PluginsTab profile="workbot" />)
window.dispatchEvent(
new MessageEvent('message', {
data: {
name: 'weather-plugin',
repo: 'https://github.com/example/weather-plugin',
sha: 'a'.repeat(40),
subdir: '',
tier: 'community',
type: 'hermes-plugin-pick'
},
origin: 'https://hermes-agent.nousresearch.com'
})
)
await waitFor(() => {
const request = $pluginInstallRequest.get()
expect(request).not.toBeNull()
expect(request?.catalogName).toBe('weather-plugin')
expect(request?.repo).toBe('https://github.com/example/weather-plugin')
expect(request?.profile).toBe('workbot')
expect(request?.sha).toBe('a'.repeat(40))
})
})
it('ignores pick messages from foreign origins', () => {
render(<PluginsTab profile={null} />)
window.dispatchEvent(
new MessageEvent('message', {
data: {
name: 'evil-plugin',
repo: 'https://github.com/evil/evil-plugin',
type: 'hermes-plugin-pick'
},
origin: 'https://evil.example.com'
})
)
expect($pluginInstallRequest.get()).toBeNull()
})
it('toggles by canonical key through plugins.manage', async () => {
$agentPlugins.set([
{
description: '',
key: 'image_gen/legacy',
name: 'Legacy plugin',
source: 'user',
status: 'disabled',
version: '0.20.0'
}
])
requestGateway.mockResolvedValueOnce({
ok: true,
plugin: { key: 'image_gen/legacy', name: 'Legacy plugin', status: 'enabled' }
} as never)
render(<PluginsTab profile={null} />)
screen.getByRole('switch', { name: 'Legacy plugin' }).click()
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith(
'plugins.manage',
expect.objectContaining({ action: 'toggle', key: 'image_gen/legacy', enable: true })
)
)
})
it('renders keyless rows read-only (no name-addressed toggle RPC)', () => {
// Name-addressed toggles flip every same-named plugin across category
// dirs — pre-contract-v6 rows must never reach the RPC.
$agentPlugins.set([
{
description: 'Returned by a pre-key backend',
name: 'Legacy plugin',
source: 'user',
status: 'disabled',
version: '0.20.0'
}
])
render(<PluginsTab profile={null} />)
const toggle = screen.getByRole('switch', { name: 'Legacy plugin' })
expect(toggle.hasAttribute('disabled') || toggle.getAttribute('aria-disabled') === 'true').toBe(true)
toggle.click()
expect(requestGateway).not.toHaveBeenCalledWith(
'plugins.manage',
expect.objectContaining({ action: 'toggle' })
)
})
it('appends the subdir fragment for multi-plugin repos', async () => {
render(<PluginsTab profile={null} />)
window.dispatchEvent(
new MessageEvent('message', {
data: {
name: 'nested-plugin',
repo: 'https://github.com/example/plugins-monorepo',
subdir: 'nested-plugin',
type: 'hermes-plugin-pick'
},
origin: 'https://hermes-agent.nousresearch.com'
})
)
await waitFor(() => {
expect($pluginInstallRequest.get()?.repo).toBe(
'https://github.com/example/plugins-monorepo#nested-plugin'
)
})
})
})
describe('PluginsTab catalog UX', () => {
beforeEach(() => {
$agentPlugins.set([])
$agentPluginsStatus.set('ready')
closePluginInstallRequest()
requestGateway.mockClear()
})
afterEach(cleanup)
it('shows an Update chip when the catalog pin moved past the installed SHA', () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
catalog_sha: 'b'.repeat(40),
catalog_tier: 'community',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: true,
version: '1.0.0'
}
])
render(<PluginsTab profile={null} />)
expect(screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` })).toBeTruthy()
})
it('re-pins through plugins.manage update when the chip is clicked', async () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
catalog_sha: 'b'.repeat(40),
catalog_tier: 'community',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: true,
version: '1.0.0'
}
])
requestGateway.mockResolvedValue({ ok: true, unchanged: false, plugins: [] } as never)
render(<PluginsTab profile="workbot" />)
screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` }).click()
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith(
'plugins.manage',
expect.objectContaining({ action: 'update', name: 'demo-weather', profile: 'workbot' })
)
)
})
it('refuses a catalog pick that is already installed and current', async () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: false,
version: '1.0.0'
}
])
render(<PluginsTab profile={null} />)
window.dispatchEvent(
new MessageEvent('message', {
data: {
name: 'demo-weather',
repo: 'https://github.com/example/demo-weather',
type: 'hermes-plugin-pick'
},
origin: 'https://hermes-agent.nousresearch.com'
})
)
// The modal must NOT open — the pick is refused with a toast.
await new Promise(resolve => setTimeout(resolve, 20))
expect($pluginInstallRequest.get()).toBeNull()
})
it('still opens the modal for an installed pick when an update is available', async () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: true,
version: '1.0.0'
}
])
render(<PluginsTab profile={null} />)
window.dispatchEvent(
new MessageEvent('message', {
data: {
name: 'demo-weather',
repo: 'https://github.com/example/demo-weather',
type: 'hermes-plugin-pick'
},
origin: 'https://hermes-agent.nousresearch.com'
})
)
await waitFor(() => expect($pluginInstallRequest.get()).not.toBeNull())
})
})
+298
View File
@@ -0,0 +1,298 @@
import { useStore } from '@nanostores/react'
import { memo, useEffect, useMemo, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import type { ProfileScope } from '@/hermes'
import { useI18n } from '@/i18n'
import { Loader2, Package } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$agentPluginBusy,
$agentPlugins,
$agentPluginsError,
$agentPluginsStatus,
type AgentPluginRow,
isDesktopRelevantPlugin,
loadAgentPlugins,
toggleAgentPlugin,
updateAgentPlugin
} from '@/store/agent-plugins'
import { notify } from '@/store/notifications'
import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
import { openPluginInstallRequest } from '@/store/plugin-install-request'
import { PanelEmpty } from '../overlays/panel'
// The REAL Plugin Catalog page (docs site) embedded as a one-click picker —
// the same pattern as the Skills tab's EmbeddedHubPicker. `?embed=picker`
// hides the docs chrome and adds "+ Add to this Agent" per card, which posts
// { type: 'hermes-plugin-pick', name, repo, sha, subdir, tier, installCmd }
// to the parent window. We validate the origin and open the shared
// dual-target install modal (agent half → catalog-pinned install into the
// scoped profile; desktop half → local app), so bundled agent+desktop
// packages install both halves in one flow.
const CATALOG_ORIGIN = 'https://hermes-agent.nousresearch.com'
const CATALOG_PICKER_URL = `${CATALOG_ORIGIN}/docs/plugins?embed=picker`
const CATALOG_PANE_ID = 'capabilities-plugin-catalog'
const CATALOG_DEFAULT_PX = 380
const CATALOG_COLLAPSED_PX = 4
interface PluginPickMessage {
installCmd?: string
name?: string
repo?: string
sha?: string
subdir?: string
tier?: string
type?: string
}
/** Derive the bare profile name a `plugins.manage` call should target. */
function profileParam(scope: ProfileScope): null | string {
if (!scope) {
return null
}
return typeof scope === 'string' ? scope : (scope.profile ?? null)
}
function PluginRow({
row,
busy,
onToggle,
onUpdate
}: {
row: AgentPluginRow
busy: boolean
onToggle: (enable: boolean) => void
onUpdate?: () => void
}) {
const { t } = useI18n()
const address = row.key ?? ''
const canToggle = Boolean(address)
const enabled = row.status === 'enabled'
return (
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-3 py-2 last:border-b-0">
<Package aria-hidden className="mt-0.5 size-4 shrink-0 text-(--ui-text-tertiary)" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.name}
{row.version && <span className="text-(--ui-text-quaternary)">v{row.version}</span>}
{row.portable && (
<span className="rounded border border-(--ui-stroke-tertiary) px-1 text-[0.65rem] text-(--ui-text-tertiary)">
{t.skills.plugins.portableBadge}
</span>
)}
{row.catalog_name && (
<Tip label={t.skills.plugins.catalogProvenance(row.installed_sha?.slice(0, 8) ?? '')}>
<span className="rounded border border-(--ui-stroke-tertiary) px-1 text-[0.65rem] text-(--ui-text-tertiary)">
{row.catalog_tier === 'official'
? t.skills.plugins.tierOfficial
: t.skills.plugins.tierCommunity}
</span>
</Tip>
)}
{row.update_available && onUpdate && (
<Button className="h-5 px-1.5 text-[0.65rem]" disabled={busy} onClick={onUpdate} size="xs" variant="outline">
{t.skills.plugins.updateToPin(row.catalog_sha?.slice(0, 8) ?? '')}
</Button>
)}
</div>
{row.description && (
<div className="mt-0.5 text-[length:var(--conversation-caption-font-size)] break-words text-(--ui-text-tertiary)">
{row.description}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{busy && <Loader2 className="size-3.5 animate-spin text-(--ui-text-tertiary)" />}
{canToggle ? (
<Switch aria-label={row.name} checked={enabled} disabled={busy} onCheckedChange={onToggle} />
) : (
<Tip label={t.skills.plugins.legacyBackend}>
<span>
<Switch aria-label={row.name} checked={enabled} disabled />
</span>
</Tip>
)}
</div>
</div>
)
}
/** Agent plugins for the Capabilities page: the scoped profile's installed
* plugins on top (toggleable), the live catalog picker underneath — same
* management-plus-discovery shape as the Skills tab. */
export const PluginsTab = memo(function PluginsTab({ profile }: { profile: ProfileScope }) {
const { t } = useI18n()
const p = t.skills.plugins
const { requestGateway } = useGatewayRequest()
const rows = useStore($agentPlugins)
const status = useStore($agentPluginsStatus)
const error = useStore($agentPluginsError)
const busyKey = useStore($agentPluginBusy)
const scope = profileParam(profile)
useEffect(() => {
void loadAgentPlugins(requestGateway, scope)
}, [requestGateway, scope])
const visible = useMemo(() => rows.filter(isDesktopRelevantPlugin), [rows])
// Catalog picker viewport (persisted height, collapse toggle) — same pane
// store contract as EmbeddedHubPicker.
const heightOverride = useStore($paneHeightOverride(CATALOG_PANE_ID))
const height = heightOverride ?? CATALOG_DEFAULT_PX
const open = height > CATALOG_COLLAPSED_PX
const [pickerMounted, setPickerMounted] = useState(open)
if (open && !pickerMounted) {
setPickerMounted(true)
}
useEffect(() => {
if (!open) {
return undefined
}
const onMessage = (event: MessageEvent) => {
if (event.origin !== CATALOG_ORIGIN) {
return
}
const data = event.data as null | PluginPickMessage
if (!data || data.type !== 'hermes-plugin-pick' || !data.name || !data.repo) {
return
}
// Already installed at (or past) this pin in the scoped profile →
// tell the user instead of re-running the install ceremony. Rows with
// update_available keep their explicit Update chip in the list above.
const existing = $agentPlugins
.get()
.find(row => row.catalog_name === data.name || row.name === data.name)
if (existing && !existing.update_available) {
notify({ kind: 'success', message: t.skills.plugins.alreadyInstalled(String(data.name)) })
return
}
// Open the shared dual-target install modal: it probes the repo for
// agent/desktop halves, installs the agent half at the catalog pin
// into the scoped profile, and offers the desktop half locally.
openPluginInstallRequest({
catalogName: String(data.name),
profile: scope,
repo: data.subdir ? `${String(data.repo)}#${String(data.subdir)}` : String(data.repo),
sha: data.sha ? String(data.sha) : undefined
})
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [open, scope, t])
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-32 flex-1 overflow-y-auto">
{status === 'error' ? (
<PanelEmpty
action={
<Button onClick={() => void loadAgentPlugins(requestGateway, scope)} size="sm">
{t.skills.refresh}
</Button>
}
description={error ?? undefined}
icon="error"
title={p.loadFailed}
/>
) : visible.length === 0 && status === 'ready' ? (
<PanelEmpty description={p.emptyHint} icon="package" title={p.empty} />
) : (
<div className="flex flex-col">
{visible.map(row => (
<PluginRow
busy={busyKey === (row.key ?? row.name) || busyKey === row.name}
key={row.key ?? row.name}
onToggle={enable => {
if (!row.key) {
return
}
void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope)
}}
onUpdate={
row.update_available
? () => {
void updateAgentPlugin(requestGateway, row.name, p.updateFailed(row.name), scope).then(
applied => {
if (applied) {
notify({ kind: 'success', message: p.updated(row.name) })
}
}
)
}
: undefined
}
row={row}
/>
))}
</div>
)}
</div>
<section
className={cn('relative flex min-h-9 flex-col overflow-hidden border-t border-(--ui-stroke-secondary)')}
>
<div className="flex shrink-0 items-center justify-between px-3 py-1.5">
<span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{p.catalogTitle}</span>
<Button onClick={() => setPaneHeightOverride(CATALOG_PANE_ID, open ? 0 : undefined)} size="xs" variant="text">
{open ? p.catalogHide : p.catalogBrowse}
</Button>
</div>
{pickerMounted && (
<div className={cn('flex min-h-0 flex-col gap-1 px-3 pb-2', !open && 'hidden')}>
<div
style={{
border: '1px solid var(--ui-stroke-secondary)',
borderRadius: 8,
flex: `0 1 ${height}px`,
maxWidth: '100%',
minHeight: 0,
minWidth: 320,
overflow: 'hidden',
position: 'relative',
width: '100%'
}}
>
<iframe
sandbox="allow-scripts allow-same-origin"
src={CATALOG_PICKER_URL}
style={{
background: 'transparent',
border: 'none',
height: '133.34%',
transform: 'scale(0.75)',
transformOrigin: 'top left',
width: '133.34%'
}}
title={p.catalogTitle}
/>
</div>
<p className="shrink-0 px-1 text-[0.65rem] leading-4 text-(--ui-text-quaternary)">{p.catalogHint}</p>
</div>
)}
</section>
</div>
)
})
+36 -11
View File
@@ -454,19 +454,14 @@ export const en: Translations = {
failed: 'failed',
empty: 'No desktop plugins installed yet.',
kinds: { bundled: 'bundled', disk: 'on disk', runtime: 'runtime' },
agentHalfMissing: 'agent half missing here',
agentHalfMissingTip:
'This is the desktop half of a bundled plugin, but its agent half is not installed on the currently connected backend/profile. Install it from Capabilities → Plugins.',
agent: {
title: 'Agent plugins',
blurb:
'Plugins you installed into the Hermes backend — tools, skills, MCP servers, hooks, and slash commands. Portable ones are Agent Plugins packages (skills + MCP bundles that work in other agents too). Toggles apply to new sessions.',
appliesTo: 'Applies to:',
empty: 'No agent plugins installed yet.',
loadFailed: 'Could not load agent plugins',
portable: 'portable',
search: 'Search plugins…',
noMatches: 'No plugins match your search.',
toggleFailed: (name: string) => `Could not toggle ${name}`,
updateBackendToManage: 'Update the Hermes backend to manage this plugin from Desktop.',
sources: { bundled: 'bundled', user: 'user', git: 'git', project: 'project', entrypoint: 'pip' }
movedToCapabilities:
'Agent plugins are managed per profile in Capabilities — installed list, toggles, and the plugin catalog live there.',
openCapabilities: 'Open Capabilities → Plugins'
},
installModal: {
installFromGit: 'Install from Git',
@@ -480,6 +475,15 @@ export const en: Translations = {
desktopLabel: 'Desktop UI',
agentTargetLocal: profile => `Installs into the ${profile} backend (~/.hermes/plugins/)`,
agentTargetRemote: profile => `Installs into the connected ${profile} backend`,
catalogPinned: (name, sha) =>
`Hermes catalog entry "${name}" — the agent component installs at the reviewed pin${sha ? ` ${sha}` : ''}, not the branch tip.`,
reviewedHeading: 'Reviewed catalog entry',
reviewedIntro:
'This entry was human-reviewed at its pinned commit. You can still inspect the exact code below.',
restartToApply: 'Restart the gateway for the plugin to take effect.',
restartNow: 'Restart gateway',
missingEnvAction: 'Set it up',
alreadyInstalled: (name: string) => `${name} is already installed.`,
desktopTarget: "Installs into this app's local desktop-plugins folder",
desktopOnlyNote: 'Desktop-only packages do not install a backend agent plugin.',
insecureWarning: 'This URL uses an insecure or local scheme. Prefer https:// or git@ for production installs.',
@@ -1504,6 +1508,27 @@ export const en: Translations = {
archive: 'Archive',
skillArchivedTitle: 'Skill archived',
skillArchivedMessage: 'Restorable via hermes curator restore.',
tabPlugins: 'Plugins',
plugins: {
empty: 'No agent plugins installed for this profile',
emptyHint: 'Browse the catalog below and install a reviewed plugin with one click.',
loadFailed: 'Could not load agent plugins',
toggleFailed: (name: string) => `Could not toggle ${name}`,
legacyBackend: 'This backend predates key-addressed plugin toggles — update Hermes to manage it here.',
portableBadge: 'portable',
catalogTitle: 'Plugin catalog',
catalogBrowse: 'Browse',
catalogHide: 'Hide the catalog browser',
catalogHint:
'Hit "+ Add to this Agent" on any plugin — reviewed entries install at their pinned commit into the selected profile. Bundled agent+desktop plugins offer both halves.',
alreadyInstalled: (name: string) => `${name} is already installed in this profile.`,
catalogProvenance: (sha: string) => `Installed from the Hermes catalog${sha ? ` at pin ${sha}` : ''}.`,
tierOfficial: 'official',
tierCommunity: 'community',
updateToPin: (sha: string) => `Update to ${sha}`,
updateFailed: (name: string) => `Could not update ${name}`,
updated: (name: string) => `${name} updated to the current catalog pin. Restart the gateway to apply.`
},
officialCatalog: 'Available to install',
officialPill: 'Official',
hub: {
+3 -17
View File
@@ -439,23 +439,9 @@ export const ru = defineLocale({
kinds: { bundled: 'встроенный', disk: 'на диске', runtime: 'runtime' },
agent: {
title: 'Плагины агента',
blurb:
'Плагины, установленные в бэкенд Hermes — инструменты, навыки, MCP-серверы, хуки и slash-команды. Переносимые — пакеты Agent Plugins (навыки + MCP-бандлы, работающие и в других агентах). Переключатели действуют для новых сеансов.',
appliesTo: 'Применяется к:',
empty: 'Плагины агента пока не установлены.',
loadFailed: 'Не удалось загрузить плагины агента',
portable: 'переносимый',
search: 'Поиск плагинов…',
noMatches: 'Плагины, подходящие под поиск, не найдены.',
toggleFailed: name => `Не удалось переключить ${name}`,
updateBackendToManage: 'Обновите бэкенд Hermes, чтобы управлять этим плагином из приложения.',
sources: {
bundled: 'встроенный',
user: 'пользовательский',
git: 'git',
project: 'проектный',
entrypoint: 'pip'
}
movedToCapabilities:
'Плагины агента управляются для каждого профиля в разделе «Возможности» — список установленных, переключатели и каталог плагинов находятся там.',
openCapabilities: 'Открыть Возможности → Плагины'
},
installModal: {
title: 'Установка плагина',
+31 -10
View File
@@ -396,18 +396,12 @@ export interface Translations {
failed: string
empty: string
kinds: { bundled: string; disk: string; runtime: string }
agentHalfMissing: string
agentHalfMissingTip: string
agent: {
title: string
blurb: string
appliesTo: string
empty: string
loadFailed: string
portable: string
search: string
noMatches: string
toggleFailed: (name: string) => string
updateBackendToManage: string
sources: Record<string, string>
movedToCapabilities: string
openCapabilities: string
}
installModal: {
installFromGit: string
@@ -421,6 +415,13 @@ export interface Translations {
desktopLabel: string
agentTargetLocal: (profile: string) => string
agentTargetRemote: (profile: string) => string
catalogPinned: (name: string, sha: string) => string
reviewedHeading: string
reviewedIntro: string
restartToApply: string
restartNow: string
missingEnvAction: string
alreadyInstalled: (name: string) => string
desktopTarget: string
desktopOnlyNote: string
insecureWarning: string
@@ -1320,6 +1321,26 @@ export interface Translations {
archive: string
skillArchivedTitle: string
skillArchivedMessage: string
tabPlugins: string
plugins: {
empty: string
emptyHint: string
loadFailed: string
toggleFailed: (name: string) => string
legacyBackend: string
portableBadge: string
catalogTitle: string
catalogBrowse: string
catalogHide: string
catalogHint: string
alreadyInstalled: (name: string) => string
catalogProvenance: (sha: string) => string
tierOfficial: string
tierCommunity: string
updateToPin: (sha: string) => string
updateFailed: (name: string) => string
updated: (name: string) => string
}
officialCatalog: string
officialPill: string
hub: {
+36 -12
View File
@@ -441,19 +441,14 @@ export const zh: Translations = {
failed: '失败',
empty: '尚未安装桌面插件。',
kinds: { bundled: '内置', disk: '磁盘', runtime: '运行时' },
agentHalfMissing: '此处缺少 agent 部分',
agentHalfMissingTip:
'这是捆绑插件的桌面部分,但其 agent 部分未安装在当前连接的后端/配置上。请在 能力 → 插件 中安装。',
agent: {
title: '智能体插件',
blurb:
'你安装到 Hermes 后端的插件——工具、技能、MCP 服务器、钩子和斜杠命令。「便携」插件是 Agent Plugins 标准包(技能 + MCP 组合,也可在其他智能体中使用)。开关在新会话中生效。',
appliesTo: '应用于:',
empty: '尚未安装智能体插件。',
loadFailed: '无法加载智能体插件',
portable: '便携',
search: '搜索插件…',
noMatches: '没有匹配的插件。',
toggleFailed: (name: string) => `无法切换 ${name}`,
updateBackendToManage: '请更新 Hermes 后端以便在桌面端管理此插件。',
sources: { bundled: '内置', user: '用户', git: 'git', project: '项目', entrypoint: 'pip' }
title: 'Agent 插件',
movedToCapabilities:
'Agent 插件按配置在「能力」页管理 — 已安装列表、开关和插件目录都在那里。',
openCapabilities: '打开 能力 → 插件'
},
installModal: {
installFromGit: '从 Git 安装',
@@ -467,6 +462,14 @@ export const zh: Translations = {
desktopLabel: '桌面 UI',
agentTargetLocal: profile => `安装到 ${profile} 后端(~/.hermes/plugins/)`,
agentTargetRemote: profile => `安装到已连接的 ${profile} 后端`,
catalogPinned: (name, sha) =>
`Hermes 目录条目「${name}」— agent 部分将安装在经过审核的固定提交${sha ? ` ${sha}` : ''},而不是分支最新代码。`,
reviewedHeading: '经过审核的目录条目',
reviewedIntro: '此条目已在其固定提交处经过人工审核。你仍可在下方检查确切代码。',
restartToApply: '重启网关后插件才会生效。',
restartNow: '重启网关',
missingEnvAction: '去设置',
alreadyInstalled: (name: string) => `${name} 已安装。`,
desktopTarget: '安装到此应用的本地 desktop-plugins 文件夹',
desktopOnlyNote: '仅桌面包不会安装后端智能体插件。',
insecureWarning: '此 URL 使用了不安全的本地 scheme。生产环境请优先使用 https:// 或 git@。',
@@ -1677,6 +1680,27 @@ export const zh: Translations = {
archive: '归档',
skillArchivedTitle: '技能已归档',
skillArchivedMessage: '可通过 hermes curator restore 恢复。',
tabPlugins: '插件',
plugins: {
empty: '此配置尚未安装任何 agent 插件',
emptyHint: '在下方目录中浏览,一键安装经过审核的插件。',
loadFailed: '无法加载 agent 插件',
toggleFailed: (name: string) => `无法切换 ${name}`,
legacyBackend: '此后端版本较旧,不支持按键名切换插件 — 请更新 Hermes 后再在此管理。',
portableBadge: '便携',
catalogTitle: '插件目录',
catalogBrowse: '浏览',
catalogHide: '隐藏目录浏览器',
catalogHint:
'点击任意插件上的「+ Add to this Agent」— 经过审核的条目会以其固定提交安装到所选配置。捆绑的 agent+桌面插件会同时提供两部分。',
alreadyInstalled: (name: string) => `${name} 已安装在此配置中。`,
catalogProvenance: (sha: string) => `从 Hermes 目录安装${sha ? `,固定提交 ${sha}` : ''}。`,
tierOfficial: '官方',
tierCommunity: '社区',
updateToPin: (sha: string) => `更新到 ${sha}`,
updateFailed: (name: string) => `无法更新 ${name}`,
updated: (name: string) => `${name} 已更新到当前目录固定提交。重启网关后生效。`
},
officialCatalog: '可安装',
officialPill: '官方',
hub: {
+55 -4
View File
@@ -26,6 +26,14 @@ export interface AgentPluginRow {
status: 'enabled' | 'disabled' | 'not enabled'
/** Agent Plugins v1 package (portable skills/MCP format) vs native Hermes. */
portable?: boolean
/** Curated-catalog provenance (from the install sidecar), when present. */
catalog_name?: string
catalog_tier?: string
installed_sha?: string
/** Current catalog pin for this entry (backend-computed). */
catalog_sha?: string
/** Installed SHA differs from the catalog pin — an update is available. */
update_available?: boolean
}
export type AgentPluginsStatus = 'idle' | 'loading' | 'ready' | 'error'
@@ -179,7 +187,16 @@ export interface AgentPluginInstallResult {
export async function installAgentPlugin(
request: GatewayRequest,
opts: { identifier: string; force?: boolean; enable?: boolean }
opts: {
identifier: string
force?: boolean
enable?: boolean
/** Curated-catalog install: the backend resolves repo + pinned SHA from
* its own plugin-catalog and records provenance in the sidecar. */
catalogName?: string
/** Target profile's HERMES_HOME (null/undefined = backend launch profile). */
profile?: string | null
}
): Promise<AgentPluginInstallResult> {
try {
const result = await request<{
@@ -188,12 +205,13 @@ export async function installAgentPlugin(
warnings?: string[]
missing_env?: string[]
error?: string
}>('plugins.manage', {
}>('plugins.manage', withProfile({
action: 'install',
identifier: opts.identifier,
force: Boolean(opts.force),
enable: opts.enable ?? true
})
enable: opts.enable ?? true,
...(opts.catalogName ? { catalog_name: opts.catalogName } : {})
}, opts.profile))
if (!result?.ok) {
return { ok: false, error: result?.error || 'Install failed' }
@@ -209,3 +227,36 @@ export async function installAgentPlugin(
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}
/** Re-pin a catalog-installed plugin to the current catalog SHA (backend
* `plugins.manage update`; catalog installs only). Refreshes the list on
* success. Returns whether the update applied. */
export async function updateAgentPlugin(
request: GatewayRequest,
name: string,
failMessage: string,
profile?: string | null
): Promise<boolean> {
$agentPluginBusy.set(name)
try {
const result = await request<{ ok?: boolean; unchanged?: boolean }>(
'plugins.manage',
withProfile({ action: 'update', name }, profile)
)
if (!result?.ok) {
throw new Error(failMessage)
}
await loadAgentPlugins(request, profile)
return !result.unchanged
} catch (e) {
notifyError(e, failMessage)
return false
} finally {
$agentPluginBusy.set(null)
}
}
@@ -10,6 +10,14 @@ export interface PluginInstallRequest {
enable?: boolean
force?: boolean
legacyHint?: PluginInstallLegacyHint
/** Curated-catalog pick: install the agent half by catalog name so the
* backend pins the reviewed SHA and records sidecar provenance. */
catalogName?: string
/** The catalog pin (display only — the backend resolves it itself). */
sha?: string
/** Capabilities profile scope the pick was made under; the agent half
* installs into THIS profile (null/undefined = active profile). */
profile?: string | null
}
export const $pluginInstallRequest = atom<PluginInstallRequest | null>(null)
+1 -1
View File
@@ -3137,7 +3137,7 @@ _SCHEMA_DEFINED_DICT_KEYS = frozenset({
"email", "sms", "dingtalk",
# MCP server template / dynamic auth dicts
"sessions", "checkpoints",
# Plugin enable/disable lists + index_url override; absent from DEFAULT_CONFIG.
# Plugin enable/disable lists + per-plugin entries; absent from DEFAULT_CONFIG.
"plugins"})
# Top-level keys that can be ANY user-supplied name.
-68
View File
@@ -1,68 +0,0 @@
{
"schema_version": 1,
"generated_at": "2026-08-12T00:00:00Z",
"plugins": [
{
"name": "hermes-media-studio",
"description": "Media Studio — generative media workspace plugin for Hermes Desktop (fal + Krea, durable job queue, library).",
"author": "NousResearch",
"tags": ["media", "image-gen", "video-gen", "dashboard", "desktop"],
"repo": "NousResearch/hermes-media-studio",
"ref": "e8d59971d2b7901405b39dac7b03bdd616272d0d",
"homepage": "https://github.com/NousResearch/hermes-media-studio",
"capabilities": ["tools", "dashboard"],
"api_version": 1,
"added_at": "2026-08-12"
},
{
"name": "hermes-telegram-business",
"description": "Observe-with-approval Telegram Business Mode (secretary bot) plugin — every drafted reply requires owner approval before it reaches the customer.",
"author": "NousResearch",
"tags": ["telegram", "gateway", "approvals", "messaging"],
"repo": "NousResearch/hermes-telegram-business",
"ref": "e905f3bc5eeaa5a9dab9bc5155601b3ebec75757",
"homepage": "https://github.com/NousResearch/hermes-telegram-business",
"capabilities": ["platform"],
"api_version": 1,
"added_at": "2026-08-12"
},
{
"name": "plugin-llm-example",
"description": "Reference plugin showing host-owned structured LLM access via ctx.llm.complete_structured(). Registers a /receipt-extract slash command.",
"author": "NousResearch",
"tags": ["example", "llm", "reference", "slash-command"],
"repo": "NousResearch/hermes-example-plugins",
"subdir": "plugin-llm-example",
"ref": "38fe0fb53eff98d477f807432e965429e665ca33",
"homepage": "https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-example",
"capabilities": ["commands", "llm"],
"api_version": 1,
"added_at": "2026-08-12"
},
{
"name": "plugin-llm-async-example",
"description": "Reference plugin demonstrating async host-owned LLM access from plugin code — the asyncio counterpart to plugin-llm-example.",
"author": "NousResearch",
"tags": ["example", "llm", "async", "reference"],
"repo": "NousResearch/hermes-example-plugins",
"subdir": "plugin-llm-async-example",
"ref": "38fe0fb53eff98d477f807432e965429e665ca33",
"homepage": "https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-async-example",
"capabilities": ["commands", "llm"],
"api_version": 1,
"added_at": "2026-08-12"
},
{
"name": "hermes-plugin-chrome-profiles",
"description": "Switch Hermes browser tools between Chrome profiles via CDP.",
"author": "anpicasso",
"tags": ["browser", "chrome", "cdp", "tools"],
"repo": "anpicasso/hermes-plugin-chrome-profiles",
"ref": "5b9c3257b464c0f926d4355149a8aed9c8f307b4",
"homepage": "https://github.com/anpicasso/hermes-plugin-chrome-profiles",
"capabilities": ["tools"],
"api_version": 1,
"added_at": "2026-08-12"
}
]
}
+287
View File
@@ -0,0 +1,287 @@
"""Plugin catalog — curated, Nous-approved Hermes plugins shipped with the repo.
Mirrors the ``optional-mcps/`` MCP-catalog pattern: one YAML file per entry under the in-tree
``plugin-catalog/`` directory, pinned to an exact 40-character commit SHA. Presence in the directory IS
the human-merged approval gate; SHA bumps are new, re-reviewed PRs; ``removed.yaml`` is the kill list
(installs of a removed name/repo are refused with the recorded reason). Full policy:
``plugin-catalog/README.md``.
Live refresh: the docs build publishes the same data as ONE JSON document
(``website/scripts/extract-plugins.py`` → ``/docs/api/plugin-catalog.json``, like the skills index), so
an installed Hermes sees new entries and removals without updating. Any fetch failure falls back to the
in-tree copy silently.
"""
from __future__ import annotations
import json
import logging
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
logger = logging.getLogger(__name__)
CATALOG_TIERS = ("official", "community")
LIVE_CATALOG_URL = "https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json"
LIVE_CATALOG_TTL_SECONDS = 6 * 60 * 60
_REQUEST_TIMEOUT = 5.0
_MAX_LIVE_BYTES = 2 * 1024 * 1024
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
@dataclass
class RemovedEntry:
name: str
repo: str = ""
reason: str = ""
date: str = ""
@dataclass
class CatalogCapabilities:
provides_tools: List[str] = field(default_factory=list)
provides_hooks: List[str] = field(default_factory=list)
provides_middleware: List[str] = field(default_factory=list)
requires_env: List[str] = field(default_factory=list)
@dataclass
class PluginCatalogEntry:
name: str # catalog key, [a-z0-9_-]{1,64}
repo: str # https:// git URL
sha: str # 40-hex pinned commit — mandatory
description: str
maintainer: str
tier: str = "community"
requires_hermes: str = ""
subdir: str = ""
docs_url: str = ""
platforms: List[str] = field(default_factory=list) # empty = all OSes
capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities)
@property
def install_identifier(self) -> str:
"""``_install_plugin_core`` identifier (``repo#subdir`` for monorepo entries)."""
return f"{self.repo}#{self.subdir}" if self.subdir else self.repo
def to_dict(self) -> Dict[str, Any]:
caps = self.capabilities
return {
"name": self.name, "repo": self.repo, "sha": self.sha, "description": self.description,
"maintainer": self.maintainer, "tier": self.tier, "requires_hermes": self.requires_hermes,
"subdir": self.subdir, "docs_url": self.docs_url, "platforms": list(self.platforms),
"capabilities": {
"provides_tools": list(caps.provides_tools), "provides_hooks": list(caps.provides_hooks),
"provides_middleware": list(caps.provides_middleware), "requires_env": list(caps.requires_env),
},
}
def get_catalog_dir() -> Path:
"""The ``plugin-catalog/`` directory shipped with this checkout."""
return Path(__file__).resolve().parent.parent / "plugin-catalog"
# ── Parsing ──────────────────────────────────────────────────────────────────
def _str_list(raw: Any) -> List[str]:
return [str(x) for x in raw if isinstance(x, (str, int, float))] if isinstance(raw, list) else []
def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]:
"""Validate one entry mapping (YAML file or live-JSON element); ``None`` + warning on any failure."""
if not isinstance(data, dict):
logger.warning("Plugin catalog: %s: entry must be a mapping", label)
return None
name = str(data.get("name") or "")
repo = str(data.get("repo") or "")
sha = str(data.get("sha") or "").strip().lower()
tier = str(data.get("tier") or "community")
problem = (
f"invalid name {name!r} (must match [a-z0-9_-]{{1,64}})" if not _NAME_RE.match(name)
else f"repo must be an https:// URL (got {repo!r})" if not repo.startswith("https://")
else f"sha must be a full 40-character hex commit SHA (got {data.get('sha')!r})" if not _SHA_RE.match(sha)
else f"tier must be one of {'/'.join(CATALOG_TIERS)} (got {tier!r})" if tier not in CATALOG_TIERS
else None)
if problem:
logger.warning("Plugin catalog: %s: %s", label, problem)
return None
caps_raw = data.get("capabilities")
caps: Dict[str, Any] = caps_raw if isinstance(caps_raw, dict) else {}
return PluginCatalogEntry(
name=name, repo=repo, sha=sha,
description=str(data.get("description") or "").strip(),
maintainer=str(data.get("maintainer") or "").strip(), tier=tier,
requires_hermes=str(data.get("requires_hermes") or "").strip(),
subdir=str(data.get("subdir") or "").strip(), docs_url=str(data.get("docs_url") or "").strip(),
platforms=_str_list(data.get("platforms")),
capabilities=CatalogCapabilities(
provides_tools=_str_list(caps.get("provides_tools")), provides_hooks=_str_list(caps.get("provides_hooks")),
provides_middleware=_str_list(caps.get("provides_middleware")),
requires_env=_str_list(caps.get("requires_env"))),
)
def _read_yaml(path: Path) -> Any:
try:
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as exc:
logger.warning("Plugin catalog: failed to read %s: %s", path, exc)
return None
def _removed_from_list(raw_list: Any) -> List[RemovedEntry]:
if not isinstance(raw_list, list):
return []
return [
RemovedEntry(name=str(r["name"]), repo=str(r.get("repo") or ""), reason=str(r.get("reason") or ""),
date=str(r.get("date") or ""))
for r in raw_list if isinstance(r, dict) and r.get("name")]
# ── In-tree catalog ──────────────────────────────────────────────────────────
def load_catalog(catalog_dir: Optional[Path] = None) -> List[PluginCatalogEntry]:
"""Every valid ``*.yaml`` entry in the catalog dir (``removed.yaml`` excluded), sorted by file name.
Malformed entries are skipped with a warning — never raises."""
root = catalog_dir or get_catalog_dir()
if not root.is_dir():
return []
entries = []
for path in sorted(root.glob("*.yaml")):
if path.name == "removed.yaml":
continue
data = _read_yaml(path)
entry = entry_from_mapping(data, str(path)) if data is not None else None
if entry is not None:
entries.append(entry)
return entries
def load_removed_list(catalog_dir: Optional[Path] = None) -> List[RemovedEntry]:
"""``removed.yaml``'s ``removed:`` list; missing/malformed → empty."""
path = (catalog_dir or get_catalog_dir()) / "removed.yaml"
data = _read_yaml(path) if path.is_file() else None
return _removed_from_list(data.get("removed")) if isinstance(data, dict) else []
def get_catalog_entry(name: str, catalog_dir: Optional[Path] = None) -> Optional[PluginCatalogEntry]:
return next((e for e in load_catalog(catalog_dir) if e.name == name), None)
def filter_entries(entries: List[PluginCatalogEntry], query: str) -> List[PluginCatalogEntry]:
"""Case-insensitive substring match over name, description and declared tools; empty query = all."""
q = (query or "").strip().lower()
if not q:
return entries
return [e for e in entries
if any(q in h.lower() for h in (e.name, e.description, *e.capabilities.provides_tools))]
def search_catalog(query: str) -> List[PluginCatalogEntry]:
return filter_entries(load_catalog(), query)
# ── Removed / blocklist ──────────────────────────────────────────────────────
def _normalize_repo(url: str) -> str:
return url.strip().rstrip("/").removesuffix(".git").lower()
def find_removed(name_or_repo: str, catalog_dir: Optional[Path] = None) -> Optional[RemovedEntry]:
"""Match a catalog name or repo URL (``.git``/trailing-slash insensitive) against the kill list.
The in-tree list and the live-fetched list are UNIONED: a removal published after this checkout
shipped must still block, and a stale live cache must not un-block an in-tree removal.
"""
if not name_or_repo:
return None
candidate = name_or_repo.strip()
candidate_repo = _normalize_repo(candidate)
for entry in load_removed_list(catalog_dir) + (live_removed_list() if catalog_dir is None else []):
if candidate == entry.name or (entry.repo and candidate_repo == _normalize_repo(entry.repo)):
return entry
return None
# ── Live catalog ─────────────────────────────────────────────────────────────
def _live_cache_path() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "plugin-catalog.json"
def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]:
"""The published ``plugin-catalog.json`` (``{"entries": [...], "removed": [...]}``), cached under
``HERMES_HOME/cache`` for :data:`LIVE_CATALOG_TTL_SECONDS`. ``None`` on ANY failure — callers fall
back to the in-tree catalog."""
cache = _live_cache_path()
try:
if not force and cache.is_file() and time.time() - cache.stat().st_mtime < LIVE_CATALOG_TTL_SECONDS:
return json.loads(cache.read_text(encoding="utf-8"))
except Exception as exc:
logger.debug("Plugin catalog: unreadable live cache %s: %s", cache, exc)
try:
import httpx
resp = httpx.get(LIVE_CATALOG_URL, timeout=_REQUEST_TIMEOUT, follow_redirects=True)
resp.raise_for_status()
if len(resp.content) > _MAX_LIVE_BYTES:
raise ValueError("live catalog payload too large")
data = resp.json()
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
raise ValueError("unexpected live catalog payload")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(data), encoding="utf-8")
return data
except Exception as exc:
logger.debug("Plugin catalog: live fetch failed: %s", exc)
try: # stale cache still beats the in-tree copy when the network is down
return json.loads(cache.read_text(encoding="utf-8")) if cache.is_file() else None
except Exception:
return None
def load_catalog_live() -> List[PluginCatalogEntry]:
"""Entries from the live (or cached) catalog, else the in-tree catalog."""
data = fetch_live_catalog()
if data is not None:
entries = [e for i, raw in enumerate(data["entries"])
if (e := entry_from_mapping(raw, f"{LIVE_CATALOG_URL}#{i}")) is not None]
if entries:
return entries
return load_catalog()
def live_removed_list() -> List[RemovedEntry]:
data = fetch_live_catalog()
return _removed_from_list(data.get("removed")) if data else []
def get_live_catalog_entry(name: str) -> Optional[PluginCatalogEntry]:
return next((e for e in load_catalog_live() if e.name == name), None)
# ── Human summaries ──────────────────────────────────────────────────────────
def entry_capability_summary(entry: PluginCatalogEntry) -> str:
"""One paragraph shown at install/enable prompts: what the user is granting."""
caps = entry.capabilities
parts = [f"{label} {', '.join(items)}" for label, items in (
("registers tool(s):", caps.provides_tools), ("hook(s):", caps.provides_hooks),
("middleware:", caps.provides_middleware), ("requires env var(s):", caps.requires_env)) if items]
bits = [f"{entry.name} ({entry.tier}, maintained by {entry.maintainer})"]
if entry.description:
bits.append(entry.description)
bits.append(f"This plugin {'; '.join(parts) if parts else 'declares no tools, hooks, middleware, or env vars'}.")
if entry.platforms:
bits.append(f"Platforms: {', '.join(entry.platforms)}.")
if entry.requires_hermes:
bits.append(f"Requires Hermes {entry.requires_hermes}.")
return " ".join(bits)
-231
View File
@@ -1,231 +0,0 @@
"""Community plugin index — fetch, cache, search, and name resolution.
Mirrors the Skills Hub catalog (``tools/skills_hub.py``): a static JSON index at a canonical URL,
cached under ``HERMES_HOME/cache/`` with a TTL, bundled seed as offline fallback / format reference.
Fallback chain: fresh cache → remote → stale cache → bundled seed.
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, List, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# Canonical index location. Override via config key ``plugins.index_url``.
DEFAULT_INDEX_URL = "https://raw.githubusercontent.com/NousResearch/hermes-plugin-index/main/index.json"
INDEX_CACHE_TTL = 24 * 3600 # a stale cache still beats the bundled seed when remote is unreachable
SEED_INDEX_PATH = Path(__file__).parent / "data" / "plugin_index.json"
_FETCH_TIMEOUT = 10.0
_MAX_INDEX_BYTES = 5 * 1024 * 1024 # refuse absurdly large index payloads
SECURITY_FOOTER = (
"Indexed \u2260 audited: inclusion in the index is a metadata review only, "
"not a code audit. Review a plugin before enabling it.")
@dataclass
class PluginIndexEntry:
"""One community plugin index entry."""
name: str
description: str = ""
author: str = ""
tags: List[str] = field(default_factory=list)
repo: str = "" # "owner/name"
ref: str = "" # pinned tag or commit SHA
subdir: Optional[str] = None # path within the repo (monorepos)
homepage: Optional[str] = None
capabilities: List[str] = field(default_factory=list)
api_version: Optional[int] = None
added_at: Optional[str] = None
@property
def install_identifier(self) -> str:
"""Identifier accepted by the existing install path (owner/repo[/subdir])."""
return f"{self.repo}/{self.subdir}" if self.subdir else self.repo
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {
"name": self.name, "description": self.description, "author": self.author,
"tags": list(self.tags), "repo": self.repo, "ref": self.ref,
}
# Optional keys are emitted only when set (api_version: only when not None).
for key, value in (
("subdir", self.subdir), ("homepage", self.homepage),
("capabilities", list(self.capabilities)),
("api_version", self.api_version), ("added_at", self.added_at),
):
if value or (key == "api_version" and value is not None):
d[key] = value
return d
def _cache_path() -> Path:
return get_hermes_home() / "cache" / "plugin_index.json"
def get_index_url() -> str:
"""Resolve the index URL: config override ``plugins.index_url`` or default."""
try:
from hermes_cli.config import cfg_get, load_config_readonly
override = cfg_get(load_config_readonly(), "plugins", "index_url", default=None)
if isinstance(override, str) and override.strip():
return override.strip()
except Exception: # pragma: no cover - config loading must never break search
logger.debug("plugin index: config override lookup failed", exc_info=True)
return DEFAULT_INDEX_URL
def _parse_entries(raw: Any) -> List[PluginIndexEntry]:
"""Parse a decoded index document (object with ``plugins`` or bare list), skipping malformed items."""
if isinstance(raw, dict):
raw = raw.get("plugins", [])
if not isinstance(raw, list):
raise ValueError("Plugin index 'plugins' field must be a list.")
elif not isinstance(raw, list):
raise ValueError("Plugin index must be a JSON object or list.")
entries: List[PluginIndexEntry] = []
for item in raw:
if not isinstance(item, dict):
continue
name = item.get("name")
repo = item.get("repo")
if not isinstance(name, str) or not name.strip():
continue
if not isinstance(repo, str) or repo.count("/") != 1 or not all(repo.split("/")):
logger.debug("plugin index: skipping entry %r with invalid repo %r", name, repo)
continue
subdir = item.get("subdir")
api_version = item.get("api_version")
entries.append(PluginIndexEntry(
name=name.strip(),
description=str(item.get("description") or ""),
author=str(item.get("author") or ""),
tags=[str(t) for t in item.get("tags") or [] if isinstance(t, (str, int))],
repo=repo.strip(),
ref=str(item.get("ref") or ""),
subdir=subdir.strip("/") if isinstance(subdir, str) and subdir.strip("/") else None,
homepage=str(item["homepage"]) if item.get("homepage") else None,
capabilities=[str(c) for c in item.get("capabilities") or []],
api_version=int(api_version) if isinstance(api_version, (int, str)) and str(api_version).isdigit() else None,
added_at=str(item["added_at"]) if item.get("added_at") else None))
return entries
def _load_seed_entries() -> List[PluginIndexEntry]:
try:
return _parse_entries(json.loads(SEED_INDEX_PATH.read_text(encoding="utf-8")))
except (OSError, ValueError) as exc: # pragma: no cover - bundled file
logger.warning("plugin index: bundled seed unreadable: %s", exc)
return []
def _read_cache(*, max_age: Optional[float]) -> Optional[List[PluginIndexEntry]]:
"""Return cached entries if the cache exists (and is younger than *max_age*)."""
cache = _cache_path()
try:
if not cache.is_file():
return None
if max_age is not None and time.time() - cache.stat().st_mtime > max_age:
return None
return _parse_entries(json.loads(cache.read_text(encoding="utf-8")))
except (OSError, ValueError) as exc:
logger.debug("plugin index: cache read failed: %s", exc)
return None
def _write_cache(text: str) -> None:
try:
cache = _cache_path()
cache.parent.mkdir(parents=True, exist_ok=True)
from utils import atomic_write_text
atomic_write_text(cache, text)
except OSError as exc: # pragma: no cover - best effort
logger.debug("plugin index: cache write failed: %s", exc)
def _fetch_remote() -> Optional[List[PluginIndexEntry]]:
"""Fetch and parse the remote index; cache the raw payload on success."""
url = get_index_url()
try:
import httpx
resp = httpx.get(url, timeout=_FETCH_TIMEOUT, follow_redirects=True)
resp.raise_for_status()
text = resp.text
if len(text.encode("utf-8", errors="ignore")) > _MAX_INDEX_BYTES:
raise ValueError("Plugin index payload exceeds size limit.")
entries = _parse_entries(json.loads(text))
_write_cache(text)
return entries
except Exception as exc:
logger.debug("plugin index: remote fetch failed (%s): %s", url, exc)
return None
def load_index(*, refresh: bool = False, offline: bool = False) -> tuple[List[PluginIndexEntry], str]:
"""Load the plugin index as ``(entries, source)``; source is ``"remote"``/``"cache"``/``"seed"``.
Order: fresh cache (unless *refresh*) → remote (unless *offline*) → stale cache → seed."""
if not refresh:
cached = _read_cache(max_age=INDEX_CACHE_TTL)
if cached is not None:
return cached, "cache"
if not offline:
remote = _fetch_remote()
if remote is not None:
return remote, "remote"
stale = _read_cache(max_age=None)
if stale is not None:
return stale, "cache"
return _load_seed_entries(), "seed"
def _score_entry(entry: PluginIndexEntry, term: str) -> float:
"""Fuzzy relevance score for *entry* against lowercase *term* (0 = no match)."""
import difflib
name = entry.name.lower()
tags = [t.lower() for t in entry.tags]
if term == name:
return 100.0
ratio = difflib.SequenceMatcher(None, term, name).ratio() # typo tolerance on the name
signals = (
(term in name, 80.0), (term in tags, 70.0), (any(term in t for t in tags), 55.0),
(term in entry.description.lower(), 50.0), (term in entry.author.lower(), 40.0),
(ratio >= 0.6, ratio * 60.0))
return max((points for hit, points in signals if hit), default=0.0)
def search_index(
entries: List[PluginIndexEntry], term: str, *, capability: Optional[str] = None
) -> List[PluginIndexEntry]:
"""Rank *entries* against *term* (fuzzy on name/description/tags/author)."""
pool = entries
if capability:
cap = capability.lower()
pool = [e for e in entries if any(cap == c.lower() for c in e.capabilities)]
term = (term or "").strip().lower()
if not term:
return sorted(pool, key=lambda e: e.name)
matched = [(e, s) for e in pool if (s := _score_entry(e, term)) > 0]
matched.sort(key=lambda pair: (-pair[1], pair[0].name))
return [e for e, _s in matched]
def resolve_name(
entries: List[PluginIndexEntry], name: str
) -> tuple[Optional[PluginIndexEntry], List[PluginIndexEntry]]:
"""Resolve a bare *name*: ``(entry, candidates)`` — a unique case-insensitive match in
``entry``, else ``None`` with the partial matches (empty = nothing similar, >1 = ambiguous)."""
lowered = name.strip().lower()
exact = [e for e in entries if e.name.lower() == lowered]
matches = exact or [e for e in entries if lowered in e.name.lower()]
return (matches[0] if len(matches) == 1 else None), matches
+12 -12
View File
@@ -45,7 +45,7 @@ class PackPluginEntry:
@property
def install_identifier(self) -> Optional[str]:
"""Identifier for the install path; None for bare names (resolved via the community index)."""
"""Identifier for the install path; None for bare names (resolved via the plugin catalog)."""
if self.repo:
return f"{self.repo}/{self.subdir}" if self.subdir else self.repo
return None
@@ -199,31 +199,31 @@ class ResolvedPackPlugin:
def resolve_pack_plugins(pack: PluginPack) -> List[ResolvedPackPlugin]:
"""Resolve every entry; bare names go through the community index. Failures do not raise —
"""Resolve every entry; bare names go through the curated plugin catalog. Failures do not raise —
they are carried per-entry so the review screen shows them and install reports partial failure."""
resolved: List[ResolvedPackPlugin] = []
index_entries = None
catalog_entries = None
for entry in pack.plugins:
if entry.install_identifier is not None:
resolved.append(ResolvedPackPlugin(entry=entry, identifier=entry.install_identifier))
continue
try:
from hermes_cli.plugin_index import load_index, resolve_name
if index_entries is None:
index_entries, _src = load_index()
match, candidates = resolve_name(index_entries, entry.name or "")
except Exception as exc: # index load must not crash pack handling
if catalog_entries is None:
from hermes_cli.plugin_catalog import load_catalog_live
catalog_entries = load_catalog_live()
except Exception as exc: # catalog load must not crash pack handling
resolved.append(ResolvedPackPlugin(
entry=entry, identifier=None, resolve_error=f"community index unavailable: {exc}"))
entry=entry, identifier=None, resolve_error=f"plugin catalog unavailable: {exc}"))
continue
match = next((e for e in catalog_entries if e.name == (entry.name or "")), None)
if match is None:
detail = "ambiguous" if len(candidates) > 1 else "not found"
resolved.append(ResolvedPackPlugin(
entry=entry, identifier=None, resolve_error=f"{detail} in the community index"))
entry=entry, identifier=None, resolve_error="not found in the plugin catalog"))
continue
caps = match.capabilities
resolved.append(ResolvedPackPlugin(
entry=entry, identifier=match.install_identifier,
index_capabilities=list(match.capabilities)))
index_capabilities=[*caps.provides_tools, *caps.provides_hooks, *caps.provides_middleware]))
return resolved
+487
View File
@@ -0,0 +1,487 @@
"""``hermes plugins validate`` — admission checks for a plugin directory.
This is the command the plugin-catalog admission CI (and the
``.github/actions/plugin-validate`` composite action) runs against a
candidate plugin. It performs static manifest checks plus a
subprocess-isolated capability probe: the plugin is imported and its
``register(ctx)`` called against a minimal recording stub context in a
scratch child process (with a throwaway ``HERMES_HOME``), so a crashing or
malicious plugin cannot take down the CLI, and the *actually registered*
tools/hooks/middleware are compared against the manifest's declared
``provides_*`` lists.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
_UPPER_SNAKE_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
_CONFIG_TYPES = {
"str", "string", "int", "integer", "float", "number",
"bool", "boolean", "list", "array", "dict", "mapping", "map",
}
_PROBE_TIMEOUT = 30
_PROBE_SENTINEL = "HERMES_VALIDATE_JSON:"
@dataclass
class ValidationReport:
"""Result of validating one plugin directory."""
checks: List[Tuple[str, bool, str]] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
@property
def failures(self) -> List[str]:
return [detail or name for name, ok, detail in self.checks if not ok]
@property
def ok(self) -> bool:
return all(ok for _name, ok, _detail in self.checks)
@property
def exit_code(self) -> int:
return 0 if self.ok else 1
def add(self, name: str, ok: bool, detail: str = "") -> None:
self.checks.append((name, ok, detail))
def warn(self, message: str) -> None:
self.warnings.append(message)
def to_dict(self) -> Dict[str, Any]:
return {
"ok": self.ok,
"checks": [
{"name": name, "ok": ok, "detail": detail}
for name, ok, detail in self.checks
],
"warnings": list(self.warnings),
}
# ─── Static checks ───────────────────────────────────────────────────────────
def _requires_hermes_spec_valid(spec: str) -> bool:
"""Strictly validate a ``requires_hermes`` spec.
Unlike :func:`hermes_cli.plugins._version_satisfies` (permissive at load
time), validation REJECTS clauses whose version segment doesn't parse —
a typo'd spec should fail admission, not silently gate nothing.
"""
from hermes_cli.plugins import _VERSION_COMPARATOR_RE, _version_tuple
for clause in spec.split(","):
clause = clause.strip()
if not clause:
continue
m = _VERSION_COMPARATOR_RE.match(clause)
target = m.group(2) if m else clause
if _version_tuple(target) is None:
return False
return True
def _check_manifest_fields(report: ValidationReport, manifest: dict) -> None:
missing = [
f for f in ("name", "version", "description") if not manifest.get(f)
]
if missing:
report.add(
"manifest fields",
False,
f"plugin.yaml missing required field(s): {', '.join(missing)}",
)
else:
report.add("manifest fields", True, "name, version, description present")
def _check_requires_hermes(report: ValidationReport, manifest: dict) -> None:
spec = str(manifest.get("requires_hermes") or "").strip()
if not spec:
report.add("requires_hermes", True, "not declared")
return
if _requires_hermes_spec_valid(spec):
report.add("requires_hermes", True, f"spec {spec!r} parses")
else:
report.add(
"requires_hermes",
False,
f"requires_hermes spec {spec!r} does not parse "
"(expected e.g. \">=0.19\" or \">=0.19, <1.0\")",
)
def _check_config_spec(report: ValidationReport, manifest: dict) -> None:
"""Validate the manifest ``config_schema`` mapping (#64165 format)."""
raw = manifest.get("config_schema")
if raw in (None, [], {}):
report.add("config schema", True, "not declared")
return
problems: List[str] = []
if not isinstance(raw, dict):
problems.append("config_schema: must be a mapping of key -> spec")
else:
for skey, spec in raw.items():
if not isinstance(spec, dict):
problems.append(
f"config_schema.{skey}: must be a mapping (e.g. {{type: str}})"
)
continue
typ = spec.get("type")
if typ is not None and str(typ).lower() not in _CONFIG_TYPES:
problems.append(
f"config_schema.{skey}: type must be one of "
f"{'/'.join(sorted(_CONFIG_TYPES))}"
)
required = spec.get("required")
if required is not None and not isinstance(required, bool):
problems.append(
f"config_schema.{skey}: required must be a boolean"
)
if problems:
report.add("config schema", False, "; ".join(problems))
else:
report.add("config schema", True, "shape valid")
def _check_requires_env(report: ValidationReport, manifest: dict) -> None:
raw = manifest.get("requires_env") or []
problems: List[str] = []
if not isinstance(raw, list):
problems.append("requires_env: must be a list")
raw = []
for i, entry in enumerate(raw):
if isinstance(entry, str):
name = entry
elif isinstance(entry, dict):
name = str(entry.get("name") or "")
else:
problems.append(f"requires_env[{i}]: must be a string or mapping")
continue
if not _UPPER_SNAKE_RE.match(name):
problems.append(
f"requires_env[{i}]: {name!r} is not UPPER_SNAKE_CASE"
)
if problems:
report.add("requires_env", False, "; ".join(problems))
else:
report.add("requires_env", True, "all entries UPPER_SNAKE")
# ─── Capability probe (subprocess-isolated) ──────────────────────────────────
# Self-contained harness run in a scratch child process. Imports the plugin
# module using the same file-location mechanics PluginManager uses, calls
# register() against a recording stub ctx, and prints a sentinel-prefixed
# JSON line of what was actually registered. Deliberately imports NOTHING
# from hermes so a hostile plugin only sees a bare interpreter.
_PROBE_SCRIPT = r"""
import importlib.util
import json
import sys
plugin_dir = sys.argv[1]
sentinel = sys.argv[2]
recorded = {"tools": [], "hooks": [], "middleware": [], "commands": []}
class RecordingContext:
plugin_config = {}
profile_name = "default"
def register_tool(self, name, *args, **kwargs):
recorded["tools"].append(str(name))
def register_hook(self, hook_name, callback):
recorded["hooks"].append(str(hook_name))
def register_middleware(self, kind, callback):
recorded["middleware"].append(str(kind))
def register_command(self, name, *args, **kwargs):
recorded["commands"].append(str(name))
def register_cli_command(self, name, *args, **kwargs):
recorded["commands"].append(str(name))
def get_config(self, key, default=None):
# Mirrors PluginContext.get_config with no config on disk: the DEFAULT, never None —
# plugins do `int(ctx.get_config("timeout", 180))` in register().
return default
def __getattr__(self, _name):
# Any other registration surface (platforms, providers, skills,
# context engines, ...) is accepted as a no-op — the probe only
# audits the declared-capability categories.
def _noop(*args, **kwargs):
return None
return _noop
def emit(payload):
print(sentinel + json.dumps(payload))
try:
spec = importlib.util.spec_from_file_location(
"hermes_validate_probe_plugin",
plugin_dir + "/__init__.py",
submodule_search_locations=[plugin_dir],
)
module = importlib.util.module_from_spec(spec)
module.__path__ = [plugin_dir]
sys.modules[spec.name] = module
spec.loader.exec_module(module)
except Exception as exc:
emit({"error": "import failed: %s" % exc})
sys.exit(0)
register = getattr(module, "register", None)
if register is None:
emit({"error": "no register() function"})
sys.exit(0)
try:
register(RecordingContext())
except Exception as exc:
emit({"error": "register() raised: %s" % exc})
sys.exit(0)
emit(recorded)
"""
def _run_capability_probe(plugin_dir: Path) -> Tuple[Optional[dict], str]:
"""Run the recording probe in a scratch subprocess.
Returns ``(recorded, error)`` — exactly one is meaningful: *recorded*
is the ``{tools, hooks, middleware, commands}`` dict on success, and
*error* is a human-readable failure description otherwise.
"""
with tempfile.TemporaryDirectory(prefix="hermes-validate-") as scratch:
env = dict(os.environ)
env["HERMES_HOME"] = scratch
try:
result = subprocess.run(
[
sys.executable,
"-c",
_PROBE_SCRIPT,
str(plugin_dir),
_PROBE_SENTINEL,
],
capture_output=True,
text=True,
timeout=_PROBE_TIMEOUT,
env=env,
)
except subprocess.TimeoutExpired:
return None, f"capability probe timed out after {_PROBE_TIMEOUT}s"
payload: Optional[dict] = None
for line in (result.stdout or "").splitlines():
if line.startswith(_PROBE_SENTINEL):
try:
payload = json.loads(line[len(_PROBE_SENTINEL):])
except json.JSONDecodeError:
payload = None
if payload is None:
err = (result.stderr or "").strip()
return None, (
"capability probe produced no result "
f"(exit {result.returncode})" + (f": {err}" if err else "")
)
if "error" in payload:
return None, str(payload["error"])
return payload, ""
def _declared_list(manifest: dict, key: str) -> List[str]:
raw = manifest.get(key) or []
if not isinstance(raw, list):
return []
return [str(item) for item in raw if isinstance(item, str)]
def _check_capabilities(
report: ValidationReport, manifest: dict, plugin_dir: Path
) -> Optional[dict]:
"""Probe actual registrations and diff against declared capabilities.
Returns the recorded dict (for the built-in collision check) or None
when the probe failed / was skipped.
"""
if not (plugin_dir / "__init__.py").is_file():
report.warn(
"no __init__.py — capability probe skipped (manifest-only plugin)"
)
report.add("capability probe", True, "skipped (no __init__.py)")
return None
recorded, error = _run_capability_probe(plugin_dir)
if recorded is None:
report.add("capability probe", False, error)
return None
report.add("capability probe", True, "register() ran in isolation")
for kind, manifest_key in (
("tools", "provides_tools"),
("hooks", "provides_hooks"),
("middleware", "provides_middleware"),
):
declared = set(_declared_list(manifest, manifest_key))
actual = set(recorded.get(kind) or [])
undeclared = sorted(actual - declared)
unregistered = sorted(declared - actual)
if undeclared:
report.add(
f"declared {kind}",
False,
f"undeclared {kind} registered (not in {manifest_key}): "
f"{', '.join(undeclared)}",
)
else:
report.add(f"declared {kind}", True, "matches registrations")
if unregistered:
report.warn(
f"{manifest_key} declares {', '.join(unregistered)} "
f"but register() did not register them"
)
return recorded
def _builtin_tool_names() -> List[str]:
"""Return the built-in tool registry names (discovery-timing safe).
``tools.registry`` starts empty — built-in tool modules self-register on
import, so we must run ``discover_builtin_tools()`` first (idempotent;
see the AGENTS.md discover_plugins timing pitfall).
"""
try:
from tools.registry import discover_builtin_tools, registry
discover_builtin_tools()
return list(registry.get_all_tool_names())
except Exception:
return []
def _check_builtin_collisions(
report: ValidationReport, manifest: dict, recorded: Optional[dict]
) -> None:
candidate_tools = set(_declared_list(manifest, "provides_tools"))
if recorded:
candidate_tools.update(recorded.get("tools") or [])
if not candidate_tools:
report.add("built-in tool collisions", True, "no tools to check")
return
builtin = set(_builtin_tool_names())
collisions = sorted(candidate_tools & builtin)
if collisions:
report.add(
"built-in tool collisions",
False,
"tool name(s) collide with built-in tools: "
f"{', '.join(collisions)}",
)
else:
report.add("built-in tool collisions", True, "no collisions")
# ─── Entry point ─────────────────────────────────────────────────────────────
def validate_plugin_dir(plugin_dir: Path) -> ValidationReport:
"""Run every admission check against *plugin_dir* and return the report."""
report = ValidationReport()
plugin_dir = Path(plugin_dir)
if not plugin_dir.is_dir():
report.add(
"plugin directory", False, f"{plugin_dir} is not a directory"
)
return report
manifest_file = plugin_dir / "plugin.yaml"
if not manifest_file.is_file():
manifest_file = plugin_dir / "plugin.yml"
if not manifest_file.is_file():
# Portable Agent Plugins v1 (#81196): a plugin.json-only package is
# admissible — validated through the portable manifest reader. When a
# package carries both manifests the native plugin.yaml always wins
# (this branch is only reached when no native manifest exists).
portable_file = plugin_dir / "plugin.json"
if portable_file.is_file():
return _validate_portable_plugin(report, plugin_dir)
report.add(
"manifest", False,
"no plugin.yaml (or portable plugin.json) in the plugin directory",
)
return report
import yaml
try:
manifest = yaml.safe_load(
manifest_file.read_text(encoding="utf-8")
)
except Exception as exc:
report.add("manifest", False, f"plugin.yaml failed to parse: {exc}")
return report
if not isinstance(manifest, dict):
report.add("manifest", False, "plugin.yaml must be a mapping")
return report
report.add("manifest", True, "plugin.yaml parses")
_check_manifest_fields(report, manifest)
_check_requires_hermes(report, manifest)
_check_config_spec(report, manifest)
_check_requires_env(report, manifest)
recorded = _check_capabilities(report, manifest, plugin_dir)
_check_builtin_collisions(report, manifest, recorded)
return report
def _validate_portable_plugin(report: ValidationReport, plugin_dir: Path) -> ValidationReport:
"""Admission checks for a portable Agent Plugins v1 (plugin.json) package.
Portable packages have no register() entry point, so the capability
probe does not apply; validation is the manifest reader's own
diagnostics (schema shape, name, supported subset).
"""
try:
from hermes_cli.agent_plugins import read_agent_plugin_manifest
manifest, diagnostics = read_agent_plugin_manifest(plugin_dir)
except Exception as exc:
report.add("portable manifest", False, f"plugin.json failed validation: {exc}")
return report
# The portable reader raises on hard failures; surviving diagnostics are
# advisory (unsupported-subset notes etc.) — surface them as warnings.
for diag in diagnostics:
scope = getattr(diag, "scope", "")
message = getattr(diag, "message", str(diag))
report.warnings.append(f"{scope}: {message}" if scope else message)
report.add("portable manifest", True, "plugin.json parses (Agent Plugins v1)")
name = str(manifest.get("name") or "").strip()
report.add(
"manifest fields",
bool(name),
"name present" if name else "plugin.json missing required 'name'",
)
return report
+86 -96
View File
@@ -660,65 +660,40 @@ def _install_plugin_core(
return target, installed_manifest, installed_manifest.get("name") or target.name
def _looks_like_bare_index_name(identifier: str) -> bool:
"""True for a bare plugin name (no slash, no URL scheme) — resolved via the community index."""
return "/" not in identifier and "\\" not in identifier and not identifier.startswith(_URL_SCHEMES)
def _resolve_index_name(identifier: str, console) -> tuple[str, Optional[str]]:
"""Resolve a bare plugin name to ``(install_identifier, pinned_ref)``; exit 1 when unknown or
ambiguous. The ref is only pinned when it is an exact 40-char SHA; tags are advisory output."""
from hermes_cli.plugin_index import SECURITY_FOOTER, load_index, resolve_name
entries, source = load_index()
entry, candidates = resolve_name(entries, identifier)
if entry is None:
if len(candidates) > 1:
console.print(
f"[red]Error:[/red] Plugin name '{identifier}' is ambiguous in the "
f"community index ({source}). Candidates:")
for c in candidates:
console.print(f" {c.name} → {c.install_identifier}")
_fail(console, "Re-run with the exact name or the owner/repo identifier.")
_fail(console, (
f"[red]Error:[/red] Plugin '{identifier}' was not found in the "
f"community index ({source}). Use `hermes plugins search <term>` to "
"browse, or install directly with an owner/repo identifier."))
pinned_ref: Optional[str] = None
if entry.ref and _EXACT_COMMIT_RE.fullmatch(entry.ref):
pinned_ref = entry.ref.lower()
elif entry.ref:
console.print(
f"[dim]Index pins ref '{entry.ref}' (not an exact commit SHA); "
"installing the default branch head instead.[/dim]")
console.print(
f"[dim]Resolved '{entry.name}' via community index ({source}) → "
f"{entry.install_identifier}"
+ (f" @ {pinned_ref[:12]}[/dim]" if pinned_ref else "[/dim]"))
console.print(f"[dim]{SECURITY_FOOTER}[/dim]")
return entry.install_identifier, pinned_ref
def cmd_install(
identifier: str,
force: bool = False,
enable: Optional[bool] = None,
ref: Optional[str] = None,
allow_removed: bool = False,
) -> None:
"""Install a plugin from a Git URL, owner/repo shorthand, or index name.
"""Install a plugin from the curated catalog (bare name), a Git URL, or owner/repo shorthand.
Bare names resolve through the community index (an explicit ``--ref`` beats the index pin).
A catalog hit installs the reviewed pinned SHA (an explicit ``--ref`` wins) and records provenance in
a ``.hermes-catalog.json`` sidecar; URLs/shorthand are flagged as custom (unreviewed) sources. Every
install is checked against the catalog kill list unless *allow_removed*.
*enable* None prompts "Enable now? [y/N]"; True/False skip the prompt.
"""
from hermes_cli import plugins_cmd_catalog as catalog
console = _console()
if _looks_like_bare_index_name(identifier):
identifier, index_ref = _resolve_index_name(identifier, console)
if ref is None:
ref = index_ref
entry = None
if catalog.looks_like_catalog_name(identifier):
entry = catalog.resolve_catalog_name(identifier, console)
identifier = entry.install_identifier
console.print(f"[bold]{entry.name}[/bold] [cyan]\\[{entry.tier}][/cyan] [dim]pinned @ {entry.sha[:8]}[/dim]")
console.print(catalog.entry_capability_summary(entry))
else:
console.print("[yellow]Warning:[/yellow] custom (unreviewed) source — not from the Hermes catalog.")
if allow_removed:
console.print(
"[bold red]WARNING:[/bold red] [red]--allow-removed set — skipping the catalog kill-list check. "
"This plugin may have been removed for security reasons.[/red]")
try:
git_url, _subdir = _resolve_git_url(identifier)
except ValueError as e:
if not allow_removed:
catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ()))
except (ValueError, PluginOperationError) as e:
_fail(console, f"[red]Error:[/red] {e}")
if git_url.startswith(("http://", "file://")):
console.print(
@@ -736,8 +711,12 @@ def cmd_install(
return _is_tty() and _ask_yes(" Install anyway? Only continue if you trust the source. [y/N]: ")
try:
target, installed_manifest, installed_name = _install_plugin_core(
identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision)
if entry is not None:
target, installed_manifest, installed_name = catalog.install_catalog_entry(
entry, force=force, ref=ref, allow_removed=True, scan_decision_cb=_interactive_scan_decision)
else:
target, installed_manifest, installed_name = _install_plugin_core(
identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision)
except PluginOperationError as e:
_fail(console, f"[red]{'Blocked' if isinstance(e, PluginScanBlocked) else 'Error'}:[/red] {e}")
if not _looks_like_plugin_dir(target):
@@ -794,8 +773,13 @@ def _pull_plugin_update(target: Path, pinned_msg, not_git_msg, before_pull=None)
def cmd_update(name: str) -> None:
"""Update an installed plugin by pulling latest from its git remote."""
from rich.markup import escape
from hermes_cli import plugins_cmd_catalog as catalog
console = _console()
target = _require_installed_plugin(name, _plugins_dir(), console)
sidecar = catalog.read_catalog_sidecar(target)
if sidecar: # catalog installs re-pin to the reviewed SHA — never `git pull`
catalog.cmd_update_catalog(name, target, sidecar, console)
return
try:
output = _pull_plugin_update(
target,
@@ -1321,18 +1305,21 @@ def cmd_list(args: Any | None = None) -> None:
enabled = _get_enabled_set()
disabled = _get_disabled_set()
entries = _filter_plugin_entries(entries, args, enabled, disabled)
from hermes_cli import plugins_cmd_catalog as catalog
# Source shows catalog provenance (``catalog:<tier>@<sha8>``); a kill-listed install is flagged.
rows = [
(name, _plugin_status(name, enabled, disabled, key=key), str(version), description, source)
(name, _plugin_status(name, enabled, disabled, key=key), str(version), description,
catalog.catalog_annotation(_dir) or source, catalog.removed_annotation(name, _dir))
for name, version, description, source, _dir, key in entries
]
if getattr(args, "json", False):
keys = ("name", "status", "version", "description", "source")
keys = ("name", "status", "version", "description", "source", "removed")
print(json.dumps([dict(zip(keys, row)) for row in rows], indent=2))
return
if getattr(args, "plain", False):
for name, status, version, _description, source in rows:
for name, status, version, _description, source, _removed in rows:
print(f"{status:12} {source:8} {version:8} {name}")
return
@@ -1343,11 +1330,17 @@ def cmd_list(args: Any | None = None) -> None:
table = _table(
(("Name", "bold"), ("Status", None), ("Version", "dim"), ("Description", None), ("Source", "dim")),
title="Plugins", show_lines=False)
for name, status_name, version, description, source in rows:
removed_lines = []
for name, status_name, version, description, source, removed in rows:
status = _STATUS_MARKUP.get(status_name, "[yellow]not enabled[/yellow]")
if removed:
name = f"[red]{name} ✗[/red]"
removed_lines.append(f"[red]✗ {name}[/red] was removed from the plugin catalog: {removed}")
table.add_row(name, status, version, description, source)
console.print()
console.print(table)
for line in removed_lines:
console.print(line)
console.print()
console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled")
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
@@ -1684,16 +1677,36 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disable
print()
def dashboard_install_plugin(identifier: str, *, force: bool, enable: bool) -> dict[str, Any]:
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
def dashboard_install_plugin(
identifier: str, *, force: bool, enable: bool, catalog_name: Optional[str] = None,
) -> dict[str, Any]:
"""Non-interactive install for the dashboard/TUI. *catalog_name* installs a curated entry at its
pinned SHA (identifier may be empty); every path enforces the kill list (no GUI bypass)."""
from hermes_cli import plugins_cmd_catalog as catalog
warnings: list[str] = []
entry = None
if catalog_name:
entry = catalog.get_live_catalog_entry(catalog_name)
if entry is None:
return {"ok": False, "error": f"'{catalog_name}' is not in the Hermes plugin catalog."}
identifier = entry.install_identifier
else:
warnings.append("Custom (unreviewed) source — not from the Hermes catalog.")
try:
if _resolve_git_url(identifier)[0].startswith(("http://", "file://")):
git_url = _resolve_git_url(identifier)[0]
if git_url.startswith(("http://", "file://")):
warnings.append("Insecure URL scheme; prefer https:// or git@ for production installs.")
catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ()))
except ValueError:
pass
except PluginOperationError as exc:
return {"ok": False, "error": str(exc)}
try:
target, installed_manifest, installed_name = _install_plugin_core(identifier, force=force)
if entry is not None:
target, installed_manifest, installed_name = catalog.install_catalog_entry(
entry, force=force, allow_removed=True)
else:
target, installed_manifest, installed_name = _install_plugin_core(identifier, force=force)
except PluginScanBlocked as exc:
fields = ("pattern_id", "severity", "category", "file", "line", "description")
return {
@@ -1799,10 +1812,15 @@ def _user_installed_plugin_dir(name: str) -> Optional[Path]:
def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
"""``git pull`` inside ``~/.hermes/plugins/<name>``."""
from hermes_cli import plugins_cmd_catalog as catalog
target = _user_installed_plugin_dir(name)
if target is None:
return {"ok": False, "error": f"Plugin '{name}' was not found under {_plugins_dir()}."}
sidecar = catalog.read_catalog_sidecar(target)
try:
if sidecar:
sha, changed = catalog.repin_catalog_plugin(target, sidecar)
return {"ok": True, "name": name, "sha": sha, "unchanged": not changed}
msg = _pull_plugin_update(
target,
lambda rec: (
@@ -1954,44 +1972,16 @@ def cmd_plugin_doctor(target: str = ".", *, ci: bool = False) -> None:
raise SystemExit(1)
def cmd_search(
term: str = "",
*,
json_output: bool = False,
capability: Optional[str] = None,
refresh: bool = False,
) -> None:
"""Search the community plugin index (fuzzy on name/description/tags)."""
from hermes_cli.plugin_index import SECURITY_FOOTER, load_index, search_index
console = _console()
entries, source = load_index(refresh=refresh)
results = search_index(entries, term, capability=capability)
if json_output:
print(json.dumps(
{"source": source, "query": term, "results": [e.to_dict() for e in results], "note": SECURITY_FOOTER},
indent=2))
return
if not results:
console.print(f"[yellow]No plugins matched '{term}'[/yellow] [dim](index source: {source})[/dim]")
return
table = _table(
(("Name", "bold"), ("Description", None), ("Author", None), ("Tags", "dim")),
title=f"Community plugins ({len(results)} match{'es' if len(results) != 1 else ''})")
for e in results:
desc = e.description if len(e.description) <= 70 else e.description[:67] + "..."
table.add_row(e.name, desc, e.author, ", ".join(e.tags))
console.print(table)
console.print(f"[dim]Index source: {source}. Install: hermes plugins install <name>[/dim]")
console.print(f"[dim]{SECURITY_FOOTER}[/dim]")
def _tri_state_flag(args, yes_attr: str, no_attr: str) -> Optional[bool]:
"""Map an argparse ``--x`` / ``--no-x`` pair to True / False / None (neither given)."""
return True if getattr(args, yes_attr, False) else (False if getattr(args, no_attr, False) else None)
def _catalog():
from hermes_cli import plugins_cmd_catalog
return plugins_cmd_catalog
def _action_pack(args):
from hermes_cli.plugin_packs import pack_command
pack_command(args)
@@ -2039,12 +2029,12 @@ _PLUGIN_ACTIONS = {
args.identifier,
force=getattr(args, "force", False),
enable=_tri_state_flag(args, "enable", "no_enable"),
ref=getattr(args, "ref", None)),
"search": lambda args: cmd_search(
getattr(args, "term", "") or "",
json_output=getattr(args, "json", False),
capability=getattr(args, "capability", None),
refresh=getattr(args, "refresh", False)),
ref=getattr(args, "ref", None),
allow_removed=getattr(args, "allow_removed", False)),
"search": lambda args: _catalog().cmd_search(
getattr(args, "term", "") or "", json_output=getattr(args, "json", False)),
"browse": lambda args: _catalog().cmd_search(""),
"validate": lambda args: _catalog().cmd_validate(args.path, as_json=getattr(args, "json", False)),
"update": lambda args: cmd_update(args.name),
"remove": lambda args: cmd_remove(args.name),
"rm": lambda args: cmd_remove(args.name),
@@ -2060,7 +2050,7 @@ _PLUGIN_ACTIONS = {
"compat": lambda args: cmd_compat(args),
"pack": _action_pack,
"show": lambda args: cmd_show(args.name),
"info": lambda args: cmd_show(args.name),
"info": lambda args: _catalog().cmd_info(args.name),
None: lambda args: cmd_toggle(),
}
+299
View File
@@ -0,0 +1,299 @@
"""``hermes plugins`` catalog surface: resolution, provenance sidecar, search/browse/info/validate,
catalog-aware update, plus the dashboard/TUI-facing catalog payload helpers.
Sibling of :mod:`hermes_cli.plugins_cmd` (the installer core, enable/disable state and console helpers
live there and are imported late — this module is imported BY ``plugins_cmd``).
"""
from __future__ import annotations
import datetime
import json
import logging
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_cli.plugin_catalog import (
PluginCatalogEntry, entry_capability_summary, filter_entries, find_removed, get_live_catalog_entry,
load_catalog_live, load_removed_list, _NAME_RE,
)
logger = logging.getLogger(__name__)
CATALOG_SIDECAR = ".hermes-catalog.json"
# ── Resolution / provenance ──────────────────────────────────────────────────
def looks_like_catalog_name(identifier: str) -> bool:
"""Bare ``[a-z0-9_-]`` token — not a URL, ``owner/repo`` or path."""
from hermes_cli.plugins_cmd import _URL_SCHEMES
return bool(identifier) and "/" not in identifier and "\\" not in identifier \
and not identifier.startswith(_URL_SCHEMES) and bool(_NAME_RE.match(identifier))
def raise_if_removed(*candidates: str) -> None:
"""``PluginOperationError`` when any candidate (name or repo URL) is on the kill list."""
from hermes_cli.plugins_cmd import PluginOperationError
for candidate in candidates:
removed = find_removed(candidate)
if removed is not None:
detail = removed.reason or "no reason recorded"
if removed.date:
detail += f" (removed {removed.date})"
raise PluginOperationError(
f"Plugin '{removed.name}' was removed from the Hermes plugin catalog and is blocked from "
f"installation: {detail}")
def resolve_catalog_name(identifier: str, console) -> PluginCatalogEntry:
"""Bare name → live catalog entry, or exit 1 with a pointer to ``search``."""
from hermes_cli.plugins_cmd import _fail
entry = get_live_catalog_entry(identifier)
if entry is None:
_fail(console, (
f"[red]Error:[/red] '{identifier}' is not in the Hermes plugin catalog and is not a Git URL or "
"owner/repo shorthand. Browse entries with `hermes plugins search`."))
raise SystemExit(1) # _fail exits; keeps type-checkers honest
return entry
def write_catalog_sidecar(target: Path, entry: PluginCatalogEntry) -> None:
"""``.hermes-catalog.json`` inside the install dir — how ``update``/``list``/dashboards know the plugin
came from the catalog and at which pin."""
sidecar = {
"catalog_name": entry.name, "repo": entry.repo, "sha": entry.sha, "tier": entry.tier,
"installed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
.replace("+00:00", "Z"),
}
try:
(target / CATALOG_SIDECAR).write_text(json.dumps(sidecar, indent=2) + "\n", encoding="utf-8")
except OSError as exc:
logger.warning("Failed to write catalog sidecar in %s: %s", target, exc)
def read_catalog_sidecar(plugin_dir) -> Optional[dict]:
"""Parsed sidecar, or ``None`` (absent/corrupt = a non-catalog install)."""
path = Path(plugin_dir) / CATALOG_SIDECAR if plugin_dir else None
if path is None or not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return data if isinstance(data, dict) and data.get("catalog_name") else None
def catalog_annotation(dir_path) -> Optional[str]:
"""``catalog:<tier>@<sha8>`` for a catalog install (``list`` Source column), else ``None``."""
sidecar = read_catalog_sidecar(dir_path)
if not sidecar:
return None
return f"catalog:{sidecar.get('tier') or 'community'}@{str(sidecar.get('sha') or '')[:8]}"
def removed_annotation(name: str, dir_path) -> Optional[str]:
"""Kill-list reason when an INSTALLED plugin matches by name, catalog name or repo, else ``None``."""
sidecar = read_catalog_sidecar(dir_path) or {}
for candidate in (name, sidecar.get("catalog_name"), sidecar.get("repo")):
removed = find_removed(str(candidate)) if candidate else None
if removed is not None:
return removed.reason or "no reason recorded"
return None
# ── Catalog-aware install / update ───────────────────────────────────────────
def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Optional[str] = None,
allow_removed: bool = False, scan_decision_cb=None) -> tuple:
"""``_install_plugin_core`` at the catalog pin (an explicit *ref* wins) + provenance sidecar.
Returns the core's ``(target, manifest, installed_name)``."""
from hermes_cli.plugins_cmd import _install_plugin_core
if not allow_removed:
raise_if_removed(entry.name, entry.repo)
target, manifest, installed_name = _install_plugin_core(
entry.install_identifier, force=force, ref=ref or entry.sha, scan_decision_cb=scan_decision_cb)
write_catalog_sidecar(target, entry)
return target, manifest, installed_name
def repin_catalog_plugin(target: Path, sidecar: dict) -> tuple[str, bool]:
"""Re-pin a catalog install to the current catalog SHA (never ``git pull``). Returns
``(new_sha, changed)``; raises ``PluginOperationError`` when the entry left the catalog."""
from hermes_cli.plugins_cmd import PluginOperationError, _get_enabled_set, _save_enabled_set
catalog_name = str(sidecar["catalog_name"])
entry = get_live_catalog_entry(catalog_name)
if entry is None:
raise PluginOperationError(
f"Plugin '{catalog_name}' is no longer in the catalog — it may have been removed. "
"See `hermes plugins info` and the removed blocklist.")
if str(sidecar.get("sha") or "").strip().lower() == entry.sha:
return entry.sha, False
was_enabled = _get_enabled_set() # the force reinstall must not flip activation state
install_catalog_entry(entry, force=True)
_save_enabled_set(was_enabled)
return entry.sha, True
def cmd_update_catalog(name: str, target: Path, sidecar: dict, console) -> None:
from hermes_cli.plugins_cmd import PluginOperationError, _fail
console.print(f"[dim]Checking catalog pin for {name}...[/dim]")
try:
sha, changed = repin_catalog_plugin(target, sidecar)
except PluginOperationError as exc:
_fail(console, f"[red]Error:[/red] {exc}")
raise SystemExit(1)
verb = "updated to" if changed else "is already at catalog pin"
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] {verb} {sha[:8]}.")
# ── search / browse / info / validate ────────────────────────────────────────
def _capability_counts(entry: PluginCatalogEntry) -> str:
caps = entry.capabilities
parts = [f"{len(items)} {label}{'s' if len(items) != 1 and label != 'middleware' else ''}"
for items, label in ((caps.provides_tools, "tool"), (caps.provides_hooks, "hook"),
(caps.provides_middleware, "middleware")) if items]
if caps.requires_env:
parts.append(f"{len(caps.requires_env)} env")
return ", ".join(parts) or "—"
def _render_entries(entries: List[PluginCatalogEntry], console) -> None:
from hermes_cli.plugins_cmd import _table
table = _table(((("Name", "bold")), ("Tier", None), ("Description", None), ("Pinned", "dim"),
("Capabilities", "dim")), title="Hermes Plugin Catalog (curated)")
for e in entries:
tier = "[cyan]official[/cyan]" if e.tier == "official" else "[magenta]community[/magenta]"
desc = e.description if len(e.description) <= 60 else e.description[:57] + "..."
table.add_row(e.name, tier, desc, e.sha[:8], _capability_counts(e))
console.print()
console.print(table)
console.print()
console.print("[dim]Details:[/dim] hermes plugins info <name> [dim]Install:[/dim] hermes plugins install <name>")
def cmd_search(term: str = "", *, json_output: bool = False) -> None:
"""Search the curated catalog (name/description/declared tools); empty term = browse everything."""
from hermes_cli.plugins_cmd import _console
matches = filter_entries(load_catalog_live(), term)
if json_output:
print(json.dumps({"query": term, "results": [e.to_dict() for e in matches]}, indent=2))
return
console = _console()
if not matches:
console.print(f"[yellow]No catalog entries matched '{term}'[/yellow]" if term
else "[dim]No catalog entries available.[/dim]")
return
_render_entries(matches, console)
def cmd_info(name: str) -> None:
"""Full catalog entry for *name*; falls back to installed-plugin details for non-catalog names."""
from hermes_cli.plugins_cmd import _console, cmd_show
entry = get_live_catalog_entry(name)
if entry is None:
cmd_show(name)
return
console = _console()
caps = entry.capabilities
console.print()
console.print(f"[bold]{entry.name}[/bold] [cyan]\\[{entry.tier}][/cyan]")
if entry.description:
console.print(entry.description)
console.print()
rows = [("Repo", entry.repo), ("Subdir", entry.subdir), ("Pinned SHA", entry.sha),
("Maintainer", entry.maintainer), ("Requires", f"hermes {entry.requires_hermes}" if entry.requires_hermes else ""),
("Platforms", ", ".join(entry.platforms)), ("Docs", entry.docs_url)]
for label, value in rows:
if value:
console.print(f"[dim]{label + ':':<12}[/dim] {value}")
console.print()
for label, items in (("Tools", caps.provides_tools), ("Hooks", caps.provides_hooks),
("Middleware", caps.provides_middleware), ("Env vars", caps.requires_env)):
console.print(f"[dim]{label + ':':<12}[/dim] {', '.join(items) or '(none)'}")
console.print()
removed = find_removed(entry.name) or find_removed(entry.repo)
if removed is not None:
console.print(f"[red bold]✗ REMOVED from catalog: {removed.reason or 'no reason recorded'}"
f"{f' ({removed.date})' if removed.date else ''}[/red bold]")
console.print()
console.print(f"[dim]Install:[/dim] hermes plugins install {entry.name}")
console.print()
def cmd_validate(path: str, as_json: bool = False) -> None:
"""Catalog-admission validation of a plugin directory (the CI gate); exits 0/1."""
from hermes_cli.plugin_validate import validate_plugin_dir
from hermes_cli.plugins_cmd import _console
report = validate_plugin_dir(Path(path))
if as_json:
print(json.dumps(report.to_dict(), indent=2))
sys.exit(report.exit_code)
console = _console()
console.print()
for check_name, ok, detail in report.checks:
console.print(f"{'[green]✓[/green]' if ok else '[red]✗[/red]'} {check_name}"
+ (f" [dim]— {detail}[/dim]" if detail else ""))
for warning in report.warnings:
console.print(f"[yellow]⚠ {warning}[/yellow]")
console.print()
console.print("[green bold]Validation passed.[/green bold]" if report.ok else "[red bold]Validation failed.[/red bold]")
sys.exit(report.exit_code)
# ── Dashboard / TUI payloads ─────────────────────────────────────────────────
def installed_catalog_state(installed: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
"""Catalog entries merged with local state for the dashboard. *installed* maps every alias (name
and registry key) of a discovered plugin to ``{"dir", "runtime_status"}``. A catalog name rarely
equals the manifest name (``hermes-plugin-x`` vs ``x``), so installs are matched through the
sidecar's ``catalog_name`` first and by name only as a fallback."""
by_catalog_name: Dict[str, Dict[str, Any]] = {}
for local in installed.values():
sidecar = read_catalog_sidecar(local["dir"])
if sidecar:
by_catalog_name[str(sidecar["catalog_name"])] = {**local, "sidecar": sidecar}
entries = []
for entry in load_catalog_live():
local = by_catalog_name.get(entry.name) or installed.get(entry.name)
sidecar = local.get("sidecar") if local else None
installed_sha = str(sidecar["sha"]) if sidecar and sidecar.get("sha") else None
entries.append({
**entry.to_dict(), "sha_short": entry.sha[:7],
"capability_summary": entry_capability_summary(entry),
"installed": local is not None, "installed_sha": installed_sha,
"update_available": bool(installed_sha) and installed_sha != entry.sha,
"runtime_status": local["runtime_status"] if local else None,
})
return {
"entries": entries,
"removed": [{"name": r.name, "repo": r.repo, "reason": r.reason, "date": r.date} for r in load_removed_list()],
"generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
}
def catalog_row_fields(dir_path, pins: Dict[str, str]) -> Dict[str, Any]:
"""Provenance fields for one installed-plugin row (TUI/desktop ``plugins.manage list``): catalog
name/tier/installed SHA and, when *pins* has the entry, the current pin + ``update_available``."""
sidecar = read_catalog_sidecar(dir_path)
if not sidecar:
return {}
installed_sha = str(sidecar.get("sha") or "").lower()
row: Dict[str, Any] = {
"catalog_name": sidecar["catalog_name"], "catalog_tier": str(sidecar.get("tier") or "community"),
"installed_sha": installed_sha}
pin = pins.get(str(sidecar["catalog_name"]))
if pin:
row["catalog_sha"] = pin
row["update_available"] = bool(installed_sha) and installed_sha != pin
return row
def catalog_pins() -> Dict[str, str]:
"""``{catalog_name: pinned_sha}`` from the live catalog; empty on failure (best effort)."""
try:
return {e.name: e.sha for e in load_catalog_live()}
except Exception:
return {}
+8
View File
@@ -278,6 +278,14 @@ class PluginLoaderMixin:
if manifest.portable:
self._load_portable_plugin(manifest, loaded)
return
# requires_hermes gate: skip cleanly (no import, no traceback) on a version mismatch.
from hermes_cli.plugins_manifest import requires_hermes_error
reason = requires_hermes_error(manifest)
if reason:
loaded.error = reason
logger.warning("Plugin '%s' skipped: %s", plugin_key, reason)
self._plugins[plugin_key] = loaded
return
# After the compat-removal date an external plugin that still imports pre-decomposition paths is
# skipped with a clear reason instead of dying on ImportError mid-register (hermes_cli.plugin_compat).
from hermes_cli.plugin_compat import disable_reason
+56 -1
View File
@@ -6,8 +6,10 @@ Split out of :mod:`hermes_cli.plugins`; validation warns and never fails a load.
from __future__ import annotations
import hashlib
import importlib.metadata
import importlib.util
import logging
import re
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
@@ -34,6 +36,7 @@ _KNOWN_MANIFEST_FIELDS: Set[str] = {
"pip_dependencies", "provides_browser_providers", "provides_web_providers",
"manifest_version", "api_version", "requires_plugins", "python_dependencies", "config_schema",
"license", "homepage", "tags", "capabilities", "emits", "listens", "hermes", "depends",
"requires_hermes",
}
# Highest manifest schema version this Hermes understands.
@@ -336,6 +339,9 @@ class PluginManifest:
# Path-derived registry key used by plugins.enabled/disabled and `hermes plugins list`: ``disk-cleanup``
# for a flat plugin, ``image_gen/openai`` for a category plugin. Empty -> name.
key: str = ""
# Hermes version requirement (``">=0.19"``, comma-separated clauses allowed). Unsatisfied plugins are
# recorded with an error and skipped before import — see ``requires_hermes_error``.
requires_hermes: str = ""
portable: bool = False
skill_namespace: str = ""
# Declared capability ids, normalized to KNOWN ids. Declaration is consent metadata, NOT a grant: live
@@ -364,6 +370,55 @@ class PluginManifest:
listens: List[str] = field(default_factory=list)
# ── requires_hermes version gate ─────────────────────────────────────────────
_VERSION_COMPARATOR_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$")
def running_hermes_version() -> str:
"""Installed ``hermes-agent`` distribution version, else ``hermes_cli.__version__`` (source checkout)."""
try:
return importlib.metadata.version("hermes-agent")
except Exception:
from hermes_cli import __version__
return __version__
def _version_tuple(v: str) -> Optional[tuple]:
"""``v1.2.3-rc1`` → ``(1, 2, 3)``; ``None`` when a segment is non-numeric."""
parts = re.split(r"[-+]", str(v).strip().lstrip("v"), 1)[0].split(".")
parts += ["0"] * (3 - len(parts))
try:
return tuple(int(x) for x in parts[:3])
except ValueError:
return None
def version_satisfies(spec: str, current: str) -> bool:
"""``>=``/``>``/``<=``/``<``/``==``/``!=`` clauses (comma = AND; bare version = ``>=``). Unparseable
versions are permissive — no PEP 440 dependency for a one-field manifest gate."""
cur = _version_tuple(current)
if cur is None:
return True
ops = {">=": cur.__ge__, "<=": cur.__le__, "==": cur.__eq__, "!=": cur.__ne__, ">": cur.__gt__, "<": cur.__lt__}
for clause in filter(None, (c.strip() for c in spec.split(","))):
m = _VERSION_COMPARATOR_RE.match(clause)
op, target = (m.group(1), m.group(2)) if m else (">=", clause)
tgt = _version_tuple(target)
if tgt is not None and not ops[op](tgt):
return False
return True
def requires_hermes_error(manifest: "PluginManifest") -> Optional[str]:
"""Load-blocking reason when the manifest's ``requires_hermes`` rejects the running version."""
if not manifest.requires_hermes:
return None
current = running_hermes_version()
if version_satisfies(manifest.requires_hermes, current):
return None
return f"requires hermes {manifest.requires_hermes}, running {current}"
def portable_plugin_manifest(child: Path, source: str, prefix: str) -> PluginManifest:
"""Build the manifest for a portable Agent Plugin directory (``plugin.json``); diagnostics warn."""
from hermes_cli.agent_plugins import read_agent_plugin_manifest
@@ -420,7 +475,7 @@ def parse_manifest_file(
requires_env=data.get("requires_env", []),
provides_tools=data.get("provides_tools", []),
provides_hooks=data.get("provides_hooks", []), source=source, path=str(plugin_dir),
kind=kind, key=key,
kind=kind, key=key, requires_hermes=str(data.get("requires_hermes") or "").strip(),
capabilities=_parse_declared_capabilities(data.get("capabilities"), name),
**_parse_manifest_v2_fields(data, key), emits=data.get("emits") or [],
listens=data.get("listens") or [],
+15 -12
View File
@@ -16,17 +16,19 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_action")
plugins_install = plugins_subparsers.add_parser(
"install", help="Install a plugin from a Git URL, owner/repo, or index name")
"install", help="Install a plugin from the curated catalog, a Git URL, or owner/repo")
plugins_install.add_argument(
"identifier",
help="Git URL, owner/repo shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles), "
"or a bare plugin name resolved through the community index "
"(see `hermes plugins search`)")
help="Bare plugin catalog entry name (see `hermes plugins search`), Git URL, or owner/repo "
"shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles)")
plugins_install.add_argument(
"--force", "-f", action="store_true", help="Remove existing plugin and reinstall")
plugins_install.add_argument(
"--ref", metavar="COMMIT_SHA",
help="Install exactly one immutable 40-character Git commit SHA")
plugins_install.add_argument(
"--allow-removed", action="store_true",
help="DANGEROUS: bypass the catalog removed-plugin blocklist check")
_install_enable_group = plugins_install.add_mutually_exclusive_group()
_install_enable_group.add_argument(
"--enable", action="store_true",
@@ -37,17 +39,18 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
)
plugins_search = plugins_subparsers.add_parser(
"search", help="Search the community plugin index")
"search", help="Search the curated Hermes plugin catalog")
plugins_search.add_argument(
"term", nargs="?", default="",
help="Search term matched fuzzily against name, description, and tags "
"(omit to browse the full index)")
help="Query matched against entry names, descriptions and declared tools (omit to list the whole catalog)")
add_json_flag(plugins_search, "Print machine-readable JSON")
plugins_search.add_argument(
"--capability", metavar="CAP",
help="Filter by declared capability (e.g. tools, platform, commands)")
plugins_search.add_argument(
"--refresh", action="store_true", help="Bypass the local cache and re-fetch the index")
plugins_subparsers.add_parser("browse", help="List every curated plugin catalog entry")
plugins_validate = plugins_subparsers.add_parser(
"validate", help="Validate a plugin directory for catalog admission (CI gate)")
plugins_validate.add_argument("path", help="Path to the plugin directory")
add_json_flag(plugins_validate, "Print machine-readable JSON (for CI)")
plugins_update = plugins_subparsers.add_parser(
"update", help="Pull latest changes for an installed plugin")
+2
View File
@@ -496,6 +496,8 @@ class _AgentPluginInstallBody(BaseModel):
identifier: str
force: bool = False
enable: bool = True
# Install by curated-catalog name (resolves repo + pinned SHA server-side).
catalog_name: Optional[str] = None
class _PluginProvidersPutBody(BaseModel):
memory_provider: Optional[str] = None
+30 -1
View File
@@ -170,12 +170,41 @@ def _plugin_action(result: dict, fallback_error: str, *, rescan: bool) -> dict:
return result
@router.get("/api/dashboard/plugins/catalog")
async def get_plugins_catalog(request: Request):
"""Curated plugin catalog merged with installed state (session protected)."""
_require_token(request)
def _run():
from hermes_cli.plugins_cmd import _discover_all_plugins, _get_disabled_set, _get_enabled_set
from hermes_cli.plugins_cmd_catalog import installed_catalog_state
from hermes_cli.web_server_dashboard import _plugin_runtime_status
enabled, disabled = _get_enabled_set(), _get_disabled_set()
installed = {}
for name, _v, _d, _s, dir_str, key in _discover_all_plugins():
aliases = {name, key} - {""}
info = {"dir": dir_str, "runtime_status": _plugin_runtime_status(aliases, enabled, disabled)}
installed.update({alias: info for alias in aliases})
return installed_catalog_state(installed)
try:
return await asyncio.to_thread(_run)
except Exception as exc:
_log.warning("plugins/catalog failed: %s", exc)
raise HTTPException(status_code=500, detail="Failed to build plugins catalog.") from exc
@router.post("/api/dashboard/agent-plugins/install")
async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
_require_token(request)
from hermes_cli.plugins_cmd import dashboard_install_plugin
result = dashboard_install_plugin(body.identifier.strip(), force=body.force, enable=body.enable)
catalog_name = (body.catalog_name or "").strip()
identifier = body.identifier.strip()
if not identifier and not catalog_name:
raise HTTPException(status_code=400, detail="Provide an identifier or a catalog_name.")
result = dashboard_install_plugin(
identifier, force=body.force, enable=body.enable, catalog_name=catalog_name or None)
result = _plugin_action(result, "Install failed.", rescan=True)
# Strip internal paths from the response
result.pop("after_install_path", None)
+8 -6
View File
@@ -634,6 +634,11 @@ def _plugin_auth_hint(name: str, provides_tools: list) -> tuple:
return False, ""
def _plugin_runtime_status(aliases: set, enabled_set: set, disabled_set: set) -> str:
"""enabled / disabled / inactive for a plugin's name+key alias set (disabled wins)."""
return "disabled" if aliases & disabled_set else "enabled" if aliases & enabled_set else "inactive"
def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
"""Agent discovery + dashboard manifests + provider picker metadata.
@@ -662,6 +667,7 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
_get_enabled_set,
_read_manifest as _read_plugin_manifest_at,
)
from hermes_cli.plugins_cmd_catalog import removed_annotation
dashboard_list = _get_dashboard_plugins()
dash_by_name = {str(p["name"]): p for p in dashboard_list}
@@ -675,12 +681,7 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
# Both the path-derived key (nested category plugins) and the bare manifest name
# count for enabled/disabled state, matching the runtime loader's back-compat lookup.
aliases = {name, key} if key else {name}
if aliases & disabled_set:
runtime_status = "disabled"
elif aliases & enabled_set:
runtime_status = "enabled"
else:
runtime_status = "inactive"
runtime_status = _plugin_runtime_status(aliases, enabled_set, disabled_set)
dir_path = Path(dir_str)
dm = dash_by_name.get(name)
@@ -708,6 +709,7 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
"auth_required": auth_required,
"auth_command": auth_command,
"user_hidden": name in hidden_plugins,
"removed_reason": removed_annotation(name, dir_str),
})
agent_names = {r["name"] for r in rows}
+60
View File
@@ -0,0 +1,60 @@
# Hermes Plugin Catalog
Curated, Nous-approved Hermes plugins. Each YAML file in this directory
(except `removed.yaml`) is one catalog entry, discoverable via
`hermes plugins catalog` / `hermes plugins search` and installable with
`hermes plugins install <name>`.
## Admission policy
Presence in this directory **is** the trust signal. The rules that keep it
meaningful:
1. **Human-merged gate.** Entries are added *only* via a PR to the
`hermes-agent` repository, reviewed and merged by a maintainer. There is
no self-serve registry, no automated ingestion.
2. **Exact SHA pins are mandatory.** Every entry pins a full 40-character
commit SHA. Branches, tags, and short SHAs are rejected by the loader.
Installs clone the repository and check out exactly that commit.
3. **Pin maturity.** The pinned release should be **at least 2 weeks old**
at pin time, mirroring the supply-chain policy used for `optional-mcps/`
and pyproject dependencies. This gives the community time to notice a
compromised release before Hermes ships a pointer to it.
4. **SHA bumps are new PRs.** Updating an entry's pin is a new PR whose diff
(old SHA → new SHA) is re-reviewed like any other change — reviewers are
expected to look at the upstream commit range being adopted.
5. **Owner-or-major-contributor submissions only.** An entry may only be
submitted by the plugin repository's owner or a major contributor to it.
Drive-by submissions of third-party repos are declined.
6. **Declared capabilities must match reality.** The `capabilities:` block
(tools, hooks, middleware, env vars) must match what the plugin actually
registers at the pinned commit. Validation fails the entry otherwise —
undeclared capability creep is treated as a security issue.
## Entry schema
```yaml
name: example-plugin # [a-z0-9_-]{1,64}, the catalog key
repo: https://github.com/owner/repo # https:// only
sha: <40-hex commit sha> # mandatory exact pin
subdir: "" # optional path within the repo
description: One-line description.
maintainer: OwnerName
tier: official # official | community (default community)
requires_hermes: ">=0.19" # optional
docs_url: "" # optional
platforms: [] # optional, e.g. [linux, macos]; empty = all
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
```
## removed.yaml — the blocklist
When an entry is pulled from the catalog for security or policy reasons, it
is recorded in `removed.yaml` with a reason and date. The installer refuses
to install anything matching a removed entry's name or repo URL, so a
malicious plugin cannot be re-installed from a stale identifier after
removal. Removals, like additions, land via reviewed PRs.
+14
View File
@@ -0,0 +1,14 @@
name: herdr-auto-reconcile
repo: https://github.com/chris-yyau/hermes-herdr-auto-reconcile
sha: 7a01878039c217f7b3a5500a3086dd62a077d100
description: Wake Hermes when allowlisted Herdr panes need reconciliation, without routing or controlling
pane work.
maintainer: chris-yyau
tier: community
docs_url: https://github.com/chris-yyau/hermes-herdr-auto-reconcile
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
@@ -0,0 +1,14 @@
name: hermes-plugin-chrome-profiles
repo: https://github.com/anpicasso/hermes-plugin-chrome-profiles
sha: 5b9c3257b464c0f926d4355149a8aed9c8f307b4
description: Switch Hermes browser tools between local and remote Chrome/Edge profiles via CDP.
maintainer: anpicasso
tier: community
docs_url: https://github.com/anpicasso/hermes-plugin-chrome-profiles
platforms: []
capabilities:
provides_tools:
- browser_profile
provides_hooks: []
provides_middleware: []
requires_env: []
+14
View File
@@ -0,0 +1,14 @@
name: hermes-snapcompact
repo: https://github.com/vimona3ds/hermes-snapcompact
sha: 8a274ca4a501e5661f1e2762150c4c3f99e374ce
description: 'Snapcompact context engine: archives conversation history as dense bitmap PNG frames that
vision models read back at ~1/3 the token cost.'
maintainer: vimona3ds
tier: community
docs_url: https://github.com/vimona3ds/hermes-snapcompact
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
@@ -0,0 +1,14 @@
name: hermes-telegram-business
repo: https://github.com/NousResearch/hermes-telegram-business
sha: e905f3bc5eeaa5a9dab9bc5155601b3ebec75757
description: 'Observe-with-approval Telegram Business Mode (secretary bot): every drafted customer reply
needs owner approval before it is sent.'
maintainer: NousResearch
tier: official
docs_url: https://github.com/NousResearch/hermes-telegram-business
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+55
View File
@@ -0,0 +1,55 @@
name: jackal-verified
repo: https://github.com/AnubisQuantumCipher/hermes-jackal-verified
sha: dab507a521406a69d308025bed380401eff967b9
description: 'Typed adapter for the reproducible JACKAL v1.7.3 release: 41 catalog-derived verification
tools (exact, numerical, Lean-checked bounded lanes, claim replay).'
maintainer: AnubisQuantumCipher
tier: community
docs_url: https://github.com/AnubisQuantumCipher/hermes-jackal-verified
platforms: []
capabilities:
provides_tools:
- jackal_alg_cmp
- jackal_alg_sign
- jackal_anubis_check_program
- jackal_anubis_verify_program
- jackal_anubis_verify_program_receipt
- jackal_atan_rat_bound
- jackal_canon
- jackal_claim
- jackal_claim_cites_test
- jackal_cos_rat_bound
- jackal_crt
- jackal_decision_rank
- jackal_decision_rank_v2
- jackal_diff
- jackal_divides
- jackal_evaluate
- jackal_exact
- jackal_exp_rat_bound
- jackal_gaussian_integral
- jackal_integrate
- jackal_integrate_adaptive
- jackal_integrate_bound
- jackal_integrate_bound_cert
- jackal_ln_rat_bound
- jackal_mod_inv
- jackal_mod_pow
- jackal_poly_canon
- jackal_poly_eq
- jackal_poly_gcd
- jackal_prime_cert
- jackal_range_bound
- jackal_ratfunc_canon
- jackal_roots_isolate
- jackal_sin_rat_bound
- jackal_solve
- jackal_sqrt_rat_bound
- jackal_tanh_rat_bound
- jackal_test_exists
- jackal_verify_bundle
- jackal_verify_receipt
- jackal_xgcd
provides_hooks: []
provides_middleware: []
requires_env: []
@@ -0,0 +1,15 @@
name: plugin-llm-async-example
repo: https://github.com/NousResearch/hermes-example-plugins
sha: 38fe0fb53eff98d477f807432e965429e665ca33
subdir: plugin-llm-async-example
description: Async reference plugin for ctx.llm — registers /translate, running forward and back translations
concurrently via acomplete().
maintainer: NousResearch
tier: official
docs_url: https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-async-example
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+15
View File
@@ -0,0 +1,15 @@
name: plugin-llm-example
repo: https://github.com/NousResearch/hermes-example-plugins
sha: 38fe0fb53eff98d477f807432e965429e665ca33
subdir: plugin-llm-example
description: Reference plugin showing host-owned structured LLM access via ctx.llm.complete_structured();
registers /receipt-extract.
maintainer: NousResearch
tier: official
docs_url: https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-example
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+6
View File
@@ -0,0 +1,6 @@
# Blocklist for plugins pulled from the catalog for security or policy
# reasons. The installer refuses to install anything whose name or repo URL
# matches an entry here (unless the caller explicitly bypasses the check).
# Each entry: {name, repo, reason, date}. Removals land via reviewed PRs,
# same as additions.
removed: []
+12
View File
@@ -30,6 +30,18 @@ command. A hook with no concrete consumer is speculative infrastructure and is r
`plugin-llm-example`, `plugin-llm-async-example`) live in
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins), not here.
## Plugin catalog (`plugin-catalog/`, Sep 2026)
The ONLY discovery system for out-of-tree plugins. One YAML per entry, 40-hex SHA pin mandatory,
human-merged via PR (`plugin-catalog/README.md` = admission policy; `plugin-catalog-ci.yml` clones
each changed entry at its pin and runs `hermes plugins validate`). `removed.yaml` is the kill list —
every install path (CLI, dashboard, TUI) refuses matches; only the CLI has a loud `--allow-removed`.
Code: `hermes_cli/plugin_catalog.py` (loader, live refresh from
`/docs/api/plugin-catalog.json` published by the docs build, in-tree fallback),
`hermes_cli/plugins_cmd_catalog.py` (resolution, `.hermes-catalog.json` provenance sidecar,
search/info/validate, re-pin on `update`, dashboard/TUI payloads). Never add a second name index:
bare names resolve through the catalog or error.
## Plugin kinds and their discovery systems
| Kind | Where | Discovery | Notes |
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""Standalone structural validator for plugin-catalog entry files.
Validates ``plugin-catalog/*.yaml`` catalog entries and
``plugin-catalog/removed.yaml`` against the catalog contract schema, using
only stdlib + PyYAML so the admission CI (and third-party repos) can run it
WITHOUT installing hermes-agent.
NOTE: this script intentionally duplicates the schema rules instead of
importing ``hermes_cli`` — the whole point is the no-install requirement for
cheap cross-repo CI use. The runtime twin of this schema lives in
``hermes_cli/plugin_catalog.py``; if the contract changes there, update the
rules here in lockstep.
Usage:
python3 scripts/validate_plugin_catalog.py plugin-catalog/
python3 scripts/validate_plugin_catalog.py entry.yaml removed.yaml
python3 scripts/validate_plugin_catalog.py --json plugin-catalog/
Exit codes: 0 = all files valid (warnings allowed), 1 = at least one error.
Human output is one ``<file>: ERROR: ...`` / ``<file>: warning: ...`` line
per finding; ``--json`` emits a machine-readable report on stdout instead.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover - dependency guidance only
print(
"ERROR: PyYAML is required (pip install pyyaml)",
file=sys.stderr,
)
sys.exit(2)
NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
TIERS = ("official", "community")
PLATFORMS = ("linux", "macos", "windows")
CAPABILITY_KEYS = (
"provides_tools",
"provides_hooks",
"provides_middleware",
"requires_env",
)
# Top-level keys the contract knows about. Unknown keys WARN (forward
# compatibility: newer catalogs must stay valid under older validators).
KNOWN_KEYS = {
"name",
"repo",
"sha",
"subdir",
"description",
"maintainer",
"tier",
"requires_hermes",
"docs_url",
"platforms",
"capabilities",
}
REQUIRED_KEYS = ("name", "repo", "sha", "description", "maintainer")
# One comparator clause of a requires_hermes spec, e.g. ">=0.19" or "!=1.2.3".
_COMPARATOR_RE = re.compile(r"^(>=|<=|==|!=|>|<)\s*\d+(\.\d+)*$")
def _is_nonempty_str(value: object) -> bool:
return isinstance(value, str) and value.strip() != ""
def _check_requires_hermes(spec: object, errors: list[str]) -> None:
if not isinstance(spec, str):
errors.append(f"requires_hermes must be a string, got {type(spec).__name__}")
return
if spec.strip() == "":
return # empty = no constraint
for clause in spec.split(","):
if not _COMPARATOR_RE.match(clause.strip()):
errors.append(
f"requires_hermes clause {clause.strip()!r} is not a valid "
"comparator spec (expected e.g. '>=0.19')"
)
def validate_entry(data: object) -> tuple[list[str], list[str]]:
"""Validate one catalog entry document. Returns (errors, warnings)."""
errors: list[str] = []
warnings: list[str] = []
if not isinstance(data, dict):
return ["top-level document must be a YAML mapping"], warnings
for key in REQUIRED_KEYS:
if key not in data:
errors.append(f"missing required key: {key}")
for key in sorted(set(data) - KNOWN_KEYS):
warnings.append(f"unknown top-level key {key!r} (ignored by this validator)")
name = data.get("name")
if "name" in data and (not isinstance(name, str) or not NAME_RE.match(name)):
errors.append(f"name {name!r} must match [a-z0-9_-]{{1,64}}")
repo = data.get("repo")
if "repo" in data and (
not isinstance(repo, str) or not repo.startswith("https://")
):
errors.append(f"repo {repo!r} must be an https:// URL")
sha = data.get("sha")
if "sha" in data and (not isinstance(sha, str) or not SHA_RE.match(sha)):
errors.append(f"sha {sha!r} must be exactly 40 lowercase hex characters")
for key in ("description", "maintainer"):
if key in data and not _is_nonempty_str(data[key]):
errors.append(f"{key} must be a non-empty string")
tier = data.get("tier", "community")
if tier not in TIERS:
errors.append(f"tier {tier!r} must be one of {list(TIERS)}")
if "requires_hermes" in data:
_check_requires_hermes(data["requires_hermes"], errors)
platforms = data.get("platforms", [])
if platforms is None:
platforms = []
if not isinstance(platforms, list):
errors.append("platforms must be a list")
else:
bad = [p for p in platforms if p not in PLATFORMS]
if bad:
errors.append(f"platforms {bad!r} not in allowed set {list(PLATFORMS)}")
caps = data.get("capabilities", {})
if caps is None:
caps = {}
if not isinstance(caps, dict):
errors.append("capabilities must be a mapping")
else:
for key in sorted(set(caps) - set(CAPABILITY_KEYS)):
warnings.append(f"unknown capabilities key {key!r}")
for key in CAPABILITY_KEYS:
if key not in caps:
continue
value = caps[key]
if not isinstance(value, list) or not all(
isinstance(item, str) for item in value
):
errors.append(f"capabilities.{key} must be a list of strings")
return errors, warnings
def validate_removed(data: object) -> tuple[list[str], list[str]]:
"""Validate the removed.yaml document. Returns (errors, warnings)."""
errors: list[str] = []
warnings: list[str] = []
if not isinstance(data, dict):
return ["top-level document must be a YAML mapping"], warnings
removed = data.get("removed")
if removed is None:
errors.append("missing required key: removed")
return errors, warnings
if not isinstance(removed, list):
errors.append("removed must be a list")
return errors, warnings
for i, item in enumerate(removed):
if not isinstance(item, dict):
errors.append(f"removed[{i}] must be a mapping")
continue
if not _is_nonempty_str(item.get("name")):
errors.append(f"removed[{i}] missing non-empty 'name'")
for key in ("repo", "reason", "date"):
if key in item and not isinstance(item[key], str):
errors.append(f"removed[{i}].{key} must be a string")
return errors, warnings
def validate_file(path: Path) -> tuple[list[str], list[str]]:
"""Validate one YAML file (dispatching on filename). Returns (errors, warnings)."""
try:
with open(path, encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except OSError as exc:
return [f"cannot read file: {exc}"], []
except yaml.YAMLError as exc:
return [f"invalid YAML: {exc}"], []
if path.name == "removed.yaml":
return validate_removed(data)
return validate_entry(data)
def collect_paths(args: list[str]) -> list[Path]:
paths: list[Path] = []
for arg in args:
p = Path(arg)
if p.is_dir():
paths.extend(sorted(p.glob("*.yaml")))
paths.extend(sorted(p.glob("*.yml")))
else:
paths.append(p)
return paths
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Standalone structural validator for plugin-catalog entry files."
)
parser.add_argument(
"paths",
nargs="+",
help="catalog entry files, removed.yaml, or a directory of them",
)
parser.add_argument(
"--json",
action="store_true",
help="emit a machine-readable JSON report on stdout",
)
opts = parser.parse_args(argv)
files = collect_paths(opts.paths)
if not files:
print("ERROR: no YAML files found", file=sys.stderr)
return 1
report = []
any_errors = False
for path in files:
errors, warnings = validate_file(path)
any_errors = any_errors or bool(errors)
report.append(
{
"path": str(path),
"ok": not errors,
"errors": errors,
"warnings": warnings,
}
)
if opts.json:
print(json.dumps({"ok": not any_errors, "files": report}, indent=2))
else:
for entry in report:
for err in entry["errors"]:
print(f"{entry['path']}: ERROR: {err}")
for warn in entry["warnings"]:
print(f"{entry['path']}: warning: {warn}")
checked = len(report)
bad = sum(1 for e in report if not e["ok"])
if any_errors:
print(f"FAIL: {bad}/{checked} file(s) invalid")
else:
print(f"OK: {checked} file(s) valid")
return 1 if any_errors else 0
if __name__ == "__main__":
sys.exit(main())
+61
View File
@@ -0,0 +1,61 @@
"""Plugin catalog contracts (hermes_cli/plugin_catalog.py): the in-tree seed is valid, bad entries are
skipped not raised, kill-list matching is name-or-repo, and the live catalog degrades to in-tree."""
from __future__ import annotations
import json
import yaml
from hermes_cli import plugin_catalog as pc
SHA = "38fe0fb53eff98d477f807432e965429e665ca33"
def _entry(name="good-plugin", **over):
data = {"name": name, "repo": "https://github.com/owner/repo", "sha": SHA, "description": "d",
"maintainer": "owner", "tier": "community", "capabilities": {"provides_tools": ["t1"]}}
data.update(over)
return data
def test_shipped_catalog_entries_are_all_valid_and_pinned():
"""Every file in plugin-catalog/ (minus removed.yaml) must parse — a dropped entry is a silent
shipping regression the admission CI only catches on changed files."""
root = pc.get_catalog_dir()
files = [p for p in root.glob("*.yaml") if p.name != "removed.yaml"]
entries = pc.load_catalog()
assert len(entries) == len(files) >= 1
assert all(pc._SHA_RE.match(e.sha) and e.repo.startswith("https://") for e in entries)
assert all(e.install_identifier.startswith(e.repo) for e in entries)
def test_invalid_entries_are_skipped_not_raised(tmp_path):
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("ok")))
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("short-sha", sha="abc123")))
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("http-repo", repo="http://x/y")))
(tmp_path / "d.yaml").write_text(yaml.safe_dump(_entry("Bad Name")))
(tmp_path / "e.yaml").write_text("- not\n- a mapping\n")
assert [e.name for e in pc.load_catalog(tmp_path)] == ["ok"]
def test_find_removed_matches_name_or_normalized_repo(tmp_path):
(tmp_path / "removed.yaml").write_text(yaml.safe_dump({"removed": [
{"name": "evil", "repo": "https://github.com/x/evil.git", "reason": "malware", "date": "2026-01-01"}]}))
assert pc.find_removed("evil", tmp_path).reason == "malware"
assert pc.find_removed("https://github.com/x/EVIL/", tmp_path) is not None
assert pc.find_removed("https://github.com/x/fine", tmp_path) is None
def test_live_catalog_falls_back_to_in_tree_and_unions_removals(tmp_path, monkeypatch):
"""Network failure → in-tree entries; a cached live doc contributes entries AND removals."""
cache = tmp_path / "cache" / "plugin-catalog.json"
monkeypatch.setattr(pc, "_live_cache_path", lambda: cache)
monkeypatch.setattr(pc, "LIVE_CATALOG_URL", "http://127.0.0.1:9/nope") # unreachable
assert [e.name for e in pc.load_catalog_live()] == [e.name for e in pc.load_catalog()]
cache.parent.mkdir(parents=True)
cache.write_text(json.dumps({"entries": [_entry("live-only")],
"removed": [{"name": "pulled-live", "reason": "cve"}]}))
assert [e.name for e in pc.load_catalog_live()] == ["live-only"]
assert pc.find_removed("pulled-live").reason == "cve"
@@ -1,498 +0,0 @@
"""Tests for the community plugin index (#64181).
Covers: index parsing, fuzzy search, cache TTL + fallback chain
(remote → cache → seed), `hermes plugins search --json`, and install-time
name resolution (unique / ambiguous / passthrough of owner/repo).
No live network — every remote fetch is mocked.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
import pytest
from hermes_cli import plugin_index
from hermes_cli.plugin_index import (
PluginIndexEntry,
_parse_entries,
load_index,
resolve_name,
search_index,
)
def _entry(name, repo="owner/repo", **kw):
return PluginIndexEntry(name=name, repo=repo, **kw)
def _index_doc(entries):
return {"schema_version": 1, "plugins": entries}
SAMPLE = _index_doc(
[
{
"name": "hermes-media-studio",
"description": "Generative media workspace plugin.",
"author": "NousResearch",
"tags": ["media", "image-gen"],
"repo": "NousResearch/hermes-media-studio",
"ref": "e" * 40,
},
{
"name": "hermes-telegram-business",
"description": "Telegram secretary bot with owner approval.",
"author": "NousResearch",
"tags": ["telegram", "gateway"],
"repo": "NousResearch/hermes-telegram-business",
"ref": "f" * 40,
"capabilities": ["platform"],
},
{
"name": "plugin-llm-example",
"description": "Reference plugin for structured LLM access.",
"author": "NousResearch",
"tags": ["example", "llm"],
"repo": "NousResearch/hermes-example-plugins",
"subdir": "plugin-llm-example",
"ref": "a" * 40,
"capabilities": ["commands", "llm"],
},
]
)
@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(plugin_index, "get_hermes_home", lambda: tmp_path)
return tmp_path
def _write_cache(home: Path, doc, *, age_seconds: float = 0) -> Path:
cache = home / "cache" / "plugin_index.json"
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(doc), encoding="utf-8")
if age_seconds:
stamp = time.time() - age_seconds
import os
os.utime(cache, (stamp, stamp))
return cache
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
class TestParsing:
def test_parses_object_form(self):
entries = _parse_entries(SAMPLE)
assert [e.name for e in entries] == [
"hermes-media-studio",
"hermes-telegram-business",
"plugin-llm-example",
]
assert entries[2].subdir == "plugin-llm-example"
assert entries[2].install_identifier == (
"NousResearch/hermes-example-plugins/plugin-llm-example"
)
assert entries[0].install_identifier == "NousResearch/hermes-media-studio"
def test_parses_bare_list_form(self):
entries = _parse_entries(SAMPLE["plugins"])
assert len(entries) == 3
def test_skips_malformed_entries(self):
doc = _index_doc(
[
{"name": "good", "repo": "o/r"},
{"name": "", "repo": "o/r"}, # empty name
{"name": "norepo"}, # missing repo
{"name": "badrepo", "repo": "not-a-repo"}, # no slash
{"name": "deep", "repo": "a/b/c"}, # too many slashes
"not-a-dict",
]
)
entries = _parse_entries(doc)
assert [e.name for e in entries] == ["good"]
def test_rejects_non_container(self):
with pytest.raises(ValueError):
_parse_entries("nope")
def test_bundled_seed_parses(self):
raw = json.loads(plugin_index.SEED_INDEX_PATH.read_text(encoding="utf-8"))
entries = _parse_entries(raw)
assert len(entries) >= 3
for e in entries:
assert e.repo.count("/") == 1
assert e.ref, f"seed entry {e.name} must pin a ref"
assert len(e.ref) == 40, f"seed entry {e.name} must pin a commit SHA"
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
class TestSearch:
entries = _parse_entries(SAMPLE)
def test_exact_name_ranks_first(self):
results = search_index(self.entries, "hermes-media-studio")
assert results[0].name == "hermes-media-studio"
def test_matches_tags(self):
results = search_index(self.entries, "telegram")
assert results and results[0].name == "hermes-telegram-business"
def test_matches_description(self):
results = search_index(self.entries, "secretary")
assert [e.name for e in results] == ["hermes-telegram-business"]
def test_fuzzy_typo_tolerance(self):
results = search_index(self.entries, "hermes-media-studo")
assert results and results[0].name == "hermes-media-studio"
def test_no_match(self):
assert search_index(self.entries, "zzzzqqqq") == []
def test_empty_term_browses_all_sorted(self):
results = search_index(self.entries, "")
assert [e.name for e in results] == sorted(e.name for e in self.entries)
def test_capability_filter(self):
results = search_index(self.entries, "", capability="platform")
assert [e.name for e in results] == ["hermes-telegram-business"]
def test_capability_filter_with_term(self):
results = search_index(self.entries, "llm", capability="commands")
assert [e.name for e in results] == ["plugin-llm-example"]
# ---------------------------------------------------------------------------
# Fallback chain: remote → cache → seed
# ---------------------------------------------------------------------------
class TestLoadIndex:
def test_fresh_cache_wins_without_network(self, hermes_home, monkeypatch):
_write_cache(hermes_home, SAMPLE)
def boom(): # pragma: no cover - must not be called
raise AssertionError("network hit despite fresh cache")
monkeypatch.setattr(plugin_index, "_fetch_remote", boom)
entries, source = load_index()
assert source == "cache"
assert len(entries) == 3
def test_expired_cache_triggers_remote(self, hermes_home, monkeypatch):
_write_cache(hermes_home, SAMPLE, age_seconds=plugin_index.INDEX_CACHE_TTL + 60)
remote_doc = _index_doc([{"name": "fresh-plugin", "repo": "o/r", "ref": "b" * 40}])
monkeypatch.setattr(
plugin_index, "_fetch_remote", lambda: _parse_entries(remote_doc)
)
entries, source = load_index()
assert source == "remote"
assert [e.name for e in entries] == ["fresh-plugin"]
def test_remote_failure_falls_back_to_stale_cache(self, hermes_home, monkeypatch):
_write_cache(hermes_home, SAMPLE, age_seconds=plugin_index.INDEX_CACHE_TTL + 60)
monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None)
entries, source = load_index()
assert source == "cache"
assert len(entries) == 3
def test_no_cache_no_remote_falls_back_to_seed(self, hermes_home, monkeypatch):
monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None)
entries, source = load_index()
assert source == "seed"
assert len(entries) >= 3
def test_refresh_bypasses_fresh_cache(self, hermes_home, monkeypatch):
_write_cache(hermes_home, SAMPLE)
remote_doc = _index_doc([{"name": "newer", "repo": "o/r", "ref": "c" * 40}])
monkeypatch.setattr(
plugin_index, "_fetch_remote", lambda: _parse_entries(remote_doc)
)
entries, source = load_index(refresh=True)
assert source == "remote"
assert [e.name for e in entries] == ["newer"]
def test_offline_skips_network(self, hermes_home, monkeypatch):
def boom(): # pragma: no cover
raise AssertionError("network hit in offline mode")
monkeypatch.setattr(plugin_index, "_fetch_remote", boom)
entries, source = load_index(offline=True)
assert source == "seed"
def test_corrupt_cache_ignored(self, hermes_home, monkeypatch):
cache = hermes_home / "cache" / "plugin_index.json"
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text("{not json", encoding="utf-8")
monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None)
entries, source = load_index()
assert source == "seed"
def test_remote_fetch_writes_cache(self, hermes_home, monkeypatch):
payload = json.dumps(SAMPLE)
class FakeResponse:
text = payload
def raise_for_status(self):
return None
import httpx
monkeypatch.setattr(httpx, "get", lambda *a, **k: FakeResponse())
entries, source = load_index()
assert source == "remote"
cache = hermes_home / "cache" / "plugin_index.json"
assert cache.is_file()
assert json.loads(cache.read_text(encoding="utf-8")) == SAMPLE
def test_index_url_config_override(self, monkeypatch):
monkeypatch.setattr(
plugin_index,
"get_index_url",
plugin_index.get_index_url, # keep real fn, patch config below
)
from hermes_cli import config as config_mod
monkeypatch.setattr(
config_mod,
"load_config_readonly",
lambda: {"plugins": {"index_url": "https://example.com/custom.json"}},
)
assert plugin_index.get_index_url() == "https://example.com/custom.json"
# ---------------------------------------------------------------------------
# Name resolution
# ---------------------------------------------------------------------------
class TestResolveName:
entries = _parse_entries(SAMPLE)
def test_exact_unique(self):
entry, candidates = resolve_name(self.entries, "hermes-media-studio")
assert entry is not None and entry.repo == "NousResearch/hermes-media-studio"
def test_case_insensitive(self):
entry, _ = resolve_name(self.entries, "Hermes-Media-Studio")
assert entry is not None
def test_unique_partial(self):
entry, _ = resolve_name(self.entries, "telegram")
assert entry is not None and entry.name == "hermes-telegram-business"
def test_ambiguous_partial(self):
entry, candidates = resolve_name(self.entries, "hermes")
assert entry is None
assert len(candidates) == 2
def test_unknown(self):
entry, candidates = resolve_name(self.entries, "nonexistent-thing")
assert entry is None and candidates == []
# ---------------------------------------------------------------------------
# Install wiring
# ---------------------------------------------------------------------------
class TestInstallResolution:
def test_bare_name_detection(self):
from hermes_cli.plugins_cmd import _looks_like_bare_index_name
assert _looks_like_bare_index_name("hermes-media-studio")
assert not _looks_like_bare_index_name("owner/repo")
assert not _looks_like_bare_index_name("https://github.com/o/r.git")
assert not _looks_like_bare_index_name("git@github.com:o/r.git")
assert not _looks_like_bare_index_name("ssh://git@github.com/o/r.git")
assert not _looks_like_bare_index_name("file:///tmp/x")
def test_install_resolves_name_and_pins_ref(self, hermes_home, monkeypatch):
from hermes_cli import plugins_cmd
monkeypatch.setattr(
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
)
captured = {}
def fake_core(identifier, *, force, ref=None, scan_decision_cb=None):
captured["identifier"] = identifier
captured["ref"] = ref
raise plugins_cmd.PluginOperationError("stop here")
monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core)
with pytest.raises(SystemExit):
plugins_cmd.cmd_install("hermes-media-studio", enable=False)
assert captured["identifier"] == "NousResearch/hermes-media-studio"
assert captured["ref"] == "e" * 40
def test_install_explicit_ref_beats_index_pin(self, hermes_home, monkeypatch):
from hermes_cli import plugins_cmd
monkeypatch.setattr(
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
)
captured = {}
def fake_core(identifier, *, force, ref=None, scan_decision_cb=None):
captured["ref"] = ref
raise plugins_cmd.PluginOperationError("stop here")
monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core)
with pytest.raises(SystemExit):
plugins_cmd.cmd_install("hermes-media-studio", enable=False, ref="d" * 40)
assert captured["ref"] == "d" * 40
def test_install_ambiguous_name_lists_candidates_and_exits(
self, hermes_home, monkeypatch, capsys
):
from hermes_cli import plugins_cmd
monkeypatch.setattr(
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
)
called = []
monkeypatch.setattr(
plugins_cmd,
"_install_plugin_core",
lambda *a, **k: called.append(1),
)
with pytest.raises(SystemExit) as exc:
plugins_cmd.cmd_install("hermes", enable=False)
assert exc.value.code == 1
assert not called
out = capsys.readouterr().out
assert "ambiguous" in out
assert "hermes-media-studio" in out
assert "hermes-telegram-business" in out
def test_install_unknown_name_exits(self, hermes_home, monkeypatch, capsys):
from hermes_cli import plugins_cmd
monkeypatch.setattr(
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
)
with pytest.raises(SystemExit) as exc:
plugins_cmd.cmd_install("totally-unknown", enable=False)
assert exc.value.code == 1
assert "not found" in capsys.readouterr().out
def test_owner_repo_passthrough_skips_index(self, hermes_home, monkeypatch):
"""Explicit owner/repo installs never consult the index."""
from hermes_cli import plugins_cmd
def boom(**kw): # pragma: no cover
raise AssertionError("index consulted for owner/repo identifier")
monkeypatch.setattr(plugin_index, "load_index", boom)
captured = {}
def fake_core(identifier, *, force, ref=None, scan_decision_cb=None):
captured["identifier"] = identifier
captured["ref"] = ref
raise plugins_cmd.PluginOperationError("stop here")
monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core)
with pytest.raises(SystemExit):
plugins_cmd.cmd_install("someowner/somerepo", enable=False)
assert captured["identifier"] == "someowner/somerepo"
assert captured["ref"] is None
# ---------------------------------------------------------------------------
# CLI search command
# ---------------------------------------------------------------------------
class TestCmdSearch:
def test_json_output(self, hermes_home, monkeypatch, capsys):
from hermes_cli import plugins_cmd
monkeypatch.setattr(
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
)
plugins_cmd.cmd_search("telegram", json_output=True)
payload = json.loads(capsys.readouterr().out)
assert payload["source"] == "seed"
assert payload["query"] == "telegram"
assert payload["results"][0]["name"] == "hermes-telegram-business"
assert payload["results"][0]["repo"] == "NousResearch/hermes-telegram-business"
assert payload["results"][0]["ref"] == "f" * 40
assert "audited" in payload["note"]
def test_table_output_includes_security_footer(
self, hermes_home, monkeypatch, capsys
):
from hermes_cli import plugins_cmd
monkeypatch.setattr(
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
)
plugins_cmd.cmd_search("media")
out = capsys.readouterr().out
assert "hermes-media-studio" in out
assert "audited" in out
def test_no_results_message(self, hermes_home, monkeypatch, capsys):
from hermes_cli import plugins_cmd
monkeypatch.setattr(
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
)
plugins_cmd.cmd_search("zzzznope")
assert "No plugins matched" in capsys.readouterr().out
def test_parser_accepts_search(self):
import argparse
from hermes_cli.subcommands.plugins import build_plugins_parser
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command")
build_plugins_parser(sub, cmd_plugins=lambda args: None)
args = parser.parse_args(
["plugins", "search", "media", "--json", "--capability", "tools", "--refresh"]
)
assert args.plugins_action == "search"
assert args.term == "media"
assert args.json is True
assert args.capability == "tools"
assert args.refresh is True
def test_dispatch_routes_search(self, hermes_home, monkeypatch):
from hermes_cli import plugins_cmd
captured = {}
def fake_search(term, *, json_output, capability, refresh):
captured.update(
term=term, json_output=json_output, capability=capability, refresh=refresh
)
monkeypatch.setattr(plugins_cmd, "cmd_search", fake_search)
import argparse
args = argparse.Namespace(
plugins_action="search", term="llm", json=True, capability=None, refresh=False
)
plugins_cmd.plugins_command(args)
assert captured == {
"term": "llm",
"json_output": True,
"capability": None,
"refresh": False,
}
@@ -409,3 +409,26 @@ class TestCtxHasPlugin:
finally:
if hasattr(sys, "_m2_probe"):
del sys._m2_probe
class TestRequiresHermes:
def test_unsatisfied_requires_hermes_skips_without_importing(self, hermes_home, monkeypatch):
"""A too-new ``requires_hermes`` records an error and never runs register(); a satisfied one loads."""
import sys
from hermes_cli import plugins_manifest
monkeypatch.setattr(plugins_manifest, "running_hermes_version", lambda: "1.2.3")
_write_plugin(hermes_home / "plugins", "future", manifest_extra={"requires_hermes": ">=99.0"},
register_body="import sys; sys._rh_future = True")
_write_plugin(hermes_home / "plugins", "current", manifest_extra={"requires_hermes": ">=1.2,<2"},
register_body="import sys; sys._rh_current = True")
_enable(hermes_home, ["future", "current"])
try:
mgr = PluginManager()
mgr.discover_and_load()
assert not hasattr(sys, "_rh_future")
assert "requires hermes >=99.0" in (mgr._plugins["future"].error or "")
assert getattr(sys, "_rh_current", False) is True
finally:
for attr in ("_rh_future", "_rh_current"):
if hasattr(sys, attr):
delattr(sys, attr)
+17 -15
View File
@@ -182,37 +182,39 @@ def test_load_pack_missing_file_errors():
# ---------------------------------------------------------------------------
# Resolution (bare index names) — index mocked, no network
# Resolution (bare catalog names) — catalog mocked, no network
# ---------------------------------------------------------------------------
def test_resolve_pack_plugins_uses_community_index_for_bare_names():
def _fake_catalog_entry(name):
from hermes_cli.plugin_catalog import CatalogCapabilities, PluginCatalogEntry
return PluginCatalogEntry(
name=name, repo="https://github.com/idx-owner/idx-repo", sha=SHA_B, description="d", maintainer="idx-owner",
capabilities=CatalogCapabilities(provides_tools=["tools"]))
def test_resolve_pack_plugins_uses_catalog_for_bare_names():
pack = parse_pack(_pack_yaml())
fake_entry = SimpleNamespace(
install_identifier="idx-owner/idx-repo", capabilities=["tools"]
)
bare_name = pack.plugins[1].name
fake_entry = _fake_catalog_entry(bare_name)
with mock.patch(
"hermes_cli.plugin_index.load_index", return_value=([fake_entry], "seed")
), mock.patch(
"hermes_cli.plugin_index.resolve_name",
return_value=(fake_entry, [fake_entry]),
"hermes_cli.plugin_catalog.load_catalog_live",
return_value=[fake_entry],
):
resolved = resolve_pack_plugins(pack)
assert resolved[0].identifier == "owner/tts-plugin" # repo entries skip the index
assert resolved[1].identifier == "idx-owner/idx-repo"
assert resolved[0].identifier == "owner/tts-plugin" # repo entries skip the catalog
assert resolved[1].identifier == "https://github.com/idx-owner/idx-repo"
assert resolved[1].index_capabilities == ["tools"]
def test_resolve_pack_plugins_carries_index_miss_as_error():
def test_resolve_pack_plugins_carries_catalog_miss_as_error():
pack = parse_pack(
yaml.safe_dump(
{"name": "p", "plugins": [{"name": "ghost", "ref": SHA_A}]}
)
)
with mock.patch(
"hermes_cli.plugin_index.load_index", return_value=([], "seed")
), mock.patch(
"hermes_cli.plugin_index.resolve_name", return_value=(None, [])
"hermes_cli.plugin_catalog.load_catalog_live", return_value=[]
):
resolved = resolve_pack_plugins(pack)
assert resolved[0].identifier is None
+114
View File
@@ -0,0 +1,114 @@
"""Tests for ``hermes plugins validate`` (hermes_cli/plugin_validate.py).
Static manifest checks + subprocess-isolated capability probing against a
recording stub context.
"""
from __future__ import annotations
from pathlib import Path
import yaml
from hermes_cli.plugin_validate import validate_plugin_dir
def _make_plugin(
tmp_path: Path,
*,
manifest: dict,
init_py: str = "def register(ctx):\n pass\n",
) -> Path:
d = tmp_path / manifest.get("name", "fixture-plugin")
d.mkdir(parents=True, exist_ok=True)
(d / "plugin.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8")
(d / "__init__.py").write_text(init_py, encoding="utf-8")
return d
BASE_MANIFEST = {
"name": "fixture-plugin",
"version": "1.0.0",
"description": "A fixture plugin.",
}
class TestCapabilityProbe:
def test_undeclared_tool_registration_fails_with_diff(self, tmp_path):
init = (
"def register(ctx):\n"
" ctx.register_tool('sneaky_tool', 'sneaky', {}, lambda a: '')\n"
)
d = _make_plugin(tmp_path, manifest=dict(BASE_MANIFEST), init_py=init)
report = validate_plugin_dir(d)
assert not report.ok
joined = " ".join(report.failures)
assert "sneaky_tool" in joined
assert "undeclared" in joined.lower()
def test_declared_and_registered_passes(self, tmp_path):
manifest = dict(BASE_MANIFEST, provides_tools=["good_tool"])
init = (
"def register(ctx):\n"
" ctx.register_tool('good_tool', 'good', {}, lambda a: '')\n"
)
d = _make_plugin(tmp_path, manifest=manifest, init_py=init)
report = validate_plugin_dir(d)
assert report.ok
def test_declared_but_not_registered_warns(self, tmp_path):
manifest = dict(BASE_MANIFEST, provides_tools=["phantom_tool"])
d = _make_plugin(tmp_path, manifest=manifest)
report = validate_plugin_dir(d)
assert report.ok # warn, not fail
assert any("phantom_tool" in w for w in report.warnings)
def test_undeclared_hook_registration_fails(self, tmp_path):
init = (
"def register(ctx):\n"
" ctx.register_hook('pre_tool_call', lambda **kw: None)\n"
)
d = _make_plugin(tmp_path, manifest=dict(BASE_MANIFEST), init_py=init)
report = validate_plugin_dir(d)
assert not report.ok
assert any("pre_tool_call" in f for f in report.failures)
def test_crashing_register_is_contained(self, tmp_path):
init = "def register(ctx):\n raise RuntimeError('boom')\n"
d = _make_plugin(tmp_path, manifest=dict(BASE_MANIFEST), init_py=init)
report = validate_plugin_dir(d) # must not raise / kill the CLI
assert not report.ok
assert any("boom" in f or "register()" in f for f in report.failures)
def test_import_time_os_exit_is_contained(self, tmp_path):
init = "import os\nos._exit(7)\n"
d = _make_plugin(tmp_path, manifest=dict(BASE_MANIFEST), init_py=init)
report = validate_plugin_dir(d)
assert not report.ok
def test_builtin_tool_collision_fails(self, tmp_path):
manifest = dict(BASE_MANIFEST, provides_tools=["terminal"])
init = (
"def register(ctx):\n"
" ctx.register_tool('terminal', 'shadow', {}, lambda a: '')\n"
)
d = _make_plugin(tmp_path, manifest=manifest, init_py=init)
report = validate_plugin_dir(d)
assert not report.ok
joined = " ".join(report.failures)
assert "terminal" in joined
assert "built-in" in joined
def test_probe_context_returns_get_config_defaults(self, tmp_path):
"""Real PluginContext.get_config yields the default when nothing is configured; the probe must
too, or every plugin doing ``int(ctx.get_config("timeout", 180))`` fails admission."""
d = _make_plugin(
tmp_path,
manifest={**BASE_MANIFEST, "provides_tools": ["t"]},
init_py=(
"def register(ctx):\n"
" int(ctx.get_config('timeout_seconds', 180))\n"
" ctx.register_tool('t', schema={}, handler=lambda **kw: None)\n"),
)
report = validate_plugin_dir(d)
assert report.ok, report.failures
@@ -0,0 +1,98 @@
"""Catalog-aware ``hermes plugins`` surface (hermes_cli/plugins_cmd_catalog.py): a bare catalog name installs
the PINNED sha and records provenance; the kill list blocks every install path (CLI needs an explicit
bypass, dashboard/TUI have none); ``update`` re-pins instead of pulling. Real git, file:// repos."""
from __future__ import annotations
import json
import os
import shutil
import subprocess as sp
from pathlib import Path
import pytest
from hermes_cli import plugin_catalog as pc_cat
from hermes_cli import plugins_cmd as pc
from hermes_cli import plugins_cmd_catalog as cat
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not available")
_GIT_ENV = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
def _commit(repo: Path, msg: str) -> str:
sp.run(["git", "add", "-A"], cwd=repo, check=True, env=_GIT_ENV)
sp.run(["git", "commit", "-q", "-m", msg], cwd=repo, check=True, env=_GIT_ENV)
return sp.run(["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True).stdout.strip()
@pytest.fixture
def world(tmp_path, monkeypatch):
"""A file:// plugin repo with two commits, a catalog pinned to the FIRST, an isolated plugins dir."""
repo = tmp_path / "repo"
repo.mkdir()
(repo / "plugin.yaml").write_text("name: cat-plugin\nversion: 1.0.0\ndescription: d\n")
(repo / "__init__.py").write_text("def register(ctx):\n pass\n")
sp.run(["git", "init", "-q"], cwd=repo, check=True, env=_GIT_ENV)
sha1 = _commit(repo, "v1")
(repo / "__init__.py").write_text("def register(ctx):\n pass # v2\n")
sha2 = _commit(repo, "v2")
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
monkeypatch.setattr(pc, "_scan_on_install_enabled", lambda: False)
monkeypatch.setattr(pc, "_console", lambda: type("C", (), {"print": lambda *a, **k: None})())
# Catalog: one entry pinned to sha1, mutable via state["pin"]; kill list via state["removed"]. The
# real loader is https-only, so the fixture entry is built directly (file:// repo).
state = {"pin": sha1, "removed": []}
def _entries():
return [pc_cat.PluginCatalogEntry(name="cat-plugin", repo=repo.as_uri(), sha=state["pin"],
description="d", maintainer="t")]
monkeypatch.setattr(pc_cat, "load_catalog", lambda catalog_dir=None: _entries())
monkeypatch.setattr(pc_cat, "fetch_live_catalog", lambda **_: None) # in-tree only, no network
monkeypatch.setattr(pc_cat, "load_removed_list", lambda catalog_dir=None: list(state["removed"]))
return {"repo": repo, "sha1": sha1, "sha2": sha2, "plugins_dir": plugins_dir, "state": state}
def _head(path: Path) -> str:
return sp.run(["git", "rev-parse", "HEAD"], cwd=path, capture_output=True, text=True).stdout.strip()
def test_catalog_name_installs_pinned_sha_with_sidecar_then_update_repins(world, monkeypatch):
entry = pc_cat.get_live_catalog_entry("cat-plugin")
assert entry is not None
target, _m, name = cat.install_catalog_entry(entry, force=False)
assert name == "cat-plugin"
assert _head(target) == world["sha1"] != world["sha2"] # pinned, not HEAD
sidecar = json.loads((target / cat.CATALOG_SIDECAR).read_text())
assert (sidecar["catalog_name"], sidecar["sha"]) == ("cat-plugin", world["sha1"])
assert cat.catalog_annotation(target) == f"catalog:community@{world['sha1'][:8]}"
# Dashboard update on a catalog install = re-pin. Pin unchanged → no-op.
assert pc.dashboard_update_user_plugin("cat-plugin") == {
"ok": True, "name": "cat-plugin", "sha": world["sha1"], "unchanged": True}
# Bump the catalog pin → the checkout moves to exactly that sha.
world["state"]["pin"] = world["sha2"]
assert pc.dashboard_update_user_plugin("cat-plugin")["unchanged"] is False
assert _head(world["plugins_dir"] / "cat-plugin") == world["sha2"]
def test_kill_list_blocks_cli_dashboard_and_tui_paths(world, monkeypatch):
world["state"]["removed"].append(
pc_cat.RemovedEntry(name="cat-plugin", repo=world["repo"].as_uri(), reason="malware"))
# Dashboard/TUI: catalog name AND raw repo URL both refused, no bypass parameter exists.
assert "malware" in pc.dashboard_install_plugin("", force=False, enable=False, catalog_name="cat-plugin")["error"]
assert "malware" in pc.dashboard_install_plugin(world["repo"].as_uri(), force=False, enable=False)["error"]
assert not (world["plugins_dir"] / "cat-plugin").exists()
# CLI: refused by default, `--allow-removed` installs anyway.
with pytest.raises(SystemExit):
pc.cmd_install("cat-plugin", enable=False)
pc.cmd_install("cat-plugin", enable=False, allow_removed=True)
assert (world["plugins_dir"] / "cat-plugin" / cat.CATALOG_SIDECAR).exists()
assert cat.removed_annotation("cat-plugin", world["plugins_dir"] / "cat-plugin") == "malware"
@@ -0,0 +1,77 @@
"""Dashboard plugin-catalog surface: GET /api/dashboard/plugins/catalog merges installed state (via the
``.hermes-catalog.json`` sidecar) and the install endpoint has NO kill-list bypass."""
from __future__ import annotations
import json
import pytest
import yaml
from hermes_cli import plugin_catalog as pc_cat
VALID_SHA = "38fe0fb53eff98d477f807432e965429e665ca33"
OTHER_SHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
@pytest.fixture
def client(monkeypatch, tmp_path, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
catalog_dir = tmp_path / "catalog"
catalog_dir.mkdir()
(catalog_dir / "alpha-plugin.yaml").write_text(yaml.safe_dump({
"name": "alpha-plugin", "repo": "https://github.com/example/alpha-plugin", "sha": VALID_SHA,
"description": "d", "maintainer": "Example", "tier": "official",
"capabilities": {"provides_tools": ["tool_a"], "requires_env": ["EXAMPLE_API_KEY"]}}))
(catalog_dir / "removed.yaml").write_text(yaml.safe_dump({"removed": [
{"name": "bad-plugin", "repo": "https://github.com/evil/bad-plugin", "reason": "exfiltrated env vars"}]}))
monkeypatch.setattr(pc_cat, "get_catalog_dir", lambda: catalog_dir)
monkeypatch.setattr(pc_cat, "fetch_live_catalog", lambda **_: None)
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
def _install(name: str, sidecar: dict | None):
from hermes_constants import get_hermes_home
d = get_hermes_home() / "plugins" / name
d.mkdir(parents=True)
(d / "plugin.yaml").write_text(yaml.safe_dump({"name": name, "version": "1.0", "description": "x"}))
if sidecar:
(d / ".hermes-catalog.json").write_text(json.dumps(sidecar))
def test_catalog_endpoint_merges_installed_state_from_sidecar(client):
from starlette.testclient import TestClient
from hermes_cli.web_server import app
assert TestClient(app).get("/api/dashboard/plugins/catalog").status_code == 401
# Manifest name differs from the catalog name (the common case): matched through the sidecar.
_install("alpha", {"catalog_name": "alpha-plugin", "sha": OTHER_SHA, "tier": "official"})
data = client.get("/api/dashboard/plugins/catalog").json()
[entry] = data["entries"]
assert (entry["name"], entry["sha_short"], entry["capabilities"]["provides_tools"]) == ("alpha-plugin", VALID_SHA[:7], ["tool_a"])
assert "tool_a" in entry["capability_summary"]
assert (entry["installed"], entry["installed_sha"], entry["update_available"]) == (True, OTHER_SHA, True)
assert entry["runtime_status"] == "inactive"
assert data["removed"][0]["reason"] == "exfiltrated env vars"
def test_install_endpoint_refuses_removed_plugins_with_no_bypass(client, monkeypatch):
from hermes_cli import plugins_cmd
monkeypatch.setattr(plugins_cmd, "_install_plugin_core", lambda *a, **k: pytest.fail("kill-listed install ran"))
for body in ({"identifier": "https://github.com/evil/bad-plugin.git"},
{"identifier": "", "catalog_name": "bad-plugin"}, {"identifier": "evil/bad-plugin"}):
resp = client.post("/api/dashboard/agent-plugins/install", json=body)
assert resp.status_code == 400, body
assert "removed" in resp.json()["detail"] or "not in the Hermes plugin catalog" in resp.json()["detail"]
assert client.post("/api/dashboard/agent-plugins/install", json={"identifier": ""}).status_code == 400
@@ -0,0 +1,273 @@
"""Behavior tests for scripts/validate_plugin_catalog.py.
The script is the no-install structural validator used by the plugin-catalog
admission CI: it must run with only stdlib + pyyaml, take file paths or a
directory, exit 0/1, and support --json machine output. These tests exercise
the CLI contract via subprocess (the same way CI invokes it).
"""
import json
import subprocess
import sys
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "validate_plugin_catalog.py"
VALID_ENTRY = {
"name": "example-plugin",
"repo": "https://github.com/NousResearch/hermes-example-plugins",
"sha": "38fe0fb53eff98d477f807432e965429e665ca33",
"subdir": "",
"description": "One-line description.",
"maintainer": "NousResearch",
"tier": "official",
"requires_hermes": ">=0.19",
"docs_url": "",
"platforms": [],
"capabilities": {
"provides_tools": ["example_tool"],
"provides_hooks": [],
"provides_middleware": [],
"requires_env": [],
},
}
def write_entry(tmp_path: Path, data: dict, filename: str | None = None) -> Path:
name = filename or f"{data.get('name', 'entry')}.yaml"
path = tmp_path / name
path.write_text(yaml.safe_dump(data), encoding="utf-8")
return path
def run_validator(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(SCRIPT), *args],
capture_output=True,
text=True,
)
# ── valid input ────────────────────────────────────────────────────────
def test_valid_entry_passes(tmp_path):
path = write_entry(tmp_path, VALID_ENTRY)
result = run_validator(str(path))
assert result.returncode == 0, result.stdout + result.stderr
def test_valid_entry_without_optional_fields_passes(tmp_path):
entry = {
"name": "minimal-plugin",
"repo": "https://github.com/example/minimal",
"sha": "a" * 40,
"description": "Minimal.",
"maintainer": "someone",
}
path = write_entry(tmp_path, entry)
result = run_validator(str(path))
assert result.returncode == 0, result.stdout + result.stderr
# ── each malformed field fails with a pointed error ────────────────────
def _expect_error(tmp_path, mutation: dict, expected_substring: str, drop: str = ""):
entry = {**VALID_ENTRY, **mutation}
if drop:
entry.pop(drop, None)
path = write_entry(tmp_path, entry, filename="entry.yaml")
result = run_validator(str(path))
combined = result.stdout + result.stderr
assert result.returncode == 1, combined
assert expected_substring in combined, combined
assert "entry.yaml" in combined, combined
def test_bad_name_fails(tmp_path):
_expect_error(tmp_path, {"name": "Bad Name!"}, "name")
def test_name_too_long_fails(tmp_path):
_expect_error(tmp_path, {"name": "x" * 65}, "name")
def test_non_https_repo_fails(tmp_path):
_expect_error(tmp_path, {"repo": "git@github.com:evil/x.git"}, "repo")
def test_short_sha_fails(tmp_path):
_expect_error(tmp_path, {"sha": "abc123"}, "sha")
def test_non_hex_sha_fails(tmp_path):
_expect_error(tmp_path, {"sha": "z" * 40}, "sha")
def test_bad_tier_fails(tmp_path):
_expect_error(tmp_path, {"tier": "platinum"}, "tier")
def test_empty_description_fails(tmp_path):
_expect_error(tmp_path, {"description": ""}, "description")
def test_empty_maintainer_fails(tmp_path):
_expect_error(tmp_path, {"maintainer": ""}, "maintainer")
def test_missing_required_field_fails(tmp_path):
_expect_error(tmp_path, {}, "sha", drop="sha")
def test_capabilities_value_not_a_list_fails(tmp_path):
_expect_error(
tmp_path,
{"capabilities": {"provides_tools": "not-a-list"}},
"provides_tools",
)
def test_capabilities_list_of_non_strings_fails(tmp_path):
_expect_error(
tmp_path,
{"capabilities": {"requires_env": [1, 2]}},
"requires_env",
)
def test_bad_requires_hermes_spec_fails(tmp_path):
_expect_error(tmp_path, {"requires_hermes": "banana"}, "requires_hermes")
def test_comma_separated_requires_hermes_passes(tmp_path):
entry = {**VALID_ENTRY, "requires_hermes": ">=0.19, <2.0"}
path = write_entry(tmp_path, entry)
result = run_validator(str(path))
assert result.returncode == 0, result.stdout + result.stderr
def test_unknown_platform_fails(tmp_path):
_expect_error(tmp_path, {"platforms": ["linux", "amiga"]}, "platforms")
def test_entry_not_a_mapping_fails(tmp_path):
path = tmp_path / "entry.yaml"
path.write_text("- just\n- a\n- list\n", encoding="utf-8")
result = run_validator(str(path))
assert result.returncode == 1
assert "mapping" in (result.stdout + result.stderr)
# ── unknown top-level keys warn but do not fail ────────────────────────
def test_unknown_key_warns_but_passes(tmp_path):
entry = {**VALID_ENTRY, "future_field": "hello"}
path = write_entry(tmp_path, entry)
result = run_validator(str(path))
combined = result.stdout + result.stderr
assert result.returncode == 0, combined
assert "future_field" in combined
assert "warning" in combined.lower()
# ── removed.yaml shape ─────────────────────────────────────────────────
def test_valid_removed_yaml_passes(tmp_path):
path = tmp_path / "removed.yaml"
path.write_text(
yaml.safe_dump(
{
"removed": [
{
"name": "some-plugin",
"repo": "https://github.com/evil/some-plugin",
"reason": "Exfiltrated env vars",
"date": "2026-07-02",
}
]
}
),
encoding="utf-8",
)
result = run_validator(str(path))
assert result.returncode == 0, result.stdout + result.stderr
def test_removed_yaml_not_a_list_fails(tmp_path):
path = tmp_path / "removed.yaml"
path.write_text(yaml.safe_dump({"removed": "nope"}), encoding="utf-8")
result = run_validator(str(path))
assert result.returncode == 1
assert "removed" in (result.stdout + result.stderr)
def test_removed_item_missing_name_fails(tmp_path):
path = tmp_path / "removed.yaml"
path.write_text(
yaml.safe_dump({"removed": [{"reason": "bad", "date": "2026-01-01"}]}),
encoding="utf-8",
)
result = run_validator(str(path))
assert result.returncode == 1
assert "name" in (result.stdout + result.stderr)
# ── --json machine output ──────────────────────────────────────────────
def test_json_output_shape_on_failure(tmp_path):
bad = write_entry(tmp_path, {**VALID_ENTRY, "sha": "short"}, filename="bad.yaml")
result = run_validator("--json", str(bad))
assert result.returncode == 1
payload = json.loads(result.stdout)
assert payload["ok"] is False
assert isinstance(payload["files"], list)
entry = next(f for f in payload["files"] if f["path"].endswith("bad.yaml"))
assert entry["ok"] is False
assert any("sha" in e for e in entry["errors"])
def test_json_output_shape_on_success_with_warning(tmp_path):
good = write_entry(tmp_path, {**VALID_ENTRY, "future_field": 1})
result = run_validator("--json", str(good))
assert result.returncode == 0
payload = json.loads(result.stdout)
assert payload["ok"] is True
(entry,) = payload["files"]
assert entry["ok"] is True
assert entry["errors"] == []
assert any("future_field" in w for w in entry["warnings"])
# ── directory mode ─────────────────────────────────────────────────────
def test_directory_mode_validates_all_entries_and_removed(tmp_path):
write_entry(tmp_path, VALID_ENTRY)
write_entry(tmp_path, {**VALID_ENTRY, "name": "bad-one", "sha": "nope"})
(tmp_path / "removed.yaml").write_text(
yaml.safe_dump({"removed": [{"name": "gone", "reason": "test"}]}),
encoding="utf-8",
)
result = run_validator(str(tmp_path))
combined = result.stdout + result.stderr
assert result.returncode == 1
assert "bad-one.yaml" in combined
# the valid entry and removed.yaml must not produce errors
assert combined.count("ERROR") == combined.count("bad-one.yaml: ERROR")
def test_directory_mode_all_valid_exits_zero(tmp_path):
write_entry(tmp_path, VALID_ENTRY)
(tmp_path / "removed.yaml").write_text(
yaml.safe_dump({"removed": []}), encoding="utf-8"
)
result = run_validator(str(tmp_path))
assert result.returncode == 0, result.stdout + result.stderr
@@ -37,6 +37,7 @@ def test_plugins_manage_install_success():
"owner/hello-world",
force=True,
enable=False,
catalog_name=None,
)
@@ -71,3 +72,51 @@ def test_plugins_manage_install_failure():
assert "error" in resp
assert "Git clone failed" in resp["error"]["message"]
def test_plugins_manage_install_catalog_name_only():
"""A catalog pick needs no identifier — the backend resolves repo + pin."""
payload = {"ok": True, "plugin_name": "weather-plugin", "enabled": False}
with patch(
"hermes_cli.plugins_cmd.dashboard_install_plugin",
return_value=payload,
) as mock_install:
resp = server.handle_request(
{
"id": "1",
"method": "plugins.manage",
"params": {
"action": "install",
"catalog_name": "weather-plugin",
"enable": False,
},
}
)
assert "result" in resp
mock_install.assert_called_once_with(
"",
force=False,
enable=False,
catalog_name="weather-plugin",
)
def test_plugins_manage_update_requires_catalog_sidecar(tmp_path, monkeypatch):
"""Non-catalog installs are refused — their update flows stay CLI-owned."""
import hermes_cli.plugins_cmd as plugins_cmd
plugins_root = tmp_path / "plugins"
(plugins_root / "plain-git-plugin").mkdir(parents=True)
monkeypatch.setattr(plugins_cmd, "_plugins_dir", lambda: plugins_root)
resp = server.handle_request(
{
"id": "1",
"method": "plugins.manage",
"params": {"action": "update", "name": "plain-git-plugin"},
}
)
assert "error" in resp
assert "not a catalog install" in resp["error"]["message"]
+200
View File
@@ -0,0 +1,200 @@
"""Tests for website/scripts/extract-plugins.py.
Behavioral contracts for the /docs/plugins catalog extractor:
1. Reads ``plugin-catalog/*.yaml`` entries (skipping ``removed.yaml``) and
emits ``plugins.json`` rows carrying name/repo/sha/tier/capabilities plus
a synthesized ``hermes plugins install <name>`` command.
2. Entries missing any of name/repo/sha are skipped (logged, not fatal).
3. A missing ``plugin-catalog/`` directory degrades gracefully: empty
catalog list, zero counts in the meta sidecar, exit 0 — the docs build
must stay green before the catalog directory lands on main.
"""
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
EXTRACT = REPO_ROOT / "website" / "scripts" / "extract-plugins.py"
@pytest.fixture(scope="module")
def mod():
spec = importlib.util.spec_from_file_location("extract_plugins", EXTRACT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _write_entry(catalog_dir: Path, name: str, **overrides) -> Path:
import yaml
entry = {
"name": name,
"repo": f"https://github.com/example/{name}",
"sha": "38fe0fb53eff98d477f807432e965429e665ca33",
"description": f"{name} does things.",
"maintainer": "Example",
"tier": "community",
}
entry.update(overrides)
# Drop keys explicitly set to None so tests can simulate missing fields.
entry = {k: v for k, v in entry.items() if v is not None}
path = catalog_dir / f"{name}.yaml"
path.write_text(yaml.safe_dump(entry), encoding="utf-8")
return path
# --------------------------------------------------------------------------
# Entry loading + validation
# --------------------------------------------------------------------------
def test_valid_entry_is_extracted_with_install_command(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(
catalog,
"example-plugin",
tier="official",
docs_url="https://example.com/docs",
requires_hermes=">=0.19",
platforms=["linux"],
capabilities={
"provides_tools": ["do_thing"],
"provides_hooks": ["on_start"],
"provides_middleware": [],
"requires_env": ["EXAMPLE_TOKEN"],
},
)
entries = mod.load_catalog_entries(catalog)
assert len(entries) == 1
e = entries[0]
assert e["name"] == "example-plugin"
assert e["repo"] == "https://github.com/example/example-plugin"
assert e["sha"] == "38fe0fb53eff98d477f807432e965429e665ca33"
assert e["shaShort"] == "38fe0fb"
assert e["tier"] == "official"
assert e["maintainer"] == "Example"
assert e["requiresHermes"] == ">=0.19"
assert e["platforms"] == ["linux"]
assert e["docsUrl"] == "https://example.com/docs"
assert e["capabilities"]["providesTools"] == ["do_thing"]
assert e["capabilities"]["providesHooks"] == ["on_start"]
assert e["capabilities"]["requiresEnv"] == ["EXAMPLE_TOKEN"]
assert e["installCommand"] == "hermes plugins install example-plugin"
def test_entries_missing_required_fields_are_skipped(mod, tmp_path, capsys):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "good-plugin")
_write_entry(catalog, "no-sha", sha=None)
_write_entry(catalog, "no-repo", repo=None)
entries = mod.load_catalog_entries(catalog)
assert [e["name"] for e in entries] == ["good-plugin"]
err = capsys.readouterr().err
assert "no-sha" in err
assert "no-repo" in err
def test_removed_yaml_is_not_treated_as_an_entry(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "kept-plugin")
(catalog / "removed.yaml").write_text(
"removed:\n - name: evil-plugin\n repo: https://github.com/evil/x\n"
' reason: "bad"\n date: "2026-07-02"\n',
encoding="utf-8",
)
entries = mod.load_catalog_entries(catalog)
assert [e["name"] for e in entries] == ["kept-plugin"]
assert mod.count_removed(catalog) == 1
def test_unknown_tier_normalizes_to_community(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "weird-tier", tier="platinum")
entries = mod.load_catalog_entries(catalog)
assert entries[0]["tier"] == "community"
# --------------------------------------------------------------------------
# Full run: outputs + graceful degradation
# --------------------------------------------------------------------------
def test_main_writes_catalog_and_meta(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "alpha", tier="official")
_write_entry(catalog, "beta")
(catalog / "removed.yaml").write_text(
"removed:\n - name: gone\n", encoding="utf-8"
)
out_dir = tmp_path / "api"
rc = mod.main(catalog_dir=catalog, output_dir=out_dir)
assert rc == 0
plugins = json.loads((out_dir / "plugins.json").read_text(encoding="utf-8"))
meta = json.loads((out_dir / "plugins-meta.json").read_text(encoding="utf-8"))
assert [p["name"] for p in plugins] == ["alpha", "beta"]
assert meta["total"] == 2
assert meta["byTier"] == {"official": 1, "community": 1}
assert meta["removedCount"] == 1
assert meta["generatedAt"]
# The live-refresh document consumed by installed clients: loader-schema entries + the kill list.
from hermes_cli.plugin_catalog import entry_from_mapping
live = json.loads((out_dir / "plugin-catalog.json").read_text(encoding="utf-8"))
assert [entry_from_mapping(raw, "live").name for raw in live["entries"]] == ["alpha", "beta"]
assert live["removed"] == [{"name": "gone"}]
def test_missing_catalog_dir_degrades_to_empty_outputs_exit_zero(mod, tmp_path):
out_dir = tmp_path / "api"
rc = mod.main(catalog_dir=tmp_path / "does-not-exist", output_dir=out_dir)
assert rc == 0
plugins = json.loads((out_dir / "plugins.json").read_text(encoding="utf-8"))
meta = json.loads((out_dir / "plugins-meta.json").read_text(encoding="utf-8"))
assert plugins == []
assert meta["total"] == 0
assert meta["byTier"] == {"official": 0, "community": 0}
assert meta["removedCount"] == 0
def test_script_exits_zero_as_subprocess_when_catalog_missing(tmp_path):
"""CLI contract: the deploy step runs the script hard (no `|| true`);
it must exit 0 even when plugin-catalog/ hasn't landed yet."""
out_dir = tmp_path / "api"
result = subprocess.run(
[
sys.executable,
str(EXTRACT),
"--catalog-dir",
str(tmp_path / "missing"),
"--output-dir",
str(out_dir),
],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stderr
assert (out_dir / "plugins.json").exists()
assert (out_dir / "plugins-meta.json").exists()
+31 -6
View File
@@ -1328,7 +1328,9 @@ def _(rid, params: dict) -> dict:
# ─── Plugins ─────────────────────────────────────────────────────────────────
def _plugin_rows() -> list[dict]:
pc = _tools_mod("hermes_cli.plugins_cmd")
cat = _tools_mod("hermes_cli.plugins_cmd_catalog")
enabled, disabled = pc._get_enabled_set(), pc._get_disabled_set()
pins = cat.catalog_pins() # powers the desktop's "Update to <pin>" affordance
out = []
for name, version, desc, source, _dir, key in sorted(pc._discover_all_plugins()):
status = pc._plugin_status(name, enabled, disabled, key=key)
@@ -1339,7 +1341,8 @@ def _plugin_rows() -> list[dict]:
# key = canonical registry key (names collide across category dirs); portable = Agent Plugins v1.
out.append({
"name": name, "key": key, "version": str(version or ""), "description": desc or "",
"source": source, "status": status, "portable": pc._is_portable_plugin_dir(_dir)})
"source": source, "status": status, "portable": pc._is_portable_plugin_dir(_dir),
**cat.catalog_row_fields(_dir, pins)})
return out
@@ -1363,22 +1366,44 @@ def _plugins_toggle(rid, params):
def _plugins_install(rid, params):
# ``catalog_name`` alone installs a curated entry at its pinned SHA (resolved server-side, kill list
# enforced, no bypass) — same contract as the dashboard endpoint.
ident = (params.get("identifier") or params.get("repo") or "").strip()
if not ident:
return _err(rid, 4019, "plugins.install requires 'identifier' or 'repo'")
catalog_name = str(params.get("catalog_name") or "").strip()
if not ident and not catalog_name:
return _err(rid, 4019, "plugins.install requires 'identifier', 'repo', or 'catalog_name'")
result = _tools_mod("hermes_cli.plugins_cmd").dashboard_install_plugin(
ident, force=bool(params.get("force")), enable=params.get("enable", True))
ident, force=bool(params.get("force")), enable=params.get("enable", True), catalog_name=catalog_name or None)
return _ok(rid, result) if result.get("ok") else _err(rid, 5026, result.get("error") or "install failed")
_PLUGINS_ACTIONS = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install}
def _plugins_update(rid, params):
"""Catalog installs only: re-pin to the current catalog SHA (non-catalog installs update via the CLI)."""
name = (params.get("name") or "").strip()
if not name:
return _err(rid, 4019, "plugins.update requires a 'name'")
pc, cat = _tools_mod("hermes_cli.plugins_cmd"), _tools_mod("hermes_cli.plugins_cmd_catalog")
target = pc._plugins_dir() / name
sidecar = cat.read_catalog_sidecar(target) if target.is_dir() else None
if not sidecar:
return _err(rid, 4020, f"'{name}' is not a catalog install — update it via the CLI")
try:
sha, changed = cat.repin_catalog_plugin(target, sidecar)
except pc.PluginOperationError as e:
return _err(rid, 4021, str(e))
return _ok(rid, {"ok": True, "unchanged": not changed, "sha": sha})
_PLUGINS_ACTIONS = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install,
"update": _plugins_update}
@_scoped_rpc("plugins.manage", 5026, catch_resolve=False)
def _(rid, params: dict) -> dict:
"""TUI Plugins Hub backend (shares primitives with ``hermes plugins`` / the dashboard):
``list`` → {plugins, user_count, bundled_count}; ``toggle`` flips ``key``/``name`` per ``enable``;
``install`` git-clones ``identifier``/``repo`` (``force``, ``enable`` default True)."""
``install`` git-clones ``identifier``/``repo`` or a curated ``catalog_name`` (``force``, ``enable``
default True); ``update`` re-pins a catalog install to the current catalog SHA."""
return _run_action(rid, params, _PLUGINS_ACTIONS, "plugins")
+15
View File
@@ -418,6 +418,21 @@ export const en: Translations = {
versionBadge: "Version",
showInSidebar: "Show in sidebar",
hideFromSidebar: "Hide from sidebar",
catalogHeading: "Plugin catalog",
catalogHint:
"Curated, Nous-reviewed plugins pinned to exact commits. Install from here for supply-chain-safe versions.",
catalogSearchPlaceholder: "Search catalog...",
catalogEmpty: "No catalog entries match.",
catalogEmptyDocsLink: "Learn about Hermes plugins",
catalogInstallBtn: "Install",
catalogInstalledBadge: "Installed ✓",
catalogUpdateBtn: "Update available",
catalogRemovedBadge: "Removed",
catalogConfirmTitle: "Install this plugin?",
catalogConfirmInstallNote:
"Plugins install disabled; enable it after install to activate.",
catalogRequiresEnv: "Requires env",
removedFromCatalog: "Removed from catalog",
},
skills: {
+14
View File
@@ -366,6 +366,20 @@ export interface Translations {
versionBadge: string;
showInSidebar: string;
hideFromSidebar: string;
// Catalog section (en-only fallback convention — optional keys).
catalogHeading?: string;
catalogHint?: string;
catalogSearchPlaceholder?: string;
catalogEmpty?: string;
catalogEmptyDocsLink?: string;
catalogInstallBtn?: string;
catalogInstalledBadge?: string;
catalogUpdateBtn?: string;
catalogRemovedBadge?: string;
catalogConfirmTitle?: string;
catalogConfirmInstallNote?: string;
catalogRequiresEnv?: string;
removedFromCatalog?: string;
};
// ── Profiles page ──
+58
View File
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { api } from "./api";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function jsonFetchMock(body: unknown = { ok: true }) {
return vi.fn<typeof fetch>(
async () =>
new Response(JSON.stringify(body), {
headers: { "Content-Type": "application/json" },
status: 200,
}),
);
}
describe("api.getPluginsCatalog", () => {
it("fetches the dashboard plugins catalog endpoint", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock({ entries: [], removed: [], generated_at: "" });
vi.stubGlobal("fetch", fetchMock);
const result = await api.getPluginsCatalog();
expect(fetchMock).toHaveBeenCalledWith(
"/api/dashboard/plugins/catalog",
expect.objectContaining({ credentials: "include" }),
);
expect(result.entries).toEqual([]);
expect(result.removed).toEqual([]);
});
});
describe("api.installAgentPlugin with catalog_name", () => {
it("posts catalog_name through to the install endpoint", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock({ ok: true, plugin_name: "alpha-plugin" });
vi.stubGlobal("fetch", fetchMock);
await api.installAgentPlugin({
identifier: "",
catalog_name: "alpha-plugin",
enable: false,
});
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("/api/dashboard/agent-plugins/install");
const body = JSON.parse(String((init as RequestInit).body));
expect(body.catalog_name).toBe("alpha-plugin");
expect(body.identifier).toBe("");
expect(body.enable).toBe(false);
});
});
+49
View File
@@ -975,6 +975,9 @@ export const api = {
getPluginsHub: () => fetchJSON<PluginsHubResponse>("/api/dashboard/plugins/hub"),
getPluginsCatalog: () =>
fetchJSON<CatalogResponse>("/api/dashboard/plugins/catalog"),
installAgentPlugin: (body: AgentPluginInstallRequest) =>
fetchJSON<AgentPluginInstallResponse>("/api/dashboard/agent-plugins/install", {
method: "POST",
@@ -2623,6 +2626,8 @@ export interface HubAgentPluginRow {
auth_required: boolean;
auth_command: string;
user_hidden: boolean;
/** Reason string when this plugin is on the catalog removed blocklist. */
removed_reason?: string | null;
}
export interface PluginsHubProviders {
@@ -2642,6 +2647,8 @@ export interface AgentPluginInstallRequest {
identifier: string;
force?: boolean;
enable?: boolean;
/** Install by curated-catalog name (resolves repo + pinned SHA server-side). */
catalog_name?: string;
}
export interface AgentPluginInstallResponse {
@@ -2654,6 +2661,48 @@ export interface AgentPluginInstallResponse {
error?: string;
}
// ── Plugin catalog types ───────────────────────────────────────────────
export interface CatalogCapabilities {
provides_tools: string[];
provides_hooks: string[];
provides_middleware: string[];
requires_env: string[];
}
export interface CatalogEntry {
name: string;
description: string;
repo: string;
sha: string;
sha_short: string;
tier: "official" | "community";
maintainer: string;
requires_hermes: string;
platforms: string[];
capabilities: CatalogCapabilities;
docs_url: string;
capability_summary: string;
/** Installed-state merge (computed server-side). */
installed: boolean;
installed_sha: string | null;
update_available: boolean;
runtime_status: "disabled" | "enabled" | "inactive" | null;
}
export interface CatalogRemovedEntry {
name: string;
repo: string;
reason: string;
date: string;
}
export interface CatalogResponse {
entries: CatalogEntry[];
removed: CatalogRemovedEntry[];
generated_at: string;
}
export interface AgentPluginUpdateResponse {
ok: boolean;
name?: string;
+273 -2
View File
@@ -1,9 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react";
import type { Translations } from "@/i18n/types";
import { Link } from "react-router";
import { api } from "@/lib/api";
import type {
CatalogEntry,
CatalogRemovedEntry,
CatalogResponse,
HubAgentPluginRow,
MemoryProviderConfig,
MemoryProviderField,
@@ -281,6 +284,11 @@ function MemoryProviderSetupHint({
export default function PluginsPage() {
const [hub, setHub] = useState<PluginsHubResponse | null>(null);
const [loading, setLoading] = useState(true);
const [catalog, setCatalog] = useState<CatalogResponse | null>(null);
const [catalogLoading, setCatalogLoading] = useState(true);
const [catalogSearch, setCatalogSearch] = useState("");
const [catalogConfirm, setCatalogConfirm] = useState<CatalogEntry | null>(null);
const [catalogBusy, setCatalogBusy] = useState<string | null>(null);
const [installId, setInstallId] = useState("");
const [installForce, setInstallForce] = useState(false);
const [installEnable, setInstallEnable] = useState(true);
@@ -316,9 +324,17 @@ export default function PluginsPage() {
.catch(() => showToast(t.common.loading, "error"));
}, [showToast, t.common.loading]);
const loadCatalog = useCallback(() => {
return api
.getPluginsCatalog()
.then(setCatalog)
.catch(() => setCatalog(null));
}, []);
useEffect(() => {
void loadHub().finally(() => setLoading(false));
}, [loadHub]);
void loadCatalog().finally(() => setCatalogLoading(false));
}, [loadHub, loadCatalog]);
useEffect(() => {
const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel;
@@ -391,6 +407,27 @@ export default function PluginsPage() {
}
};
const onCatalogInstall = async (entry: CatalogEntry) => {
setCatalogConfirm(null);
setCatalogBusy(entry.name);
try {
const r = await api.installAgentPlugin({
identifier: "",
catalog_name: entry.name,
force: entry.installed,
enable: false,
});
showToast(`${r.plugin_name ?? entry.name} installed`, "success");
if ((r.missing_env?.length ?? 0) > 0)
showToast(`${t.pluginsPage.missingEnvWarn} ${r.missing_env!.join(", ")}`, "error");
await Promise.all([loadHub(), loadCatalog()]);
} catch (e) {
showToast(e instanceof Error ? e.message : "Install failed", "error");
} finally {
setCatalogBusy(null);
}
};
const onRescan = useCallback(async () => {
setRescanBusy(true);
try {
@@ -506,6 +543,27 @@ export default function PluginsPage() {
const rows = hub?.plugins ?? [];
const providers = hub?.providers;
const catalogEntries = useMemo(() => {
const entries = catalog?.entries ?? [];
const q = catalogSearch.trim().toLowerCase();
if (!q) return entries;
return entries.filter((entry) =>
[
entry.name,
entry.description,
entry.maintainer,
...entry.capabilities.provides_tools,
].some((haystack) => haystack.toLowerCase().includes(q)),
);
}, [catalog, catalogSearch]);
const removedByName = useMemo(() => {
const map = new Map<string, CatalogRemovedEntry>();
for (const r of catalog?.removed ?? []) map.set(r.name, r);
return map;
}, [catalog]);
const selectedMemoryName = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel;
const selectedMemoryInfo = selectedMemoryName
? providers?.memory_options.find((provider) => provider.name === selectedMemoryName)
@@ -833,6 +891,59 @@ export default function PluginsPage() {
</CardContent>
</Card>
<div className="flex flex-col gap-3" data-testid="plugin-catalog-section">
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.pluginsPage.catalogHeading ?? "Plugin catalog"}
</h3>
<p className="text-xs tracking-[0.06em] text-text-tertiary">
{t.pluginsPage.catalogHint ??
"Curated, Nous-reviewed plugins pinned to exact commits."}
</p>
<Input
className="max-w-md"
placeholder={t.pluginsPage.catalogSearchPlaceholder ?? "Search catalog..."}
value={catalogSearch}
onChange={(e) => setCatalogSearch(e.target.value)}
aria-label={t.pluginsPage.catalogSearchPlaceholder ?? "Search catalog..."}
/>
{catalogLoading ? (
<div className="flex items-center gap-2 py-4 text-xs text-text-tertiary">
<Spinner />
<span>{t.common.loading}</span>
</div>
) : catalogEntries.length === 0 ? (
<p className="text-xs text-text-tertiary">
{t.pluginsPage.catalogEmpty ?? "No catalog entries match."}{" "}
<a
className="underline"
href="https://hermes-agent.nousresearch.com/docs/plugins"
target="_blank"
rel="noreferrer"
>
{t.pluginsPage.catalogEmptyDocsLink ?? "Learn about Hermes plugins"}
</a>
</p>
) : (
<ul className="flex flex-col gap-3">
{catalogEntries.map((entry) => (
<li key={entry.name}>
<CatalogEntryCard
busy={catalogBusy === entry.name}
entry={entry}
onInstall={() => setCatalogConfirm(entry)}
removed={removedByName.get(entry.name) ?? null}
t={t}
/>
</li>
))}
</ul>
)}
</div>
<div className="flex flex-col gap-3">
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
@@ -907,6 +1018,30 @@ export default function PluginsPage() {
<Toast toast={toast} />
<PluginSlot name="plugins:bottom" />
<ConfirmDialog
open={catalogConfirm !== null}
onCancel={() => setCatalogConfirm(null)}
onConfirm={() => {
if (catalogConfirm) void onCatalogInstall(catalogConfirm);
}}
title={t.pluginsPage.catalogConfirmTitle ?? "Install this plugin?"}
description={
catalogConfirm
? [
catalogConfirm.capability_summary,
catalogConfirm.capabilities.requires_env.length
? `${t.pluginsPage.catalogRequiresEnv ?? "Requires env"}: ${catalogConfirm.capabilities.requires_env.join(", ")}`
: "",
t.pluginsPage.catalogConfirmInstallNote ??
"Plugins install disabled; enable it after install to activate.",
]
.filter(Boolean)
.join("\n\n")
: ""
}
confirmLabel={t.pluginsPage.catalogInstallBtn ?? "Install"}
/>
</div>
);
}
@@ -972,6 +1107,12 @@ function PluginRowCard(props: PluginRowCardProps) {
{row.auth_required ? (
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
) : null}
{row.removed_reason ? (
<Badge tone="destructive">
{t.pluginsPage.catalogRemovedBadge ?? "Removed"}
</Badge>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
@@ -1081,6 +1222,12 @@ function PluginRowCard(props: PluginRowCardProps) {
</p>
) : null}
{row.removed_reason ? (
<p className="border border-destructive/50 px-3 py-2 text-xs text-destructive">
{t.pluginsPage.removedFromCatalog ?? "Removed from catalog"}: {row.removed_reason}
</p>
) : null}
{dm?.slots?.length ? (
<p className="text-xs tracking-[0.05em] text-text-tertiary">
@@ -1122,3 +1269,127 @@ function PluginRowCard(props: PluginRowCardProps) {
</Card>
);
}
interface CatalogEntryCardProps {
busy: boolean;
entry: CatalogEntry;
onInstall: () => void;
removed: CatalogRemovedEntry | null;
t: Translations;
}
function CatalogEntryCard(props: CatalogEntryCardProps) {
const { busy, entry, onInstall, removed, t } = props;
const caps = entry.capabilities;
const chips: string[] = [];
if (caps.provides_tools.length) chips.push(`${caps.provides_tools.length} tools`);
if (caps.provides_hooks.length) chips.push(`${caps.provides_hooks.length} hooks`);
if (caps.provides_middleware.length)
chips.push(`${caps.provides_middleware.length} middleware`);
if (caps.requires_env.length) chips.push(`env: ${caps.requires_env.join(", ")}`);
const isRemoved = removed !== null;
return (
<Card className={cn(busy ? "opacity-70" : undefined)}>
<CardContent className="flex flex-col gap-3 px-6 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<span className="truncate font-semibold">{entry.name}</span>
<Badge tone={entry.tier === "official" ? "success" : "secondary"}>
{entry.tier}
</Badge>
{entry.installed && entry.runtime_status ? (
<Badge tone="outline">{entry.runtime_status}</Badge>
) : null}
{isRemoved ? (
<Badge tone="destructive">
{t.pluginsPage.catalogRemovedBadge ?? "Removed"}
</Badge>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
{isRemoved ? null : entry.installed && !entry.update_available ? (
<Badge tone="success">
{t.pluginsPage.catalogInstalledBadge ?? "Installed ✓"}
</Badge>
) : (
<Button disabled={busy} ghost size="sm" onClick={onInstall}>
{busy ? <Spinner /> : null}
{entry.update_available
? t.pluginsPage.catalogUpdateBtn ?? "Update available"
: t.pluginsPage.catalogInstallBtn ?? "Install"}
</Button>
)}
</div>
</div>
{isRemoved ? (
<p className="border border-destructive/50 px-3 py-2 text-xs text-destructive">
{t.pluginsPage.removedFromCatalog ?? "Removed from catalog"}
{removed.reason ? `: ${removed.reason}` : ""}
{removed.date ? ` (${removed.date})` : ""}
</p>
) : null}
{entry.description ? (
<p className="min-w-0 w-full text-xs tracking-[0.06em] text-text-secondary break-words">
{entry.description}
</p>
) : null}
{chips.length ? (
<div className="flex flex-wrap gap-2">
{chips.map((chip) => (
<code
key={chip}
className="border border-border bg-background/40 px-2 py-1 font-mono text-[0.6875rem]"
>
{chip}
</code>
))}
</div>
) : null}
<div className="flex flex-wrap items-center gap-3 text-xs text-text-tertiary">
<span>{entry.maintainer}</span>
<a
className="inline-flex items-center gap-1 font-mono underline"
href={`${entry.repo.replace(/\.git$/, "")}/tree/${entry.sha}`}
target="_blank"
rel="noreferrer"
>
{entry.sha_short}
<ExternalLink className="h-3 w-3 opacity-65" />
</a>
{entry.docs_url ? (
<a
className="inline-flex items-center gap-1 underline"
href={entry.docs_url}
target="_blank"
rel="noreferrer"
>
docs
<ExternalLink className="h-3 w-3 opacity-65" />
</a>
) : null}
{entry.requires_hermes ? (
<span>hermes {entry.requires_hermes}</span>
) : null}
{entry.platforms.length ? (
<span>{entry.platforms.join(", ")}</span>
) : null}
</div>
</CardContent>
</Card>
);
}
+2 -2
View File
@@ -1462,8 +1462,8 @@ Unified plugin management — general plugins, memory providers, and context eng
| Subcommand | Description |
|------------|-------------|
| *(none)* | Composite interactive UI — general plugin toggles + provider plugin configuration. |
| `install <identifier> [--force] [--ref COMMIT_SHA]` | Install a plugin from a Git URL, `owner/repo`, or a bare index name. Bare names (no slash) are resolved through the community plugin index to `owner/repo` plus the index-pinned commit; ambiguous names list candidates and exit. `--ref` accepts only a full 40-character commit SHA, installs that exact immutable revision, and overrides any index pin. |
| `search [term] [--json] [--capability CAP] [--refresh]` | Search the community plugin index (fuzzy match on name/description/tags; omit `term` to browse). Fetched from `plugins.index_url` (default: the NousResearch plugin index), cached under `~/.hermes/cache/` for 24h, falling back to the stale cache and then the bundled seed when offline. Indexed ≠ audited — inclusion is a metadata review only. |
| `install <identifier> [--force] [--ref COMMIT_SHA] [--allow-removed]` | Install a plugin from the Hermes plugin catalog (bare entry name), a Git URL, or `owner/repo` shorthand. Catalog names resolve to the entry's repo at its pinned 40-hex commit SHA, show the declared capability summary, and record catalog provenance in a `.hermes-catalog.json` sidecar. Raw URLs are flagged as custom (unreviewed) sources; `--ref` (full 40-character commit SHA) pins them. `--allow-removed` (DANGEROUS) bypasses the removed-plugin blocklist. |
| `search [term] [--json]` | Search the Hermes plugin catalog (matches entry names, descriptions, and declared tools; omit `term` to list everything). The catalog is curated in-repo (`plugin-catalog/`), refreshed from the live repo with a 6-hour cache, and falls back to the in-tree copy offline. Cataloged ≠ audited — admission reviews the entry, not the code. |
| `update <name>` | Pull latest changes for an unpinned installed plugin. Pinned plugins must be reinstalled with `--force --ref <new-commit>` to move. |
| `remove <name>` (aliases: `rm`, `uninstall`) | Remove an installed plugin. |
| `enable <name>` | Enable a disabled plugin. |
@@ -0,0 +1,143 @@
---
sidebar_position: 13
sidebar_label: "Plugin Catalog"
title: "Plugin Catalog"
description: "Browse and install reviewed, SHA-pinned Hermes plugins from the curated catalog"
---
# Plugin Catalog
The plugin catalog is a curated, human-reviewed directory of Hermes plugins you
can install by name with a single command:
```bash
hermes plugins install <name>
```
Browse it visually at **[/docs/plugins](/plugins)** — search, tier filters
(Official / Community), capability chips, and copyable install commands for
every entry.
The catalog complements — it does not replace — the existing
[plugin system](plugins.md). Anything you can install from the catalog is a
normal plugin under the hood; the catalog just adds discovery and a review
layer on top.
## What's in an entry
Each catalog entry is a small YAML file in the
[`plugin-catalog/`](https://github.com/NousResearch/hermes-agent/tree/main/plugin-catalog)
directory of the hermes-agent repository, declaring:
| Field | Meaning |
|---|---|
| `name` | The catalog key you pass to `hermes plugins install` |
| `repo` | The plugin's public git repository |
| `sha` | The **exact 40-hex commit** that was reviewed — installs check out this pin, not a branch tip |
| `tier` | `official` (maintained by NousResearch) or `community` |
| `maintainer` | Who owns the plugin |
| `capabilities` | Declared tools, hooks, middleware, and required env vars |
| `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional) |
| `platforms` | OS restrictions, empty = all (optional) |
| `docs_url` | External documentation link (optional) |
## Trust model
The catalog is designed so you know exactly what you're installing:
- **Human-merged admission.** Every entry (and every pin update) lands via a
pull request reviewed by a maintainer. Nothing enters the catalog
automatically.
- **Exact SHA pins.** Entries pin a specific commit, not a branch. A plugin
author pushing new code to their repo does **not** change what the catalog
installs — updating the pin requires another reviewed PR.
- **Capability declarations.** Entries state up front which tools, hooks, and
middleware the plugin provides and which environment variables (API keys
etc.) it needs, so you can judge its blast radius before installing.
- **Removed list.** Plugins pulled from the catalog (for example after a
security incident) go on `plugin-catalog/removed.yaml` with a reason and
date. The installer refuses to install anything on the removed list.
- **Installed ≠ enabled.** Installing a catalog plugin puts it on disk; like
any plugin it must still be enabled before it loads. See
[Plugins → Enabling and disabling](plugins.md).
:::warning Catalog review is a point-in-time review
A catalog entry means the pinned commit was looked at by a human, capability
declarations were checked, and the repo met the submission bar. It is not a
security audit, and it says nothing about other commits in the same
repository. Review the code of anything you give credentials to.
:::
## Installing from the catalog
```bash
# Install a reviewed catalog entry by name (checks out the pinned SHA)
hermes plugins install <name>
# Then enable it, as with any plugin
hermes plugins enable <name>
```
The install prompt shows the entry's capability summary — declared tools,
hooks, and required env vars — before anything is cloned.
### Updating a catalog install
`hermes plugins update <name>` never runs `git pull` for catalog installs —
it compares your installed pin against the current catalog pin and, when the
catalog moved (via a reviewed PR), force-reinstalls at the new SHA. Your
enabled/disabled state is preserved. `hermes plugins list` shows catalog
installs as `catalog:<tier>@<sha>` so you can see provenance at a glance.
### Names not in the catalog
A bare name that isn't a catalog entry is an error: there is no second,
unreviewed name index. Install such plugins by `owner/repo` or Git URL instead
(custom source, see below), or submit them to the catalog.
### Live refresh
The docs build publishes the catalog as one JSON document
(`https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json`).
`search`/`install`/`update` fetch it at most every six hours and cache it under
`~/.hermes/cache/`, so new entries and removals reach installed clients without
updating Hermes. Offline, the copy shipped with your checkout is used. Removals
from the in-tree list and the live list are always both enforced.
### Custom git URLs are different
`hermes plugins install <git-url>` still works for any repository, but it
bypasses the catalog entirely:
- **No review** — you get whatever is at the branch tip, not a reviewed pin.
- **A warning banner** is shown to make clear the code is unvetted.
- The removed list is still consulted (a known-bad repo is refused by URL).
Use the git-URL path for your own plugins and repos you already trust; use the
catalog for discovery.
## Submitting a plugin to the catalog
Submissions are pull requests that add one `plugin-catalog/<name>.yaml` file.
The full checklist lives in the
[plugin-catalog README](https://github.com/NousResearch/hermes-agent/tree/main/plugin-catalog);
in short, an entry must be:
1. **Owner-submitted** — the PR author owns or maintains the plugin repo.
2. **A public repository** — the `repo` URL is publicly cloneable.
3. **Released** — the repo has real releases/tags, not just a default branch.
4. **Passing validation** — the catalog validation GitHub Action is green on
the PR (schema, SHA format, reachability).
5. **Pinned to settled code** — the pinned SHA is at least **2 weeks old**, so
the catalog never points at code pushed moments before review.
Pin updates (bumping `sha` to a newer commit) follow the same PR + review
process.
## See also
- [Plugins](plugins.md) — the plugin system itself: manifest format, enabling,
configuration
- [Built-in Plugins](built-in-plugins.md) — plugins that ship with Hermes
- [Build a Hermes Plugin](/developer-guide/plugins) — write your own
- [Plugin Catalog page](/plugins) — the browsable catalog
+28 -61
View File
@@ -340,8 +340,8 @@ Declarative plugins are symlinked with a `nix-managed-` prefix — they coexist
```bash
hermes plugins # unified interactive UI
hermes plugins list # table: enabled / disabled / not enabled
hermes plugins search <term> # search the community plugin index
hermes plugins install <name> # install by index name (resolved to repo @ pinned ref)
hermes plugins search <term> # search the Hermes plugin catalog
hermes plugins install <name> # install a catalog entry (repo @ reviewed pinned SHA)
hermes plugins install user/repo # install from Git, then prompt Enable? [y/N]
hermes plugins install user/repo --enable # install AND enable (no prompt)
hermes plugins install user/repo --no-enable # install but leave disabled (no prompt)
@@ -494,73 +494,40 @@ capability (`gateway.raw_events`) with a "no stability guarantee" label and a
separate design, and has not shipped.
:::
### Discovering community plugins
### Discovering plugins — the Hermes plugin catalog
`hermes plugins search <term>` searches the **community plugin index** — a
static, machine-readable JSON catalog of community plugins. Matching is fuzzy
across name, description, and tags:
`hermes plugins search <term>` searches the **Hermes plugin catalog** — the
curated, SHA-pinned catalog maintained in the hermes-agent repository
(`plugin-catalog/`). Matching covers entry names, descriptions, and declared
tools:
```bash
hermes plugins search telegram # fuzzy search
hermes plugins search # browse the whole index
hermes plugins search --capability platform # filter by declared capability
hermes plugins search media --json # machine-readable output
hermes plugins search --refresh # bypass the 24h local cache
hermes plugins search telegram # search the catalog
hermes plugins browse # browse every entry
hermes plugins info <name> # full details for one entry
```
Once you've found a plugin, install it by bare name — the name is resolved
through the index to its `owner/repo` plus the index-pinned commit:
Once you've found a plugin, install it by bare name — the name resolves to
the entry's repository at its **pinned commit SHA**, and catalog provenance is
recorded so `hermes plugins update` can re-pin when the catalog moves:
```bash
hermes plugins install hermes-media-studio
hermes plugins install <catalog-name>
```
If a name matches more than one entry, the candidates are listed and nothing
is installed. Explicit `owner/repo` or Git-URL identifiers never touch the
index and keep working exactly as before. An explicit `--ref <sha>` always
overrides the index pin.
Explicit `owner/repo` or Git-URL identifiers never touch the catalog and are
flagged as custom (unreviewed) sources. An explicit
`--ref <40-char commit SHA>` pins a custom install.
**How the index is fetched.** The index lives at a canonical URL
(`https://raw.githubusercontent.com/NousResearch/hermes-plugin-index/main/index.json`,
overridable via `hermes config set plugins.index_url <url>`). Fetches are
cached under `~/.hermes/cache/plugin_index.json` for 24 hours; when the
remote is unreachable the stale cache is used, and when there is no cache at
all a bundled seed copy ships with Hermes — so search works fully offline.
See [Plugin Catalog](./plugin-catalog.md) for the full trust model, admission
CI, and submission workflow.
**Index entry format.** Each entry is a JSON object:
```json
{
"name": "hermes-media-studio",
"description": "Generative media workspace plugin.",
"author": "NousResearch",
"tags": ["media", "image-gen"],
"repo": "NousResearch/hermes-media-studio",
"ref": "<40-char commit SHA>",
"subdir": null,
"homepage": "https://github.com/NousResearch/hermes-media-studio",
"capabilities": ["tools", "dashboard"],
"api_version": 1,
"added_at": "2026-08-12"
}
```
`repo` is the `owner/name` GitHub identifier, `ref` pins an immutable commit
SHA, and optional `subdir` supports monorepos. The bundled seed file
(`hermes_cli/data/plugin_index.json` in the repo) is the format reference.
**Submitting a plugin.** The index is maintained as a plain JSON file —
submit a pull request to the
[hermes-plugin-index](https://github.com/NousResearch/hermes-plugin-index)
repository adding your entry (name, description, author, tags, `owner/repo`,
and a pinned commit SHA). Review covers the entry's *metadata* only.
:::warning Indexed ≠ audited
Inclusion in the community index means the entry's metadata was reviewed —
**it is not a code audit**. Installing still goes through the normal
consent/review flow (plugins install disabled by default, enabling is an
explicit step, and tool-override rights require a separate grant). Review a
plugin's source before enabling it.
:::warning Cataloged ≠ audited
A catalog entry means the entry's metadata and declared capabilities were
reviewed at admission — **it is not a code audit**. Installing still goes
through the normal consent flow (plugins install disabled by default,
enabling is an explicit step, and tool-override rights require a separate
grant). Review a plugin's source before enabling it.
:::
### Plugin packs
@@ -575,8 +542,8 @@ description: STT + streaming TTS + approval relay
author: hyper
version: 1.0.0
plugins:
- name: hermes-media-studio # bare community-index name…
ref: e8d59971d2b7901405b39dac7b03bdd616272d0d
- name: hermes-telegram-business # bare plugin-catalog name…
ref: e905f3bc5eeaa5a9dab9bc5155601b3ebec75757
- repo: owner/approval-relay # …or explicit owner/repo (or git URL)
ref: 8f3c2d1a9b4e5f6071829304a5b6c7d8e9f00112
subdir: plugins/relay # optional monorepo path
@@ -595,7 +562,7 @@ hermes plugins pack export --enabled-only # only plugins.enabled
**Supply-chain posture.** Every entry's `ref` must be an exact 40-character
commit SHA — tags and branch names are rejected with an error naming the
entry, the same rule as the community index. Pack installs ride the exact
entry, the same rule as the plugin catalog. Pack installs ride the exact
same pinned install path as `hermes plugins install --ref <sha>` and record
the same provenance in `plugins/.install-metadata.json`, so two installs of
the same pack resolve identically. Packs build on the
+5
View File
@@ -134,6 +134,11 @@ const config: Config = {
label: 'Skills',
position: 'left',
},
{
to: '/plugins',
label: 'Plugins',
position: 'left',
},
{
href: 'https://hermes-agent.nousresearch.com/',
label: 'Download',
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Extract plugin-catalog entries into website/static/api/plugins.json.
Feeds the Plugin Catalog page at /docs/plugins (website/src/pages/plugins/).
Data source: ``plugin-catalog/*.yaml`` at the repo root — one YAML file per
catalog entry (see plugin-catalog/README.md for the entry schema), plus
``plugin-catalog/removed.yaml`` listing plugins pulled from the catalog.
No network, no crawling: the catalog is human-merged data in the checkout.
Graceful degradation: when ``plugin-catalog/`` does not exist yet (the
catalog PR may not have merged), we emit an EMPTY catalog list and zeroed
meta counts and exit 0 so the docs build stays green. The page renders a
"catalog is just getting started" state.
Outputs (both under website/static/api/, CDN-served at /docs/api/):
- ``plugins.json`` — list of catalog entries for the page (camelCase)
- ``plugins-meta.json`` — counts by tier + generatedAt + removedCount
- ``plugin-catalog.json`` — ``{"entries": [raw YAML mappings], "removed": [...]}`` in the loader's own
schema; installed Hermes clients fetch this for live catalog refresh
(``hermes_cli.plugin_catalog.LIVE_CATALOG_URL``) so new entries and removals reach them without
updating. Emitting it here means the docs deploy IS the publish step — no second pipeline.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CATALOG_DIR = REPO_ROOT / "plugin-catalog"
DEFAULT_OUTPUT_DIR = REPO_ROOT / "website" / "static" / "api"
CATALOG_TIERS = ("official", "community")
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
def _log(msg: str) -> None:
print(f"[extract-plugins] {msg}", file=sys.stderr)
def _str_list(value) -> list[str]:
if isinstance(value, str):
return [value] if value.strip() else []
if isinstance(value, list):
return [str(x) for x in value if x]
return []
def _normalize_capabilities(raw) -> dict:
raw = raw if isinstance(raw, dict) else {}
return {
"providesTools": _str_list(raw.get("provides_tools")),
"providesHooks": _str_list(raw.get("provides_hooks")),
"providesMiddleware": _str_list(raw.get("provides_middleware")),
"requiresEnv": _str_list(raw.get("requires_env")),
}
def load_catalog_entries(catalog_dir: Path) -> list[dict]:
"""Parse all ``*.yaml`` files (except removed.yaml) into page entries.
Entries missing any of name/repo/sha are skipped with a stderr log —
a malformed community entry must never break the docs deploy.
"""
entries: list[dict] = []
if not catalog_dir.is_dir():
return entries
for path in sorted(catalog_dir.glob("*.yaml")):
if path.name == "removed.yaml":
continue
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError) as e:
_log(f"skipping {path.name}: unreadable YAML ({e})")
continue
if not isinstance(raw, dict):
_log(f"skipping {path.name}: not a mapping")
continue
name = str(raw.get("name") or "").strip()
repo = str(raw.get("repo") or "").strip()
sha = str(raw.get("sha") or "").strip().lower()
missing = [
field
for field, value in (("name", name), ("repo", repo), ("sha", sha))
if not value
]
if missing:
_log(f"skipping {path.name}: missing required field(s) {', '.join(missing)}")
continue
if not SHA_RE.match(sha):
_log(f"skipping {path.name} ({name}): sha is not a 40-hex commit pin")
continue
tier = str(raw.get("tier") or "community").strip().lower()
if tier not in CATALOG_TIERS:
_log(f"{path.name} ({name}): unknown tier {tier!r}, treating as community")
tier = "community"
entries.append({
"name": name,
"description": str(raw.get("description") or "").strip(),
"repo": repo,
"sha": sha,
"shaShort": sha[:7],
"tier": tier,
"maintainer": str(raw.get("maintainer") or "").strip(),
"subdir": str(raw.get("subdir") or "").strip(),
"requiresHermes": str(raw.get("requires_hermes") or "").strip(),
"platforms": _str_list(raw.get("platforms")),
"capabilities": _normalize_capabilities(raw.get("capabilities")),
"docsUrl": str(raw.get("docs_url") or "").strip(),
"installCommand": f"hermes plugins install {name}",
})
entries.sort(key=lambda e: (0 if e["tier"] == "official" else 1, e["name"]))
return entries
def load_removed(catalog_dir: Path) -> list[dict]:
"""``removed:`` list from plugin-catalog/removed.yaml (mappings only)."""
removed_path = catalog_dir / "removed.yaml"
if not removed_path.is_file():
return []
try:
raw = yaml.safe_load(removed_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError) as e:
_log(f"could not read removed.yaml: {e}")
return []
removed = raw.get("removed") if isinstance(raw, dict) else None
return [r for r in removed if isinstance(r, dict)] if isinstance(removed, list) else []
def count_removed(catalog_dir: Path) -> int:
return len(load_removed(catalog_dir))
def load_raw_entries(catalog_dir: Path) -> list[dict]:
"""Raw entry mappings (loader schema, snake_case) for ``plugin-catalog.json``; the client re-validates."""
entries: list[dict] = []
if not catalog_dir.is_dir():
return entries
for path in sorted(catalog_dir.glob("*.yaml")):
if path.name == "removed.yaml":
continue
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError):
continue
if isinstance(raw, dict) and raw.get("name") and raw.get("repo") and raw.get("sha"):
entries.append(raw)
return entries
def main(catalog_dir: Path = DEFAULT_CATALOG_DIR, output_dir: Path = DEFAULT_OUTPUT_DIR) -> int:
if not catalog_dir.is_dir():
_log(
f"plugin-catalog directory not found at {catalog_dir}; "
"emitting empty catalog (this is expected until the catalog lands)"
)
entries = load_catalog_entries(catalog_dir)
removed_count = count_removed(catalog_dir)
by_tier = Counter(e["tier"] for e in entries)
meta = {
"generatedAt": datetime.now(timezone.utc).isoformat(),
"total": len(entries),
"byTier": {tier: by_tier.get(tier, 0) for tier in CATALOG_TIERS},
"removedCount": removed_count,
}
output_dir.mkdir(parents=True, exist_ok=True)
with open(output_dir / "plugins.json", "w", encoding="utf-8") as f:
json.dump(entries, f, separators=(",", ":"), ensure_ascii=False)
with open(output_dir / "plugins-meta.json", "w", encoding="utf-8") as f:
json.dump(meta, f, separators=(",", ":"), ensure_ascii=False)
with open(output_dir / "plugin-catalog.json", "w", encoding="utf-8") as f:
json.dump({"generated_at": meta["generatedAt"], "entries": load_raw_entries(catalog_dir),
"removed": load_removed(catalog_dir)}, f, separators=(",", ":"), ensure_ascii=False)
print(
f"Extracted {len(entries)} plugin catalog entries "
f"({meta['byTier']['official']} official, {meta['byTier']['community']} community, "
f"{removed_count} removed) to {output_dir / 'plugins.json'}"
)
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--catalog-dir", type=Path, default=DEFAULT_CATALOG_DIR)
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
args = parser.parse_args()
sys.exit(main(catalog_dir=args.catalog_dir, output_dir=args.output_dir))
+22
View File
@@ -32,7 +32,10 @@ const websiteDir = resolve(scriptDir, "..");
const extractScript = join(scriptDir, "extract-skills.py");
const llmsScript = join(scriptDir, "generate-llms-txt.py");
const cronBlueprintsScript = join(scriptDir, "extract-automation-blueprints.py");
const pluginsScript = join(scriptDir, "extract-plugins.py");
const outputFile = join(websiteDir, "static", "api", "skills.json");
const pluginsOutputFile = join(websiteDir, "static", "api", "plugins.json");
const pluginsMetaOutputFile = join(websiteDir, "static", "api", "plugins-meta.json");
const unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json");
const UNIFIED_INDEX_URL =
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json";
@@ -143,3 +146,22 @@ runPython(llmsScript, "generate-llms-txt.py");
// 3) automation-blueprints-index.json — Automation Blueprints catalog page. Non-fatal; the page
// renders an empty state if the generator can't run.
runPython(cronBlueprintsScript, "extract-automation-blueprints.py");
// 4) plugins.json + plugins-meta.json — Plugin Catalog page. The script itself
// degrades gracefully (empty catalog, exit 0) when plugin-catalog/ is absent;
// if python3 is missing entirely, write the same empty fallback so the page
// renders its "just getting started" state instead of a fetch error.
if (!runPython(pluginsScript, "extract-plugins.py")) {
mkdirSync(dirname(pluginsOutputFile), { recursive: true });
writeFileSync(pluginsOutputFile, "[]\n");
writeFileSync(
pluginsMetaOutputFile,
JSON.stringify({
generatedAt: new Date().toISOString(),
total: 0,
byTier: { official: 0, community: 0 },
removedCount: 0,
}) + "\n",
);
console.warn("[prebuild] wrote empty plugins.json fallback");
}
+1
View File
@@ -88,6 +88,7 @@ const sidebars: SidebarsConfig = {
'user-guide/features/skins',
'user-guide/features/plugins',
'user-guide/features/built-in-plugins',
'user-guide/features/plugin-catalog',
],
},
{
+622
View File
@@ -0,0 +1,622 @@
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import styles from "./styles.module.css";
interface PluginCapabilities {
providesTools?: string[];
providesHooks?: string[];
providesMiddleware?: string[];
requiresEnv?: string[];
}
interface CatalogPlugin {
name: string;
description: string;
repo: string;
sha: string;
shaShort: string;
tier: string;
maintainer: string;
subdir?: string;
requiresHermes?: string;
platforms?: string[];
capabilities?: PluginCapabilities;
docsUrl?: string;
installCommand: string;
/** Lowercase pre-joined haystack for the search filter (built at load). */
_search?: string;
}
interface CatalogMeta {
generatedAt?: string;
total?: number;
byTier?: Record<string, number>;
removedCount?: number;
}
// Routes Docusaurus serves the static API JSON from. `baseUrl` is `/docs/`,
// `static/api/` ends up at `/docs/api/` — same pattern as the Skills Hub.
const PLUGINS_URL = "/docs/api/plugins.json";
const META_URL = "/docs/api/plugins-meta.json";
const CATALOG_README_URL =
"https://github.com/NousResearch/hermes-agent/tree/main/plugin-catalog";
const TIER_CONFIG: Record<
string,
{ label: string; color: string; bg: string; border: string; icon: string }
> = {
official: {
label: "Official",
color: "#ffd700",
bg: "rgba(255, 215, 0, 0.08)",
border: "rgba(255, 215, 0, 0.25)",
icon: "\u{2713}",
},
community: {
label: "Community",
color: "#94a3b8",
bg: "rgba(148, 163, 184, 0.08)",
border: "rgba(148, 163, 184, 0.2)",
icon: "\u{2756}",
},
};
const TIER_ORDER = ["all", "official", "community"];
function formatRelativeTime(iso?: string): string | null {
if (!iso) return null;
const then = new Date(iso).getTime();
if (!Number.isFinite(then)) return null;
const diffMs = Date.now() - then;
if (diffMs < 0) return "just now";
const mins = Math.floor(diffMs / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
const months = Math.floor(days / 30);
return `${months} month${months === 1 ? "" : "s"} ago`;
}
function highlightMatch(text: string, query: string): React.ReactNode {
if (!query || !text) return text;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<mark className={styles.highlight}>{text.slice(idx, idx + query.length)}</mark>
{text.slice(idx + query.length)}
</>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const onCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard?.writeText(text).then(
() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
},
() => {},
);
},
[text],
);
return (
<button
className={styles.copyBtn}
onClick={onCopy}
title="Copy install command"
aria-label="Copy install command"
>
{copied ? (
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
<path
fillRule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clipRule="evenodd"
/>
</svg>
) : (
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
<path d="M7 3.5A1.5 1.5 0 018.5 2h3.879a1.5 1.5 0 011.06.44l3.122 3.12A1.5 1.5 0 0117 6.622V12.5a1.5 1.5 0 01-1.5 1.5h-1v-3.379a3 3 0 00-.879-2.121L10.5 5.379A3 3 0 008.379 4.5H7v-1z" />
<path d="M4.5 6A1.5 1.5 0 003 7.5v9A1.5 1.5 0 004.5 18h7a1.5 1.5 0 001.5-1.5v-5.879a1.5 1.5 0 00-.44-1.06L9.44 6.439A1.5 1.5 0 008.378 6H4.5z" />
</svg>
)}
<span className={styles.copyBtnLabel}>{copied ? "Copied" : "Copy"}</span>
</button>
);
}
function PluginCard({
plugin,
query,
expanded,
onToggle,
onPick,
style,
}: {
plugin: CatalogPlugin;
query: string;
expanded: boolean;
onToggle: () => void;
/** Picker embed mode: render "+ Add to this Agent" and call this. */
onPick?: (plugin: CatalogPlugin) => void;
style?: React.CSSProperties;
}) {
const tier = TIER_CONFIG[plugin.tier] || TIER_CONFIG.community;
const caps = plugin.capabilities || {};
const toolCount = caps.providesTools?.length || 0;
const hookCount = caps.providesHooks?.length || 0;
const middlewareCount = caps.providesMiddleware?.length || 0;
const pinUrl = `${plugin.repo.replace(/\.git$/, "").replace(/\/$/, "")}/tree/${plugin.sha}`;
return (
<div
className={`${styles.card} ${expanded ? styles.cardExpanded : ""}`}
onClick={onToggle}
style={style}
>
<div className={styles.cardAccent} style={{ background: tier.color }} />
<div className={styles.cardInner}>
<div className={styles.cardTop}>
<span className={styles.cardIcon}>{"\u{1F50C}"}</span>
<div className={styles.cardTitleGroup}>
<h3 className={styles.cardTitle}>{highlightMatch(plugin.name, query)}</h3>
<span
className={styles.tierPill}
style={{
color: tier.color,
background: tier.bg,
borderColor: tier.border,
}}
>
{tier.icon} {tier.label}
</span>
</div>
</div>
<p className={`${styles.cardDesc} ${expanded ? styles.cardDescFull : ""}`}>
{highlightMatch(plugin.description || "No description available.", query)}
</p>
<div className={styles.cardMeta}>
{toolCount > 0 && (
<span className={styles.capChip}>
{toolCount} tool{toolCount === 1 ? "" : "s"}
</span>
)}
{hookCount > 0 && (
<span className={styles.capChip}>
{hookCount} hook{hookCount === 1 ? "" : "s"}
</span>
)}
{middlewareCount > 0 && (
<span className={styles.capChip}>
{middlewareCount} middleware
</span>
)}
{caps.requiresEnv?.map((v) => (
<code key={v} className={styles.envChip}>
{v}
</code>
))}
{plugin.platforms?.map((p) => (
<span key={p} className={styles.platformPill}>
{p === "macos" ? "\u{F8FF} macOS" : p === "linux" ? "\u{1F427} Linux" : p}
</span>
))}
</div>
{onPick && (
<button
className={styles.pickBtn}
onClick={(e) => {
e.stopPropagation();
onPick(plugin);
}}
>
+ Add to this Agent
</button>
)}
{expanded && (
<div className={styles.cardDetail}>
{plugin.maintainer && (
<div className={styles.metaRow}>
<span className={styles.metaLabel}>Maintainer</span>
<span className={styles.metaValue}>{plugin.maintainer}</span>
</div>
)}
{plugin.requiresHermes && (
<div className={styles.metaRow}>
<span className={styles.metaLabel}>Requires</span>
<span className={styles.metaValue}>
<code>hermes {plugin.requiresHermes}</code>
</span>
</div>
)}
<div className={styles.metaRow}>
<span className={styles.metaLabel}>Pinned</span>
<span className={styles.metaValue}>
<a
href={pinUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={styles.shaLink}
title={plugin.sha}
>
<code>{plugin.shaShort}</code> ↗
</a>
</span>
</div>
{caps.providesTools?.length ? (
<div className={styles.metaRow}>
<span className={styles.metaLabel}>Tools</span>
<span className={styles.chipList}>
{caps.providesTools.map((t) => (
<code key={t} className={styles.envChip}>
{t}
</code>
))}
</span>
</div>
) : null}
<div className={styles.installHint}>
<code>{plugin.installCommand}</code>
<CopyButton text={plugin.installCommand} />
</div>
<div className={styles.cardLinks}>
<a
className={styles.docsLink}
href={plugin.repo}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Repository ↗
</a>
{plugin.docsUrl ? (
<a
className={styles.docsLink}
href={plugin.docsUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Documentation ↗
</a>
) : null}
</div>
</div>
)}
</div>
</div>
);
}
function StatCard({ value, label, color }: { value: number; label: string; color: string }) {
return (
<div className={styles.stat}>
<span className={styles.statValue} style={{ color }}>
{value}
</span>
<span className={styles.statLabel}>{label}</span>
</div>
);
}
function buildSearchHaystack(p: CatalogPlugin): string {
return [
p.name,
p.description,
p.maintainer,
p.tier,
...(p.capabilities?.providesTools || []),
...(p.capabilities?.providesHooks || []),
...(p.capabilities?.requiresEnv || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
}
export default function PluginCatalogPage() {
// Picker embed mode (?embed=picker): the page is iframed by a host app
// (Hermes desktop's Capabilities > Plugins tab) as a one-click catalog
// picker. Site chrome is hidden via CSS and every card gains an
// "+ Add to this Agent" button that posts
// { type: 'hermes-plugin-pick', name, repo, sha, subdir, tier,
// installCmd }
// to the parent window. The HOST performs the actual install through its
// own gateway (plugins.manage, catalog_name=<name>) — this page never
// installs anything; parents must validate event.origin before acting.
const pickerMode =
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("embed") === "picker";
const pickPlugin = useCallback((plugin: CatalogPlugin) => {
if (typeof window === "undefined" || window.parent === window) return;
window.parent.postMessage(
{
type: "hermes-plugin-pick",
name: plugin.name,
repo: plugin.repo,
sha: plugin.sha,
subdir: plugin.subdir || "",
tier: plugin.tier,
installCmd: plugin.installCommand || `hermes plugins install ${plugin.name}`,
},
"*"
);
}, []);
const [data, setData] = useState<{ plugins: CatalogPlugin[]; meta: CatalogMeta } | null>(
null,
);
const [loadError, setLoadError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [tierFilter, setTierFilter] = useState("all");
const [expandedCard, setExpandedCard] = useState<string | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [pl, mt] = await Promise.all([
fetch(PLUGINS_URL).then((r) => {
if (!r.ok) throw new Error(`plugins.json HTTP ${r.status}`);
return r.json();
}),
fetch(META_URL).then((r) => (r.ok ? r.json() : {})).catch(() => ({})),
]);
if (cancelled) return;
const arr = Array.isArray(pl) ? (pl as CatalogPlugin[]) : [];
for (const p of arr) p._search = buildSearchHaystack(p);
setData({ plugins: arr, meta: mt || {} });
} catch (err) {
if (cancelled) return;
setLoadError(err instanceof Error ? err.message : String(err));
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
e.preventDefault();
searchRef.current?.focus();
}
if (e.key === "Escape") {
searchRef.current?.blur();
setExpandedCard(null);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const allPlugins: CatalogPlugin[] = data?.plugins ?? [];
const meta: CatalogMeta = data?.meta ?? {};
const filtered = useMemo(() => {
const q = search.toLowerCase().trim();
return allPlugins.filter((p) => {
if (tierFilter !== "all" && p.tier !== tierFilter) return false;
if (q) return (p._search || "").includes(q);
return true;
});
}, [search, tierFilter, allPlugins]);
useEffect(() => {
setExpandedCard(null);
}, [search, tierFilter]);
const clearAll = useCallback(() => {
setSearch("");
setTierFilter("all");
}, []);
const catalogEmpty = data !== null && allPlugins.length === 0;
return (
<Layout
title="Plugin Catalog"
description="Browse reviewed, SHA-pinned plugins for Hermes Agent"
>
<div className={`${styles.page} ${pickerMode ? styles.pickerMode : ""}`}>
<header className={styles.hero}>
<div className={styles.heroGlow} />
<div className={styles.heroContent}>
<p className={styles.heroEyebrow}>Hermes Agent</p>
<h1 className={styles.heroTitle}>Plugin Catalog</h1>
<nav className={styles.crossNav} aria-label="Catalog pages">
<Link className={styles.crossNavLink} to="/skills">
Skills
</Link>
<span className={`${styles.crossNavLink} ${styles.crossNavActive}`}>
Plugins
</span>
</nav>
<p className={styles.heroSub}>
Reviewed, SHA-pinned plugins you can install with one command.
{loadError && (
<span style={{ color: "#f87171", marginLeft: 8 }}>
· failed to load catalog ({loadError})
</span>
)}
</p>
{meta.generatedAt && !catalogEmpty && (
<p className={styles.heroSub} style={{ fontSize: "0.85rem", opacity: 0.75 }}>
Catalog refreshed{" "}
<span title={meta.generatedAt}>
{formatRelativeTime(meta.generatedAt) || "recently"}
</span>
</p>
)}
{!catalogEmpty && (
<div className={styles.statsRow}>
<StatCard
value={allPlugins.filter((p) => p.tier === "official").length}
label="Official"
color="#ffd700"
/>
<StatCard
value={allPlugins.filter((p) => p.tier === "community").length}
label="Community"
color="#94a3b8"
/>
<StatCard value={meta.removedCount ?? 0} label="Removed" color="#f87171" />
</div>
)}
</div>
</header>
{!catalogEmpty && (
<div className={styles.controlsBar}>
<div className={styles.searchWrap}>
<svg
className={styles.searchIcon}
viewBox="0 0 20 20"
fill="currentColor"
width="18"
height="18"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
/>
</svg>
<input
ref={searchRef}
type="text"
placeholder='Search plugins... (press "/" to focus)'
value={search}
onChange={(e) => setSearch(e.target.value)}
className={styles.searchInput}
/>
{search && (
<button className={styles.clearBtn} onClick={() => setSearch("")}>
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
<div className={styles.tierPills}>
{TIER_ORDER.map((tier) => {
const active = tierFilter === tier;
const conf = TIER_CONFIG[tier];
const count =
tier === "all"
? allPlugins.length
: allPlugins.filter((p) => p.tier === tier).length;
return (
<button
key={tier}
className={`${styles.tierBtn} ${active ? styles.tierBtnActive : ""}`}
onClick={() => setTierFilter(tier)}
style={
active && conf
? ({
"--pill-color": conf.color,
"--pill-bg": conf.bg,
"--pill-border": conf.border,
} as React.CSSProperties)
: undefined
}
>
{tier === "all" ? "All" : conf?.label || tier}
<span className={styles.tierCount}>{count}</span>
</button>
);
})}
</div>
</div>
)}
<main className={styles.main}>
{!data && !loadError ? (
<div className={styles.empty}>
<div className={styles.loadingSpinner} />
<h3 className={styles.emptyTitle}>Loading the catalog…</h3>
</div>
) : catalogEmpty ? (
<div className={styles.empty}>
<div className={styles.emptyIcon}>{"\u{1F331}"}</div>
<h3 className={styles.emptyTitle}>The catalog is just getting started</h3>
<p className={styles.emptyDesc}>
The plugin catalog is a curated, human-reviewed list of Hermes
plugins — each entry pinned to an exact commit. Want yours listed?
Submissions are open.
</p>
<div className={styles.emptyActions}>
<a
className={styles.emptyCta}
href={CATALOG_README_URL}
target="_blank"
rel="noopener noreferrer"
>
How to submit a plugin ↗
</a>
<Link className={styles.emptyCtaSecondary} to="/user-guide/features/plugin-catalog">
Read the catalog docs
</Link>
</div>
</div>
) : filtered.length > 0 ? (
<div className={styles.grid}>
{filtered.map((plugin, i) => {
const key = `${plugin.tier}-${plugin.name}`;
return (
<PluginCard
key={key}
plugin={plugin}
query={search}
expanded={expandedCard === key}
onToggle={() => setExpandedCard(expandedCard === key ? null : key)}
onPick={pickerMode ? pickPlugin : undefined}
style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }}
/>
);
})}
</div>
) : (
<div className={styles.empty}>
<div className={styles.emptyIcon}>{"\u{1F50D}"}</div>
<h3 className={styles.emptyTitle}>No plugins found</h3>
<p className={styles.emptyDesc}>
Try a different search term or clear your filters.
</p>
<button className={styles.emptyReset} onClick={clearAll}>
Reset all filters
</button>
</div>
)}
</main>
</div>
</Layout>
);
}
+734
View File
@@ -0,0 +1,734 @@
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
.page {
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, sans-serif;
min-height: 100vh;
}
.hero {
position: relative;
overflow: hidden;
padding: 4rem 2rem 2.5rem;
text-align: center;
}
.heroGlow {
position: absolute;
top: -120px;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 400px;
background: radial-gradient(
ellipse at center,
rgba(255, 215, 0, 0.07) 0%,
transparent 70%
);
pointer-events: none;
}
.heroContent {
position: relative;
z-index: 1;
max-width: 720px;
margin: 0 auto;
}
.heroEyebrow {
font-family: "JetBrains Mono", monospace;
font-size: 0.75rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: rgba(255, 215, 0, 0.5);
margin-bottom: 0.75rem;
}
.heroTitle {
font-size: 3rem;
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1.1;
margin: 0 0 0.75rem;
}
[data-theme="dark"] .heroTitle {
color: #fafaf6;
}
.heroSub {
font-size: 1.05rem;
color: var(--ifm-font-color-secondary, #9a968e);
line-height: 1.5;
margin: 0 0 1.5rem;
}
/* Cross-nav between the Skills Hub and Plugin Catalog pages. */
.crossNav {
display: inline-flex;
gap: 0.35rem;
margin: 0 0 1.25rem;
padding: 0.25rem;
border: 1px solid rgba(255, 215, 0, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
.crossNavLink {
font-family: "DM Sans", sans-serif;
font-size: 0.82rem;
font-weight: 500;
padding: 0.3rem 0.9rem;
border-radius: 7px;
color: var(--ifm-font-color-secondary, #9a968e);
text-decoration: none;
transition: all 0.15s;
}
.crossNavLink:hover {
color: #ffd700;
text-decoration: none;
}
.crossNavActive {
background: rgba(255, 215, 0, 0.08);
color: #ffd700;
}
.statsRow {
display: flex;
justify-content: center;
gap: 2.5rem;
flex-wrap: wrap;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.statValue {
font-family: "JetBrains Mono", monospace;
font-size: 1.6rem;
font-weight: 700;
line-height: 1;
}
.statLabel {
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ifm-font-color-secondary, #9a968e);
}
.controlsBar {
position: sticky;
top: 60px; /* below Docusaurus navbar */
z-index: 50;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
padding: 1rem 2rem;
backdrop-filter: blur(16px) saturate(1.4);
border-bottom: 1px solid rgba(255, 215, 0, 0.06);
}
[data-theme="dark"] .controlsBar {
background: rgba(7, 7, 13, 0.85);
}
.searchWrap {
position: relative;
width: 100%;
max-width: 560px;
}
.searchIcon {
position: absolute;
left: 0.85rem;
top: 50%;
transform: translateY(-50%);
color: rgba(255, 215, 0, 0.35);
pointer-events: none;
}
.searchInput {
width: 100%;
padding: 0.7rem 2.5rem 0.7rem 2.6rem;
font-size: 0.95rem;
font-family: "DM Sans", sans-serif;
border: 1px solid rgba(255, 215, 0, 0.12);
border-radius: 10px;
background: rgba(15, 15, 24, 0.6);
color: var(--ifm-font-color-base, #e8e4dc);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.searchInput:focus {
border-color: rgba(255, 215, 0, 0.4);
box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.06);
}
.searchInput::placeholder {
color: var(--ifm-font-color-secondary, #9a968e);
opacity: 0.5;
}
.clearBtn {
position: absolute;
right: 0.6rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--ifm-font-color-secondary);
cursor: pointer;
padding: 0.15rem;
display: flex;
opacity: 0.6;
transition: opacity 0.15s;
}
.clearBtn:hover {
opacity: 1;
color: #ffd700;
}
/* Tier tabs: All / Official / Community (skills page's source-pill pattern). */
.tierPills {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: center;
}
.tierBtn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 20px;
background: transparent;
color: var(--ifm-font-color-secondary, #9a968e);
font-family: "DM Sans", sans-serif;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.tierBtn:hover {
border-color: rgba(255, 255, 255, 0.15);
color: var(--ifm-font-color-base);
}
.tierBtnActive {
border-color: var(--pill-border, rgba(255, 215, 0, 0.3));
background: var(--pill-bg, rgba(255, 215, 0, 0.06));
color: var(--pill-color, #ffd700);
}
.tierCount {
font-family: "JetBrains Mono", monospace;
font-size: 0.68rem;
background: rgba(255, 255, 255, 0.05);
padding: 0.05rem 0.35rem;
border-radius: 8px;
}
.tierBtnActive .tierCount {
background: rgba(255, 255, 255, 0.08);
}
.main {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem 2rem 3rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 0.75rem;
}
@keyframes cardIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
animation: cardIn 0.35s ease both;
}
[data-theme="dark"] .card {
background: #0c0c16;
}
.card:hover {
border-color: rgba(255, 215, 0, 0.15);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 215, 0, 0.05);
transform: translateY(-1px);
}
.cardExpanded {
border-color: rgba(255, 215, 0, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 215, 0, 0.08);
}
.cardAccent {
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
opacity: 0.5;
transition: opacity 0.2s;
}
.card:hover .cardAccent {
opacity: 1;
}
.cardInner {
padding: 1rem 1rem 0.85rem 1.15rem;
}
.cardTop {
display: flex;
align-items: flex-start;
gap: 0.6rem;
margin-bottom: 0.5rem;
}
.cardIcon {
font-size: 1.15rem;
line-height: 1;
flex-shrink: 0;
margin-top: 0.1rem;
opacity: 0.7;
}
.cardTitleGroup {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.cardTitle {
font-size: 0.92rem;
font-weight: 600;
line-height: 1.3;
margin: 0;
word-break: break-word;
color: var(--ifm-font-color-base);
}
.tierPill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: "JetBrains Mono", monospace;
font-size: 0.62rem;
font-weight: 500;
padding: 0.15rem 0.45rem;
border-radius: 4px;
border: 1px solid;
white-space: nowrap;
flex-shrink: 0;
margin-top: 0.1rem;
}
.cardDesc {
font-size: 0.82rem;
line-height: 1.55;
color: var(--ifm-font-color-secondary, #9a968e);
margin: 0 0 0.6rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cardDescFull {
-webkit-line-clamp: unset;
}
.cardMeta {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
/* Capability chips: tools/hooks/middleware counts. */
.capChip {
font-family: "JetBrains Mono", monospace;
font-size: 0.66rem;
padding: 0.15rem 0.45rem;
border: 1px solid rgba(255, 215, 0, 0.12);
border-radius: 3px;
background: rgba(255, 215, 0, 0.04);
color: rgba(255, 215, 0, 0.7);
}
/* Required env var chips. */
.envChip {
font-family: "JetBrains Mono", monospace;
font-size: 0.66rem;
padding: 0.12rem 0.4rem;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 3px;
background: rgba(255, 255, 255, 0.02);
color: rgba(255, 215, 0, 0.6);
}
.platformPill {
font-size: 0.66rem;
padding: 0.12rem 0.4rem;
border-radius: 3px;
background: rgba(96, 165, 250, 0.06);
color: rgba(96, 165, 250, 0.8);
border: 1px solid rgba(96, 165, 250, 0.1);
}
.cardDetail {
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px solid rgba(255, 255, 255, 0.04);
animation: cardIn 0.2s ease both;
}
.metaRow {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.3rem;
}
.metaLabel {
font-family: "JetBrains Mono", monospace;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ifm-font-color-secondary);
opacity: 0.5;
min-width: 4.5rem;
padding-top: 0.15rem;
}
.metaValue {
font-size: 0.78rem;
color: var(--ifm-font-color-base);
}
.metaValue code {
font-family: "JetBrains Mono", monospace;
font-size: 0.72rem;
background: rgba(255, 255, 255, 0.03);
padding: 0.05rem 0.3rem;
border-radius: 3px;
}
.chipList {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.shaLink {
color: rgba(96, 165, 250, 0.9);
text-decoration: none;
}
.shaLink:hover {
color: rgba(96, 165, 250, 1);
text-decoration: none;
}
.installHint {
margin-top: 0.65rem;
padding: 0.45rem 0.65rem;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 215, 0, 0.06);
border-radius: 5px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.installHint code {
font-family: "JetBrains Mono", monospace;
font-size: 0.72rem;
color: rgba(255, 215, 0, 0.7);
background: none;
padding: 0;
flex: 1;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
}
.installHint code::-webkit-scrollbar {
display: none;
}
.copyBtn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
padding: 0.2rem 0.45rem;
border: 1px solid rgba(255, 215, 0, 0.18);
border-radius: 4px;
background: rgba(255, 215, 0, 0.06);
color: rgba(255, 215, 0, 0.85);
font-size: 0.68rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.copyBtn:hover {
background: rgba(255, 215, 0, 0.14);
color: rgba(255, 215, 0, 1);
}
.copyBtnLabel {
line-height: 1;
}
.cardLinks {
display: flex;
gap: 0.5rem;
}
.cardLinks .docsLink {
flex: 1;
}
.docsLink {
display: block;
margin-top: 0.65rem;
padding: 0.45rem 0.65rem;
border: 1px solid rgba(96, 165, 250, 0.2);
border-radius: 5px;
background: rgba(96, 165, 250, 0.06);
color: rgba(96, 165, 250, 0.9);
font-size: 0.78rem;
text-decoration: none;
text-align: center;
transition: all 0.15s;
}
.docsLink:hover {
background: rgba(96, 165, 250, 0.12);
color: rgba(96, 165, 250, 1);
border-color: rgba(96, 165, 250, 0.35);
text-decoration: none;
}
.highlight {
background: rgba(255, 215, 0, 0.2);
color: #ffd700;
border-radius: 2px;
padding: 0 1px;
}
.loadingSpinner {
width: 2.25rem;
height: 2.25rem;
margin: 0 auto 1rem;
border: 3px solid rgba(255, 215, 0, 0.15);
border-top-color: rgba(255, 215, 0, 0.7);
border-radius: 50%;
animation: pluginsSpin 0.8s linear infinite;
}
@keyframes pluginsSpin {
to {
transform: rotate(360deg);
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 5rem 2rem;
text-align: center;
}
.emptyIcon {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.6;
}
.emptyTitle {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.5rem;
color: var(--ifm-font-color-base);
}
.emptyDesc {
font-size: 0.85rem;
color: var(--ifm-font-color-secondary);
margin: 0 0 1.25rem;
max-width: 480px;
line-height: 1.6;
}
.emptyActions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
justify-content: center;
}
.emptyCta {
font-family: "DM Sans", sans-serif;
font-size: 0.85rem;
font-weight: 600;
padding: 0.55rem 1.25rem;
border: 1px solid rgba(255, 215, 0, 0.3);
border-radius: 6px;
background: rgba(255, 215, 0, 0.06);
color: #ffd700;
text-decoration: none;
transition: all 0.2s;
}
.emptyCta:hover {
background: rgba(255, 215, 0, 0.12);
color: #ffd700;
text-decoration: none;
}
.emptyCtaSecondary {
font-family: "DM Sans", sans-serif;
font-size: 0.85rem;
padding: 0.55rem 1.25rem;
border: 1px solid rgba(96, 165, 250, 0.25);
border-radius: 6px;
background: rgba(96, 165, 250, 0.05);
color: rgba(96, 165, 250, 0.9);
text-decoration: none;
transition: all 0.2s;
}
.emptyCtaSecondary:hover {
background: rgba(96, 165, 250, 0.1);
color: rgba(96, 165, 250, 1);
text-decoration: none;
}
.emptyReset {
font-family: "DM Sans", sans-serif;
font-size: 0.85rem;
padding: 0.5rem 1.25rem;
border: 1px solid rgba(255, 215, 0, 0.25);
border-radius: 6px;
background: transparent;
color: #ffd700;
cursor: pointer;
transition: all 0.2s;
}
.emptyReset:hover {
background: rgba(255, 215, 0, 0.08);
}
@media (max-width: 900px) {
.hero {
padding: 2.5rem 1.25rem 1.75rem;
}
.heroTitle {
font-size: 2rem;
}
.statsRow {
gap: 1.5rem;
}
.statValue {
font-size: 1.25rem;
}
.controlsBar {
padding: 0.75rem 1rem;
}
.main {
padding: 0.75rem 1rem 2rem;
}
.grid {
grid-template-columns: 1fr;
}
}
/* ── Picker embed mode (?embed=picker, iframed by the desktop app) ────── */
.pickerMode .hero {
display: none;
}
.pickerMode {
padding-top: 0.5rem;
}
/* The navbar is hidden in picker mode, so the sticky controls bar must pin
to the frame top — its normal 60px navbar offset would float it over the
first card row. */
.pickerMode .controlsBar {
top: 0;
}
:global(html):has(.pickerMode) :global(.navbar),
:global(html):has(.pickerMode) :global(footer) {
display: none;
}
.pickBtn {
display: block;
width: 100%;
margin-top: 0.5rem;
padding: 0.45rem 0.75rem;
border: 1px solid var(--ifm-color-primary);
border-radius: 8px;
background: transparent;
color: var(--ifm-color-primary);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.pickBtn:hover {
background: var(--ifm-color-primary);
color: #fff;
}
+9
View File
@@ -1,5 +1,6 @@
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import styles from "./styles.module.css";
interface Skill {
@@ -686,6 +687,14 @@ export default function SkillsDashboard() {
<div className={styles.heroContent}>
<p className={styles.heroEyebrow}>Hermes Agent</p>
<h1 className={styles.heroTitle}>Skills Hub</h1>
<nav className={styles.crossNav} aria-label="Catalog pages">
<span className={`${styles.crossNavLink} ${styles.crossNavActive}`}>
Skills
</span>
<Link className={styles.crossNavLink} to="/plugins">
Plugins
</Link>
</nav>
<p className={styles.heroSub}>
Discover, search, and install from{" "}
<strong className={styles.heroAccent}>
@@ -69,6 +69,38 @@
font-variant-numeric: tabular-nums;
}
/* Cross-nav between the Skills Hub and Plugin Catalog pages. */
.crossNav {
display: inline-flex;
gap: 0.35rem;
margin: 0 0 1.25rem;
padding: 0.25rem;
border: 1px solid rgba(255, 215, 0, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
.crossNavLink {
font-family: "DM Sans", sans-serif;
font-size: 0.82rem;
font-weight: 500;
padding: 0.3rem 0.9rem;
border-radius: 7px;
color: var(--ifm-font-color-secondary, #9a968e);
text-decoration: none;
transition: all 0.15s;
}
.crossNavLink:hover {
color: #ffd700;
text-decoration: none;
}
.crossNavActive {
background: rgba(255, 215, 0, 0.08);
color: #ffd700;
}
.statsRow {
display: flex;
justify-content: center;