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 <name> [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).
This commit is contained in:
Teknium 2026-08-13 20:12:39 -07:00
parent f52feed1ef
commit ff190a6462
5 changed files with 554 additions and 5 deletions

View File

@ -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 <name> 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

View File

@ -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 <name>` 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":

View File

@ -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"

View File

@ -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) == []

View File

@ -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 <name> 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