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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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: 'Установка плагина',
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
|
||||
@@ -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 [],
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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: []
|
||||
@@ -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 |
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user