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:
Teknium
2026-08-28 01:52:33 -07:00
parent f7a26d8f9d
commit cbf86f4265
15 changed files with 649 additions and 17 deletions
@@ -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>
+11 -4
View File
@@ -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'
)
})
})
})
+254
View File
@@ -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>
)
})
+19
View File
@@ -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',
+16
View File
@@ -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
+19
View File
@@ -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: '搜索',
+14 -4
View File
@@ -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",
)
+8 -2
View File
@@ -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")
+1
View File
@@ -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")),
+47 -1
View File
@@ -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;
}