From b4cf5b65dd1cfa676f5d6077699dba06d38e606e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 23 May 2026 02:39:09 -0700 Subject: [PATCH] feat(portal): one-shot setup, status CLI, and Nous-included markers (#30860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(portal): one-shot setup, status CLI, and Nous-included markers Four small Portal-aware surfaces that drive subscription value without adding friction for non-Portal users. - hermes setup --portal: one-shot Nous OAuth + provider switch + Tool Gateway opt-in. Shareable as a single command from docs/social. - hermes portal {status,open,tools}: small surface over Portal auth + Tool Gateway routing. Defaults to 'status' when no subcommand. - Tool picker (hermes tools): when the user is logged into Nous, mark Nous-managed provider rows with a star and 'Included with your Nous subscription'. Suppressed when not authed — non-subscribers see the picker unchanged. - BYOK setup hint: a single dim line 'Available through Nous Portal subscription.' appears when the user is being prompted for a paid API key (Firecrawl, FAL, ElevenLabs, Browserbase, etc.) AND the category has a Nous-managed sibling AND the user is not already authed to Nous. Suppressed in all other cases. Tested live end-to-end in an isolated HERMES_HOME with a simulated authed and unauthed user. Targeted suite (tests/hermes_cli/ test_tools_config.py + test_setup.py) passes 97/97. * fix: add portal to _BUILTIN_SUBCOMMANDS so plugin discovery fast-path skips it --- hermes_cli/main.py | 22 +++- hermes_cli/portal_cli.py | 219 +++++++++++++++++++++++++++++++++++++ hermes_cli/setup.py | 118 ++++++++++++++++++++ hermes_cli/tools_config.py | 44 +++++++- 4 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 hermes_cli/portal_cli.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4488995dc9d..27d24f7eb63 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6097,6 +6097,13 @@ def cmd_webhook(args): webhook_command(args) +def cmd_portal(args): + """Nous Portal status and Tool Gateway routing surface.""" + from hermes_cli.portal_cli import portal_command + + return portal_command(args) + + def cmd_slack(args): """Slack integration helpers. @@ -10647,7 +10654,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "config", "cron", "curator", "dashboard", "debug", "doctor", "dump", "fallback", "gateway", "hooks", "import", "insights", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", - "model", "pairing", "plugins", "postinstall", "profile", "proxy", + "model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy", "send", "sessions", "setup", "skills", "slack", "status", "tools", "uninstall", "update", "version", "webhook", "whatsapp", "chat", "secrets", @@ -11384,6 +11391,13 @@ def main(): help="On existing installs: only prompt for items that are missing " "or unset, instead of running the full reconfigure wizard.", ) + setup_parser.add_argument( + "--portal", + action="store_true", + help="One-shot Nous Portal setup: log in via OAuth, set Nous as the " + "inference provider, and opt into the Tool Gateway. Skips the " + "rest of the wizard.", + ) setup_parser.set_defaults(func=cmd_setup) # ========================================================================= @@ -11859,6 +11873,12 @@ def main(): webhook_parser.set_defaults(func=cmd_webhook) + # ========================================================================= + # portal command — Nous Portal status + Tool Gateway routing + # ========================================================================= + from hermes_cli.portal_cli import add_parser as _add_portal_parser + _add_portal_parser(subparsers) + # ========================================================================= # kanban command — multi-profile collaboration board # ========================================================================= diff --git a/hermes_cli/portal_cli.py b/hermes_cli/portal_cli.py new file mode 100644 index 00000000000..aa658e41d21 --- /dev/null +++ b/hermes_cli/portal_cli.py @@ -0,0 +1,219 @@ +"""``hermes portal`` — small CLI surface for Nous Portal users. + +Subcommands: + status Show Portal auth state + which Tool Gateway tools are routed. + open Open the Portal subscription page in the user's default browser. + tools List Tool Gateway tools and which are active in the current config. + +This command is intentionally minimal — it does not duplicate functionality +already in ``hermes auth`` or ``hermes tools``. It's a discovery + status +surface for the Portal subscription itself. +""" +from __future__ import annotations + +import sys +import webbrowser +from typing import Optional + +from hermes_cli.colors import Colors, color +from hermes_cli.config import load_config + +DEFAULT_PORTAL_URL = "https://portal.nousresearch.com" +SUBSCRIPTION_URL = "https://portal.nousresearch.com/manage-subscription" +DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway" + + +def _nous_portal_base_url() -> str: + """Resolve the Portal base URL from auth state or default.""" + try: + from hermes_cli.auth import get_nous_auth_status + status = get_nous_auth_status() or {} + url = status.get("portal_base_url") + if isinstance(url, str) and url.strip(): + return url.rstrip("/") + except Exception: + pass + return DEFAULT_PORTAL_URL + + +def _cmd_status(args) -> int: + """Show Portal auth + Tool Gateway routing summary.""" + from hermes_cli.auth import get_nous_auth_status + from hermes_cli.nous_subscription import get_nous_subscription_features + + config = load_config() or {} + + try: + auth = get_nous_auth_status() or {} + except Exception: + auth = {} + + logged_in = bool(auth.get("logged_in")) + + print() + print(color(" Nous Portal", Colors.MAGENTA)) + print(color(" ───────────", Colors.MAGENTA)) + if logged_in: + portal = auth.get("portal_base_url") or DEFAULT_PORTAL_URL + print(f" Auth: {color('✓ logged in', Colors.GREEN)}") + print(f" Portal: {portal}") + inference = auth.get("inference_base_url") + if inference: + print(f" API: {inference}") + else: + print(f" Auth: {color('not logged in', Colors.YELLOW)}") + print(f" Sign up: {SUBSCRIPTION_URL}") + print(f" Login: hermes auth add nous --type oauth") + + # Provider selection (independent of auth) + model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {} + provider = str(model_cfg.get("provider") or "").strip().lower() + if provider == "nous": + print(f" Model: {color('✓ using Nous as inference provider', Colors.GREEN)}") + elif provider: + print(f" Model: currently {provider} (switch with `hermes model`)") + + # Tool Gateway routing + print() + print(color(" Tool Gateway", Colors.MAGENTA)) + print(color(" ────────────", Colors.MAGENTA)) + try: + features = get_nous_subscription_features(config) + except Exception: + features = None + + if features is None: + print(" (could not resolve subscription state)") + return 0 + + rows = [] + for feat in features.items(): + if feat.managed_by_nous: + state = color("via Nous Portal", Colors.GREEN) + elif feat.active and feat.current_provider: + state = feat.current_provider + elif feat.active: + state = "active" + else: + state = color("not configured", Colors.DIM) + rows.append((feat.label, state)) + + width = max((len(r[0]) for r in rows), default=0) + for label, state in rows: + print(f" {label:<{width}} {state}") + + if not logged_in: + print() + print(color(f" Docs: {DOCS_URL}", Colors.DIM)) + return 0 + + +def _cmd_open(args) -> int: + """Open the Portal subscription page in the default browser.""" + target = SUBSCRIPTION_URL + print(f"Opening {target}") + try: + opened = webbrowser.open(target) + except Exception: + opened = False + if not opened: + print() + print("Could not launch a browser. Visit the URL above manually.") + return 1 + return 0 + + +def _cmd_tools(args) -> int: + """List the Tool Gateway catalog + current routing.""" + from hermes_cli.nous_subscription import get_nous_subscription_features + + config = load_config() or {} + try: + features = get_nous_subscription_features(config) + except Exception: + print("Could not resolve Tool Gateway state.", file=sys.stderr) + return 1 + + # Static catalog — the partners Tool Gateway routes to today. + catalog = [ + ("web", "Web search & extract", "Firecrawl"), + ("image_gen", "Image generation", "FAL"), + ("tts", "Text-to-speech", "OpenAI TTS"), + ("browser", "Browser automation", "Browser Use"), + ("modal", "Cloud terminal", "Modal"), + ] + + print() + print(color(" Tool Gateway catalog", Colors.MAGENTA)) + print(color(" ────────────────────", Colors.MAGENTA)) + + if not features.nous_auth_present: + print(color(" Not logged into Nous Portal — sign in with `hermes auth add nous --type oauth`.", Colors.YELLOW)) + print() + + label_width = max(len(label) for _, label, _ in catalog) + for key, label, partner in catalog: + feat = features.features.get(key) + if feat is None: + state = color("unknown", Colors.DIM) + elif feat.managed_by_nous: + state = color("✓ via Nous Portal", Colors.GREEN) + elif feat.active and feat.current_provider: + state = feat.current_provider + elif feat.active: + state = "active" + else: + state = color("not configured", Colors.DIM) + print(f" {label:<{label_width}} partner: {partner:<14} {state}") + + print() + print(color(f" Manage your subscription: {SUBSCRIPTION_URL}", Colors.DIM)) + print(color(f" Docs: {DOCS_URL}", Colors.DIM)) + return 0 + + +def portal_command(args) -> int: + """Top-level dispatch for `hermes portal `.""" + sub = getattr(args, "portal_command", None) + if sub in {None, ""}: + # Default to status — matches gh / kubectl conventions where the + # subcommand-less form gives a useful overview. + return _cmd_status(args) + if sub == "status": + return _cmd_status(args) + if sub == "open": + return _cmd_open(args) + if sub == "tools": + return _cmd_tools(args) + print(f"Unknown portal subcommand: {sub}", file=sys.stderr) + print("Run `hermes portal -h` for usage.", file=sys.stderr) + return 1 + + +def add_parser(subparsers) -> None: + """Register `hermes portal` on the given argparse subparsers object.""" + portal_parser = subparsers.add_parser( + "portal", + help="Nous Portal status, subscription, and Tool Gateway routing", + description=( + "Inspect Nous Portal auth, Tool Gateway routing, and open the " + "Portal subscription page. Subcommands: status (default), " + "open, tools." + ), + ) + portal_sub = portal_parser.add_subparsers(dest="portal_command") + + portal_sub.add_parser( + "status", + help="Show Portal auth + Tool Gateway routing summary (default)", + ) + portal_sub.add_parser( + "open", + help="Open the Portal subscription page in your default browser", + ) + portal_sub.add_parser( + "tools", + help="List Tool Gateway tools and which are routed via Nous", + ) + + portal_parser.set_defaults(func=portal_command) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 8f7c4947ef8..20525205db0 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -3060,6 +3060,119 @@ SETUP_SECTIONS = [ ] +def _run_portal_one_shot(config: dict) -> None: + """One-shot Nous Portal setup — OAuth + provider switch + Tool Gateway. + + Wired into ``hermes setup --portal``. Does NOT prompt for anything + besides what the underlying OAuth + Tool Gateway prompts already need. + Designed to be shareable as a single command (``hermes setup --portal``) + that gets a brand-new user from zero to a fully working Hermes session + with web/image/tts/browser tools all routed via their Portal sub. + """ + from types import SimpleNamespace + + from hermes_cli.auth_commands import auth_add_command + from hermes_cli.config import save_config + from hermes_cli.auth import get_nous_auth_status + from hermes_cli.nous_subscription import prompt_enable_tool_gateway + + print() + print( + color( + "┌─────────────────────────────────────────────────────────┐", + Colors.MAGENTA, + ) + ) + print(color("│ ⚕ Hermes Setup — Nous Portal (one-shot) │", Colors.MAGENTA)) + print( + color( + "└─────────────────────────────────────────────────────────┘", + Colors.MAGENTA, + ) + ) + print() + print_info(" One subscription, 300+ models, plus the Tool Gateway:") + print_info(" web search, image generation, TTS, browser automation") + print_info(" — all routed through your Nous Portal sub.") + print() + print_info(" Sign up: https://portal.nousresearch.com/manage-subscription") + print() + + # Skip OAuth if already logged in (don't re-prompt every time the user + # runs `hermes setup --portal` after a successful first run). + already_logged_in = False + try: + already_logged_in = bool((get_nous_auth_status() or {}).get("logged_in")) + except Exception: + already_logged_in = False + + if already_logged_in: + print_success(" Already logged into Nous Portal.") + else: + # Hand off to the shared auth wiring so the device-code flow is + # identical to `hermes auth add nous --type oauth`. SimpleNamespace + # mirrors the argparse Namespace contract that auth_add_command expects. + ns = SimpleNamespace( + provider="nous", + auth_type="oauth", + label=None, + api_key=None, + portal_url=None, + inference_url=None, + client_id=None, + scope=None, + no_browser=False, + timeout=None, + insecure=False, + ca_bundle=None, + min_key_ttl_seconds=5 * 60, + ) + try: + auth_add_command(ns) + except SystemExit as e: + print() + print_error(f" Nous Portal login failed (exit {e.code}).") + print_info(" You can retry later with `hermes auth add nous --type oauth`.") + return + except (KeyboardInterrupt, EOFError): + print() + print_info(" Setup cancelled.") + return + except Exception as exc: + print() + print_error(f" Nous Portal login failed: {exc}") + print_info(" You can retry later with `hermes auth add nous --type oauth`.") + return + + # Set provider → nous so the model picker, status surfaces, and + # managed-tool gating all light up. Leave model.model empty so the + # runtime picks Nous's default model; the user can change it later + # with `hermes model`. + model_cfg = config.get("model") + if not isinstance(model_cfg, dict): + model_cfg = {} + config["model"] = model_cfg + model_cfg["provider"] = "nous" + save_config(config) + print() + print_success(" Nous set as your inference provider.") + + # Offer the Tool Gateway opt-in (single Y/n) — same flow that fires + # from `hermes model` after picking Nous. + print() + try: + prompt_enable_tool_gateway(config) + except (KeyboardInterrupt, EOFError): + pass + except Exception as exc: + print_warning(f" Tool Gateway prompt skipped: {exc}") + + print() + print_success("Portal setup complete.") + print_info(" Run `hermes portal status` to inspect routing.") + print_info(" Run `hermes` to start chatting.") + + def run_setup_wizard(args): """Run the interactive setup wizard. @@ -3115,6 +3228,11 @@ def run_setup_wizard(args): ) return + # --portal: one-shot Nous Portal setup. Skips the rest of the wizard. + if bool(getattr(args, "portal", False)): + _run_portal_one_shot(config) + return + # Check if a specific section was requested section = getattr(args, "section", None) if section: diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 87e7816169c..23cb8e685fd 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1925,6 +1925,16 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict): print() # Plain text labels only (no ANSI codes in menu items) + # When the user is logged into Nous, surface a marker on providers + # whose access is included in their subscription so it's visually + # obvious which options cost extra vs. cost nothing on top of Nous. + try: + _nous_logged_in = bool( + get_nous_subscription_features(config).nous_auth_present + ) + except Exception: + _nous_logged_in = False + provider_choices = [] for p in providers: badge = f" [{p['badge']}]" if p.get("badge") else "" @@ -1938,7 +1948,15 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict): configured = "" else: configured = " [configured]" - provider_choices.append(f"{p['name']}{badge}{tag}{configured}") + # Highlight Nous-managed entries when the user has Portal auth. + # curses_radiolist can't render ANSI inside item strings, so we + # use a plain unicode star + parenthetical phrase. Suppressed + # when no Portal auth is present so non-subscribers see the + # picker unchanged. + sub_marker = "" + if _nous_logged_in and p.get("managed_nous_feature"): + sub_marker = " ★ Included with your Nous subscription" + provider_choices.append(f"{p['name']}{badge}{tag}{configured}{sub_marker}") # Add skip option provider_choices.append("Skip — keep defaults / configure later") @@ -2405,6 +2423,30 @@ def _configure_provider(provider: dict, config: dict): # Prompt for each required env var all_configured = True + # If this BYOK provider lives in a category that ALSO has a + # Nous-managed sibling, show a single dim hint so users know + # they can avoid the key entirely via a Portal subscription. + # Suppressed when the user is already authed to Nous. + _show_portal_hint = False + if env_vars and not managed_feature and not provider.get("requires_nous_auth"): + try: + _has_managed_sibling = False + for _cat_key, _cat in TOOL_CATEGORIES.items(): + _providers = _cat.get("providers", []) + if provider in _providers and any( + sib.get("managed_nous_feature") for sib in _providers + ): + _has_managed_sibling = True + break + if _has_managed_sibling: + _features = get_nous_subscription_features(config) + _show_portal_hint = not _features.nous_auth_present + except Exception: + _show_portal_hint = False + + if _show_portal_hint: + _print_info(" Available through Nous Portal subscription.") + for var in env_vars: existing = get_env_value(var["key"]) if existing: