feat(desktop): streamline catalog UX — reviewed-entry install flow, update chips, actionable drift badge
- Catalog picks skip the raw-git ceremony: reviewed heading instead of the scare card, no force toggle, enable pre-checked; success toast offers 'Restart gateway' inline; missing-env warning deep-links to Tools & Keys. - plugins.manage list now carries catalog provenance (sidecar) + a backend- computed update_available; new 'update' action re-pins catalog installs to the current catalog SHA (non-catalog installs refused — CLI-owned). - Capabilities rows show tier chip + 'Update to <sha>' button; catalog picks for an already-current install are refused with a toast instead of the modal reopening. - Settings drift badge is now a button: opens the dual-target modal pre-filled (agent half only, sidecar repo+pin when present).
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
} from '@/store/plugin-install-request'
|
||||
import { $activeGatewayProfile, $profileScope } from '@/store/profile'
|
||||
import { $connection } from '@/store/session'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
|
||||
type ProbeResult = Awaited<ReturnType<NonNullable<NonNullable<Window['hermesDesktop']>['probePluginRepo']>>>
|
||||
|
||||
@@ -87,6 +88,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)
|
||||
|
||||
@@ -177,6 +180,7 @@ export function PluginInstallModal() {
|
||||
|
||||
const errors: string[] = []
|
||||
const successes: string[] = []
|
||||
let agentInstalled = false
|
||||
|
||||
try {
|
||||
if (installAgent && probe.agent) {
|
||||
@@ -190,11 +194,20 @@ export function PluginInstallModal() {
|
||||
|
||||
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)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -230,8 +243,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
|
||||
}
|
||||
@@ -284,8 +308,12 @@ export function PluginInstallModal() {
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2.5">
|
||||
<div className="space-y-2 text-[length:var(--conversation-caption-font-size)]">
|
||||
<div className="font-medium text-foreground">{m.securityHeading}</div>
|
||||
<p className="text-(--ui-text-secondary)">{m.securityIntro}</p>
|
||||
<div className="font-medium text-foreground">
|
||||
{request.catalogName ? m.reviewedHeading : m.securityHeading}
|
||||
</div>
|
||||
<p className="text-(--ui-text-secondary)">
|
||||
{request.catalogName ? m.reviewedIntro : m.securityIntro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sourceLinks && (
|
||||
@@ -389,12 +417,14 @@ export function PluginInstallModal() {
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-foreground">
|
||||
{m.forceReinstall}
|
||||
</span>
|
||||
<Switch checked={forceReinstall} disabled={busy} onCheckedChange={setForceReinstall} />
|
||||
</label>
|
||||
{!request.catalogName && (
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-foreground">
|
||||
{m.forceReinstall}
|
||||
</span>
|
||||
<Switch checked={forceReinstall} disabled={busy} onCheckedChange={setForceReinstall} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { FolderOpen, Monitor, Package, RefreshCw } from '@/lib/icons'
|
||||
import { $agentPlugins, $agentPluginsStatus, loadAgentPlugins } from '@/store/agent-plugins'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { openPluginInstallRequest } from '@/store/plugin-install-request'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
import { EmptyState, Pill, SettingsContent, SettingsSection } from './primitives'
|
||||
@@ -96,6 +97,46 @@ function unifiedPackageName(file?: string): null | string {
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
repo = sidecar.repo ?? ''
|
||||
catalogName = sidecar.catalog_name
|
||||
sha = sidecar.sha
|
||||
}
|
||||
} catch {
|
||||
// No sidecar (raw-git bundled install) — fall through to the name hint.
|
||||
}
|
||||
|
||||
openPluginInstallRequest({
|
||||
catalogName,
|
||||
legacyHint: 'agent',
|
||||
repo: repo || packageName,
|
||||
sha
|
||||
})
|
||||
}
|
||||
|
||||
function PluginRow({ record, agentHalfMissing }: { record: PluginRecord; agentHalfMissing?: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.plugins
|
||||
@@ -136,9 +177,14 @@ function PluginRow({ record, agentHalfMissing }: { record: PluginRecord; agentHa
|
||||
{record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>}
|
||||
{agentHalfMissing && (
|
||||
<Tip label={p.agentHalfMissingTip}>
|
||||
<span>
|
||||
<Pill tone="primary">{p.agentHalfMissing}</Pill>
|
||||
</span>
|
||||
<Button
|
||||
className="h-5 px-1.5 text-[0.65rem]"
|
||||
onClick={() => void repairAgentHalf(record, unifiedPackageName(record.file) ?? record.name)}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
{p.agentHalfMissing}
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -189,3 +189,130 @@ describe('PluginsTab', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PluginsTab catalog UX', () => {
|
||||
beforeEach(() => {
|
||||
$agentPlugins.set([])
|
||||
$agentPluginsStatus.set('ready')
|
||||
closePluginInstallRequest()
|
||||
requestGateway.mockClear()
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
it('shows an Update chip when the catalog pin moved past the installed SHA', () => {
|
||||
$agentPlugins.set([
|
||||
{
|
||||
catalog_name: 'demo-weather',
|
||||
catalog_sha: 'b'.repeat(40),
|
||||
catalog_tier: 'community',
|
||||
description: '',
|
||||
installed_sha: 'a'.repeat(40),
|
||||
key: 'demo-weather',
|
||||
name: 'demo-weather',
|
||||
source: 'git',
|
||||
status: 'enabled',
|
||||
update_available: true,
|
||||
version: '1.0.0'
|
||||
}
|
||||
])
|
||||
|
||||
render(<PluginsTab profile={null} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('re-pins through plugins.manage update when the chip is clicked', async () => {
|
||||
$agentPlugins.set([
|
||||
{
|
||||
catalog_name: 'demo-weather',
|
||||
catalog_sha: 'b'.repeat(40),
|
||||
catalog_tier: 'community',
|
||||
description: '',
|
||||
installed_sha: 'a'.repeat(40),
|
||||
key: 'demo-weather',
|
||||
name: 'demo-weather',
|
||||
source: 'git',
|
||||
status: 'enabled',
|
||||
update_available: true,
|
||||
version: '1.0.0'
|
||||
}
|
||||
])
|
||||
requestGateway.mockResolvedValue({ ok: true, unchanged: false, plugins: [] } as never)
|
||||
|
||||
render(<PluginsTab profile="workbot" />)
|
||||
|
||||
screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` }).click()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'plugins.manage',
|
||||
expect.objectContaining({ action: 'update', name: 'demo-weather', profile: 'workbot' })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a catalog pick that is already installed and current', async () => {
|
||||
$agentPlugins.set([
|
||||
{
|
||||
catalog_name: 'demo-weather',
|
||||
description: '',
|
||||
installed_sha: 'a'.repeat(40),
|
||||
key: 'demo-weather',
|
||||
name: 'demo-weather',
|
||||
source: 'git',
|
||||
status: 'enabled',
|
||||
update_available: false,
|
||||
version: '1.0.0'
|
||||
}
|
||||
])
|
||||
|
||||
render(<PluginsTab profile={null} />)
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
name: 'demo-weather',
|
||||
repo: 'https://github.com/example/demo-weather',
|
||||
type: 'hermes-plugin-pick'
|
||||
},
|
||||
origin: 'https://hermes-agent.nousresearch.com'
|
||||
})
|
||||
)
|
||||
|
||||
// The modal must NOT open — the pick is refused with a toast.
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect($pluginInstallRequest.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('still opens the modal for an installed pick when an update is available', async () => {
|
||||
$agentPlugins.set([
|
||||
{
|
||||
catalog_name: 'demo-weather',
|
||||
description: '',
|
||||
installed_sha: 'a'.repeat(40),
|
||||
key: 'demo-weather',
|
||||
name: 'demo-weather',
|
||||
source: 'git',
|
||||
status: 'enabled',
|
||||
update_available: true,
|
||||
version: '1.0.0'
|
||||
}
|
||||
])
|
||||
|
||||
render(<PluginsTab profile={null} />)
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
name: 'demo-weather',
|
||||
repo: 'https://github.com/example/demo-weather',
|
||||
type: 'hermes-plugin-pick'
|
||||
},
|
||||
origin: 'https://hermes-agent.nousresearch.com'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => expect($pluginInstallRequest.get()).not.toBeNull())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
type AgentPluginRow,
|
||||
isDesktopRelevantPlugin,
|
||||
loadAgentPlugins,
|
||||
toggleAgentPlugin
|
||||
toggleAgentPlugin,
|
||||
updateAgentPlugin
|
||||
} from '@/store/agent-plugins'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
|
||||
import { openPluginInstallRequest } from '@/store/plugin-install-request'
|
||||
|
||||
@@ -61,11 +63,13 @@ function profileParam(scope: ProfileScope): null | string {
|
||||
function PluginRow({
|
||||
row,
|
||||
busy,
|
||||
onToggle
|
||||
onToggle,
|
||||
onUpdate
|
||||
}: {
|
||||
row: AgentPluginRow
|
||||
busy: boolean
|
||||
onToggle: (enable: boolean) => void
|
||||
onUpdate?: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const address = row.key ?? ''
|
||||
@@ -84,6 +88,20 @@ function PluginRow({
|
||||
{t.skills.plugins.portableBadge}
|
||||
</span>
|
||||
)}
|
||||
{row.catalog_name && (
|
||||
<Tip label={t.skills.plugins.catalogProvenance(row.installed_sha?.slice(0, 8) ?? '')}>
|
||||
<span className="rounded border border-(--ui-stroke-tertiary) px-1 text-[0.65rem] text-(--ui-text-tertiary)">
|
||||
{row.catalog_tier === 'official'
|
||||
? t.skills.plugins.tierOfficial
|
||||
: t.skills.plugins.tierCommunity}
|
||||
</span>
|
||||
</Tip>
|
||||
)}
|
||||
{row.update_available && onUpdate && (
|
||||
<Button className="h-5 px-1.5 text-[0.65rem]" disabled={busy} onClick={onUpdate} size="xs" variant="outline">
|
||||
{t.skills.plugins.updateToPin(row.catalog_sha?.slice(0, 8) ?? '')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{row.description && (
|
||||
<div className="mt-0.5 text-[length:var(--conversation-caption-font-size)] break-words text-(--ui-text-tertiary)">
|
||||
@@ -155,6 +173,19 @@ export const PluginsTab = memo(function PluginsTab({ profile }: { profile: Profi
|
||||
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.
|
||||
@@ -169,7 +200,7 @@ export const PluginsTab = memo(function PluginsTab({ profile }: { profile: Profi
|
||||
window.addEventListener('message', onMessage)
|
||||
|
||||
return () => window.removeEventListener('message', onMessage)
|
||||
}, [open, scope])
|
||||
}, [open, scope, t])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -191,7 +222,7 @@ export const PluginsTab = memo(function PluginsTab({ profile }: { profile: Profi
|
||||
<div className="flex flex-col">
|
||||
{visible.map(row => (
|
||||
<PluginRow
|
||||
busy={busyKey === (row.key ?? row.name)}
|
||||
busy={busyKey === (row.key ?? row.name) || busyKey === row.name}
|
||||
key={row.key ?? row.name}
|
||||
onToggle={enable => {
|
||||
if (!row.key) {
|
||||
@@ -200,6 +231,19 @@ export const PluginsTab = memo(function PluginsTab({ profile }: { profile: Profi
|
||||
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -438,6 +438,13 @@ export const en: Translations = {
|
||||
agentTargetRemote: profile => `Installs into the connected ${profile} backend`,
|
||||
catalogPinned: (name, sha) =>
|
||||
`Hermes catalog entry "${name}" — the agent component installs at the reviewed pin${sha ? ` ${sha}` : ''}, not the branch tip.`,
|
||||
reviewedHeading: 'Reviewed catalog entry',
|
||||
reviewedIntro:
|
||||
'This entry was human-reviewed at its pinned commit. You can still inspect the exact code below.',
|
||||
restartToApply: 'Restart the gateway for the plugin to take effect.',
|
||||
restartNow: 'Restart gateway',
|
||||
missingEnvAction: 'Set it up',
|
||||
alreadyInstalled: (name: string) => `${name} is already installed.`,
|
||||
desktopTarget: "Installs into this app's local desktop-plugins folder",
|
||||
desktopOnlyNote: 'Desktop-only packages do not install a backend agent plugin.',
|
||||
insecureWarning: 'This URL uses an insecure or local scheme. Prefer https:// or git@ for production installs.',
|
||||
@@ -1317,7 +1324,14 @@ export const en: Translations = {
|
||||
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.'
|
||||
'Hit "+ Add to this Agent" on any plugin — reviewed entries install at their pinned commit into the selected profile. Bundled agent+desktop plugins offer both halves.',
|
||||
alreadyInstalled: (name: string) => `${name} is already installed in this profile.`,
|
||||
catalogProvenance: (sha: string) => `Installed from the Hermes catalog${sha ? ` at pin ${sha}` : ''}.`,
|
||||
tierOfficial: 'official',
|
||||
tierCommunity: 'community',
|
||||
updateToPin: (sha: string) => `Update to ${sha}`,
|
||||
updateFailed: (name: string) => `Could not update ${name}`,
|
||||
updated: (name: string) => `${name} updated to the current catalog pin. Restart the gateway to apply.`
|
||||
},
|
||||
hub: {
|
||||
searchPlaceholder: 'Search the skill hub',
|
||||
|
||||
@@ -378,6 +378,12 @@ export interface Translations {
|
||||
agentTargetLocal: (profile: string) => string
|
||||
agentTargetRemote: (profile: string) => string
|
||||
catalogPinned: (name: string, sha: string) => string
|
||||
reviewedHeading: string
|
||||
reviewedIntro: string
|
||||
restartToApply: string
|
||||
restartNow: string
|
||||
missingEnvAction: string
|
||||
alreadyInstalled: (name: string) => string
|
||||
desktopTarget: string
|
||||
desktopOnlyNote: string
|
||||
insecureWarning: string
|
||||
@@ -1153,6 +1159,13 @@ export interface Translations {
|
||||
catalogBrowse: string
|
||||
catalogHide: string
|
||||
catalogHint: string
|
||||
alreadyInstalled: (name: string) => string
|
||||
catalogProvenance: (sha: string) => string
|
||||
tierOfficial: string
|
||||
tierCommunity: string
|
||||
updateToPin: (sha: string) => string
|
||||
updateFailed: (name: string) => string
|
||||
updated: (name: string) => string
|
||||
}
|
||||
hub: {
|
||||
searchPlaceholder: string
|
||||
|
||||
@@ -426,6 +426,12 @@ export const zh: Translations = {
|
||||
agentTargetRemote: profile => `安装到已连接的 ${profile} 后端`,
|
||||
catalogPinned: (name, sha) =>
|
||||
`Hermes 目录条目「${name}」— agent 部分将安装在经过审核的固定提交${sha ? ` ${sha}` : ''},而不是分支最新代码。`,
|
||||
reviewedHeading: '经过审核的目录条目',
|
||||
reviewedIntro: '此条目已在其固定提交处经过人工审核。你仍可在下方检查确切代码。',
|
||||
restartToApply: '重启网关后插件才会生效。',
|
||||
restartNow: '重启网关',
|
||||
missingEnvAction: '去设置',
|
||||
alreadyInstalled: (name: string) => `${name} 已安装。`,
|
||||
desktopTarget: '安装到此应用的本地 desktop-plugins 文件夹',
|
||||
desktopOnlyNote: '仅桌面包不会安装后端智能体插件。',
|
||||
insecureWarning: '此 URL 使用了不安全的本地 scheme。生产环境请优先使用 https:// 或 git@。',
|
||||
@@ -1506,7 +1512,14 @@ export const zh: Translations = {
|
||||
catalogBrowse: '浏览',
|
||||
catalogHide: '隐藏目录浏览器',
|
||||
catalogHint:
|
||||
'点击任意插件上的「+ Add to this Agent」— 经过审核的条目会以其固定提交安装到所选配置。捆绑的 agent+桌面插件会同时提供两部分。'
|
||||
'点击任意插件上的「+ Add to this Agent」— 经过审核的条目会以其固定提交安装到所选配置。捆绑的 agent+桌面插件会同时提供两部分。',
|
||||
alreadyInstalled: (name: string) => `${name} 已安装在此配置中。`,
|
||||
catalogProvenance: (sha: string) => `从 Hermes 目录安装${sha ? `,固定提交 ${sha}` : ''}。`,
|
||||
tierOfficial: '官方',
|
||||
tierCommunity: '社区',
|
||||
updateToPin: (sha: string) => `更新到 ${sha}`,
|
||||
updateFailed: (name: string) => `无法更新 ${name}`,
|
||||
updated: (name: string) => `${name} 已更新到当前目录固定提交。重启网关后生效。`
|
||||
},
|
||||
hub: {
|
||||
searchPlaceholder: '搜索技能中心',
|
||||
|
||||
@@ -26,6 +26,14 @@ export interface AgentPluginRow {
|
||||
status: 'enabled' | 'disabled' | 'not enabled'
|
||||
/** Agent Plugins v1 package (portable skills/MCP format) vs native Hermes. */
|
||||
portable?: boolean
|
||||
/** Curated-catalog provenance (from the install sidecar), when present. */
|
||||
catalog_name?: string
|
||||
catalog_tier?: string
|
||||
installed_sha?: string
|
||||
/** Current catalog pin for this entry (backend-computed). */
|
||||
catalog_sha?: string
|
||||
/** Installed SHA differs from the catalog pin — an update is available. */
|
||||
update_available?: boolean
|
||||
}
|
||||
|
||||
export type AgentPluginsStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
@@ -219,3 +227,36 @@ export async function installAgentPlugin(
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-pin a catalog-installed plugin to the current catalog SHA (backend
|
||||
* `plugins.manage update`; catalog installs only). Refreshes the list on
|
||||
* success. Returns whether the update applied. */
|
||||
export async function updateAgentPlugin(
|
||||
request: GatewayRequest,
|
||||
name: string,
|
||||
failMessage: string,
|
||||
profile?: string | null
|
||||
): Promise<boolean> {
|
||||
$agentPluginBusy.set(name)
|
||||
|
||||
try {
|
||||
const result = await request<{ ok?: boolean; unchanged?: boolean }>(
|
||||
'plugins.manage',
|
||||
withProfile({ action: 'update', name }, profile)
|
||||
)
|
||||
|
||||
if (!result?.ok) {
|
||||
throw new Error(failMessage)
|
||||
}
|
||||
|
||||
await loadAgentPlugins(request, profile)
|
||||
|
||||
return !result.unchanged
|
||||
} catch (e) {
|
||||
notifyError(e, failMessage)
|
||||
|
||||
return false
|
||||
} finally {
|
||||
$agentPluginBusy.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,3 +100,23 @@ def test_plugins_manage_install_catalog_name_only():
|
||||
enable=False,
|
||||
catalog_name="weather-plugin",
|
||||
)
|
||||
|
||||
|
||||
def test_plugins_manage_update_requires_catalog_sidecar(tmp_path, monkeypatch):
|
||||
"""Non-catalog installs are refused — their update flows stay CLI-owned."""
|
||||
import hermes_cli.plugins_cmd as plugins_cmd
|
||||
|
||||
plugins_root = tmp_path / "plugins"
|
||||
(plugins_root / "plain-git-plugin").mkdir(parents=True)
|
||||
monkeypatch.setattr(plugins_cmd, "_plugins_dir", lambda: plugins_root)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "plugins.manage",
|
||||
"params": {"action": "update", "name": "plain-git-plugin"},
|
||||
}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
assert "not a catalog install" in resp["error"]["message"]
|
||||
|
||||
@@ -2455,6 +2455,8 @@ def _(rid, params: dict) -> dict:
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
from pathlib import Path as _Path
|
||||
|
||||
from hermes_cli.plugins_cmd import (
|
||||
_bundled_default_on,
|
||||
_discover_all_plugins,
|
||||
@@ -2462,11 +2464,22 @@ def _(rid, params: dict) -> dict:
|
||||
_get_enabled_set,
|
||||
_is_portable_plugin_dir,
|
||||
_plugin_status,
|
||||
_read_catalog_sidecar,
|
||||
)
|
||||
|
||||
def _catalog_pins():
|
||||
"""{catalog_name: pinned_sha} from the live catalog (best effort)."""
|
||||
try:
|
||||
from hermes_cli.plugin_catalog import load_catalog_live
|
||||
|
||||
return {e.name: e.sha for e in load_catalog_live()}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _rows():
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
pins = _catalog_pins()
|
||||
out = []
|
||||
for name, version, desc, source, _dir, key in sorted(
|
||||
_discover_all_plugins()
|
||||
@@ -2482,22 +2495,40 @@ def _(rid, params: dict) -> dict:
|
||||
and _bundled_default_on(_dir)
|
||||
):
|
||||
status = "enabled"
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
# Canonical registry key (e.g. ``image_gen/fal``). Names
|
||||
# can collide across category dirs — both fal backends
|
||||
# are named "fal" — so toggles must address the key.
|
||||
"key": key,
|
||||
"version": str(version or ""),
|
||||
"description": desc or "",
|
||||
"source": source,
|
||||
"status": status,
|
||||
# Agent Plugins v1 package (plugin.json — the portable
|
||||
# skills/MCP format) vs a native Hermes plugin.
|
||||
"portable": _is_portable_plugin_dir(_dir),
|
||||
}
|
||||
)
|
||||
row = {
|
||||
"name": name,
|
||||
# Canonical registry key (e.g. ``image_gen/fal``). Names
|
||||
# can collide across category dirs — both fal backends
|
||||
# are named "fal" — so toggles must address the key.
|
||||
"key": key,
|
||||
"version": str(version or ""),
|
||||
"description": desc or "",
|
||||
"source": source,
|
||||
"status": status,
|
||||
# Agent Plugins v1 package (plugin.json — the portable
|
||||
# skills/MCP format) vs a native Hermes plugin.
|
||||
"portable": _is_portable_plugin_dir(_dir),
|
||||
}
|
||||
# Catalog provenance (``.hermes-catalog.json`` sidecar) +
|
||||
# whether the catalog pin has moved past the installed SHA —
|
||||
# powers the desktop's "Update to <pin>" affordance.
|
||||
try:
|
||||
sidecar = _read_catalog_sidecar(_Path(_dir)) if _dir else None
|
||||
except Exception:
|
||||
sidecar = None
|
||||
if sidecar and sidecar.get("catalog_name"):
|
||||
catalog_name = str(sidecar["catalog_name"])
|
||||
installed_sha = str(sidecar.get("sha") or "").lower()
|
||||
pin = pins.get(catalog_name)
|
||||
row["catalog_name"] = catalog_name
|
||||
row["catalog_tier"] = str(sidecar.get("tier") or "community")
|
||||
row["installed_sha"] = installed_sha
|
||||
if pin:
|
||||
row["catalog_sha"] = pin
|
||||
row["update_available"] = bool(
|
||||
installed_sha and installed_sha != pin
|
||||
)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
if action == "list":
|
||||
@@ -2562,6 +2593,53 @@ def _(rid, params: dict) -> dict:
|
||||
return _err(rid, 5026, result.get("error") or "install failed")
|
||||
return _ok(rid, result)
|
||||
|
||||
if action == "update":
|
||||
# Catalog installs only: re-pin to the current catalog SHA (the
|
||||
# same semantics as `hermes plugins update <name>` for sidecar
|
||||
# installs). Non-catalog installs keep their CLI-only flows.
|
||||
from hermes_cli.plugins_cmd import (
|
||||
_catalog_install_identifier,
|
||||
_get_live_catalog_entry,
|
||||
_install_plugin_core,
|
||||
_plugins_dir,
|
||||
_write_catalog_sidecar,
|
||||
PluginOperationError,
|
||||
)
|
||||
|
||||
name = (params.get("name") or "").strip()
|
||||
if not name:
|
||||
return _err(rid, 4019, "plugins.update requires a 'name'")
|
||||
target = _plugins_dir() / name
|
||||
sidecar = _read_catalog_sidecar(target) if target.is_dir() else None
|
||||
if not sidecar or not sidecar.get("catalog_name"):
|
||||
return _err(
|
||||
rid, 4020,
|
||||
f"'{name}' is not a catalog install — update it via the CLI",
|
||||
)
|
||||
entry = _get_live_catalog_entry(str(sidecar["catalog_name"]))
|
||||
if entry is None:
|
||||
return _err(
|
||||
rid, 4021,
|
||||
f"'{sidecar['catalog_name']}' is no longer in the catalog",
|
||||
)
|
||||
installed_sha = str(sidecar.get("sha") or "").lower()
|
||||
if installed_sha == entry.sha:
|
||||
return _ok(rid, {"ok": True, "unchanged": True, "sha": entry.sha})
|
||||
was_enabled = _get_enabled_set()
|
||||
try:
|
||||
new_target, _manifest, _installed = _install_plugin_core(
|
||||
_catalog_install_identifier(entry),
|
||||
force=True,
|
||||
ref=entry.sha,
|
||||
)
|
||||
except PluginOperationError as e:
|
||||
return _err(rid, 5026, str(e))
|
||||
_write_catalog_sidecar(new_target, entry)
|
||||
from hermes_cli.plugins_cmd import _save_enabled_set
|
||||
|
||||
_save_enabled_set(was_enabled)
|
||||
return _ok(rid, {"ok": True, "unchanged": False, "sha": entry.sha})
|
||||
|
||||
return _err(rid, 4017, f"unknown plugins action: {action}")
|
||||
except Exception as e:
|
||||
return _err(rid, 5026, str(e))
|
||||
|
||||
Reference in New Issue
Block a user