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 5e549e9..9feca95 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 @@ -10,6 +11,7 @@ from __future__ import annotations import argparse +import importlib.util import json import os import shutil @@ -102,6 +104,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 +146,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 +210,47 @@ 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) + + 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: + print(f"✗ pip upgrade failed with exit code {pip_result.returncode}") + 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'])", + ] + 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..faed4f1 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,98 @@ 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", + "-I", + "-m", + "pip", + "install", + "--upgrade", + "--no-input", + "hermes-plugin-clawrouter", + ], + [ + "/venv/bin/python", + "-I", + "-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 + + +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