diff --git a/.github/actions/plugin-validate/action.yml b/.github/actions/plugin-validate/action.yml new file mode 100644 index 0000000000..5484aaf56c --- /dev/null +++ b/.github/actions/plugin-validate/action.yml @@ -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@ + - 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 diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 26e11ee9fb..b75bbaed00 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -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 diff --git a/.github/workflows/plugin-catalog-ci.yml b/.github/workflows/plugin-catalog-ci.yml new file mode 100644 index 0000000000..84efbc0761 --- /dev/null +++ b/.github/workflows/plugin-catalog-ci.yml @@ -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:-}" + { + 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" diff --git a/.gitignore b/.gitignore index 7cf39fcfc9..0407b5eb73 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/apps/desktop/src/app/settings/plugin-install-modal.tsx b/apps/desktop/src/app/settings/plugin-install-modal.tsx index 1034e20367..7c1f3cf8b8 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.tsx @@ -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['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() {
{request.repo}
+ {request.catalogName && ( +

+ {m.catalogPinned(request.catalogName, request.sha?.slice(0, 8) ?? '')} +

+ )}
-
{m.securityHeading}
-

{m.securityIntro}

+
+ {request.catalogName ? m.reviewedHeading : m.securityHeading} +
+

+ {request.catalogName ? m.reviewedIntro : m.securityIntro} +

{sourceLinks && ( @@ -414,12 +449,14 @@ export function PluginInstallModal() { )} - + {!request.catalogName && ( + + )}
)} diff --git a/apps/desktop/src/app/settings/plugins-settings.test.tsx b/apps/desktop/src/app/settings/plugins-settings.test.tsx index 917e7e093a..355770de44 100644 --- a/apps/desktop/src/app/settings/plugins-settings.test.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.test.tsx @@ -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>()), - 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( - + - + ) 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) => { - 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' })) }) }) diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx index b85c7f917d..af3c882b45 100644 --- a/apps/desktop/src/app/settings/plugins-settings.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -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 = { disk: 0, runtime: 1, bundled: 2 } -// User-installed plugins first — mirrors `hermes plugins list --user`. -const SOURCE_ORDER: Record = { 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=`). */ 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//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 = ( - { - 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 ( - {toggle}} - description={row.description || (row.version ? `v${row.version}` : undefined)} - id={pluginElementId(key ?? row.name)} - title={ - <> - {row.name} - {p.agent.sources[row.source] ?? row.source} - {row.portable && {p.agent.portable}} - +/** 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) - 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 ( - -

- {p.agent.blurb} -

- - {profiles.length > 1 && ( -
- - {p.agent.appliesTo} - - -
- )} - - {connection?.mode !== 'remote' && !requestProfile && ( -
- -
- )} - - setQuery(event.target.value)} - placeholder={p.agent.search} - spellCheck={false} - value={query} - /> - - {status === 'loading' || status === 'idle' ? ( -
- - - -
- ) : status === 'error' ? ( - - ) : sorted.length === 0 ? ( - needle ? ( -

- {p.agent.noMatches} -

- ) : ( - - ) - ) : ( -
- {sorted.map(row => ( - - ))} -
- )} -
- ) + 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 }) { {record.name} {p.kinds[record.kind]} {record.status === 'error' && {p.failed}} + {agentHalfMissing && ( + + + + )} } /> @@ -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=): rows render as soon // as their store hydrates, so "ready" is simply target-present; the polling @@ -406,14 +263,31 @@ export function PluginsSettings() { ) : (
- {rows.map(record => ( - - ))} + {rows.map(record => { + const packageName = unifiedPackageName(record.file) + + return ( + + ) + })}
)} - + +

+ {p.agent.movedToCapabilities}{' '} + + {p.agent.openCapabilities} + +

+
) } diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 81e1dfb1e9..c7e38e6ccc 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -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}
- {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. + + ) : 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 diff --git a/apps/desktop/src/app/skills/plugins-tab.test.tsx b/apps/desktop/src/app/skills/plugins-tab.test.tsx new file mode 100644 index 0000000000..183987c7d8 --- /dev/null +++ b/apps/desktop/src/app/skills/plugins-tab.test.tsx @@ -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() + + 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() + + expect(screen.queryByText('fal')).toBeNull() + expect(screen.getByText(/No agent plugins installed/)).toBeTruthy() + }) + + it('loads the plugin list scoped to the selected profile', () => { + render() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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()) + }) +}) diff --git a/apps/desktop/src/app/skills/plugins-tab.tsx b/apps/desktop/src/app/skills/plugins-tab.tsx new file mode 100644 index 0000000000..600b73aeac --- /dev/null +++ b/apps/desktop/src/app/skills/plugins-tab.tsx @@ -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 ( +
+ +
+
+ {row.name} + {row.version && v{row.version}} + {row.portable && ( + + {t.skills.plugins.portableBadge} + + )} + {row.catalog_name && ( + + + {row.catalog_tier === 'official' + ? t.skills.plugins.tierOfficial + : t.skills.plugins.tierCommunity} + + + )} + {row.update_available && onUpdate && ( + + )} +
+ {row.description && ( +
+ {row.description} +
+ )} +
+
+ {busy && } + {canToggle ? ( + + ) : ( + + + + + + )} +
+
+ ) +} + +/** 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 ( +
+
+ {status === 'error' ? ( + void loadAgentPlugins(requestGateway, scope)} size="sm"> + {t.skills.refresh} + + } + description={error ?? undefined} + icon="error" + title={p.loadFailed} + /> + ) : visible.length === 0 && status === 'ready' ? ( + + ) : ( +
+ {visible.map(row => ( + { + 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} + /> + ))} +
+ )} +
+ +
+
+ {p.catalogTitle} + +
+ {pickerMounted && ( +
+
+