feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split (#68689)

* feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split

Bring the plain (non-TUI) CLI billing surface to parity with the desktop/TUI
billing changes:

- /subscription on Free (admin/owner, interactive) prints the plan catalog
  (name · $/mo · $credits/mo, from the same tiers[] data the TUI uses; monthly
  credits render as dollars). A numbered pick opens the manage-subscription
  deep-link directly with plan=<tier_id> appended.
- subscription_manage_url(state, tier_id=...) appends plan=<tier_id> (the stable
  tiers[] id) when a tier was picked, org_id first — mirrors the TUI's ?plan=.
  The paid change flow's blocked/unknown-preview portal fallback carries plan=
  for upgrades only; downgrades stay generic/native.
- /topup overview splits one-time top-up from automatic refill, the distinction
  stated in each first sentence ("Add funds now — a single charge…" vs "Refill
  when low — charges … automatically …"), keeping "credits" out of the
  dollars-only surface.
- Downgrades remain native (chargeless scheduled change), unchanged.

Updates the CLI-parity section of docs/billing-lifecycle.md and tests under
tests/hermes_cli + tests/agent.

* refactor(billing): share plan-catalog helpers + harden manage-url builder

- subscription_manage_url now preserves unrelated portal query params (parse_qsl,
  popping only the contract-owned org_id/plan) and restricts to http/https schemes,
  matching the desktop URL builder — the function owns the contract.
- Lift the plan-catalog derivation into agent/subscription_view.py so the CLI Free
  catalog and the paid picker/blocked-preview branch share one implementation:
  selectable_tiers (enabled paid, not current, sorted), format_tier_row (name · $/mo
  · $credits/mo — thousands-grouped like the TUI's toLocaleString, credits suffix
  hidden when absent/zero), and is_upgrade(state, tier_id).

* fix(cli): numbered pick, canonical guarded browser opener, partial auto-refill copy

- Free catalog: accept a bare digit as a pick (the shared normalizer only knows the
  confirm-dialog digit aliases, so `1` used to resolve to None → "Cancelled"). The
  Nth digit maps to the Nth printed row.
- Extract one _open_url_in_browser used by every "open the portal" path, applying the
  device-code flows' console-browser / remote-session guard (webbrowser.open returns
  True even for lynx/w3m over SSH) and returning whether a real browser opened.
- Consume the shared selectable_tiers / format_tier_row / is_upgrade helpers from the
  Free catalog, the paid picker, and the blocked-preview branch.
- /topup auto-refill copy: the concrete "charges $X … below $Y." sentence only when
  both amounts are present and finite; otherwise the generic sentence.

* docs(billing): correct CLI-parity rows (drop cross-repo ref, downgrade invariant)

Remove the other-repo PR reference from the manage-URL row, and state the real
downgrade invariant: a blocked downgrade may print the generic manage URL but never
carries plan=<tier_id> — selected-tier deep-links are reserved for new subscriptions
and upgrades.
This commit is contained in:
Siddharth Balyan 2026-07-22 08:11:09 +05:30 committed by GitHub
parent 14f8441009
commit 9baad4e0aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 625 additions and 35 deletions

View file

@ -254,12 +254,107 @@ class CLIBillingMixin:
if is_free:
# Starting a NEW subscription needs a fresh card — deep-link only.
self._subscription_open_portal(state, manage_url, verb="Start a subscription")
# Show the plan catalog, let the user pick, and carry ``plan=<tier_id>``
# into the portal deep-link so it preselects the chosen plan.
self._subscription_free_catalog(state, manage_url)
return
# Paid + admin/owner + interactive → the in-terminal change flow.
self._subscription_change_menu(state, manage_url)
def _open_url_in_browser(self, url: str) -> bool:
"""Open ``url`` in a REAL graphical browser; return whether one opened.
The one opener behind every "open the portal" path in this mixin. Applies
the same console-browser / remote-session guard the device-code auth flows
use (``hermes_cli.auth``): ``webbrowser.open()`` returns ``True`` even when
it launched a text-mode browser (w3m/lynx over SSH) that hijacks the TTY,
so we refuse those and let the caller print the URL instead. Returns
``False`` on any guard refusal or open failure, ``True`` only when a real
graphical browser launched.
"""
if not url:
return False
try:
from hermes_cli.auth import _can_open_graphical_browser, _is_remote_session
if _is_remote_session() or not _can_open_graphical_browser():
return False
except Exception:
# Guard unavailable → fall through to a plain best-effort open.
pass
try:
import webbrowser
return bool(webbrowser.open(url))
except Exception:
return False
def _subscription_free_catalog(self, state, manage_url):
"""Free + admin/owner + interactive: print the plan catalog, pick one, then
open the portal manage-subscription deep-link with ``plan=<tier_id>``.
The catalog mirrors the TUI Free rows (name · $/mo · $credits/mo, from the
same ``tiers[]`` data via the shared ``selectable_tiers`` / ``format_tier_row``
helpers). Monthly credits are DOLLARS rendered ``$X credits/mo`` (hidden
when absent/zero). Starting a NEW subscription needs a fresh card, so the
only action is the portal hand-off (the terminal never charges here); the
picked tier rides along as ``plan=`` so the portal preselects it.
"""
from cli import _cprint, _b, _d
from agent.subscription_view import (
format_tier_row,
selectable_tiers,
subscription_manage_url,
)
tiers = selectable_tiers(state)
if not tiers:
# No catalog to show → the plain portal hand-off (no plan= to append).
self._subscription_open_portal(state, manage_url, verb="Start a subscription")
return
print()
_cprint(f"{_b('Choose a plan')}")
print(f" {'' * 41}")
for i, t in enumerate(tiers, 1):
print(f" {i}. {format_tier_row(t)}")
_cprint(f" {_d('Starting a subscription opens the portal to add your card.')}")
choices = [(t.tier_id, format_tier_row(t), f"start {t.name} on the portal") for t in tiers]
choices.append(("cancel", "Cancel", "do nothing"))
raw = self._prompt_text_input_modal(
title="Start a subscription",
detail="Pick a plan to open it on the portal.",
choices=choices,
)
# The rows are printed numbered, so accept a bare number as a pick (the
# shared normalizer only knows the confirm-dialog digit aliases).
_digit = (raw or "").strip()
if _digit.isdigit() and 1 <= int(_digit) <= len(tiers):
choice = tiers[int(_digit) - 1].tier_id
else:
choice = self._normalize_slash_confirm_choice(raw, choices)
if not choice or choice == "cancel":
print(" 🟡 Cancelled. No plan started.")
return
# Numbered pick → open the portal deep-link directly, with the picked tier's
# plan= param so the portal preselects it (spec: pick → opens the portal).
tier_url = subscription_manage_url(state, tier_id=choice) or manage_url
if not tier_url:
_cprint(f" {_d('No manage URL available — is your portal configured?')}")
return
picked = next((t for t in tiers if t.tier_id == choice), None)
label = picked.name if picked else "your plan"
if self._open_url_in_browser(tier_url):
print(f" Opening the portal to start {label}")
else:
# No graphical browser (headless / SSH / console browser): print the
# link so it stays actionable.
print(f" Open this URL to start {label}: {tier_url}")
print(" Finish in your browser, then re-run /subscription.")
def _subscription_open_portal(self, state, manage_url, *, verb="Manage your subscription"):
"""Open / copy the manage-subscription URL — the portal hand-off."""
from cli import _cprint, _d
@ -277,14 +372,7 @@ class CLIBillingMixin:
raw = self._prompt_text_input_modal(title=verb, detail="", choices=choices)
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice == "open":
opened = False
try:
import webbrowser
opened = webbrowser.open(manage_url)
except Exception:
opened = False
if not opened:
if not self._open_url_in_browser(manage_url):
print(f" Open this URL: {manage_url}")
print()
print(" Finish in your browser, then re-run /subscription.")
@ -333,24 +421,20 @@ class CLIBillingMixin:
def _subscription_pick_tier(self, state):
"""Tier picker → preview → confirm (mirrors the TUI picker screen)."""
from agent.billing_view import format_money
from agent.subscription_view import format_tier_row, is_upgrade, selectable_tiers
c = state.current
tiers = tuple(state.tiers or ())
cur_order = next((t.tier_order for t in tiers if t.is_current), 0)
# Selectable = enabled paid tiers other than current (free/no-sub excluded;
# dropping to free is a cancellation, on the change menu). Sorted by price.
selectable = sorted(
[t for t in tiers if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0],
key=lambda t: t.tier_order or 0,
)
# Shared with the Free catalog + blocked-preview branch (one derivation).
selectable = selectable_tiers(state)
if not selectable:
print(" No other plans are available to switch to right now.")
return
choices = []
for t in selectable:
direction = "upgrade" if (t.tier_order or 0) > cur_order else "downgrade"
choices.append((t.tier_id, f"{t.name} · {format_money(t.dollars_per_month)}/mo · {direction}", f"switch to {t.name}"))
direction = "upgrade" if is_upgrade(state, t.tier_id) else "downgrade"
choices.append((t.tier_id, f"{format_tier_row(t)} · {direction}", f"switch to {t.name}"))
choices.append(("cancel", "Back", "do nothing"))
raw = self._prompt_text_input_modal(
title="Change plan",
@ -397,11 +481,15 @@ class CLIBillingMixin:
if effect not in ("charge_now", "scheduled"):
# blocked OR an unknown/unexpected effect → fail SAFE (never schedule a
# real change on an unrecognized string, unlike a bare `else`), and
# re-offer the portal hand-off like the TUI's blocked branch.
from agent.subscription_view import subscription_manage_url
# re-offer the portal hand-off like the TUI's blocked branch. The picked
# tier rides along as plan= only for an UPGRADE hand-off — new-sub /
# upgrade deep-links carry the plan; downgrades stay native (binding
# ruling), so a blocked downgrade keeps the generic manage link.
from agent.subscription_view import is_upgrade, subscription_manage_url
_plan = tier_id if is_upgrade(state, tier_id) else None
_cprint(f" 🟡 {p.reason or 'This change cannot be confirmed here — manage it on the portal.'}")
_mu = subscription_manage_url(state)
_mu = subscription_manage_url(state, tier_id=_plan)
if _mu:
print(f" Manage on portal: {_mu}")
return
@ -780,6 +868,31 @@ class CLIBillingMixin:
self._billing_portal_hint(state)
return
# One-time vs automatic — the two ways to add funds, the distinction stated
# up front in each first sentence (parity with the desktop revamp's split
# copy). "credits" stays out of the dollars-only /topup surface: "Add funds
# now" carries the one-time meaning without it.
_cprint(f" {_d('Add funds now — a single charge, added to your balance today.')}")
if (
ar is not None
and ar.enabled
and ar.reload_to_usd is not None
and ar.reload_to_usd.is_finite()
and ar.threshold_usd is not None
and ar.threshold_usd.is_finite()
):
_auto_line = (
f"Refill when low — charges {format_money(ar.reload_to_usd)} automatically "
f"when your balance falls below {format_money(ar.threshold_usd)}."
)
else:
_auto_line = (
"Refill when low — charges your card automatically when your balance "
"falls below the amount you set."
)
_cprint(f" {_d(_auto_line)}")
print(f" {'' * 41}")
# Add funds first, then settings, then the scopeless browser handoff.
# No "Allow Remote Spending" item — that's discovered at pay time.
# "Add funds" charges in-terminal against the org's portal-saved card
@ -787,8 +900,8 @@ class CLIBillingMixin:
# missing card is NOT gated here: the buy flow reacts to the server's
# no_payment_method 403 and hands off to the portal at charge time.
choices = [
("buy", "Add funds", "add money to your balance"),
("auto", "Auto-reload", "configure automatic top-ups"),
("buy", "Add funds", "a single charge, added to your balance today"),
("auto", "Auto-reload", "refill automatically when your balance runs low"),
("limit", "Monthly limit", "show the monthly spend cap (read-only)"),
("portal", "Manage on portal", "open the billing page in your browser"),
("cancel", "Cancel", "do nothing"),
@ -837,14 +950,7 @@ class CLIBillingMixin:
if not url:
print(" No portal URL available.")
return
opened = False
try:
import webbrowser
opened = webbrowser.open(url)
except Exception:
opened = False
if not opened:
if not self._open_url_in_browser(url):
print(f" Open this URL: {url}")
print(" Complete billing changes in the browser.")