Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,16 @@ 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
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 <setup|wallet|doctor|route|stats>` 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 <setup|update|wallet|doctor|route|stats>` 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`
Expand Down
51 changes: 50 additions & 1 deletion src/clawrouter_hermes/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -10,6 +11,7 @@
from __future__ import annotations

import argparse
import importlib.util
import json
import os
import shutil
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -138,7 +146,7 @@ def clawrouter_command(args: argparse.Namespace) -> None:

def _default_help(_: argparse.Namespace) -> None:
print(
"Usage: hermes-clawrouter <setup|wallet|doctor|route|stats>\n\n"
"Usage: hermes-clawrouter <setup|update|wallet|doctor|route|stats>\n\n"
"Run `hermes-clawrouter <sub> --help` for details.",
)

Expand Down Expand Up @@ -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.

Expand Down
97 changes: 97 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Loading