feat(cli): make hermes portal the human-readable Portal onboarding alias

`hermes portal` (no subcommand) now runs the one-shot Nous Portal onboarding
— OAuth login, switch provider to Nous, offer Tool Gateway — identical to
`hermes setup --portal` and the human-readable alias for
`hermes auth add nous --type oauth` (which still works).

The prior status default moves to `hermes portal info`; `status` is kept as a
hidden back-compat alias. `open`/`tools` subcommands are unchanged.

User-facing hints and docs (status.py, conversation_loop 401 guidance,
SystemPage, README, website docs + zh-Hans) now point at `hermes portal` /
`hermes portal info`. `--manual-paste` references keep the explicit auth
command since `hermes portal` does not expose that flag.
This commit is contained in:
kshitijk4poor 2026-06-04 01:19:28 +05:30
parent 39fee4f3bc
commit da4f407e51
21 changed files with 212 additions and 58 deletions

View file

@ -48,8 +48,8 @@ def test_nous_401_guidance_strings_present():
# (Nous Portal has no API key path — auth_type=oauth_device_code only).
assert "Nous Portal OAuth token was rejected" in source
# Must give the exact re-auth command, not a generic "hermes setup".
assert "hermes auth add nous --type oauth" in source
# Must give a concrete re-auth command, not a generic "hermes setup".
assert "hermes portal" in source
# Must point at the portal so users can check account/credit status.
assert "portal.nousresearch.com" in source

View file

@ -0,0 +1,111 @@
"""Tests for `hermes portal` dispatch.
`hermes portal` (no subcommand) is the human-readable alias for the Nous Portal
one-shot onboarding (`hermes auth add nous --type oauth` / `hermes setup
--portal`). The prior status default moved to `hermes portal info`, with
`status` retained as a back-compat alias.
"""
from __future__ import annotations
import argparse
from types import SimpleNamespace
import pytest
from hermes_cli import portal_cli
def _args(portal_command):
return SimpleNamespace(portal_command=portal_command)
@pytest.mark.parametrize("sub", [None, "", "login"])
def test_bare_portal_and_login_run_one_shot(monkeypatch, sub):
"""`hermes portal`, `hermes portal login` -> one-shot onboarding."""
calls = {"login": 0, "status": 0}
def fake_one_shot(config):
calls["login"] += 1
def fake_status(args):
calls["status"] += 1
return 0
monkeypatch.setattr(
"hermes_cli.setup._run_portal_one_shot", fake_one_shot
)
monkeypatch.setattr(portal_cli, "_cmd_status", fake_status)
monkeypatch.setattr(portal_cli, "load_config", lambda: {})
rc = portal_cli.portal_command(_args(sub))
assert rc == 0
assert calls["login"] == 1
assert calls["status"] == 0
@pytest.mark.parametrize("sub", ["info", "status"])
def test_info_and_status_alias_run_status(monkeypatch, sub):
"""`hermes portal info` and the `status` back-compat alias -> status."""
calls = {"login": 0, "status": 0}
monkeypatch.setattr(
"hermes_cli.setup._run_portal_one_shot",
lambda config: calls.__setitem__("login", calls["login"] + 1),
)
def fake_status(args):
calls["status"] += 1
return 0
monkeypatch.setattr(portal_cli, "_cmd_status", fake_status)
rc = portal_cli.portal_command(_args(sub))
assert rc == 0
assert calls["status"] == 1
assert calls["login"] == 0
def test_open_and_tools_dispatch(monkeypatch):
seen = []
monkeypatch.setattr(portal_cli, "_cmd_open", lambda a: seen.append("open") or 0)
monkeypatch.setattr(portal_cli, "_cmd_tools", lambda a: seen.append("tools") or 0)
assert portal_cli.portal_command(_args("open")) == 0
assert portal_cli.portal_command(_args("tools")) == 0
assert seen == ["open", "tools"]
def test_unknown_subcommand_returns_error(capsys):
rc = portal_cli.portal_command(_args("bogus"))
assert rc == 1
err = capsys.readouterr().err
assert "Unknown portal subcommand" in err
def test_login_cancelled_returns_one(monkeypatch):
def boom(config):
raise KeyboardInterrupt
monkeypatch.setattr("hermes_cli.setup._run_portal_one_shot", boom)
monkeypatch.setattr(portal_cli, "load_config", lambda: {})
rc = portal_cli.portal_command(_args(None))
assert rc == 1
def test_parser_registers_subcommands():
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command")
portal_cli.add_parser(subparsers)
# Bare `portal` resolves to portal_command with no portal_command set.
ns = parser.parse_args(["portal"])
assert ns.func is portal_cli.portal_command
assert getattr(ns, "portal_command", None) in (None, "")
# All documented subcommands parse.
for sub in ("login", "info", "status", "open", "tools"):
ns = parser.parse_args(["portal", sub])
assert ns.portal_command == sub

View file

@ -77,7 +77,7 @@ def test_show_status_reports_nous_auth_error(monkeypatch, capsys, tmp_path):
status_mod.show_status(SimpleNamespace(all=False, deep=False))
output = capsys.readouterr().out
assert "Nous Portal ✗ not logged in (run: hermes auth add nous --type oauth)" in output
assert "Nous Portal ✗ not logged in (run: hermes portal)" in output
assert "Error: Refresh session has been revoked" in output
assert "Access exp:" in output
assert "Key exp:" in output