mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 09:21:36 +00:00
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:
parent
4474873d2c
commit
7ba5df0d52
36 changed files with 944 additions and 172 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
82
cli.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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** — ツールの動作は表示されません。"
|
||||
|
|
|
|||
|
|
@ -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** — 도구 활동이 표시되지 않습니다."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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** — активность инструментов не показывается."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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** — активність інструментів не показується."
|
||||
|
|
|
|||
|
|
@ -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** — 不顯示任何工具活動。"
|
||||
|
|
|
|||
|
|
@ -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** — 不显示任何工具活动。"
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────
|
||||
|
|
|
|||
260
tests/agent/test_credits_view.py
Normal file
260
tests/agent/test_credits_view.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
144
ui-tui/src/__tests__/creditsCommand.test.ts
Normal file
144
ui-tui/src/__tests__/creditsCommand.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
57
ui-tui/src/app/slash/commands/credits.ts
Normal file
57
ui-tui/src/app/slash/commands/credits.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue