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:
Teknium
2026-08-29 00:37:09 -07:00
parent 56ff4a5162
commit dcb3e27b23
10 changed files with 461 additions and 35 deletions
@@ -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())
})
})
+48 -4
View File
@@ -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}
/>
))}
+15 -1
View File
@@ -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',
+13
View File
@@ -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
+14 -1
View File
@@ -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: '搜索技能中心',
+41
View File
@@ -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"]
+94 -16
View File
@@ -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))