feat(billing): /credits command — balance + portal top-up handoff (#44776)

* feat(billing): /usage → portal top-up browser handoff

Add the terminal side of the billing slice (phase 2a): start a top-up by
throwing the user to the portal billing page with the top-up modal open. The
terminal does not confirm, poll, or track payment — checkout completes in the
browser and the next /usage shows the new balance.

- nous_account.py: parse organisation.slug/name from /api/oauth/account into
  NousPortalAccountInfo; add nous_portal_topup_url() building the org-pinned
  {base}/orgs/{slug}/billing?topup=open with a null-slug fallback to the legacy
  {base}/billing?topup=open (never /orgs/None/...).
- portal_cli.py: 'hermes portal topup' — fresh account fetch, identity line
  (Topping up as <email> / org <name>), browser open with printed-URL fallback,
  no-wait closing copy. No polling/confirmation (deferred to 2b).
- account_usage.py: the shared /usage credits block now links the org-pinned
  top-up URL (auto-opens the modal) + points to the command.

Depends on NAS #409 (organisation.slug/name + ?topup=open). Do not merge until
that is live on the target env; until then /api/oauth/account returns
organisation: { id } only and the URL falls back to legacy.

* feat(billing): /credits command for balance + top-up handoff

Replace the standalone `hermes portal topup` subcommand with an in-session
/credits slash command — a focused money surface (balance in, top-up out) that
works in the CLI, TUI, and every messaging platform from one registry entry.

- commands.py: register /credits (Info category). Slack is at its 50-slash cap,
  so /credits is routed via /hermes credits on Slack only (new
  _SLACK_VIA_HERMES_ONLY set) to avoid clamping a canonical command off the
  native list and breaking Telegram parity; native everywhere else.
- account_usage.py: build_credits_view() — one portal fetch → balance lines +
  identity line + org-pinned top-up URL + depleted flag, consumed by all
  surfaces. Reuses the same snapshot/URL builder as /usage so numbers match.
- cli.py: _show_credits() — balance block + identity line + 3-button panel
  (Open top-up / Copy link / Cancel) via the existing prompt_toolkit modal.
  ASK, never auto-launch; headless falls back to printing the URL.
- gateway/slash_commands.py: _handle_credits_command() — renders the block +
  tappable top-up URL + no-wait copy; works on button and plain-text platforms.
- /usage credits line now points to /credits.
- Retire `hermes portal topup` (portal_cli.py back to baseline); the engine
  (slug/name parse + nous_portal_topup_url) stays as the shared core.

No polling, no payment confirmation (billing phase 2a). Depends on NAS #409.

* fix(credits): /credits works in the TUI slash-worker (non-interactive)

In the TUI, /credits runs in the slash-worker subprocess where there is no
live prompt_toolkit app and stdin is the JSON-RPC pipe. _show_credits called
the 3-button modal unconditionally, which fell back to reading stdin →
exception → slash.exec rejected → the command produced no output (only the
pre-existing 'Credit access paused' banner showed).

- _show_credits: when self._app is None (TUI worker / piped / non-interactive),
  render the text variant — balance block + tappable top-up URL + no-wait line,
  same affordance as the messaging surfaces — and skip the modal entirely. The
  3-button panel still renders in the interactive CLI.
- Depleted banner copy: 'run /usage for balance' → 'run /credits to top up'
  now that /credits is the dedicated money surface (+ tests).
- Regression tests: _show_credits with self._app=None renders text and never
  invokes the modal; logged-out path.

* feat(tui): credits.view RPC for the /credits tappable top-up button

Add a credits.view JSON-RPC method returning the structured CreditsView
(logged_in, balance_lines, identity_line, topup_url, depleted) so the TUI can
render a clickable <Link> top-up button instead of plain text. Account-
independent (portal fetch gated on a logged-in Nous account), fail-open to
{logged_in: false} on any hiccup. Mirrors session.usage's credits-block pattern.

Frontend (TUI-local /credits command + Ink component) lands separately.

* feat(tui): /credits command with keyboard-driven top-up confirm

TUI-local /credits: fetches the structured balance via the credits.view RPC,
prints the balance + identity + top-up URL, then arms the EXISTING confirm
overlay (Enter = open top-up in browser via openExternalUrl, Esc = cancel).
Reuses ConfirmReq — no new overlay component/state/input handler. Headless
(openExternalUrl returns false) falls back to printing the URL.

- gatewayTypes.ts: CreditsViewResponse.
- commands/credits.ts: the command (mirrors /status's rpc+guarded pattern).
- registry.ts: register creditsCommands.
- test: balance+overlay armed, headless fallback, no-url, logged-out (4 cases).

Matches the CLI /credits 'Enter to open' affordance. Phase 2a: no polling.
This commit is contained in:
Siddharth Balyan 2026-06-12 14:21:10 +05:30 • committed by GitHub
parent 4474873d2c
commit 7ba5df0d52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 944 additions and 172 deletions

View file

@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
account info to show (fail-open: caller just shows nothing).
"""
try:
from hermes_cli.nous_account import nous_portal_billing_url
from hermes_cli.nous_account import nous_portal_topup_url
if account_info is None or not getattr(account_info, "logged_in", False):
return None
@ -213,7 +213,8 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
if not windows and not details:
return None
details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
details.append("(or run /credits)")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@ -337,6 +338,93 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
return None
@dataclass(frozen=True)
class CreditsView:
"""Surface-agnostic data for the ``/credits`` command.
One portal fetch, one parse — consumed identically by the CLI panel, the
gateway button, and any other money surface. Fail-open: when not logged in
or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None
and callers degrade gracefully.
"""
logged_in: bool
balance_lines: tuple[str, ...] = ()
identity_line: Optional[str] = None
topup_url: Optional[str] = None
depleted: bool = False
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
"""Build the /credits view: balance block + identity line + top-up URL.
Reuses the same account fetch + snapshot + URL builder as the /usage credits
block, so the numbers always match. The balance block is the rendered
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
"""
not_logged_in = CreditsView(logged_in=False)
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return not_logged_in
except Exception:
return not_logged_in
try:
import concurrent.futures
from hermes_cli.nous_account import (
get_nous_portal_account_info,
nous_portal_topup_url,
)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(
timeout=timeout
)
except Exception:
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
return not_logged_in
if account is None or not getattr(account, "logged_in", False):
return not_logged_in
snapshot = build_nous_credits_snapshot(account)
# Balance lines = the snapshot block minus the two trailing affordance lines
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
# appends for the /usage surface. /credits renders its own button/panel.
balance_lines: list[str] = []
if snapshot is not None:
rendered = render_account_usage_lines(snapshot, markdown=markdown)
balance_lines = [
line
for line in rendered
if not line.lstrip().startswith("Top up:")
and not line.lstrip().startswith("(or run")
]
# Identity line — shown before any open (roadmap §4.4).
email = getattr(account, "email", None)
org_name = getattr(account, "org_name", None)
who: list[str] = []
if email:
who.append(str(email))
if org_name:
who.append(f"org {org_name}")
identity_line = ("Topping up as " + " / ".join(who)) if who else None
return CreditsView(
logged_in=True,
balance_lines=tuple(balance_lines),
identity_line=identity_line,
topup_url=nous_portal_topup_url(account),
depleted=getattr(account, "paid_service_access", None) is False,
)
def _resolve_codex_usage_url(base_url: str) -> str:
normalized = (base_url or "").strip().rstrip("/")
if not normalized:

View file

@ -355,7 +355,7 @@ def evaluate_credits_notices(
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /usage for balance",
text="✕ Credit access paused · run /credits to top up",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",

82
cli.py
View file

@ -7451,6 +7451,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._manual_compress(cmd_original)
elif canonical == "usage":
self._show_usage()
elif canonical == "credits":
self._show_credits()
elif canonical == "insights":
self._show_insights(cmd_original)
elif canonical == "copy":
@ -8352,6 +8354,86 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
print(f" {line}")
return True
def _show_credits(self):
"""`/credits` — focused Nous credit balance + top-up handoff.
Interactive CLI: balance block + identity line + a 3-button panel
(Open top-up / Copy link / Cancel). Non-interactive contexts — the TUI
slash-worker subprocess and any place without a live prompt_toolkit app
(``self._app is None``) — render a text variant (balance + tappable
top-up URL), because the modal would try to read the RPC stdin and crash
the worker. The terminal never confirms or polls payment (billing phase
2a). Fail-open: a portal hiccup or logged-out account degrades to a clear
message, never a crash.
"""
from agent.account_usage import build_credits_view
view = build_credits_view()
if not view.logged_in:
print()
print(f" 💳 {_DIM}Not logged into Nous Portal.{_RST}")
print(" Run `hermes portal` to log in, then /credits.")
return
print()
print(" 💳 Nous credits")
print(f" {'─' * 41}")
for line in view.balance_lines:
# Drop the helper's own "📈 Nous credits" header — we print our own.
if line.lstrip().startswith("📈"):
continue
print(f" {line}")
print(f" {'─' * 41}")
if view.identity_line:
print(f" {view.identity_line}")
if not view.topup_url:
return
# Non-interactive (TUI slash-worker, piped, no live app): the
# prompt_toolkit modal can't run here — it would read the worker's
# JSON-RPC stdin and crash the command. Render the text variant: the
# tappable URL IS the affordance, same as the messaging surfaces.
if not getattr(self, "_app", None):
print()
print(f" Top up: {view.topup_url}")
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
return
choices = [
("open", "Open top-up in browser", "launch the portal billing page"),
("copy", "Copy link", "copy the top-up URL to your clipboard"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Add credits?",
detail=f"Top-up page:\n{view.topup_url}",
choices=choices,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice == "open":
opened = False
try:
import webbrowser
opened = webbrowser.open(view.topup_url)
except Exception:
opened = False
if not opened:
print(f" Open this URL to top up: {view.topup_url}")
print()
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
elif choice == "copy":
try:
self._write_osc52_clipboard(view.topup_url)
print(f" 📋 Copied: {view.topup_url}")
except Exception:
print(f" Top-up URL: {view.topup_url}")
else:
print(" 🟡 Cancelled. No credits added.")
def _show_insights(self, command: str = "/insights"):
"""Show usage insights and analytics from session history."""
# Parse optional --days flag

View file

@ -7278,6 +7278,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "usage":
return await self._handle_usage_command(event)
if canonical == "credits":
return await self._handle_credits_command(event)
if canonical == "insights":
return await self._handle_insights_command(event)

View file

@ -2942,6 +2942,40 @@ class GatewaySlashCommandsMixin:
key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many"
return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id)
async def _handle_credits_command(self, event: MessageEvent) -> str:
"""Handle /credits -- show Nous credit balance and the top-up handoff.
Renders the balance block + identity line + a tappable top-up URL that
opens the portal billing page with the modal open. The terminal does NOT
confirm, poll, or track payment (billing phase 2a) — checkout happens in
the browser and the next /credits shows the new balance. The tappable URL
is the affordance: it works on every platform (button-capable or plain
text like SMS/email). Fetched off the event loop; fail-open.
"""
from agent.account_usage import build_credits_view
try:
view = await asyncio.to_thread(build_credits_view, markdown=True)
except Exception:
view = None
if view is None or not view.logged_in:
return t("gateway.credits.not_logged_in")
lines: list[str] = ["💳 **Nous credits**"]
for line in view.balance_lines:
if line.lstrip().startswith("📈"):
continue # drop the helper's header; we print our own
lines.append(line)
if view.identity_line:
lines.append("")
lines.append(view.identity_line)
if view.topup_url:
lines.append("")
lines.append(f"Top up: {view.topup_url}")
lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.")
return "\n".join(lines)
async def _handle_usage_command(self, event: MessageEvent) -> str:
"""Handle /usage command -- show token usage for the current session.

View file

@ -214,6 +214,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session",
gateway_only=True),
CommandDef("usage", "Show token usage and rate limits for the current session", "Info"),
CommandDef("credits", "Show Nous credit balance and top up", "Info"),
CommandDef("insights", "Show usage insights and analytics", "Info",
args_hint="[days]"),
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
@ -1043,6 +1044,17 @@ _SLACK_RESERVED_COMMANDS = frozenset({
# native slot, the alias spelling stays reachable via /hermes reset).
_SLACK_PRIORITY_ALIASES = ("btw", "bg")
# Canonical commands intentionally NOT given a native Slack slash slot. Slack
# caps apps at 50 slash commands and the registry is at that ceiling; rather
# than let the clamp silently drop whichever command sorts last (and break
# Telegram parity), we explicitly route a few low-frequency commands through
# ``/hermes <command>`` on Slack only. They remain native on every other
# surface (CLI, TUI, Telegram, Discord). Keep this list TIGHT and intentional —
# the telegram-parity test reads it so an entry here is a deliberate
# "Slack-via-/hermes" decision, not a silent clamp.
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits"})
def _sanitize_slack_name(raw: str) -> str:
"""Convert a command name to a valid Slack slash command name.
@ -1091,6 +1103,9 @@ def slack_native_slashes() -> list[tuple[str, str, str]]:
return
if slack_name in _SLACK_RESERVED_COMMANDS:
return
if slack_name in _SLACK_VIA_HERMES_ONLY:
# Intentionally Slack-via-/hermes only (see _SLACK_VIA_HERMES_ONLY).
return
if len(entries) >= _SLACK_MAX_SLASH_COMMANDS:
return
# Slack description cap is 2000 chars; keep it short.

View file

@ -80,6 +80,8 @@ class NousPortalAccountInfo:
fresh: bool
user_id: Optional[str] = None
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
client_id: Optional[str] = None
product_id: Optional[str] = None
nous_client: Optional[str] = None
@ -140,6 +142,29 @@ def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None
return f"{base.rstrip('/')}/billing"
def nous_portal_topup_url(account_info: Optional[NousPortalAccountInfo] = None) -> str:
"""Return the portal top-up URL that auto-opens the top-up modal.
Prefers the org-pinned page ``{base}/orgs/{slug}/billing?topup=open`` (skips
the legacy shim's re-resolution + multi-org disambiguation). Falls back to the
legacy ``{base}/billing?topup=open`` when the account has no ``org_slug`` (the
portal's ``slug`` is nullable; the legacy page forwards the param through to
the org-pinned page). Never builds ``/orgs/None/billing``.
The ``?topup=open`` query is the NAS enabler that lands the user in the
top-up flow rather than just on the billing page.
"""
base_billing = nous_portal_billing_url(account_info) # {base}/billing
base = base_billing[: -len("/billing")] # strip the trailing /billing
slug = getattr(account_info, "org_slug", None) if account_info is not None else None
if isinstance(slug, str) and slug.strip():
from urllib.parse import quote
return f"{base}/orgs/{quote(slug.strip(), safe='')}/billing?topup=open"
return f"{base}/billing?topup=open"
def format_nous_portal_entitlement_message(
account_info: Optional[NousPortalAccountInfo],
*,
@ -607,12 +632,10 @@ def _info_from_account_payload(
state: dict[str, Any],
portal_base_url: Optional[str],
) -> NousPortalAccountInfo:
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
organisation = (
payload.get("organisation")
if isinstance(payload.get("organisation"), dict)
else {}
)
raw_user = payload.get("user")
user: dict[str, Any] = raw_user if isinstance(raw_user, dict) else {}
raw_org = payload.get("organisation")
organisation: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
subscription = _subscription_from_payload(payload.get("subscription"))
access = _paid_service_access_from_payload(payload.get("paid_service_access"))
paid_access = access.allowed if access else None
@ -624,6 +647,8 @@ def _info_from_account_payload(
source="account_api",
fresh=True,
org_id=_coerce_str(organisation.get("id")) or (access.organisation_id if access else None),
org_slug=_coerce_str(organisation.get("slug")),
org_name=_coerce_str(organisation.get("name")),
client_id=_coerce_str(state.get("client_id")),
portal_base_url=portal_base_url,
inference_base_url=_coerce_str(state.get("inference_base_url")),

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Gedetailleerde gebruik beskikbaar na die eerste agent-antwoord)_"
no_data: "Geen gebruiksdata beskikbaar vir hierdie sessie nie."
credits:
not_logged_in: "Nie by Nous Portal aangemeld nie. Meld aan om jou kredietsaldo te sien en op te laai."
verbose:
not_enabled: "Die `/verbose`-opdrag is nie vir boodskapplatforms geaktiveer nie.\n\nAktiveer dit in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Gereedskap-vordering: **AF** — geen gereedskap-aktiwiteit word vertoon nie."

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Detaillierte Nutzung nach der ersten Agentenantwort verfügbar)_"
no_data: "Keine Nutzungsdaten für diese Sitzung verfügbar."
credits:
not_logged_in: "Nicht bei Nous Portal angemeldet. Melde dich an, um dein Guthaben zu sehen und aufzuladen."
verbose:
not_enabled: "Der Befehl `/verbose` ist für Messaging-Plattformen nicht aktiviert.\n\nIn `config.yaml` aktivieren:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Tool-Fortschritt: **OFF** — keine Tool-Aktivität angezeigt."

View file

@ -346,6 +346,9 @@ gateway:
detailed_after_first: "_(Detailed usage available after the first agent response)_"
no_data: "No usage data available for this session."
credits:
not_logged_in: "Not logged into Nous Portal. Log in to see your credit balance and top up."
verbose:
not_enabled: "The `/verbose` command is not enabled for messaging platforms.\n\nEnable it in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Tool progress: **OFF** — no tool activity shown."

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Uso detallado disponible tras la primera respuesta del agente)_"
no_data: "No hay datos de uso disponibles para esta sesión."
credits:
not_logged_in: "No has iniciado sesión en Nous Portal. Inicia sesión para ver tu saldo de créditos y recargar."
verbose:
not_enabled: "El comando `/verbose` no está habilitado para plataformas de mensajería.\n\nHabilítalo en `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Progreso de herramientas: **OFF** — no se muestra actividad de herramientas."

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Utilisation détaillée disponible après la première réponse de l'agent)_"
no_data: "Aucune donnée d'utilisation disponible pour cette session."
credits:
not_logged_in: "Non connecté à Nous Portal. Connecte-toi pour voir ton solde de crédits et recharger."
verbose:
not_enabled: "La commande `/verbose` n'est pas activée pour les plateformes de messagerie.\n\nActivez-la dans `config.yaml` :\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Progression des outils : **OFF** — aucune activité d'outil affichée."

View file

@ -338,6 +338,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Úsáid mhionsonraithe ar fáil tar éis chéad fhreagra an ghníomhaire)_"
no_data: "Níl aon sonraí úsáide ar fáil don seisiún seo."
credits:
not_logged_in: "Níl tú logáilte isteach i Nous Portal. Logáil isteach chun d'iarmhéid creidmheasa a fheiceáil agus breis a chur leis."
verbose:
not_enabled: "Níl an t-ordú `/verbose` cumasaithe d'ardáin teachtaireachtaí.\n\nCumasaigh in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Dul chun cinn uirlise: **AS** — gan aon ghníomhaíocht uirlise á thaispeáint."

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(A részletes használat az első ügynökválasz után érhető el)_"
no_data: "Ehhez a munkamenethez nincsenek elérhető használati adatok."
credits:
not_logged_in: "Nincs bejelentkezve a Nous Portalra. Jelentkezz be a kreditegyenleg megtekintéséhez és feltöltéséhez."
verbose:
not_enabled: "A `/verbose` parancs nincs engedélyezve az üzenetküldő platformokon.\n\nEngedélyezd a `config.yaml` fájlban:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Eszközfolyamat: **OFF** — nem jelenik meg eszközaktivitás."

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(L'uso dettagliato sarà disponibile dopo la prima risposta dell'agente)_"
no_data: "Nessun dato di utilizzo disponibile per questa sessione."
credits:
not_logged_in: "Non hai effettuato l'accesso a Nous Portal. Accedi per vedere il saldo dei crediti e ricaricare."
verbose:
not_enabled: "Il comando `/verbose` non è abilitato per le piattaforme di messaggistica.\n\nAbilitalo in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Progresso strumenti: **OFF** — nessuna attività degli strumenti mostrata."

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(詳細な使用状況は最初のエージェント応答後に利用可能)_"
no_data: "このセッションの使用データはありません。"
credits:
not_logged_in: "Nous Portal にログインしていません。ログインすると残高の確認とチャージができます。"
verbose:
not_enabled: "`/verbose` コマンドはメッセージングプラットフォームで有効になっていません。\n\n`config.yaml` で有効にしてください:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ ツール進捗: **OFF** — ツールの動作は表示されません。"

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(자세한 사용량은 첫 에이전트 응답 이후 확인할 수 있습니다)_"
no_data: "이 세션에 사용 가능한 사용량 데이터가 없습니다."
credits:
not_logged_in: "Nous Portal에 로그인되어 있지 않습니다. 로그인하면 크레딧 잔액 확인 및 충전을 할 수 있습니다."
verbose:
not_enabled: "`/verbose` 명령은 메시징 플랫폼에서 활성화되어 있지 않습니다.\n\n`config.yaml`에서 활성화하세요:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ 도구 진행 상황: **OFF** — 도구 활동이 표시되지 않습니다."

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Utilização detalhada disponível após a primeira resposta do agente)_"
no_data: "Não há dados de utilização disponíveis para esta sessão."
credits:
not_logged_in: "Você não está conectado ao Nous Portal. Faça login para ver seu saldo de créditos e recarregar."
verbose:
not_enabled: "O comando `/verbose` não está ativado para plataformas de mensagens.\n\nAtiva-o em `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Progresso de ferramentas: **OFF** — não é mostrada qualquer atividade de ferramentas."

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Подробное использование доступно после первого ответа агента)_"
no_data: "Данные об использовании для этого сеанса отсутствуют."
credits:
not_logged_in: "Вы не вошли в Nous Portal. Войдите, чтобы увидеть баланс кредитов и пополнить его."
verbose:
not_enabled: "Команда `/verbose` не включена для платформ обмена сообщениями.\n\nВключите в `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Прогресс инструментов: **OFF** — активность инструментов не показывается."

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Ayrıntılı kullanım, ilk ajan yanıtından sonra kullanılabilir)_"
no_data: "Bu oturum için kullanım verisi yok."
credits:
not_logged_in: "Nous Portal'a giriş yapılmadı. Bakiyenizi görmek ve yükleme yapmak için giriş yapın."
verbose:
not_enabled: "`/verbose` komutu mesajlaşma platformlarında etkin değil.\n\n`config.yaml` içinde etkinleştirin:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Araç ilerlemesi: **OFF** — araç etkinliği gösterilmez."

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Детальне використання доступне після першої відповіді агента)_"
no_data: "Дані про використання для цього сеансу відсутні."
credits:
not_logged_in: "Ви не ввійшли в Nous Portal. Увійдіть, щоб переглянути баланс кредитів і поповнити його."
verbose:
not_enabled: "Команду `/verbose` не ввімкнено для платформ обміну повідомленнями.\n\nУвімкніть у `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Прогрес інструментів: **OFF** — активність інструментів не показується."

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(首次代理回應後可檢視詳細使用情況)_"
no_data: "此工作階段沒有可用的使用資料。"
credits:
not_logged_in: "未登入 Nous Portal。登入後即可查看額度餘額並儲值。"
verbose:
not_enabled: "`/verbose` 指令未在訊息平台上啟用。\n\n請在 `config.yaml` 中啟用:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ 工具進度:**OFF** — 不顯示任何工具活動。"

View file

@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(首次代理响应后可查看详细使用情况)_"
no_data: "此会话暂无使用数据。"
credits:
not_logged_in: "未登录 Nous Portal。登录后即可查看额度余额并充值。"
verbose:
not_enabled: "`/verbose` 命令未在消息平台启用。\n\n请在 `config.yaml` 中启用:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ 工具进度:**OFF** — 不显示任何工具活动。"

View file

@ -464,12 +464,12 @@ class TestNoticeCopy:
assert "$12.34" in grant_notice.text
assert "top-up left" in grant_notice.text
def test_depleted_mentions_usage_command(self):
def test_depleted_mentions_credits_command(self):
latch = fresh_latch()
s = CreditsState(paid_access=False)
to_show, _ = evaluate_credits_notices(s, latch)
depleted_notice = next(n for n in to_show if n.key == "credits.depleted")
assert "/usage" in depleted_notice.text
assert "/credits" in depleted_notice.text
# ── Scenario 8: severity order in a single call ──────────────────────────────

View file

@ -0,0 +1,260 @@
"""Tests for the /credits command — shared view core + gateway handler.
`/credits` is the focused money surface (balance in, top-up out). These tests
exercise the surface-agnostic `build_credits_view()` core and assert the gateway
handler renders the block + tappable top-up URL + no-wait copy. The CLI panel is
a thin wrapper over the same view (interactive prompt_toolkit modal — covered by
the view-core tests plus manual verification).
"""
from __future__ import annotations
import asyncio
import pytest
import agent.account_usage as account_usage
from agent.account_usage import CreditsView, build_credits_view
from hermes_cli.nous_account import NousPortalAccountInfo, NousPaidServiceAccessInfo
def _account(**kwargs) -> NousPortalAccountInfo:
kwargs.setdefault("logged_in", True)
kwargs.setdefault("source", "account_api")
kwargs.setdefault("fresh", True)
kwargs.setdefault("portal_base_url", "https://portal.example.test")
return NousPortalAccountInfo(**kwargs)
@pytest.fixture
def _logged_in_account(monkeypatch):
"""Stub the auth token + account fetch so build_credits_view runs offline."""
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {"access_token": "tok", "portal_base_url": "https://portal.example.test"},
)
def _install(account):
monkeypatch.setattr(
"hermes_cli.nous_account.get_nous_portal_account_info",
lambda *a, **kw: account,
)
return _install
# ── build_credits_view core ─────────────────────────────────────────────────
def test_view_logged_out_when_no_token(monkeypatch):
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: {})
view = build_credits_view()
assert view == CreditsView(logged_in=False)
def test_view_built_with_org_pinned_url_and_identity(_logged_in_account):
_logged_in_account(
_account(
org_slug="acme",
org_name="Acme Inc",
email="alice@example.test",
paid_service_access=True,
paid_service_access_info=NousPaidServiceAccessInfo(
purchased_credits_remaining=30.0,
total_usable_credits=30.0,
),
subscription=None,
)
)
view = build_credits_view()
assert view.logged_in is True
assert view.topup_url == "https://portal.example.test/orgs/acme/billing?topup=open"
assert view.identity_line == "Topping up as alice@example.test / org Acme Inc"
assert view.depleted is False
# Balance lines carry the magnitudes but NOT the /usage affordance lines.
blob = "\n".join(view.balance_lines)
assert "Top-up credits: $30.00" in blob
assert "Top up:" not in blob # the trailing /usage affordance is stripped
assert "(or run" not in blob
def test_view_depleted_flag(_logged_in_account):
_logged_in_account(
_account(
org_slug="acme",
email="alice@example.test",
paid_service_access=False,
paid_service_access_info=NousPaidServiceAccessInfo(
total_usable_credits=0.0,
),
subscription=None,
)
)
view = build_credits_view()
assert view.depleted is True
def test_view_falls_back_to_legacy_url_when_slug_null(_logged_in_account):
_logged_in_account(
_account(
org_slug=None,
email="alice@example.test",
paid_service_access=True,
paid_service_access_info=NousPaidServiceAccessInfo(
purchased_credits_remaining=5.0,
total_usable_credits=5.0,
),
subscription=None,
)
)
view = build_credits_view()
assert view.topup_url == "https://portal.example.test/billing?topup=open"
assert "/orgs/" not in view.topup_url
def test_view_fetch_failure_is_logged_out(monkeypatch):
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {"access_token": "tok"},
)
def _boom(*a, **kw):
raise RuntimeError("portal down")
monkeypatch.setattr("hermes_cli.nous_account.get_nous_portal_account_info", _boom)
view = build_credits_view()
assert view.logged_in is False
# ── gateway _handle_credits_command ─────────────────────────────────────────
class _FakeEvent:
pass
def _make_gateway_stub():
"""Minimal object exposing the mixin's _handle_credits_command."""
from gateway.slash_commands import GatewaySlashCommandsMixin
class _Stub(GatewaySlashCommandsMixin):
def __init__(self):
pass
return _Stub()
def test_gateway_credits_renders_block_and_url(monkeypatch):
view = CreditsView(
logged_in=True,
balance_lines=("📈 Nous credits", "Total usable: $52.50"),
identity_line="Topping up as alice@example.test / org Acme",
topup_url="https://portal.example.test/orgs/acme/billing?topup=open",
depleted=False,
)
monkeypatch.setattr(account_usage, "build_credits_view", lambda *a, **kw: view)
stub = _make_gateway_stub()
out = asyncio.run(stub._handle_credits_command(_FakeEvent()))
assert "💳" in out
assert "Total usable: $52.50" in out
assert "Topping up as alice@example.test / org Acme" in out
assert "https://portal.example.test/orgs/acme/billing?topup=open" in out
assert "credits will appear in /credits shortly" in out
# The helper's own 📈 header line is dropped (we render our own 💳 header).
assert "📈 Nous credits" not in out
def test_gateway_credits_not_logged_in(monkeypatch):
monkeypatch.setattr(
account_usage, "build_credits_view", lambda *a, **kw: CreditsView(logged_in=False)
)
stub = _make_gateway_stub()
out = asyncio.run(stub._handle_credits_command(_FakeEvent()))
assert "Not logged into Nous Portal" in out
def test_gateway_credits_fetch_exception_is_not_logged_in(monkeypatch):
def _boom(*a, **kw):
raise RuntimeError("boom")
monkeypatch.setattr(account_usage, "build_credits_view", _boom)
stub = _make_gateway_stub()
out = asyncio.run(stub._handle_credits_command(_FakeEvent()))
assert "Not logged into Nous Portal" in out
# ── command registry ────────────────────────────────────────────────────────
def test_credits_command_registered():
from hermes_cli.commands import resolve_command, COMMAND_REGISTRY
cmd = resolve_command("credits")
assert cmd is not None and cmd.name == "credits"
# Available on every surface (not cli_only / gateway_only).
entry = next(c for c in COMMAND_REGISTRY if c.name == "credits")
assert entry.cli_only is False
assert entry.gateway_only is False
# ── CLI _show_credits non-interactive (TUI slash-worker) path ───────────────
def test_cli_show_credits_non_interactive_renders_text_not_modal(monkeypatch, capsys):
"""In the TUI slash-worker (no self._app), /credits must render the text
variant — never invoke the prompt_toolkit modal, which would read the
worker's JSON-RPC stdin and crash the command (only the depleted banner
would survive). Regression for that exact failure.
"""
import agent.account_usage as account_usage
from cli import HermesCLI
monkeypatch.setattr(
account_usage,
"build_credits_view",
lambda *a, **k: CreditsView(
logged_in=True,
balance_lines=("📈 Nous credits", "Total usable: $0.00"),
identity_line="Topping up as a@b.c / org Acme",
topup_url="https://prev.test/orgs/acme/billing?topup=open",
depleted=True,
),
)
cli = HermesCLI.__new__(HermesCLI)
cli._app = None # non-interactive, like the slash worker
# Must NOT call the modal in this context.
def _boom_modal(*a, **k):
raise AssertionError("modal must not run without a live app")
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
cli._show_credits()
out = capsys.readouterr().out
assert "💳 Nous credits" in out
assert "Total usable: $0.00" in out
assert "Topping up as a@b.c / org Acme" in out
assert "https://prev.test/orgs/acme/billing?topup=open" in out
assert "credits will appear in /credits shortly" in out
def test_cli_show_credits_logged_out(monkeypatch, capsys):
import agent.account_usage as account_usage
from cli import HermesCLI
monkeypatch.setattr(
account_usage, "build_credits_view", lambda *a, **k: CreditsView(logged_in=False)
)
cli = HermesCLI.__new__(HermesCLI)
cli._app = None
cli._show_credits()
assert "Not logged into Nous Portal" in capsys.readouterr().out

View file

@ -124,3 +124,42 @@ def test_never_raises_empty():
)
# No usable numbers and not depleted -> None, without raising.
assert build_nous_credits_snapshot(info) is None
def test_topup_line_is_org_pinned_when_slug_present():
info = _account(
portal_base_url="https://portal.example.test",
org_slug="acme",
org_name="Acme Inc",
paid_service_access=True,
paid_service_access_info=NousPaidServiceAccessInfo(
purchased_credits_remaining=30.0,
total_usable_credits=30.0,
),
subscription=None,
)
snap = build_nous_credits_snapshot(info)
assert snap is not None
blob = "\n".join(_all_lines(snap))
# The /usage top-up link auto-opens the modal and is org-pinned.
assert "https://portal.example.test/orgs/acme/billing?topup=open" in blob
assert "/credits" in blob
def test_topup_line_falls_back_to_legacy_when_slug_null():
info = _account(
portal_base_url="https://portal.example.test",
org_slug=None,
paid_service_access=True,
paid_service_access_info=NousPaidServiceAccessInfo(
purchased_credits_remaining=30.0,
total_usable_credits=30.0,
),
subscription=None,
)
snap = build_nous_credits_snapshot(info)
assert snap is not None
blob = "\n".join(_all_lines(snap))
# Null slug → legacy page (which forwards the param); never /orgs/None/...
assert "https://portal.example.test/billing?topup=open" in blob
assert "/orgs/" not in blob

View file

@ -28,9 +28,9 @@ class TestRenderNoticeLine:
)
assert (
render_notice_line(
AgentNotice(text="✕ Credit access paused · run /usage for balance", level="error")
AgentNotice(text="✕ Credit access paused · run /credits to top up", level="error")
)
== "✕ Credit access paused · run /usage for balance"
== "✕ Credit access paused · run /credits to top up"
)
def test_does_not_prepend_a_second_glyph(self):

View file

@ -14,6 +14,7 @@ from hermes_cli.commands import (
SlashCommandCompleter,
_CMD_NAME_LIMIT,
_SLACK_RESERVED_COMMANDS,
_SLACK_VIA_HERMES_ONLY,
_TG_NAME_LIMIT,
_clamp_command_names,
_clamp_telegram_names,
@ -378,7 +379,10 @@ class TestSlackNativeSlashes:
slack_norm = {_norm(n) for n in slack_names}
tg_norm = {_norm(n) for n in tg_names}
reserved_norm = {_norm(n) for n in _SLACK_RESERVED_COMMANDS}
missing = (tg_norm - slack_norm) - reserved_norm
# Commands deliberately routed through /hermes <command> on Slack only
# (Slack's 50-slash cap) are expected to be absent from native slashes.
via_hermes_norm = {_norm(n) for n in _SLACK_VIA_HERMES_ONLY}
missing = (tg_norm - slack_norm) - reserved_norm - via_hermes_norm
assert not missing, (
f"commands on Telegram but missing from Slack native slashes: {sorted(missing)}"
)

View file

@ -14,6 +14,7 @@ from hermes_cli.nous_account import (
NousPortalAccountInfo,
format_nous_portal_entitlement_message,
get_nous_portal_account_info,
nous_portal_topup_url,
reset_nous_portal_account_info_cache,
)
@ -545,3 +546,89 @@ def test_entitlement_message_for_account_missing():
assert message is not None
assert "could not find a Nous Portal account or organisation" in message
# ── org slug/name parsing + top-up URL builder ──────────────────────────────
def test_account_payload_parses_org_slug_and_name(monkeypatch):
token = _jwt({"sub": "user_123", "org_id": "org_123", "exp": int(time.time()) + 900})
payload = {
"user": {"email": "alice@example.test"},
"organisation": {"id": "org_123", "slug": "acme", "name": "Acme Inc"},
"paid_service_access": {"allowed": True, "paid_access": True},
}
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: "fresh-token")
monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload)
info = get_nous_portal_account_info(force_fresh=True)
assert info.source == "account_api"
assert info.org_slug == "acme"
assert info.org_name == "Acme Inc"
def test_account_payload_org_without_slug_leaves_fields_none(monkeypatch):
# Mirrors current main: organisation: { id } only (slug nullable on the portal).
token = _jwt({"sub": "user_123", "org_id": "org_123", "exp": int(time.time()) + 900})
payload = {
"user": {"email": "alice@example.test"},
"organisation": {"id": "org_123"},
"paid_service_access": {"allowed": True, "paid_access": True},
}
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: "fresh-token")
monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload)
info = get_nous_portal_account_info(force_fresh=True)
assert info.org_id == "org_123"
assert info.org_slug is None
assert info.org_name is None
def test_topup_url_is_org_pinned_when_slug_present():
info = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
portal_base_url="https://portal.example.test",
org_slug="acme",
)
assert (
nous_portal_topup_url(info)
== "https://portal.example.test/orgs/acme/billing?topup=open"
)
def test_topup_url_falls_back_to_legacy_when_slug_null():
info = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
portal_base_url="https://portal.example.test",
org_slug=None,
)
url = nous_portal_topup_url(info)
assert url == "https://portal.example.test/billing?topup=open"
assert "/orgs/" not in url
def test_topup_url_strips_trailing_slash_and_encodes_slug():
info = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
portal_base_url="https://portal.example.test/",
org_slug="a/b team",
)
assert (
nous_portal_topup_url(info)
== "https://portal.example.test/orgs/a%2Fb%20team/billing?topup=open"
)
def test_topup_url_defaults_to_production_portal_for_none():
url = nous_portal_topup_url(None)
assert url == "https://portal.nousresearch.com/billing?topup=open"

