mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(plugins): one switch and one action for unattended plugin updates
main already ships `hermes plugins update` (no name = update all) and the opt-in `plugins.auto_apply` cadence. This makes both reachable from every surface and closes the gaps reported on the original PR: - Catalog installs now ride `plugins.auto_apply` too (it moved only git rows, so catalog plugins stayed on their install-day pin). The update path still refuses a widened pin or a too-new requires_hermes unattended. - One updater per plugin: a cross-process lock around update_one and the dashboard/Desktop update, so the gateway's auto-apply tick and a manual update never run on the same plugin at once; the loser changes nothing. - `hermes update` names, per profile, the catalog installs its new catalog pins left behind and re-arms that profile's plugin check, so an opted-in gateway applies them on the next tick. - New CLI verb `hermes plugins auto-update [on|off]`; `/plugins update` and `/plugins auto-update [on|off]` in the classic CLI and the TUI (slash worker). - RPC `plugins.manage` actions `update_all` and `auto_update` (contract + regenerated apps/shared outputs) for Desktop. Off by default (legacy behaviour); all writes are profile-scoped through get_hermes_home()/the scoped RPC.
This commit is contained in:
1 parent
c58e6de692
commit
5819226468
15 files changed
+583
-24
No files matched your search
@@ -4466,7 +4466,7 @@ export interface PluginToolsetRow {
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
/** ``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name`` (+ ``force``, ``enable``, ``ref``, ``inspected_sha`` = the commit ``inspect`` read: a custom source with no ``ref`` whose branch moved since is refused); ``inspect``: ``identifier``/``repo`` or ``catalog_name``; ``install_desktop``: ``identifier``/``repo`` (any source ``install`` takes) or ``catalog_name``, always with ``ref`` (the commit ``inspect`` read and the dialog showed; another commit is refused) (+ ``force``) — the desktop half onto the host that serves this client; ``remove_desktop``: ``name`` (+ ``force`` for a package's own half); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin that widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose security scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only); ``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only); ``presence``: ``names`` (catalog names, answered in that order). */
|
||||
/** ``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name`` (+ ``force``, ``enable``, ``ref``, ``inspected_sha`` = the commit ``inspect`` read: a custom source with no ``ref`` whose branch moved since is refused); ``inspect``: ``identifier``/``repo`` or ``catalog_name``; ``install_desktop``: ``identifier``/``repo`` (any source ``install`` takes) or ``catalog_name``, always with ``ref`` (the commit ``inspect`` read and the dialog showed; another commit is refused) (+ ``force``) — the desktop half onto the host that serves this client; ``remove_desktop``: ``name`` (+ ``force`` for a package's own half); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin that widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose security scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only); ``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only); ``presence``: ``names`` (catalog names, answered in that order); ``update_all``: no fields; ``auto_update``: ``enable`` to set ``plugins.auto_apply`` (omit to read it). */
|
||||
export interface PluginsManageParams {
|
||||
profile?: string | null
|
||||
action?: PluginsAction
|
||||
@@ -4483,7 +4483,7 @@ export interface PluginsManageParams {
|
||||
values?: Record<string, unknown> | null
|
||||
names?: string[] | null
|
||||
}
|
||||
export type PluginsAction = 'list' | 'toggle' | 'install' | 'inspect' | 'install_desktop' | 'remove_desktop' | 'update' | 'remove' | 'settings' | 'onboarding' | 'presence'
|
||||
export type PluginsAction = 'list' | 'toggle' | 'install' | 'inspect' | 'install_desktop' | 'remove_desktop' | 'update' | 'remove' | 'settings' | 'onboarding' | 'presence' | 'update_all' | 'auto_update'
|
||||
/** ``list`` → ``plugins`` + counts; ``toggle`` → ``ok``/``unchanged``/``restart_required``/``name`` (the canonical key written)/``plugin``; ``install`` → ``hermes_cli.plugins_cmd.dashboard_install_plugin``'s ok payload; ``inspect`` → ``ok``/``agent``/``desktop``/``agent_name``/``desktop_name``/``sha`` (the commit read)/``warnings``/``insecure`` or ``ok=false`` + ``error``; ``toggle``/``install``/``update`` that loaded a plugin also carry ``gateway_reloaded`` (the running gateway picked it up and re-wired its handlers) and ``activation`` — the honest split of what is live now vs deferred, so ``restart_required`` is True only when no gateway answered or the running gateway/backend cannot import the plugin's Python dependencies (``activation.missing_dependencies``); ``update`` → ``ok``/``unchanged``/``sha``, or ``ok=false`` + ``consent_required`` with the ``delta`` (``{surface: [added...]}``) / ``delta_lines`` a widened pin adds — nothing changed until the client retries with ``accept_capabilities`` — or ``ok=false`` + ``scan_blocked`` with ``scan_verdict`` / ``scan_findings`` (retry a caution verdict with ``force``); ``remove`` → ``ok``/``name`` plus ``cleared_memory_provider`` when the removed plugin was the live ``memory.provider``. */
|
||||
export interface PluginsManageResult {
|
||||
plugins?: AgentPluginRow[] | null
|
||||
@@ -4523,6 +4523,11 @@ export interface PluginsManageResult {
|
||||
path?: string | null
|
||||
paired_with_agent?: boolean | null
|
||||
unpaired_from?: string | null
|
||||
results?: PluginUpdateAllRow[] | null
|
||||
skipped?: PluginUpdateAllSkip[] | null
|
||||
current?: number | null
|
||||
auto_apply?: boolean | null
|
||||
check_hours?: number | null
|
||||
}
|
||||
/** ``methods_tools._plugin_rows`` + ``plugins_cmd_catalog.catalog_row_fields`` provenance. */
|
||||
export interface AgentPluginRow {
|
||||
@@ -4621,6 +4626,17 @@ export interface CatalogPluginPresence {
|
||||
sentence: string
|
||||
disclosure: string
|
||||
}
|
||||
/** One plugin ``update_all`` tried. ``needs_review``: nothing changed because the update widens the plugin or its scan returned caution; the plugin's own ``update`` walks that review. */
|
||||
export interface PluginUpdateAllRow {
|
||||
name: string
|
||||
status: PluginUpdateAllStatus
|
||||
detail?: string
|
||||
}
|
||||
export type PluginUpdateAllStatus = 'updated' | 'current' | 'failed' | 'needs_review'
|
||||
export interface PluginUpdateAllSkip {
|
||||
name: string
|
||||
reason: string
|
||||
}
|
||||
export interface PluginCatalogLookupParams {
|
||||
profile?: string | null
|
||||
name: string
|
||||
|
||||
@@ -28292,6 +28292,59 @@
|
||||
"title": "PluginToolsetRow",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginUpdateAllRow": {
|
||||
"additionalProperties": false,
|
||||
"description": "One plugin ``update_all`` tried. ``needs_review``: nothing changed because the update widens the\nplugin or its scan returned caution; the plugin's own ``update`` walks that review.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/PluginUpdateAllStatus"
|
||||
},
|
||||
"detail": {
|
||||
"default": "",
|
||||
"title": "Detail",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"status"
|
||||
],
|
||||
"title": "PluginUpdateAllRow",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginUpdateAllSkip": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"title": "Reason",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"reason"
|
||||
],
|
||||
"title": "PluginUpdateAllSkip",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginUpdateAllStatus": {
|
||||
"enum": [
|
||||
"updated",
|
||||
"current",
|
||||
"failed",
|
||||
"needs_review"
|
||||
],
|
||||
"title": "PluginUpdateAllStatus",
|
||||
"type": "string"
|
||||
},
|
||||
"PluginsAction": {
|
||||
"enum": [
|
||||
"list",
|
||||
@@ -28304,7 +28357,9 @@
|
||||
"remove",
|
||||
"settings",
|
||||
"onboarding",
|
||||
"presence"
|
||||
"presence",
|
||||
"update_all",
|
||||
"auto_update"
|
||||
],
|
||||
"title": "PluginsAction",
|
||||
"type": "string"
|
||||
@@ -28364,7 +28419,7 @@
|
||||
},
|
||||
"PluginsManageParams": {
|
||||
"additionalProperties": false,
|
||||
"description": "``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name``\n(+ ``force``, ``enable``, ``ref``, ``inspected_sha`` = the commit ``inspect`` read: a custom source with no\n``ref`` whose branch moved since is refused); ``inspect``: ``identifier``/``repo`` or ``catalog_name``;\n``install_desktop``: ``identifier``/``repo`` (any source ``install`` takes) or ``catalog_name``, always\nwith ``ref`` (the commit ``inspect`` read and the dialog showed; another commit is refused) (+ ``force``) \u2014 the desktop half onto\nthe host that serves this client; ``remove_desktop``: ``name``\n(+ ``force`` for a package's own half); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin\nthat widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose\nsecurity scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only);\n``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only);\n``presence``: ``names`` (catalog names, answered in that order).",
|
||||
"description": "``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name``\n(+ ``force``, ``enable``, ``ref``, ``inspected_sha`` = the commit ``inspect`` read: a custom source with no\n``ref`` whose branch moved since is refused); ``inspect``: ``identifier``/``repo`` or ``catalog_name``;\n``install_desktop``: ``identifier``/``repo`` (any source ``install`` takes) or ``catalog_name``, always\nwith ``ref`` (the commit ``inspect`` read and the dialog showed; another commit is refused) (+ ``force``) \u2014 the desktop half onto\nthe host that serves this client; ``remove_desktop``: ``name``\n(+ ``force`` for a package's own half); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin\nthat widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose\nsecurity scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only);\n``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only);\n``presence``: ``names`` (catalog names, answered in that order); ``update_all``: no fields;\n``auto_update``: ``enable`` to set ``plugins.auto_apply`` (omit to read it).",
|
||||
"properties": {
|
||||
"profile": {
|
||||
"anyOf": [
|
||||
@@ -29015,6 +29070,72 @@
|
||||
],
|
||||
"default": null,
|
||||
"title": "Unpaired From"
|
||||
},
|
||||
"results": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginUpdateAllRow"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Results"
|
||||
},
|
||||
"skipped": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginUpdateAllSkip"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Skipped"
|
||||
},
|
||||
"current": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Current"
|
||||
},
|
||||
"auto_apply": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Auto Apply"
|
||||
},
|
||||
"check_hours": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Check Hours"
|
||||
}
|
||||
},
|
||||
"title": "PluginsManageResult",
|
||||
|
||||
@@ -280,6 +280,13 @@ class CLILoopsMixin:
|
||||
|
||||
def _cmd_plugins(self, cmd_original: str):
|
||||
from hermes_constants import display_hermes_home
|
||||
parts = cmd_original.split()[1:]
|
||||
if parts and parts[0] in ("update", "auto-update"):
|
||||
from hermes_cli.plugins_auto_update import plugins_slash_action
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
for line in plugins_slash_action(parts):
|
||||
print(line)
|
||||
return
|
||||
try:
|
||||
# Discover from disk (bundled + user) like `hermes plugins list`, so
|
||||
# installed-but-not-enabled plugins show up; the plugin manager only knows
|
||||
|
||||
@@ -306,7 +306,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
cli_only=True, args_hint="[connect|disconnect|status]",
|
||||
subcommands=("connect", "disconnect", "status")),
|
||||
CommandDef("plugins", "List installed plugins and their status",
|
||||
"Tools & Skills", cli_only=True, desktop="terminal"),
|
||||
"Tools & Skills", cli_only=True, desktop="terminal", args_hint="[update|auto-update [on|off]]",
|
||||
subcommands=("update", "auto-update")),
|
||||
|
||||
# Info
|
||||
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
|
||||
|
||||
@@ -1705,11 +1705,11 @@ DEFAULT_CONFIG = {
|
||||
# abandoned. 0 = no deadline (load inline). Max 600.
|
||||
"load_timeout_seconds": 10,
|
||||
# Read-only plugin update-check cadence, hours (gateway tick; 0 disables). Applying stays
|
||||
# explicit: `hermes plugins update <name>`, or auto_apply below (git-class plugins only,
|
||||
# scan-gated by that same pipeline).
|
||||
# explicit: `hermes plugins update <name>`, or auto_apply below (catalog and tracked git
|
||||
# plugins, scan-gated by that same pipeline).
|
||||
"auto_update_check_hours": 24,
|
||||
# Opt-in unattended apply for the cadence check. Git-row plugins ONLY; every apply runs the
|
||||
# same security scan / consent pipeline as the manual update command.
|
||||
# Opt-in unattended apply (`hermes plugins auto-update on`, Desktop's Plugins tab): catalog +
|
||||
# tracked git plugins, same scan/consent pipeline as the manual update (never a widened pin).
|
||||
"auto_apply": False,
|
||||
# Where third-party Python plugins run. in_process: imported into Hermes (default).
|
||||
# host: one plugin-host process per profile runs them and they reach Hermes only through
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Unattended plugin updates shared by every surface: the opt-in ``plugins.auto_apply`` switch, the
|
||||
one-click "update all" (Desktop, dashboard, ``/plugins update``), the one-updater-per-plugin lock,
|
||||
and the notice ``hermes update`` prints when it moved catalog pins.
|
||||
|
||||
Sibling of :mod:`hermes_cli.plugins_cmd` (the facade is imported late, never at module level).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A second updater of the same plugin is refused rather than queued: the first one's result already
|
||||
# answers the second's question, and a queued pull would run against a tree the first just replaced.
|
||||
_LOCK_WAIT_SECONDS = 1.0
|
||||
_lock_holders: dict[str, threading.local] = {}
|
||||
_lock_holders_guard = threading.Lock()
|
||||
|
||||
|
||||
def _pc():
|
||||
from hermes_cli import plugins_cmd
|
||||
return plugins_cmd
|
||||
|
||||
|
||||
def _holder(key: str) -> threading.local:
|
||||
with _lock_holders_guard:
|
||||
return _lock_holders.setdefault(key, threading.local())
|
||||
|
||||
|
||||
@contextmanager
|
||||
def plugin_update_lock(target: Path) -> Iterator[None]:
|
||||
"""Hold the one-updater lock for the plugin at *target*, across threads and processes (the
|
||||
gateway's auto-apply tick, a terminal ``hermes plugins update``, Desktop and the dashboard).
|
||||
A busy lock raises ``PluginOperationError`` after a short wait, so every caller reports it like
|
||||
any other refusal (nothing changed); reentrant per thread."""
|
||||
from hermes_cli.auth import _file_lock
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
lock = get_hermes_home() / "cache" / "plugin-update-locks" / f"{target.name}.lock"
|
||||
stack = ExitStack()
|
||||
try:
|
||||
stack.enter_context(_file_lock(lock, _holder(str(lock)), _LOCK_WAIT_SECONDS, "plugin update busy"))
|
||||
except TimeoutError as exc:
|
||||
raise _pc().PluginOperationError(
|
||||
f"Plugin '{target.name}' is already being updated by another Hermes process; "
|
||||
"nothing was changed. Try again when that update finishes.") from exc
|
||||
with stack:
|
||||
yield
|
||||
|
||||
|
||||
# ── the auto-update switch ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def auto_update_state() -> dict[str, Any]:
|
||||
"""``{"auto_apply", "check_hours"}`` for the active profile."""
|
||||
from hermes_cli.plugins_cadence import auto_apply_enabled, check_interval_hours
|
||||
return {"auto_apply": auto_apply_enabled(), "check_hours": check_interval_hours()}
|
||||
|
||||
|
||||
def set_auto_update(enabled: bool) -> dict[str, Any]:
|
||||
"""Write ``plugins.auto_apply`` for the active profile and return the new state. Turning it on also
|
||||
clears the check marker, so the gateway's next housekeeping tick checks and applies instead of
|
||||
waiting out the rest of the interval."""
|
||||
from hermes_cli.config import get_config_path, is_managed
|
||||
from hermes_cli.config_backend import write_config_key
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
if is_managed():
|
||||
raise _pc().PluginOperationError(
|
||||
"This Hermes install's configuration is managed by a package manager; set plugins.auto_apply there.")
|
||||
write_config_key(get_config_path(), "plugins.auto_apply", bool(enabled))
|
||||
if enabled:
|
||||
_clear_check_marker(get_hermes_home())
|
||||
return auto_update_state()
|
||||
|
||||
|
||||
def _clear_check_marker(home: Path) -> None:
|
||||
from hermes_cli.plugins_cadence import _MARKERS_DIR
|
||||
(home / _MARKERS_DIR / "last-run").unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ── update all, unattended ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _apply_one(name: str) -> dict[str, str]:
|
||||
"""One plan row through the dashboard's non-interactive update. A widened catalog pin or a
|
||||
caution scan is NOT accepted on the user's behalf: the row comes back ``needs_review``."""
|
||||
from hermes_cli.plugins_cmd_update import dashboard_update_user_plugin
|
||||
try:
|
||||
res = dashboard_update_user_plugin(name)
|
||||
except Exception as exc: # per-plugin batch boundary: one crash must not stop the rest
|
||||
logger.warning("update of plugin %s failed", name, exc_info=True)
|
||||
return {"name": name, "status": "failed", "detail": f"{type(exc).__name__}: {exc}"}
|
||||
if res.get("consent_required") or res.get("scan_blocked"):
|
||||
return {"name": name, "status": "needs_review", "detail": str(res.get("error") or "")}
|
||||
if not res.get("ok"):
|
||||
return {"name": name, "status": "failed", "detail": str(res.get("error") or "")}
|
||||
return {"name": str(res.get("name") or name), "status": "current" if res.get("unchanged") else "updated",
|
||||
"detail": ""}
|
||||
|
||||
|
||||
def update_all_unattended() -> dict[str, Any]:
|
||||
"""Check every installed plugin, then update each one ``hermes plugins update`` would move (same
|
||||
plan: catalog re-pins and tracked Git pulls; pinned, held and pip rows are skipped with why).
|
||||
|
||||
Returns ``{"results": [{name, status, detail}], "skipped": [{name, reason}], "current": n}``;
|
||||
status is ``updated`` / ``current`` / ``failed`` / ``needs_review`` (nothing changed; the
|
||||
plugin's own Update action walks the consent or scan review)."""
|
||||
from hermes_cli.plugins_cadence import record_check_results, snapshot_installs
|
||||
from hermes_cli.plugins_cmd_update_all import plan_update_all
|
||||
from hermes_cli.plugins_updates import run_checks
|
||||
|
||||
plugins_dir = _pc()._plugins_dir()
|
||||
checked = snapshot_installs(plugins_dir)
|
||||
results = run_checks(plugins_dir)
|
||||
record_check_results(results, checked)
|
||||
plan = plan_update_all(results)
|
||||
return {"results": [_apply_one(row.name) for row in plan.movable],
|
||||
"skipped": [{"name": row.name, "reason": reason} for row, reason in plan.skipped],
|
||||
"current": plan.current}
|
||||
|
||||
|
||||
_STATUS_MARKS = {"updated": "✓ updated", "current": "= already current", "failed": "✗ failed",
|
||||
"needs_review": "! needs review"}
|
||||
|
||||
|
||||
def summary_lines(outcome: dict[str, Any]) -> list[str]:
|
||||
"""Plain-text lines for an :func:`update_all_unattended` result (``/plugins update``)."""
|
||||
lines = [f" {_STATUS_MARKS[r['status']]} {r['name']}" + (f" ({r['detail']})" if r["detail"] else "")
|
||||
for r in outcome["results"]]
|
||||
lines += [f" - skipped {s['name']}: {s['reason']}" for s in outcome["skipped"]]
|
||||
if not outcome["results"]:
|
||||
lines.append(" No plugin updates to apply.")
|
||||
if any(r["status"] == "needs_review" for r in outcome["results"]):
|
||||
lines.append(" Review those with `hermes plugins update <name>` in a terminal.")
|
||||
if any(r["status"] == "updated" for r in outcome["results"]):
|
||||
lines.append(" Updated plugin code loads in new sessions.")
|
||||
return lines
|
||||
|
||||
|
||||
def plugins_slash_action(parts: list[str]) -> list[str]:
|
||||
"""``/plugins update`` (update every plugin with an update, unattended) and ``/plugins auto-update
|
||||
[on|off]`` for the classic CLI and the TUI (which runs the same handler in its slash worker)."""
|
||||
if parts[0] == "update":
|
||||
return ["Plugin updates:", *summary_lines(update_all_unattended())]
|
||||
arg = parts[1].lower() if len(parts) > 1 else ""
|
||||
if arg not in ("", "on", "off"):
|
||||
return ["Usage: /plugins auto-update [on|off]"]
|
||||
try:
|
||||
state = auto_update_state() if not arg else set_auto_update(arg == "on")
|
||||
except _pc().PluginOperationError as exc:
|
||||
return [f" {exc}"]
|
||||
word = "on" if state["auto_apply"] else "off"
|
||||
return [f"Plugin auto-update: {word} (gateway check every {state['check_hours']:g}h; "
|
||||
"updates load in new sessions)"]
|
||||
|
||||
|
||||
# ── after `hermes update` ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def catalog_pins_behind(home: Path) -> list[str]:
|
||||
"""Catalog installs under *home* whose installed pin is not the in-tree catalog's pin. Offline:
|
||||
reads the install records and the catalog shipped with this checkout."""
|
||||
from hermes_cli.plugin_catalog import get_catalog_entry
|
||||
from hermes_cli.plugins_cmd_catalog import at_catalog_pin
|
||||
from hermes_cli.plugins_provenance import read_sidecar_rows
|
||||
|
||||
behind = []
|
||||
for name, row in read_sidecar_rows(home / "plugins").items():
|
||||
block = row.get("catalog") if isinstance(row, dict) else None
|
||||
if not isinstance(block, dict) or not block.get("name"):
|
||||
continue
|
||||
entry = get_catalog_entry(str(block["name"]))
|
||||
if entry is not None and not at_catalog_pin(block, entry.sha):
|
||||
behind.append(str(name))
|
||||
return sorted(behind)
|
||||
|
||||
|
||||
def report_catalog_updates_after_hermes_update(home: Path, sibling_homes: list[tuple[str, Path]]) -> None:
|
||||
"""An update can move catalog pins; installed plugins stay on their old pin until updated. Name
|
||||
them per profile, and clear that profile's check marker so its gateway re-checks on the next tick
|
||||
(which applies them when that profile opted into ``plugins.auto_apply``)."""
|
||||
for label, profile_home in [("this profile", home), *sibling_homes]:
|
||||
behind = catalog_pins_behind(profile_home)
|
||||
if not behind:
|
||||
continue
|
||||
_clear_check_marker(profile_home)
|
||||
print(f" → {len(behind)} plugin update(s) from the new catalog in {label}: {', '.join(behind)}")
|
||||
print(" apply: hermes plugins update (or turn on: hermes plugins auto-update on)")
|
||||
@@ -5,7 +5,7 @@ Runs at gateway start + the periodic tick when
|
||||
due, writes a plugin-check receipt (pm.receipt, kind 'plugin-check'),
|
||||
and logs ONE actionable line when updates exist. Applying updates
|
||||
stays explicit — ``plugins.auto_apply: true`` (default false) opts into
|
||||
unattended apply for git-row plugins ONLY, scan-gated like cmd_update.
|
||||
unattended apply for catalog and tracked git plugins, scan-gated like cmd_update.
|
||||
|
||||
Pure, injectable clock/check/update seams for hermetic tests.
|
||||
"""
|
||||
@@ -243,16 +243,18 @@ def _run_check_locked(
|
||||
names,
|
||||
)
|
||||
|
||||
# Opt-in unattended apply: git rows ONLY, scan-gated by the update
|
||||
# path itself. Pinned/manual/drift/pip are never auto-applied
|
||||
# (their update_available is False/None or their class excludes it).
|
||||
# Opt-in unattended apply: the classes `hermes plugins update` can move (catalog re-pins to the
|
||||
# reviewed sha, tracked git pulls), scan-gated by the update path itself, which also refuses a
|
||||
# widened catalog pin or a too-new requires_hermes without a terminal. Pinned/manual/drift/pip
|
||||
# are never auto-applied (their update_available is False/None or their class excludes it).
|
||||
if apply_updates_fn and updates and auto_apply_enabled(config_get):
|
||||
for r in updates:
|
||||
if getattr(r, "klass", "") == "git":
|
||||
try:
|
||||
apply_updates_fn(r.name)
|
||||
except (Exception, SystemExit):
|
||||
log.warning("auto-apply of %s failed", r.name, exc_info=True)
|
||||
if getattr(r, "klass", "") not in ("git", "catalog"):
|
||||
continue
|
||||
try:
|
||||
apply_updates_fn(r.name)
|
||||
except (Exception, SystemExit):
|
||||
log.warning("auto-apply of %s failed", r.name, exc_info=True)
|
||||
|
||||
# stamp the marker AFTER a completed run (even a failed one — a
|
||||
# failing check retrying every tick would hammer the network)
|
||||
|
||||
@@ -1093,6 +1093,20 @@ def _action_update(args):
|
||||
cmd_update_all(yes=getattr(args, "yes", False))
|
||||
|
||||
|
||||
def _action_auto_update(args):
|
||||
from hermes_cli.plugins_auto_update import auto_update_state, set_auto_update
|
||||
console = _console()
|
||||
state = getattr(args, "state", None)
|
||||
try:
|
||||
current = auto_update_state() if state is None else set_auto_update(state == "on")
|
||||
except PluginOperationError as exc:
|
||||
_fail(console, f"[red]Error:[/red] {exc}")
|
||||
word = "[green]on[/green]" if current["auto_apply"] else "[dim]off[/dim]"
|
||||
cadence = (f"checked every {current['check_hours']:g}h by the gateway" if current["check_hours"] > 0
|
||||
else "the gateway's check is off: plugins.auto_update_check_hours is 0")
|
||||
console.print(f"Plugin auto-update: {word} ({cadence}; updates load in new sessions)")
|
||||
|
||||
|
||||
def _action_pack(args):
|
||||
from hermes_cli.plugin_packs import pack_command
|
||||
pack_command(args)
|
||||
@@ -1114,6 +1128,8 @@ _PLUGIN_ACTIONS = {
|
||||
"validate": lambda args: _catalog().cmd_validate(
|
||||
args.path, as_json=getattr(args, "json", False), install_deps=getattr(args, "install_deps", False)),
|
||||
"update": _action_update,
|
||||
"auto-update": _action_auto_update,
|
||||
"autoupdate": _action_auto_update,
|
||||
"adopt": lambda args: cmd_adopt(args.name),
|
||||
"trust-update-url": lambda args: cmd_trust_update_url(args.name),
|
||||
"check-updates": lambda args: cmd_check_updates(args),
|
||||
|
||||
@@ -77,6 +77,14 @@ def _reclone_plugin_update(target: Path, source: str, previous_revision: object,
|
||||
|
||||
|
||||
def update_one(name: str, target: Path, console, *, interactive: bool = True, force: bool = False) -> bool:
|
||||
""":func:`_update_one_locked` under the plugin's one-updater lock (a terminal update, the gateway's
|
||||
auto-apply and a Desktop click never pull the same plugin at once; the loser changes nothing)."""
|
||||
from hermes_cli.plugins_auto_update import plugin_update_lock
|
||||
with plugin_update_lock(target):
|
||||
return _update_one_locked(name, target, console, interactive=interactive, force=force)
|
||||
|
||||
|
||||
def _update_one_locked(name: str, target: Path, console, *, interactive: bool = True, force: bool = False) -> bool:
|
||||
"""Update one installed plugin, printing progress and consent prompts; True when it moved.
|
||||
|
||||
Every update is scanned. A caution verdict on a git update asks ``Update anyway? [y/N]`` when
|
||||
@@ -315,12 +323,21 @@ def dashboard_update_user_plugin(name: str, *, accept_capabilities: bool = False
|
||||
(retry with *accept_capabilities*), and a git update the security scan blocks returns
|
||||
``{"ok": False, "scan_blocked": True, "scan_verdict", "scan_findings"}`` (retry a caution verdict
|
||||
with *force* after the user saw the findings; a dangerous verdict never updates)."""
|
||||
from hermes_cli import plugins_cmd_catalog as catalog
|
||||
from hermes_cli.plugins_activation import activate_plugin_now
|
||||
from hermes_cli.plugins_cmd_install import scan_blocked_result
|
||||
from hermes_cli.plugins_auto_update import plugin_update_lock
|
||||
target = _pc()._user_installed_plugin_dir(name)
|
||||
if target is None:
|
||||
return {"ok": False, "error": f"Plugin '{name}' was not found under {_pc()._plugins_dir()}."}
|
||||
try:
|
||||
with plugin_update_lock(target):
|
||||
return _dashboard_update_locked(name, target, accept_capabilities=accept_capabilities, force=force)
|
||||
except _pc().PluginOperationError as exc: # the lock is busy: another updater owns this plugin
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def _dashboard_update_locked(name: str, target: Path, *, accept_capabilities: bool, force: bool) -> dict[str, Any]:
|
||||
from hermes_cli import plugins_cmd_catalog as catalog
|
||||
from hermes_cli.plugins_activation import activate_plugin_now
|
||||
from hermes_cli.plugins_cmd_install import scan_blocked_result
|
||||
sidecar = catalog.catalog_install_record(target)
|
||||
try:
|
||||
if sidecar:
|
||||
|
||||
@@ -77,6 +77,16 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
|
||||
help="Apply the update even when the security scan returns a caution verdict (a dangerous verdict "
|
||||
"still blocks). Without it, a terminal asks first")
|
||||
|
||||
plugins_auto = plugins_subparsers.add_parser(
|
||||
"auto-update", aliases=["autoupdate"],
|
||||
help="Show or set unattended plugin updates (plugins.auto_apply)",
|
||||
description=(
|
||||
"With auto-update on, the gateway's update check (every plugins.auto_update_check_hours) "
|
||||
"applies the catalog and tracked Git updates `hermes plugins update` would apply, through the "
|
||||
"same security scan. Updated code loads in new sessions; a widened catalog pin or new "
|
||||
"capabilities are never accepted unattended. Off by default."))
|
||||
plugins_auto.add_argument("state", nargs="?", choices=["on", "off"], help="Omit to show the current setting")
|
||||
|
||||
plugins_adopt = plugins_subparsers.add_parser(
|
||||
"adopt",
|
||||
help="Adopt a self-cloned plugin dir into provenance tracking",
|
||||
|
||||
@@ -507,6 +507,14 @@ def _invalidate_live_plugin_catalog_caches() -> None:
|
||||
invalidate_live_cache_for_home(profile_home)
|
||||
|
||||
|
||||
def _report_catalog_plugin_updates() -> None:
|
||||
from hermes_cli.backup import _sibling_profile_homes
|
||||
from hermes_cli.plugins_auto_update import report_catalog_updates_after_hermes_update
|
||||
from hermes_cli.update_cmd import get_hermes_home
|
||||
home = get_hermes_home()
|
||||
report_catalog_updates_after_hermes_update(home, _sibling_profile_homes(home))
|
||||
|
||||
|
||||
def _print_bundled_skills_sync_report() -> None:
|
||||
"""Run ``sync_skills`` (copies new, updates changed, respects user deletions) and print its summary."""
|
||||
from tools.skills_sync import sync_skills
|
||||
@@ -1043,6 +1051,10 @@ def _run_post_update_maintenance(
|
||||
# rest of the cache TTL (#119340).
|
||||
with _best_effort('Live plugin catalog cache invalidation failed: %s'):
|
||||
_invalidate_live_plugin_catalog_caches()
|
||||
# New catalog pins are not applied by this update: name them per profile and re-arm each
|
||||
# profile's plugin check so an opted-in (plugins.auto_apply) gateway applies them next tick.
|
||||
with _best_effort('Plugin catalog update notice failed: %s'):
|
||||
_report_catalog_plugin_updates()
|
||||
|
||||
with _best_effort('Skills sync during update failed: %s'):
|
||||
print()
|
||||
|
||||
@@ -375,6 +375,7 @@ _UPDATE_DEPENDENCIES = (
|
||||
"hermes_cli/_parser.py",
|
||||
"hermes_cli/gateway_multiplex_mode.py",
|
||||
"hermes_cli/plugin_catalog.py",
|
||||
"hermes_cli/plugins_auto_update.py", # update_cmd_maint's catalog-pin notice after the code swap
|
||||
"hermes_cli/steward.py",
|
||||
"hermes_cli/observability/shared_metrics_update.py",
|
||||
"hermes_cli/main_install_repair.py",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Unattended plugin updates: catalog installs ride ``plugins.auto_apply``, one updater per plugin, the
|
||||
Desktop/TUI RPC switch + update-all, and the catalog notice after ``hermes update``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes-home"
|
||||
(home / "plugins").mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home
|
||||
|
||||
|
||||
class _Row:
|
||||
def __init__(self, name, klass, update_available=True):
|
||||
self.name, self.klass, self.update_available = name, klass, update_available
|
||||
self.needs_fixing = self.reason = self.min_hermes = None
|
||||
self.current, self.latest = "a" * 40, "b" * 40
|
||||
|
||||
def to_json(self):
|
||||
return {"name": self.name, "class": self.klass, "update_available": self.update_available}
|
||||
|
||||
|
||||
def test_auto_apply_moves_catalog_installs_too(home):
|
||||
"""A catalog install is re-pinned by auto-apply like a git install; pip rows still never move."""
|
||||
from hermes_cli import plugins_cadence as cad
|
||||
|
||||
applied = []
|
||||
rows = [_Row("git-plug", "git"), _Row("catalog-plug", "catalog"), _Row("pip-plug", "pip")]
|
||||
cad.run_scheduled_check(run_checks_fn=lambda _dir: rows, plugins_dir=home / "plugins",
|
||||
apply_updates_fn=applied.append,
|
||||
config_get=lambda _s, key: True if key == "auto_apply" else 24)
|
||||
assert applied == ["git-plug", "catalog-plug"]
|
||||
|
||||
|
||||
def test_second_updater_of_the_same_plugin_is_refused_without_touching_it(home):
|
||||
"""The gateway's auto-apply tick holds the plugin; a Desktop click on Update changes nothing."""
|
||||
from hermes_cli.plugins_auto_update import plugin_update_lock
|
||||
from hermes_cli.plugins_cmd_update import dashboard_update_user_plugin
|
||||
|
||||
target = home / "plugins" / "busy-plug"
|
||||
target.mkdir()
|
||||
(target / "plugin.yaml").write_text("name: busy-plug\nversion: 1.0.0\n", encoding="utf-8")
|
||||
held, release = threading.Event(), threading.Event()
|
||||
|
||||
def holder():
|
||||
with plugin_update_lock(target):
|
||||
held.set()
|
||||
release.wait(10)
|
||||
|
||||
thread = threading.Thread(target=holder)
|
||||
thread.start()
|
||||
try:
|
||||
assert held.wait(5)
|
||||
result = dashboard_update_user_plugin("busy-plug")
|
||||
finally:
|
||||
release.set()
|
||||
thread.join(5)
|
||||
assert result["ok"] is False and "already being updated" in result["error"]
|
||||
assert (target / "plugin.yaml").read_text(encoding="utf-8").startswith("name: busy-plug")
|
||||
|
||||
|
||||
def test_rpc_switch_and_update_all_round_trip(home, monkeypatch):
|
||||
"""Desktop's switch writes the profile's plugins.auto_apply; Update all reports each plugin and
|
||||
never accepts a widened pin on the user's behalf."""
|
||||
from hermes_cli import plugins_cmd_update, plugins_updates
|
||||
from tui_gateway import server
|
||||
|
||||
def rpc(**params):
|
||||
return server.handle_request({"id": "1", "method": "plugins.manage", "params": params})["result"]
|
||||
|
||||
assert rpc(action="auto_update")["auto_apply"] is False
|
||||
assert rpc(action="auto_update", enable=True)["auto_apply"] is True
|
||||
assert "auto_apply: true" in (home / "config.yaml").read_text(encoding="utf-8")
|
||||
|
||||
rows = [_Row("fresh", "git"), _Row("widened", "catalog"), _Row("pinned", "git", update_available=False)]
|
||||
rows[2].reason = "pinned to an exact revision"
|
||||
monkeypatch.setattr(plugins_updates, "run_checks", lambda _dir: rows)
|
||||
replies = {"fresh": {"ok": True, "name": "fresh", "unchanged": False},
|
||||
"widened": {"ok": False, "consent_required": True, "error": "adds tools"}}
|
||||
calls = []
|
||||
monkeypatch.setattr(plugins_cmd_update, "dashboard_update_user_plugin",
|
||||
lambda name, **kw: calls.append((name, kw)) or replies[name])
|
||||
out = rpc(action="update_all")
|
||||
assert [(r["name"], r["status"]) for r in out["results"]] == [("fresh", "updated"), ("widened", "needs_review")]
|
||||
assert out["skipped"] == [{"name": "pinned", "reason": "pinned to an exact revision"}]
|
||||
assert calls == [("fresh", {}), ("widened", {})] # no accept_capabilities / force
|
||||
|
||||
|
||||
def test_hermes_update_names_catalog_pins_it_moved_and_rearms_the_check(home, monkeypatch, capsys):
|
||||
from hermes_cli import plugin_catalog
|
||||
from hermes_cli.plugins_auto_update import report_catalog_updates_after_hermes_update
|
||||
|
||||
new_pin = "c" * 40
|
||||
entry = plugin_catalog.PluginCatalogEntry(name="cat-plug", repo="https://example.invalid/r", sha=new_pin,
|
||||
description="d", maintainer="m")
|
||||
monkeypatch.setattr(plugin_catalog, "load_catalog", lambda catalog_dir=None: [entry])
|
||||
(home / "plugins" / ".install-metadata.json").write_text(json.dumps({
|
||||
"cat-plug": {"catalog": {"name": "cat-plug", "sha": "a" * 40, "pin": "a" * 40}},
|
||||
"current": {"catalog": {"name": "cat-plug", "sha": new_pin, "pin": new_pin}}}), encoding="utf-8")
|
||||
marker = home / "plugin-update-checks" / "last-run"
|
||||
marker.parent.mkdir()
|
||||
marker.write_text("1", encoding="utf-8")
|
||||
|
||||
report_catalog_updates_after_hermes_update(home, [])
|
||||
assert "1 plugin update(s) from the new catalog in this profile: cat-plug" in capsys.readouterr().out
|
||||
assert not marker.exists()
|
||||
@@ -626,6 +626,8 @@ class PluginsAction(WireEnum):
|
||||
settings = "settings"
|
||||
onboarding = "onboarding"
|
||||
presence = "presence"
|
||||
update_all = "update_all"
|
||||
auto_update = "auto_update"
|
||||
|
||||
|
||||
class PluginsManageParams(ProfileParams):
|
||||
@@ -639,7 +641,8 @@ class PluginsManageParams(ProfileParams):
|
||||
that widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose
|
||||
security scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only);
|
||||
``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only);
|
||||
``presence``: ``names`` (catalog names, answered in that order)."""
|
||||
``presence``: ``names`` (catalog names, answered in that order); ``update_all``: no fields;
|
||||
``auto_update``: ``enable`` to set ``plugins.auto_apply`` (omit to read it)."""
|
||||
|
||||
action: PluginsAction = PluginsAction.list
|
||||
key: str | None = None
|
||||
@@ -813,6 +816,27 @@ class PluginScanFinding(Result):
|
||||
description: str
|
||||
|
||||
|
||||
class PluginUpdateAllStatus(WireEnum):
|
||||
updated = "updated"
|
||||
current = "current"
|
||||
failed = "failed"
|
||||
needs_review = "needs_review"
|
||||
|
||||
|
||||
class PluginUpdateAllRow(Result):
|
||||
"""One plugin ``update_all`` tried. ``needs_review``: nothing changed because the update widens the
|
||||
plugin or its scan returned caution; the plugin's own ``update`` walks that review."""
|
||||
|
||||
name: str
|
||||
status: PluginUpdateAllStatus
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class PluginUpdateAllSkip(Result):
|
||||
name: str
|
||||
reason: str
|
||||
|
||||
|
||||
class PluginsManageResult(Result):
|
||||
"""``list`` → ``plugins`` + counts; ``toggle`` → ``ok``/``unchanged``/``restart_required``/``name``
|
||||
(the canonical key written)/``plugin``; ``install`` → ``hermes_cli.plugins_cmd.dashboard_install_plugin``'s
|
||||
@@ -876,6 +900,14 @@ class PluginsManageResult(Result):
|
||||
path: str | None = None
|
||||
paired_with_agent: bool | None = None
|
||||
unpaired_from: str | None = None
|
||||
# ``update_all`` → one row per plugin it tried, the ones it could not move (with why) and how many
|
||||
# were already current.
|
||||
results: list[PluginUpdateAllRow] | None = None
|
||||
skipped: list[PluginUpdateAllSkip] | None = None
|
||||
current: int | None = None
|
||||
# ``auto_update`` → the profile's ``plugins.auto_apply`` and ``plugins.auto_update_check_hours``.
|
||||
auto_apply: bool | None = None
|
||||
check_hours: float | None = None
|
||||
|
||||
|
||||
method("plugins.manage", params=PluginsManageParams, result=PluginsManageResult,
|
||||
|
||||
@@ -1891,6 +1891,24 @@ def _portable_server_names(plugin_dir: Path, key: str) -> set[str]:
|
||||
return {name_for(key, server) for server in package.mcp_servers}
|
||||
|
||||
|
||||
def _plugins_update_all(rid, params):
|
||||
"""Check every installed plugin and update each one ``hermes plugins update`` would move. Nothing is
|
||||
accepted on the user's behalf: a widened catalog pin or a caution scan comes back ``needs_review``."""
|
||||
_ensure_plugin_activation_listener()
|
||||
return _ok(rid, _tools_mod("hermes_cli.plugins_auto_update").update_all_unattended())
|
||||
|
||||
|
||||
def _plugins_auto_update(rid, params):
|
||||
"""Read ``plugins.auto_apply`` for the scoped profile, or set it when ``enable`` is given."""
|
||||
auto = _tools_mod("hermes_cli.plugins_auto_update")
|
||||
if params.get("enable") is None:
|
||||
return _ok(rid, auto.auto_update_state())
|
||||
try:
|
||||
return _ok(rid, auto.set_auto_update(bool(params.get("enable"))))
|
||||
except _tools_mod("hermes_cli.plugins_cmd").PluginOperationError as e:
|
||||
return _err(rid, 4021, str(e))
|
||||
|
||||
|
||||
def _plugins_onboarding(rid, params):
|
||||
"""Catalog plugins curated for the onboarding card that this OS runs, each with its app state."""
|
||||
return _ok(rid, {"onboarding": _tools_mod("hermes_cli.plugin_catalog_presence").onboarding_entries()})
|
||||
@@ -1904,7 +1922,8 @@ def _plugins_presence(rid, params):
|
||||
_PLUGINS_ACTIONS = {"list": _plugins_list, "onboarding": _plugins_onboarding, "presence": _plugins_presence,
|
||||
"toggle": _plugins_toggle, "install": _plugins_install, "inspect": _plugins_inspect,
|
||||
"install_desktop": _plugins_install_desktop, "remove_desktop": _plugins_remove_desktop,
|
||||
"update": _plugins_update, "remove": _plugins_remove, "settings": _plugins_settings}
|
||||
"update": _plugins_update, "remove": _plugins_remove, "settings": _plugins_settings,
|
||||
"update_all": _plugins_update_all, "auto_update": _plugins_auto_update}
|
||||
|
||||
|
||||
@_scoped_rpc("plugins.manage", 5026, catch_resolve=False)
|
||||
@@ -1913,7 +1932,8 @@ def _(rid, params: dict) -> dict:
|
||||
``list`` → {plugins, user_count, bundled_count}; ``toggle`` flips ``key``/``name`` per ``enable``;
|
||||
``install`` git-clones ``identifier``/``repo`` or a curated ``catalog_name`` (``force``, ``enable``
|
||||
default True); ``inspect`` reports which halves that source ships, installing nothing; ``update`` re-pins a catalog install or pulls a git install (``force`` accepts a caution scan); ``remove`` deletes
|
||||
a user install by ``name``; ``settings`` writes manifest-declared ``values`` for ``key``."""
|
||||
a user install by ``name``; ``settings`` writes manifest-declared ``values`` for ``key``; ``update_all``
|
||||
updates every plugin with an update; ``auto_update`` reads or (with ``enable``) sets ``plugins.auto_apply``."""
|
||||
return _run_action(rid, params, _PLUGINS_ACTIONS, "plugins")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user