diff --git a/apps/desktop/src/app/settings/plugin-install-modal.tsx b/apps/desktop/src/app/settings/plugin-install-modal.tsx index d88734a0f2..9aefbbb334 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.tsx @@ -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['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() {
-
{m.securityHeading}
-

{m.securityIntro}

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

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

{sourceLinks && ( @@ -389,12 +417,14 @@ export function PluginInstallModal() { )} - + {!request.catalogName && ( + + )}
)} diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx index 465242368e..870601bbd4 100644 --- a/apps/desktop/src/app/settings/plugins-settings.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -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' && {p.failed}} {agentHalfMissing && ( - - {p.agentHalfMissing} - + )} diff --git a/apps/desktop/src/app/skills/plugins-tab.test.tsx b/apps/desktop/src/app/skills/plugins-tab.test.tsx index 81e263b625..183987c7d8 100644 --- a/apps/desktop/src/app/skills/plugins-tab.test.tsx +++ b/apps/desktop/src/app/skills/plugins-tab.test.tsx @@ -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() + + expect(screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` })).toBeTruthy() + }) + + it('re-pins through plugins.manage update when the chip is clicked', async () => { + $agentPlugins.set([ + { + catalog_name: 'demo-weather', + catalog_sha: 'b'.repeat(40), + catalog_tier: 'community', + description: '', + installed_sha: 'a'.repeat(40), + key: 'demo-weather', + name: 'demo-weather', + source: 'git', + status: 'enabled', + update_available: true, + version: '1.0.0' + } + ]) + requestGateway.mockResolvedValue({ ok: true, unchanged: false, plugins: [] } as never) + + render() + + screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` }).click() + + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith( + 'plugins.manage', + expect.objectContaining({ action: 'update', name: 'demo-weather', profile: 'workbot' }) + ) + ) + }) + + it('refuses a catalog pick that is already installed and current', async () => { + $agentPlugins.set([ + { + catalog_name: 'demo-weather', + description: '', + installed_sha: 'a'.repeat(40), + key: 'demo-weather', + name: 'demo-weather', + source: 'git', + status: 'enabled', + update_available: false, + version: '1.0.0' + } + ]) + + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'demo-weather', + repo: 'https://github.com/example/demo-weather', + type: 'hermes-plugin-pick' + }, + origin: 'https://hermes-agent.nousresearch.com' + }) + ) + + // The modal must NOT open — the pick is refused with a toast. + await new Promise(resolve => setTimeout(resolve, 20)) + expect($pluginInstallRequest.get()).toBeNull() + }) + + it('still opens the modal for an installed pick when an update is available', async () => { + $agentPlugins.set([ + { + catalog_name: 'demo-weather', + description: '', + installed_sha: 'a'.repeat(40), + key: 'demo-weather', + name: 'demo-weather', + source: 'git', + status: 'enabled', + update_available: true, + version: '1.0.0' + } + ]) + + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'demo-weather', + repo: 'https://github.com/example/demo-weather', + type: 'hermes-plugin-pick' + }, + origin: 'https://hermes-agent.nousresearch.com' + }) + ) + + await waitFor(() => expect($pluginInstallRequest.get()).not.toBeNull()) + }) +}) diff --git a/apps/desktop/src/app/skills/plugins-tab.tsx b/apps/desktop/src/app/skills/plugins-tab.tsx index 94da93f140..600b73aeac 100644 --- a/apps/desktop/src/app/skills/plugins-tab.tsx +++ b/apps/desktop/src/app/skills/plugins-tab.tsx @@ -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} )} + {row.catalog_name && ( + + + {row.catalog_tier === 'official' + ? t.skills.plugins.tierOfficial + : t.skills.plugins.tierCommunity} + + + )} + {row.update_available && onUpdate && ( + + )} {row.description && (
@@ -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 (
@@ -191,7 +222,7 @@ export const PluginsTab = memo(function PluginsTab({ profile }: { profile: Profi
{visible.map(row => ( { 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} /> ))} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 037a5f55f4..14eff19595 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index ceb151735d..5ea6edb737 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 5924855f45..6b9f923131 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '搜索技能中心', diff --git a/apps/desktop/src/store/agent-plugins.ts b/apps/desktop/src/store/agent-plugins.ts index 9f38384aeb..cf47fad8b6 100644 --- a/apps/desktop/src/store/agent-plugins.ts +++ b/apps/desktop/src/store/agent-plugins.ts @@ -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 { + $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) + } +} diff --git a/tests/tui_gateway/test_plugins_manage_install.py b/tests/tui_gateway/test_plugins_manage_install.py index e12bfde9c0..b878986d43 100644 --- a/tests/tui_gateway/test_plugins_manage_install.py +++ b/tests/tui_gateway/test_plugins_manage_install.py @@ -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"] diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 96a4289fe1..a1a0c20444 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -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 " 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 ` 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))