From ff190a646203c43aa378920f1a5a182c5ad19619 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:12:39 -0700 Subject: [PATCH] Inspired by Copilot CLI: plugin auto-update at session start + update --all Copilot CLI v1.0.79 added an autoUpdate marketplace setting that refreshes plugins at session start. Hermes adaptation: - hermes plugins update --all: sweep every git-installed plugin; pinned plugins and non-git dirs are skipped with a note instead of aborting. - hermes plugins autoupdate [on|off]: per-plugin opt-in flag stored in the install metadata sidecar (pinned/non-git plugins are rejected). - Startup sweep: opted-in plugins are git-pulled on the background plugin-discovery thread AFTER discovery completes, throttled to once per 24h via a stamp file. The running session keeps the code it already imported; updates take effect next session (stale bytecode cleared), so the live registry and prompt cache are never touched. - Non-interactive updates leave newly declared capabilities ungranted (fail closed), same as the existing update path. - Docs + 20 new tests (real-git E2E for pull/revision/bytecode/throttle). --- hermes_cli/plugins.py | 11 + hermes_cli/plugins_cmd.py | 260 +++++++++++++++++++- hermes_cli/subcommands/plugins.py | 30 ++- tests/hermes_cli/test_plugins_autoupdate.py | 247 +++++++++++++++++++ website/docs/user-guide/features/plugins.md | 11 + 5 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 tests/hermes_cli/test_plugins_autoupdate.py diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 5124516ca034f..1f13127a3cfdb 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -5545,6 +5545,17 @@ def start_background_plugin_discovery() -> None: _persist_plugin_toolset_keys() except Exception: logger.warning("background plugin discovery failed", exc_info=True) + # Auto-update sweep for opted-in plugins (hermes plugins + # autoupdate on). Runs strictly AFTER discovery so the + # pull never races this session's plugin imports — the fresh + # checkout takes effect next session. Throttled to once/24h + # inside the sweep; never raises. + try: + from hermes_cli.plugins_cmd import run_startup_auto_update_sweep + + run_startup_auto_update_sweep() + except Exception: + logger.warning("plugin auto-update sweep failed", exc_info=True) _background_discovery_thread = threading.Thread( target=_run, name="plugin-discovery", daemon=True diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index ecf14b2f78bb7..e8923b95f34d8 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -19,6 +19,7 @@ import shutil import subprocess import sys import tempfile +import time import urllib.parse from pathlib import Path from typing import Any, Optional @@ -976,14 +977,35 @@ def cmd_install( console.print() -def cmd_update(name: str) -> None: - """Update an installed plugin by pulling latest from its git remote.""" +def cmd_update(name: Optional[str] = None, all_plugins: bool = False) -> None: + """Update installed plugin(s) by pulling latest from their git remotes. + + With ``all_plugins`` (``hermes plugins update --all``), every + git-installed plugin is pulled in turn; pinned plugins and non-git + directories are skipped with a note instead of aborting the sweep. + Inspired by Copilot CLI's marketplace plugin auto-update (v1.0.79). + """ from rich.console import Console from rich.markup import escape console = Console() plugins_dir = _plugins_dir() + if all_plugins: + if name: + console.print("[red]Error:[/red] Pass a plugin name OR --all, not both.") + sys.exit(1) + results = _update_all_plugins(console) + if not results: + console.print("[dim]No git-installed plugins found to update.[/dim]") + return + if not name: + console.print( + "[red]Error:[/red] Missing plugin name. " + "Run `hermes plugins update ` or `hermes plugins update --all`." + ) + sys.exit(1) + try: target = _require_installed_plugin(name, plugins_dir, console) except ValueError as e: @@ -1067,6 +1089,233 @@ def cmd_update(name: str) -> None: console.print(f"[dim]{out}[/dim]") +def _iter_updatable_plugins() -> list[tuple[Path, dict]]: + """Git-installed plugin dirs under ``~/.hermes/plugins`` + their metadata. + + Returns ``(path, install_record)`` pairs for every direct child directory + with a ``.git`` inside. Pinned plugins ARE included — callers decide how + to surface the skip. + """ + plugins_dir = _plugins_dir() + if not plugins_dir.is_dir(): + return [] + try: + metadata = _read_install_metadata() + except PluginOperationError: + metadata = {} + out: list[tuple[Path, dict]] = [] + for child in sorted(plugins_dir.iterdir()): + if not child.is_dir() or child.name.startswith("."): + continue + if not (child / ".git").exists(): + continue + record = metadata.get(child.name) + out.append((child, record if isinstance(record, dict) else {})) + return out + + +def _update_one_plugin_dir(target: Path, install_record: dict) -> dict: + """Pull one plugin checkout; shared by --all sweep and auto-update. + + Returns ``{"name", "ok", "skipped", "unchanged", "message"}``. Never + raises. Does NOT handle capability re-consent — interactive consent is + the single-plugin ``cmd_update`` path's job; here newly declared + capabilities simply stay ungranted (fail closed, same as any + non-interactive update). + """ + name = target.name + if install_record.get("pinned") is True: + return { + "name": name, "ok": True, "skipped": True, "unchanged": True, + "message": f"pinned to {str(install_record.get('revision', ''))[:12]} — skipped", + } + ok, output = _git_pull_plugin_dir(target) + if not ok: + return { + "name": name, "ok": False, "skipped": False, "unchanged": False, + "message": output.strip(), + } + unchanged = "Already up to date" in output + if not unchanged: + git_exe = _resolve_git_executable() + if git_exe: + try: + metadata = _read_install_metadata() + except PluginOperationError: + metadata = {} + record = metadata.get(name) + if isinstance(record, dict): + record["revision"] = _git_head_revision(target, git_exe) + metadata[name] = record + try: + _write_install_metadata(metadata) + except Exception: + logger.warning( + "could not record updated revision for plugin %s", name + ) + _clear_plugin_bytecode(target) + return { + "name": name, "ok": True, "skipped": False, "unchanged": unchanged, + "message": "already up to date" if unchanged else "updated", + } + + +_AUTOUPDATE_STAMP_NAME = "plugin_autoupdate_stamp" +_AUTOUPDATE_INTERVAL_SECONDS = 24 * 3600 + + +def cmd_autoupdate(name: str, state: Optional[str] = None) -> None: + """Show or set a plugin's ``auto_update`` flag in the install metadata. + + Opted-in plugins are ``git pull``ed by a background sweep at interactive + session start, at most once per 24h (Copilot CLI v1.0.79's + ``autoUpdate`` marketplace setting, adapted). The pull happens AFTER + plugin discovery finishes, so the running session keeps the code it + already imported; updates take effect next session. + """ + from rich.console import Console + + console = Console() + plugins_dir = _plugins_dir() + try: + target = _require_installed_plugin(name, plugins_dir, console) + except ValueError as e: + console.print(f"[red]Error:[/red] {e}") + sys.exit(1) + + try: + metadata = _read_install_metadata() + except PluginOperationError as exc: + console.print(f"[red]Error:[/red] {exc}") + sys.exit(1) + record = metadata.get(target.name) + record = record if isinstance(record, dict) else {} + + if state is None: + current = record.get("auto_update") is True + console.print( + f"Auto-update for [bold]{target.name}[/bold]: " + f"{'[green]on[/green]' if current else '[dim]off[/dim]'}" + ) + return + + if state == "on": + if record.get("pinned") is True: + console.print( + f"[red]Error:[/red] Plugin '{target.name}' is pinned to an exact " + "revision; unpin it (reinstall without --ref) before enabling " + "auto-update." + ) + sys.exit(1) + if not (target / ".git").exists(): + console.print( + f"[red]Error:[/red] Plugin '{target.name}' is not a git checkout; " + "auto-update needs a git-installed plugin." + ) + sys.exit(1) + record["auto_update"] = True + metadata[target.name] = record + _write_install_metadata(metadata) + console.print( + f"[green]✓[/green] Auto-update enabled for [bold]{target.name}[/bold]. " + "It will be pulled in the background at session start (max once/24h); " + "updates apply to new sessions." + ) + else: + record.pop("auto_update", None) + metadata[target.name] = record + _write_install_metadata(metadata) + console.print( + f"[green]✓[/green] Auto-update disabled for [bold]{target.name}[/bold]." + ) + + +def _autoupdate_stamp_path() -> Path: + return get_hermes_home() / "cache" / _AUTOUPDATE_STAMP_NAME + + +def run_startup_auto_update_sweep(force: bool = False) -> list[dict]: + """Pull every ``auto_update``-flagged plugin, at most once per 24h. + + Called from the background plugin-discovery thread AFTER discovery has + completed, so the pull never races the imports of the session that + triggered it — the running session keeps the already-imported code and + the fresh checkout is picked up by the NEXT session (bytecode for the + old revision is cleared by :func:`_update_one_plugin_dir`). + + Throttled by a stamp file so back-to-back session starts don't hammer + git remotes. Never raises; returns per-plugin result dicts (empty when + throttled or nothing is opted in). + """ + flagged = [ + (target, record) + for target, record in _iter_updatable_plugins() + if record.get("auto_update") is True and record.get("pinned") is not True + ] + if not flagged: + return [] + + stamp = _autoupdate_stamp_path() + if not force: + try: + age = time.time() - stamp.stat().st_mtime + if 0 <= age < _AUTOUPDATE_INTERVAL_SECONDS: + return [] + except OSError: + pass # no stamp yet → run + try: + stamp.parent.mkdir(parents=True, exist_ok=True) + stamp.touch() + except OSError: + logger.debug("could not write plugin auto-update stamp", exc_info=True) + + results = [] + for target, record in flagged: + try: + res = _update_one_plugin_dir(target, record) + except Exception: + logger.warning( + "plugin auto-update failed for %s", target.name, exc_info=True + ) + continue + results.append(res) + if not res["ok"]: + logger.warning( + "plugin auto-update: %s: %s", res["name"], res["message"] + ) + elif not res["unchanged"]: + logger.info( + "plugin auto-update: %s updated (takes effect next session)", + res["name"], + ) + return results + + +def _update_all_plugins(console) -> list[dict]: + """``hermes plugins update --all`` sweep. Returns per-plugin results.""" + results = [] + for target, record in _iter_updatable_plugins(): + res = _update_one_plugin_dir(target, record) + results.append(res) + if res["skipped"]: + console.print(f"[dim]— {res['name']}: {res['message']}[/dim]") + elif not res["ok"]: + console.print(f"[red]✗ {res['name']}:[/red] {res['message']}") + elif res["unchanged"]: + console.print(f"[dim]✓ {res['name']}: already up to date[/dim]") + else: + console.print(f"[green]✓ {res['name']}: updated[/green]") + from rich.console import Console as _C + + _copy_example_files(target, console if isinstance(console, _C) else _C()) + updated = [r for r in results if r["ok"] and not r["unchanged"]] + if updated: + console.print( + "[dim]Updated plugin code takes effect in new sessions.[/dim]" + ) + return results + + def _remove_plugin_core(target: Path) -> None: """Remove one plugin and its metadata without splitting their state.""" metadata = _read_install_metadata() @@ -2964,7 +3213,12 @@ def plugins_command(args) -> None: refresh=getattr(args, "refresh", False), ) elif action == "update": - cmd_update(args.name) + cmd_update( + getattr(args, "name", None), + all_plugins=getattr(args, "all_plugins", False), + ) + elif action == "autoupdate": + cmd_autoupdate(args.name, getattr(args, "state", None)) elif action in {"remove", "rm", "uninstall"}: cmd_remove(args.name) elif action == "enable": diff --git a/hermes_cli/subcommands/plugins.py b/hermes_cli/subcommands/plugins.py index 975770c2fd53e..323c32891be5d 100644 --- a/hermes_cli/subcommands/plugins.py +++ b/hermes_cli/subcommands/plugins.py @@ -82,9 +82,35 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None: ) plugins_update = plugins_subparsers.add_parser( - "update", help="Pull latest changes for an installed plugin" + "update", help="Pull latest changes for installed plugins" + ) + plugins_update.add_argument( + "name", nargs="?", help="Plugin name to update (omit with --all)" + ) + plugins_update.add_argument( + "--all", + action="store_true", + dest="all_plugins", + help="Update every git-installed plugin (pinned plugins are skipped)", + ) + + plugins_autoupdate = plugins_subparsers.add_parser( + "autoupdate", + help="Enable/disable auto-update at session start for a plugin", + description=( + "Opt a git-installed plugin into a background `git pull` sweep that " + "runs at most once per 24h at interactive session start. Updated " + "plugin code takes effect the NEXT session (the running session's " + "plugin registry and prompt cache are never touched)." + ), + ) + plugins_autoupdate.add_argument("name", help="Plugin name") + plugins_autoupdate.add_argument( + "state", + nargs="?", + choices=["on", "off"], + help="Turn auto-update on or off (omit to show current state)", ) - plugins_update.add_argument("name", help="Plugin name to update") plugins_remove = plugins_subparsers.add_parser( "remove", aliases=["rm", "uninstall"], help="Remove an installed plugin" diff --git a/tests/hermes_cli/test_plugins_autoupdate.py b/tests/hermes_cli/test_plugins_autoupdate.py new file mode 100644 index 0000000000000..4c7ead60a7278 --- /dev/null +++ b/tests/hermes_cli/test_plugins_autoupdate.py @@ -0,0 +1,247 @@ +"""Tests for plugin auto-update: update --all sweep, autoupdate flag, startup sweep. + +Inspired by Copilot CLI v1.0.79's marketplace ``autoUpdate`` setting. +Real-git E2E where the behavior depends on git (pull, revision recording), +mocks only for pure dispatch/throttle logic. +""" + +import json +import subprocess as sp +import time +from unittest.mock import MagicMock, patch + +import pytest + +import hermes_cli.plugins_cmd as pc + + +def _git(cwd, *args): + r = sp.run(["git", *args], cwd=str(cwd), capture_output=True, text=True) + assert r.returncode == 0, r.stderr + return r.stdout + + +def _make_plugin_env(tmp_path, monkeypatch, names=("alpha",)): + """Create a fake HERMES_HOME with git-installed plugins cloned from origins.""" + home = tmp_path / "hermes-home" + plugins_dir = home / "plugins" + plugins_dir.mkdir(parents=True) + monkeypatch.setattr(pc, "get_hermes_home", lambda: home) + monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir) + + origins = {} + for name in names: + origin = tmp_path / f"origin-{name}" + origin.mkdir() + _git(origin, "init", "-q", "-b", "main") + _git(origin, "config", "user.email", "t@t") + _git(origin, "config", "user.name", "t") + (origin / "plugin.yaml").write_text(f"name: {name}\n", encoding="utf-8") + (origin / "mod.py").write_text("VALUE = 1\n", encoding="utf-8") + _git(origin, "add", ".") + _git(origin, "commit", "-qm", "init") + checkout = plugins_dir / name + _git(tmp_path, "clone", "-q", str(origin), str(checkout)) + _git(checkout, "config", "user.email", "t@t") + _git(checkout, "config", "user.name", "t") + origins[name] = origin + return home, plugins_dir, origins + + +def _advance_origin(origin, value): + (origin / "mod.py").write_text(f"VALUE = {value}\n", encoding="utf-8") + _git(origin, "add", ".") + _git(origin, "commit", "-qm", f"bump {value}") + + +def _write_metadata(home, metadata): + path = home / "plugins" / pc._INSTALL_METADATA_FILE + path.write_text(json.dumps(metadata), encoding="utf-8") + + +def _read_metadata(home): + path = home / "plugins" / pc._INSTALL_METADATA_FILE + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + + +class TestIterUpdatablePlugins: + def test_lists_git_dirs_only(self, tmp_path, monkeypatch): + home, plugins_dir, _ = _make_plugin_env(tmp_path, monkeypatch, ("alpha", "beta")) + (plugins_dir / "not-git").mkdir() # plain dir — excluded + (plugins_dir / ".hidden").mkdir() # dot dir — excluded + names = [t.name for t, _ in pc._iter_updatable_plugins()] + assert names == ["alpha", "beta"] + + def test_missing_plugins_dir_returns_empty(self, tmp_path, monkeypatch): + monkeypatch.setattr(pc, "_plugins_dir", lambda: tmp_path / "nope") + assert pc._iter_updatable_plugins() == [] + + +class TestUpdateOnePluginDir: + def test_pull_updates_and_records_revision(self, tmp_path, monkeypatch): + home, plugins_dir, origins = _make_plugin_env(tmp_path, monkeypatch) + _write_metadata(home, {"alpha": {"pinned": False, "revision": "old", "source": "x"}}) + _advance_origin(origins["alpha"], 2) + + res = pc._update_one_plugin_dir(plugins_dir / "alpha", {}) + assert res["ok"] is True and res["unchanged"] is False + assert (plugins_dir / "alpha" / "mod.py").read_text() == "VALUE = 2\n" + rev = _read_metadata(home)["alpha"]["revision"] + assert len(rev) == 40 and rev != "old" + + def test_unchanged_pull(self, tmp_path, monkeypatch): + home, plugins_dir, _ = _make_plugin_env(tmp_path, monkeypatch) + res = pc._update_one_plugin_dir(plugins_dir / "alpha", {}) + assert res["ok"] is True and res["unchanged"] is True + + def test_pinned_is_skipped(self, tmp_path, monkeypatch): + home, plugins_dir, origins = _make_plugin_env(tmp_path, monkeypatch) + _advance_origin(origins["alpha"], 3) + res = pc._update_one_plugin_dir( + plugins_dir / "alpha", {"pinned": True, "revision": "a" * 40} + ) + assert res["skipped"] is True + # Checkout untouched + assert (plugins_dir / "alpha" / "mod.py").read_text() == "VALUE = 1\n" + + def test_clears_stale_bytecode(self, tmp_path, monkeypatch): + home, plugins_dir, origins = _make_plugin_env(tmp_path, monkeypatch) + cache = plugins_dir / "alpha" / "__pycache__" + cache.mkdir() + (cache / "mod.cpython-311.pyc").write_bytes(b"stale") + _advance_origin(origins["alpha"], 4) + res = pc._update_one_plugin_dir(plugins_dir / "alpha", {}) + assert res["ok"] is True and res["unchanged"] is False + assert not cache.exists() + + +class TestUpdateAllPlugins: + def test_mixed_sweep(self, tmp_path, monkeypatch): + home, plugins_dir, origins = _make_plugin_env( + tmp_path, monkeypatch, ("alpha", "beta", "gamma") + ) + _write_metadata(home, {"beta": {"pinned": True, "revision": "b" * 40, "source": "x"}}) + _advance_origin(origins["alpha"], 9) + + console = MagicMock() + results = {r["name"]: r for r in pc._update_all_plugins(console)} + assert results["alpha"]["unchanged"] is False + assert results["beta"]["skipped"] is True + assert results["gamma"]["unchanged"] is True + + +class TestCmdUpdateDispatch: + def test_all_flag_runs_sweep(self, tmp_path, monkeypatch): + with patch.object(pc, "_update_all_plugins", return_value=[]) as sweep: + pc.cmd_update(None, all_plugins=True) + sweep.assert_called_once() + + def test_name_plus_all_rejected(self): + with pytest.raises(SystemExit): + pc.cmd_update("alpha", all_plugins=True) + + def test_no_name_no_all_rejected(self): + with pytest.raises(SystemExit): + pc.cmd_update(None) + + +class TestCmdAutoupdate: + def test_enable_persists_flag(self, tmp_path, monkeypatch): + home, plugins_dir, _ = _make_plugin_env(tmp_path, monkeypatch) + _write_metadata(home, {"alpha": {"pinned": False, "revision": "r", "source": "s"}}) + pc.cmd_autoupdate("alpha", "on") + assert _read_metadata(home)["alpha"]["auto_update"] is True + + def test_disable_removes_flag(self, tmp_path, monkeypatch): + home, plugins_dir, _ = _make_plugin_env(tmp_path, monkeypatch) + _write_metadata( + home, + {"alpha": {"pinned": False, "revision": "r", "source": "s", "auto_update": True}}, + ) + pc.cmd_autoupdate("alpha", "off") + assert "auto_update" not in _read_metadata(home)["alpha"] + + def test_pinned_plugin_rejected(self, tmp_path, monkeypatch): + home, plugins_dir, _ = _make_plugin_env(tmp_path, monkeypatch) + _write_metadata(home, {"alpha": {"pinned": True, "revision": "a" * 40, "source": "s"}}) + with pytest.raises(SystemExit): + pc.cmd_autoupdate("alpha", "on") + + def test_non_git_plugin_rejected(self, tmp_path, monkeypatch): + home = tmp_path / "hh" + plugins_dir = home / "plugins" + (plugins_dir / "plain").mkdir(parents=True) + monkeypatch.setattr(pc, "get_hermes_home", lambda: home) + monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir) + with pytest.raises(SystemExit): + pc.cmd_autoupdate("plain", "on") + + def test_missing_plugin_rejected(self, tmp_path, monkeypatch): + home, plugins_dir, _ = _make_plugin_env(tmp_path, monkeypatch) + with pytest.raises(SystemExit): + pc.cmd_autoupdate("ghost", "on") + + +class TestStartupSweep: + def test_only_flagged_plugins_pulled(self, tmp_path, monkeypatch): + home, plugins_dir, origins = _make_plugin_env( + tmp_path, monkeypatch, ("alpha", "beta") + ) + _write_metadata( + home, + { + "alpha": {"pinned": False, "revision": "r", "source": "s", "auto_update": True}, + "beta": {"pinned": False, "revision": "r", "source": "s"}, + }, + ) + _advance_origin(origins["alpha"], 7) + _advance_origin(origins["beta"], 7) + + results = pc.run_startup_auto_update_sweep(force=True) + assert [r["name"] for r in results] == ["alpha"] + assert (plugins_dir / "alpha" / "mod.py").read_text() == "VALUE = 7\n" + assert (plugins_dir / "beta" / "mod.py").read_text() == "VALUE = 1\n" + + def test_no_flagged_plugins_is_noop(self, tmp_path, monkeypatch): + home, plugins_dir, _ = _make_plugin_env(tmp_path, monkeypatch) + assert pc.run_startup_auto_update_sweep(force=True) == [] + # No stamp written when nothing is opted in + assert not pc._autoupdate_stamp_path().exists() + + def test_throttled_by_stamp(self, tmp_path, monkeypatch): + home, plugins_dir, origins = _make_plugin_env(tmp_path, monkeypatch) + _write_metadata( + home, + {"alpha": {"pinned": False, "revision": "r", "source": "s", "auto_update": True}}, + ) + stamp = pc._autoupdate_stamp_path() + stamp.parent.mkdir(parents=True, exist_ok=True) + stamp.touch() # fresh stamp → throttled + assert pc.run_startup_auto_update_sweep() == [] + + def test_stale_stamp_runs(self, tmp_path, monkeypatch): + import os + + home, plugins_dir, origins = _make_plugin_env(tmp_path, monkeypatch) + _write_metadata( + home, + {"alpha": {"pinned": False, "revision": "r", "source": "s", "auto_update": True}}, + ) + stamp = pc._autoupdate_stamp_path() + stamp.parent.mkdir(parents=True, exist_ok=True) + stamp.touch() + old = time.time() - (pc._AUTOUPDATE_INTERVAL_SECONDS + 60) + os.utime(stamp, (old, old)) + results = pc.run_startup_auto_update_sweep() + assert [r["name"] for r in results] == ["alpha"] + # Stamp refreshed + assert time.time() - stamp.stat().st_mtime < 60 + + def test_never_raises_on_broken_checkout(self, tmp_path, monkeypatch): + home, plugins_dir, _ = _make_plugin_env(tmp_path, monkeypatch) + _write_metadata( + home, + {"alpha": {"pinned": False, "revision": "r", "source": "s", "auto_update": True}}, + ) + with patch.object(pc, "_update_one_plugin_dir", side_effect=RuntimeError("boom")): + assert pc.run_startup_auto_update_sweep(force=True) == [] diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 3909e7dd79f20..95f59e5710ad8 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -338,12 +338,23 @@ hermes plugins install user/repo # install from Git, then prompt Ena hermes plugins install user/repo --enable # install AND enable (no prompt) hermes plugins install user/repo --no-enable # install but leave disabled (no prompt) hermes plugins update my-plugin # pull latest (local edits are autostashed and re-applied) +hermes plugins update --all # pull every git-installed plugin (pinned ones are skipped) +hermes plugins autoupdate my-plugin on # auto-pull in the background at session start (max once/24h) +hermes plugins autoupdate my-plugin off # turn auto-update off (omit on/off to show current state) hermes plugins remove my-plugin # uninstall hermes plugins enable my-plugin # add to allow-list hermes plugins disable my-plugin # remove from allow-list + add to disabled hermes plugins capabilities [my-plugin] # declared vs granted capabilities ``` +Auto-update (`hermes plugins autoupdate on`) opts a git-installed plugin +into a background pull that runs after plugin discovery at interactive session +start, at most once per 24 hours. The running session keeps the code it already +imported — updated plugin code takes effect the **next** session, so the plugin +registry and prompt cache of the live conversation are never touched. Pinned +plugins (`--ref`) are never auto-updated, and capabilities newly declared by an +update stay ungranted until you re-consent interactively (fail closed). + ### Plugin capabilities and consent Plugins can declare the privileged host surfaces they want in their