feat(desktop): plugin catalog in Capabilities — embedded picker, one-click dual-target install, drift badge
- Capabilities gains a 4th tab (Plugins): the scoped (connection, profile)'s installed agent plugins with toggles, and the live docs-site plugin catalog embedded underneath (?embed=picker) — same shape as the Skills hub. - '+ Add to this Agent' on a catalog card opens the existing dual-target install modal: the agent half installs at the catalog's reviewed pin into the scoped profile (plugins.manage catalog_name= passthrough), the desktop half installs locally; bundled agent+desktop packages offer both in one flow. - Settings > Plugins: desktop halves of bundled packages get an 'agent half missing here' badge when the currently connected backend/profile lacks the agent component — the one-app/N-agents drift is now visible. - Docs /plugins page: picker embed mode (chrome hidden, pick button posts hermes-plugin-pick), subdir carried through the extractor. - Gateway plugins.manage install accepts catalog_name (resolved server-side against the backend's own catalog; removed-blocklist enforced, no bypass).
This commit is contained in:
@@ -145,7 +145,7 @@ export function PluginInstallModal() {
|
||||
void runProbe(request)
|
||||
}, [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,7 +183,9 @@ export function PluginInstallModal() {
|
||||
const result = await installAgentPlugin(requestGateway, {
|
||||
identifier: request.repo,
|
||||
force: forceReinstall,
|
||||
enable: enableAgent
|
||||
enable: enableAgent,
|
||||
catalogName: request.catalogName,
|
||||
profile: request.profile
|
||||
})
|
||||
|
||||
if (result.ok) {
|
||||
@@ -273,6 +275,11 @@ 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">
|
||||
|
||||
@@ -310,7 +310,21 @@ function AgentPluginsSection() {
|
||||
)
|
||||
}
|
||||
|
||||
function PluginRow({ record }: { record: PluginRecord }) {
|
||||
/** 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
|
||||
}
|
||||
|
||||
const match = /[\\/]plugins[\\/]([^\\/]+)[\\/]desktop[\\/]plugin\.js$/.exec(file)
|
||||
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
function PluginRow({ record, agentHalfMissing }: { record: PluginRecord; agentHalfMissing?: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.plugins
|
||||
|
||||
@@ -348,6 +362,13 @@ 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}>
|
||||
<span>
|
||||
<Pill tone="primary">{p.agentHalfMissing}</Pill>
|
||||
</span>
|
||||
</Tip>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -358,6 +379,13 @@ export function PluginsSettings() {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.plugins
|
||||
const records = useStore($pluginRecords)
|
||||
// The agent-plugin list for the CURRENTLY scoped backend/profile — used 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).
|
||||
const agentRows = useStore($agentPlugins)
|
||||
const agentStatus = useStore($agentPluginsStatus)
|
||||
const agentNames = new Set(agentRows.flatMap(row => [row.name, row.key ?? row.name]))
|
||||
|
||||
// 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
|
||||
@@ -400,9 +428,19 @@ 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>
|
||||
|
||||
@@ -63,12 +63,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
|
||||
@@ -771,14 +772,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
|
||||
@@ -788,7 +790,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,136 @@
|
||||
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('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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
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
|
||||
} from '@/store/agent-plugins'
|
||||
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
|
||||
}: {
|
||||
row: AgentPluginRow
|
||||
busy: boolean
|
||||
onToggle: (enable: boolean) => 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>
|
||||
)}
|
||||
</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
|
||||
}
|
||||
|
||||
// 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])
|
||||
|
||||
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)}
|
||||
key={row.key ?? row.name}
|
||||
onToggle={enable => {
|
||||
if (!row.key) {
|
||||
return
|
||||
}
|
||||
|
||||
void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope)
|
||||
}}
|
||||
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>
|
||||
)
|
||||
})
|
||||
@@ -418,6 +418,9 @@ 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:
|
||||
@@ -441,6 +444,8 @@ 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.`,
|
||||
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.',
|
||||
@@ -1308,6 +1313,20 @@ 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.'
|
||||
},
|
||||
hub: {
|
||||
searchPlaceholder: 'Search the skill hub',
|
||||
search: 'Search',
|
||||
|
||||
@@ -361,6 +361,8 @@ export interface Translations {
|
||||
failed: string
|
||||
empty: string
|
||||
kinds: { bundled: string; disk: string; runtime: string }
|
||||
agentHalfMissing: string
|
||||
agentHalfMissingTip: string
|
||||
agent: {
|
||||
title: string
|
||||
blurb: string
|
||||
@@ -383,6 +385,7 @@ export interface Translations {
|
||||
desktopLabel: string
|
||||
agentTargetLocal: (profile: string) => string
|
||||
agentTargetRemote: (profile: string) => string
|
||||
catalogPinned: (name: string, sha: string) => string
|
||||
desktopTarget: string
|
||||
desktopOnlyNote: string
|
||||
insecureWarning: string
|
||||
@@ -1146,6 +1149,19 @@ 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
|
||||
}
|
||||
hub: {
|
||||
searchPlaceholder: string
|
||||
search: string
|
||||
|
||||
@@ -406,6 +406,9 @@ export const zh: Translations = {
|
||||
failed: '失败',
|
||||
empty: '尚未安装桌面插件。',
|
||||
kinds: { bundled: '内置', disk: '磁盘', runtime: '运行时' },
|
||||
agentHalfMissing: '此处缺少 agent 部分',
|
||||
agentHalfMissingTip:
|
||||
'这是捆绑插件的桌面部分,但其 agent 部分未安装在当前连接的后端/配置上。请在 能力 → 插件 中安装。',
|
||||
agent: {
|
||||
title: '智能体插件',
|
||||
blurb:
|
||||
@@ -429,6 +432,8 @@ export const zh: Translations = {
|
||||
desktopLabel: '桌面 UI',
|
||||
agentTargetLocal: profile => `安装到 ${profile} 后端(~/.hermes/plugins/)`,
|
||||
agentTargetRemote: profile => `安装到已连接的 ${profile} 后端`,
|
||||
catalogPinned: (name, sha) =>
|
||||
`Hermes 目录条目「${name}」— agent 部分将安装在经过审核的固定提交${sha ? ` ${sha}` : ''},而不是分支最新代码。`,
|
||||
desktopTarget: '安装到此应用的本地 desktop-plugins 文件夹',
|
||||
desktopOnlyNote: '仅桌面包不会安装后端智能体插件。',
|
||||
insecureWarning: '此 URL 使用了不安全的本地 scheme。生产环境请优先使用 https:// 或 git@。',
|
||||
@@ -1497,6 +1502,20 @@ 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+桌面插件会同时提供两部分。'
|
||||
},
|
||||
hub: {
|
||||
searchPlaceholder: '搜索技能中心',
|
||||
search: '搜索',
|
||||
|
||||
@@ -179,7 +179,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 +197,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' }
|
||||
|
||||
@@ -9,6 +9,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)
|
||||
|
||||
@@ -37,6 +37,7 @@ def test_plugins_manage_install_success():
|
||||
"owner/hello-world",
|
||||
force=True,
|
||||
enable=False,
|
||||
catalog_name=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -71,3 +72,31 @@ 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",
|
||||
)
|
||||
|
||||
@@ -2543,14 +2543,20 @@ def _(rid, params: dict) -> dict:
|
||||
ident = (
|
||||
params.get("identifier") or params.get("repo") or ""
|
||||
).strip()
|
||||
if not ident:
|
||||
# Curated-catalog install: resolve repo + pinned SHA server-side
|
||||
# (same contract as the dashboard endpoint). ``catalog_name``
|
||||
# alone is enough — identifier may be empty.
|
||||
catalog_name = str(params.get("catalog_name") or "").strip()
|
||||
if not ident and not catalog_name:
|
||||
return _err(
|
||||
rid, 4019, "plugins.install requires 'identifier' or 'repo'"
|
||||
rid, 4019,
|
||||
"plugins.install requires 'identifier', 'repo', or 'catalog_name'",
|
||||
)
|
||||
result = dashboard_install_plugin(
|
||||
ident,
|
||||
force=bool(params.get("force")),
|
||||
enable=params.get("enable", True),
|
||||
catalog_name=catalog_name or None,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return _err(rid, 5026, result.get("error") or "install failed")
|
||||
|
||||
@@ -111,6 +111,7 @@ def load_catalog_entries(catalog_dir: Path) -> list[dict]:
|
||||
"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")),
|
||||
|
||||
@@ -18,6 +18,7 @@ interface CatalogPlugin {
|
||||
shaShort: string;
|
||||
tier: string;
|
||||
maintainer: string;
|
||||
subdir?: string;
|
||||
requiresHermes?: string;
|
||||
platforms?: string[];
|
||||
capabilities?: PluginCapabilities;
|
||||
@@ -140,12 +141,15 @@ function PluginCard({
|
||||
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;
|
||||
@@ -213,6 +217,18 @@ function PluginCard({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onPick && (
|
||||
<button
|
||||
className={styles.pickBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPick(plugin);
|
||||
}}
|
||||
>
|
||||
+ Add to this Agent
|
||||
</button>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className={styles.cardDetail}>
|
||||
{plugin.maintainer && (
|
||||
@@ -316,6 +332,35 @@ function buildSearchHaystack(p: CatalogPlugin): string {
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
@@ -394,7 +439,7 @@ export default function PluginCatalogPage() {
|
||||
title="Plugin Catalog"
|
||||
description="Browse reviewed, SHA-pinned plugins for Hermes Agent"
|
||||
>
|
||||
<div className={styles.page}>
|
||||
<div className={`${styles.page} ${pickerMode ? styles.pickerMode : ""}`}>
|
||||
<header className={styles.hero}>
|
||||
<div className={styles.heroGlow} />
|
||||
<div className={styles.heroContent}>
|
||||
@@ -552,6 +597,7 @@ export default function PluginCatalogPage() {
|
||||
query={search}
|
||||
expanded={expandedCard === key}
|
||||
onToggle={() => setExpandedCard(expandedCard === key ? null : key)}
|
||||
onPick={pickerMode ? pickPlugin : undefined}
|
||||
style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -689,3 +689,39 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ── Picker embed mode (?embed=picker, iframed by the desktop app) ────── */
|
||||
|
||||
.pickerMode .hero {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pickerMode {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user