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:
parent
f52feed1ef
commit
ff190a6462
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) == []
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue