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' }