View file

@ -1,157 +0,0 @@
"""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
def test_one_shot_delegates_to_model_flow_nous(monkeypatch):
"""`hermes portal` must run the quick-setup Nous flow (login + MODEL PICK +
provider + Tool Gateway), i.e. delegate to `_model_flow_nous` — not the
lighter auth-only path that skipped model selection.
"""
import hermes_cli.setup as setup_mod
calls = {"model_flow": 0}
def fake_model_flow(config):
calls["model_flow"] += 1
# _model_flow_nous lives in hermes_cli.main and is imported lazily inside
# _run_portal_one_shot, so patch it at the source module.
monkeypatch.setattr("hermes_cli.main._model_flow_nous", fake_model_flow)
# Keep the disk re-sync a no-op so the test never touches real config.
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
setup_mod._run_portal_one_shot({})
assert calls["model_flow"] == 1, (
"`hermes portal` must route through _model_flow_nous so the model "
"picker runs every time (matching quick setup)."
)
@pytest.mark.parametrize("exc", [KeyboardInterrupt, EOFError, SystemExit])
def test_one_shot_swallows_cancel_and_systemexit(monkeypatch, exc):
"""A cancel/abort from the delegated Nous flow must NOT escape and kill the
CLI. `_login_nous` raises SystemExit(130)/(1) on cancel/failure, and the
expired-session re-login path inside `_model_flow_nous` only catches
Exception — so SystemExit could otherwise propagate out. The portal handler
must treat KeyboardInterrupt/EOFError/SystemExit as a graceful cancel.
"""
import hermes_cli.setup as setup_mod
def boom(config):
raise exc
monkeypatch.setattr("hermes_cli.main._model_flow_nous", boom)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
# Must return normally (None), not propagate the exception.
assert setup_mod._run_portal_one_shot({}) is None

View file

@ -4409,6 +4409,37 @@ def _(rid, params: dict) -> dict:
return _ok(rid, usage)
@method("credits.view")
def _(rid, params: dict) -> dict:
"""Structured Nous credit view for the TUI /credits command.
Account-independent (a portal fetch gated on "a Nous account is logged in"),
so it works with no live agent / on a resumed session — same as the /usage
credits block. Returns the surface-agnostic CreditsView fields so the TUI can
render a clickable top-up <Link>. Fail-open: a portal hiccup or logged-out
account yields {logged_in: false}, never an error the user has to parse.
"""
try:
from agent.account_usage import build_credits_view
view = build_credits_view()
return _ok(
rid,
{
"logged_in": bool(view.logged_in),
"balance_lines": [
line for line in view.balance_lines if not line.lstrip().startswith("📈")
],
"identity_line": view.identity_line,
"topup_url": view.topup_url,
"depleted": bool(view.depleted),
},
)
except Exception:
# Fail-open: TUI treats this as "not logged in" and shows the prompt.
return _ok(rid, {"logged_in": False, "balance_lines": [], "identity_line": None, "topup_url": None, "depleted": False})
@method("session.status")
def _(rid, params: dict) -> dict:
session, err = _sess_nowait(params, rid)

View file

@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { creditsCommands } from '../app/slash/commands/credits.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import type { CreditsViewResponse } from '../gatewayTypes.js'
// The command opens the top-up URL through this helper on confirm. Mock it so
// the test never shells out to a real browser/`xdg-open` and we can assert the
// success/failure messaging deterministically.
vi.mock('../lib/openExternalUrl.js', () => ({
openExternalUrl: vi.fn(() => true)
}))
import { openExternalUrl } from '../lib/openExternalUrl.js'
const openExternalUrlMock = vi.mocked(openExternalUrl)
const creditsCommand = creditsCommands.find(cmd => cmd.name === 'credits')!
const buildView = (overrides: Partial<CreditsViewResponse> = {}): CreditsViewResponse => ({
balance_lines: ['Grant: $9.50 left', 'Top-up: $25.00'],
depleted: false,
identity_line: 'Signed in as ada@example.com',
logged_in: true,
topup_url: 'https://portal.nousresearch.com/billing/topup',
...overrides
})
// Mirror createSlashHandler's real `guarded` wrapper: skip the handler when the
// command is stale OR the response is falsy. Tests stay non-stale, so this is a
// straightforward "run the handler when we got a response" shim.
const guarded =
<T,>(fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
}
}
const buildCtx = (rpcResult: CreditsViewResponse) => {
const sys = vi.fn()
const rpc = vi.fn(() => Promise.resolve(rpcResult))
const guardedErr = vi.fn()
const ctx = {
gateway: { rpc },
guarded,
guardedErr,
sid: 'sid-abc',
stale: () => false,
transcript: { page: vi.fn(), panel: vi.fn(), sys }
}
// Run the command, then await the rpc promise so the .then() handler has
// flushed before assertions — deterministic, no polling/timeouts.
const run = async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
creditsCommand.run('', ctx as any, 'credits')
await rpc.mock.results[0]?.value
// Allow the chained .then() microtask to settle.
await Promise.resolve()
}
return { ctx, rpc, run, sys }
}
describe('/credits slash command', () => {
beforeEach(() => {
resetOverlayState()
openExternalUrlMock.mockClear()
openExternalUrlMock.mockReturnValue(true)
})
it('renders the balance (including top-up URL) and arms the confirm overlay', async () => {
const view = buildView()
const { rpc, run, sys } = buildCtx(view)
await run()
expect(rpc).toHaveBeenCalledWith('credits.view', { session_id: 'sid-abc' })
// (a) sys received the balance text including the topup_url
const printed = sys.mock.calls.map(call => call[0]).join('\n')
expect(printed).toContain('💳 Nous credits')
expect(printed).toContain('Grant: $9.50 left')
expect(printed).toContain('Signed in as ada@example.com')
expect(printed).toContain(view.topup_url)
// (b) confirm overlay set with the expected label + detail
const confirm = getOverlayState().confirm
expect(confirm).toBeTruthy()
expect(confirm?.confirmLabel).toBe('Open top-up in browser')
expect(confirm?.cancelLabel).toBe('Cancel')
expect(confirm?.title).toBe('Add credits?')
expect(confirm?.detail).toBe(view.topup_url)
// onConfirm opens the URL and reports success back to the transcript
confirm?.onConfirm()
expect(openExternalUrlMock).toHaveBeenCalledWith(view.topup_url)
expect(sys).toHaveBeenCalledWith(
'Complete your top-up in the browser — credits will appear in /credits shortly.'
)
})
it('falls back to printing the URL when the browser open is rejected', async () => {
openExternalUrlMock.mockReturnValue(false)
const view = buildView()
const { run, sys } = buildCtx(view)
await run()
const confirm = getOverlayState().confirm
expect(confirm).toBeTruthy()
confirm?.onConfirm()
expect(sys).toHaveBeenCalledWith(`Open this URL to top up: ${view.topup_url}`)
})
it('does not arm the confirm overlay when there is no top-up URL', async () => {
const view = buildView({ topup_url: null })
const { run, sys } = buildCtx(view)
await run()
const printed = sys.mock.calls.map(call => call[0]).join('\n')
expect(printed).toContain('💳 Nous credits')
expect(getOverlayState().confirm).toBeNull()
})
it('shows the not-logged-in message and does NOT arm the confirm overlay', async () => {
const view = buildView({
balance_lines: [],
identity_line: null,
logged_in: false,
topup_url: null
})
const { run, sys } = buildCtx(view)
await run()
expect(sys).toHaveBeenCalledWith('💳 Not logged into Nous Portal — run /portal to log in.')
expect(getOverlayState().confirm).toBeNull()
expect(openExternalUrlMock).not.toHaveBeenCalled()
})
})

View file

@ -35,7 +35,7 @@ describe('turnController.startMessage — flash-and-yield notices clear on next
it('leaves a sticky credits.depleted notice across a new turn', () => {
patchUiState({
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /usage for balance' }
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /credits to top up' }
})
turnController.startMessage()
expect(getUiState().notice?.key).toBe('credits.depleted')

View file

@ -0,0 +1,57 @@
import type { CreditsViewResponse } from '../../../gatewayTypes.js'
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
import { patchOverlayState } from '../../overlayStore.js'
import type { SlashCommand } from '../types.js'
export const creditsCommands: SlashCommand[] = [
{
help: 'Show Nous credit balance and top up',
name: 'credits',
run: (_arg, ctx) => {
ctx.gateway
.rpc<CreditsViewResponse>('credits.view', { session_id: ctx.sid })
.then(
ctx.guarded<CreditsViewResponse>(view => {
if (!view.logged_in) {
ctx.transcript.sys('💳 Not logged into Nous Portal — run /portal to log in.')
return
}
const lines = ['💳 Nous credits', ...view.balance_lines]
if (view.identity_line) {
lines.push('', view.identity_line)
}
if (view.topup_url) {
lines.push('', `Top up: ${view.topup_url}`)
}
ctx.transcript.sys(lines.join('\n'))
const url = view.topup_url
if (url) {
patchOverlayState({
confirm: {
cancelLabel: 'Cancel',
confirmLabel: 'Open top-up in browser',
detail: url,
onConfirm: () => {
const ok = openExternalUrl(url)
ctx.transcript.sys(
ok
? 'Complete your top-up in the browser — credits will appear in /credits shortly.'
: `Open this URL to top up: ${url}`
)
},
title: 'Add credits?'
}
})
}
})
)
.catch(ctx.guardedErr)
}
}
]

View file

@ -1,4 +1,5 @@
import { coreCommands } from './commands/core.js'
import { creditsCommands } from './commands/credits.js'
import { debugCommands } from './commands/debug.js'
import { opsCommands } from './commands/ops.js'
import { sessionCommands } from './commands/session.js'
@ -7,6 +8,7 @@ import type { SlashCommand } from './types.js'
export const SLASH_COMMANDS: SlashCommand[] = [
...coreCommands,
...creditsCommands,
...sessionCommands,
...opsCommands,
...setupCommands,

View file

@ -43,6 +43,16 @@ export interface SlashExecResponse {
warning?: string
}
// ── Credits / top-up ─────────────────────────────────────────────────
export interface CreditsViewResponse {
balance_lines: string[]
depleted: boolean
identity_line: string | null
logged_in: boolean
topup_url: string | null
}
export type CommandDispatchResponse =
| { output?: string; type: 'exec' | 'plugin' }
| { target: string; type: 'alias' }