From 79ec78dcbc410c950d8a04381c7018d3f28691fc Mon Sep 17 00:00:00 2001 From: TheCheetah Date: Sun, 2 Aug 2026 23:31:01 +0100 Subject: [PATCH 1/2] feat: add hermes-clawrouter update command --- src/clawrouter_hermes/cli.py | 35 +++++++++++++++++++++++- tests/test_cli.py | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/clawrouter_hermes/cli.py b/src/clawrouter_hermes/cli.py index 5e549e9..8967dee 100644 --- a/src/clawrouter_hermes/cli.py +++ b/src/clawrouter_hermes/cli.py @@ -2,6 +2,7 @@ Subcommands: - setup materialize the model-provider plugin, verify Node + wallet + - update upgrade this package, then refresh setup-managed files - wallet print wallet address + USDC balances - doctor pass/fail health check - route show/set routing profile @@ -102,6 +103,12 @@ def register_cli(subparser: argparse.ArgumentParser) -> None: ) setup_p.set_defaults(func=_setup) + update_p = subs.add_parser( + "update", + help="Upgrade hermes-plugin-clawrouter and refresh setup-managed files", + ) + update_p.set_defaults(func=_update) + wallet_p = subs.add_parser("wallet", help="Show wallet address + USDC balances") wallet_p.add_argument("--json", action="store_true", help="Emit JSON instead of text") wallet_p.set_defaults(func=_wallet) @@ -138,7 +145,7 @@ def clawrouter_command(args: argparse.Namespace) -> None: def _default_help(_: argparse.Namespace) -> None: print( - "Usage: hermes-clawrouter \n\n" + "Usage: hermes-clawrouter \n\n" "Run `hermes-clawrouter --help` for details.", ) @@ -202,6 +209,32 @@ def _setup(args: argparse.Namespace) -> None: print(" hermes --provider clawrouter -m blockrun/auto") +def _update(_: argparse.Namespace) -> None: + """Upgrade this plugin package and refresh the generated Hermes wiring.""" + print("== ClawRouter for Hermes — update ==", flush=True) + print(f"Current {_DIST_NAME}: {_package_version()}", flush=True) + + pip_cmd = [sys.executable, "-m", "pip", "install", "--upgrade", _DIST_NAME] + print(f"Updating {_DIST_NAME}…", flush=True) + pip_result = subprocess.run(pip_cmd) + if pip_result.returncode != 0: + print(f"✗ pip upgrade failed with exit code {pip_result.returncode}") + sys.exit(pip_result.returncode) + + print("Refreshing Hermes integration…", flush=True) + setup_cmd = [ + sys.executable, + "-c", + "from clawrouter_hermes.cli import main; main(['setup'])", + ] + setup_result = subprocess.run(setup_cmd) + if setup_result.returncode != 0: + print(f"✗ setup refresh failed with exit code {setup_result.returncode}") + sys.exit(setup_result.returncode) + + print("Update complete. Restart any running Hermes gateway to load refreshed code.") + + def _stamp_plugin_version(text: str, version: str) -> str: """Rewrite plugin.yaml's ``version:`` line with the version that wrote it. diff --git a/tests/test_cli.py b/tests/test_cli.py index 3022d11..d655675 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,10 @@ from __future__ import annotations import tomllib +from argparse import Namespace from importlib import metadata from pathlib import Path +from types import SimpleNamespace import pytest @@ -40,3 +42,54 @@ def test_dist_name_matches_pyproject(): assert _VERSION == project["version"], ( "clawrouter_hermes._VERSION has drifted from pyproject.toml" ) + + +def test_update_upgrades_package_then_runs_setup(monkeypatch, capsys): + calls = [] + + def fake_run(cmd): + calls.append(cmd) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(cli.subprocess, "run", fake_run) + monkeypatch.setattr(cli.sys, "executable", "/venv/bin/python") + monkeypatch.setattr(cli, "_package_version", lambda: "0.3.17") + + cli._update(Namespace()) + + assert calls == [ + [ + "/venv/bin/python", + "-m", + "pip", + "install", + "--upgrade", + "hermes-plugin-clawrouter", + ], + [ + "/venv/bin/python", + "-c", + "from clawrouter_hermes.cli import main; main(['setup'])", + ], + ] + out = capsys.readouterr().out + assert "Current hermes-plugin-clawrouter: 0.3.17" in out + assert "Update complete." in out + + +def test_update_exits_when_package_upgrade_fails(monkeypatch, capsys): + calls = [] + + def fake_run(cmd): + calls.append(cmd) + return SimpleNamespace(returncode=7) + + monkeypatch.setattr(cli.subprocess, "run", fake_run) + monkeypatch.setattr(cli.sys, "executable", "/venv/bin/python") + + with pytest.raises(SystemExit) as exc: + cli._update(Namespace()) + + assert exc.value.code == 7 + assert len(calls) == 1 + assert "pip upgrade failed with exit code 7" in capsys.readouterr().out From ac30f6e61d4ae83ee89a22f19e6fa6ca3fc1a20d Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Mon, 3 Aug 2026 00:29:12 -0500 Subject: [PATCH 2/2] review: harden update command (-I isolation, pip preflight, --no-input) + tests and docs - pass -I to both child interpreters so a CWD/PYTHONPATH clawrouter_hermes package cannot shadow the installed one during the refresh - preflight importlib.util.find_spec('pip') with actionable guidance for uv/pipx environments that ship without pip - add --no-input so a keyring/index prompt cannot hang the upgrade - cover setup-refresh failure, missing-pip, and argparse dispatch in tests - document the update subcommand in README --- README.md | 5 ++-- src/clawrouter_hermes/cli.py | 18 ++++++++++++++- tests/test_cli.py | 44 ++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d5de6ae..74d5720 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ ClawRouter installer. `setup` writes the model-provider plugin to `~/.hermes/plugins/model-providers/clawrouter/`, seeds `CLAWROUTER_API_KEY=clawrouter-local` in `~/.hermes/.env`, and registers ClawRouter in `~/.hermes/config.yaml` so Hermes' `/model` picker can show the provider and curated BlockRun chat models. -Upgrading is just `pip install -U`. The materialized plugin is stamped with the +Upgrading is just `pip install -U` (or `hermes-clawrouter update`, which +upgrades and refreshes the integration in one step). The materialized plugin is stamped with the version that wrote it, so the next Hermes start notices an older stamp and rewrites the plugin in place — no `setup --force` needed to pick up newly added models. (Before 0.3.16 that refresh never happened, which is why a plain @@ -46,7 +47,7 @@ upgrade could leave you on an old model list.) `CLAWROUTER_API_KEY` is intentionally a non-secret placeholder. ClawRouter payments use the local wallet/proxy, but Hermes hides API-key-style providers from `/model` unless the configured key env var exists. -`hermes-clawrouter` is provided because some Hermes releases do not add plugin-defined top-level CLI commands before the plugin is enabled. Once the plugin is loaded, `hermes clawrouter ` may also be available. +`hermes-clawrouter` is provided because some Hermes releases do not add plugin-defined top-level CLI commands before the plugin is enabled. Once the plugin is loaded, `hermes clawrouter ` may also be available. If `hermes-clawrouter --version` shows an older version after updating, your shell may be finding an old `~/.local/bin/hermes-clawrouter` from a previous `pip --user` diff --git a/src/clawrouter_hermes/cli.py b/src/clawrouter_hermes/cli.py index 8967dee..9feca95 100644 --- a/src/clawrouter_hermes/cli.py +++ b/src/clawrouter_hermes/cli.py @@ -11,6 +11,7 @@ from __future__ import annotations import argparse +import importlib.util import json import os import shutil @@ -214,7 +215,19 @@ def _update(_: argparse.Namespace) -> None: print("== ClawRouter for Hermes — update ==", flush=True) print(f"Current {_DIST_NAME}: {_package_version()}", flush=True) - pip_cmd = [sys.executable, "-m", "pip", "install", "--upgrade", _DIST_NAME] + if importlib.util.find_spec("pip") is None: + # uv-created venvs ship without pip, and pipx's shared pip can break. + print(f"✗ pip is not available in this environment ({sys.executable}).") + print(" If Hermes was installed with the one-command installer, run:") + print(" ~/.hermes/hermes-agent/venv/bin/hermes-clawrouter update") + print(" Otherwise upgrade with your environment's tooling, e.g.:") + print(f" uv pip install --upgrade {_DIST_NAME}") + sys.exit(1) + + # -I (isolated mode) keeps child interpreters from importing a shadowing + # package from the invoking CWD or PYTHONPATH — `python -c`/`-m` otherwise + # put the CWD first on sys.path. + pip_cmd = [sys.executable, "-I", "-m", "pip", "install", "--upgrade", "--no-input", _DIST_NAME] print(f"Updating {_DIST_NAME}…", flush=True) pip_result = subprocess.run(pip_cmd) if pip_result.returncode != 0: @@ -222,8 +235,11 @@ def _update(_: argparse.Namespace) -> None: sys.exit(pip_result.returncode) print("Refreshing Hermes integration…", flush=True) + # Run setup in a fresh interpreter so it executes the just-upgraded + # package, not the stale module already imported into this process. setup_cmd = [ sys.executable, + "-I", "-c", "from clawrouter_hermes.cli import main; main(['setup'])", ] diff --git a/tests/test_cli.py b/tests/test_cli.py index d655675..faed4f1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -60,14 +60,17 @@ def fake_run(cmd): assert calls == [ [ "/venv/bin/python", + "-I", "-m", "pip", "install", "--upgrade", + "--no-input", "hermes-plugin-clawrouter", ], [ "/venv/bin/python", + "-I", "-c", "from clawrouter_hermes.cli import main; main(['setup'])", ], @@ -93,3 +96,44 @@ def fake_run(cmd): assert exc.value.code == 7 assert len(calls) == 1 assert "pip upgrade failed with exit code 7" in capsys.readouterr().out + + +def test_update_exits_when_setup_refresh_fails(monkeypatch, capsys): + calls = [] + + def fake_run(cmd): + calls.append(cmd) + return SimpleNamespace(returncode=0 if len(calls) == 1 else 5) + + monkeypatch.setattr(cli.subprocess, "run", fake_run) + monkeypatch.setattr(cli.sys, "executable", "/venv/bin/python") + monkeypatch.setattr(cli, "_package_version", lambda: "0.3.17") + + with pytest.raises(SystemExit) as exc: + cli._update(Namespace()) + + assert exc.value.code == 5 + assert len(calls) == 2 + assert "setup refresh failed with exit code 5" in capsys.readouterr().out + + +def test_update_exits_when_pip_is_missing(monkeypatch, capsys): + calls = [] + monkeypatch.setattr(cli.importlib.util, "find_spec", lambda name: None) + monkeypatch.setattr(cli.subprocess, "run", lambda cmd: calls.append(cmd)) + + with pytest.raises(SystemExit) as exc: + cli._update(Namespace()) + + assert exc.value.code == 1 + assert calls == [] + assert "pip is not available" in capsys.readouterr().out + + +def test_main_dispatches_update(monkeypatch): + called = [] + monkeypatch.setattr(cli, "_update", lambda args: called.append(args)) + + cli.main(["update"]) + + assert len(called) == 1