From 0c955d4828a1998274f38d57b19ef0889ac5513c Mon Sep 17 00:00:00 2001 From: Edward Park Date: Fri, 7 Aug 2026 23:02:29 -0700 Subject: [PATCH] feat: refine Horizon account output --- docs/cli/overview.mdx | 4 +- docs/deployment/prefect-horizon.mdx | 2 +- fastmcp_slim/fastmcp/cli/deploy/command.py | 50 +++--- fastmcp_slim/fastmcp/cli/deploy/output.py | 177 +++++++++++++++++---- tests/cli/deploy/test_command.py | 21 +-- tests/cli/deploy/test_output.py | 45 +++--- 6 files changed, 208 insertions(+), 91 deletions(-) diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 9a41239ff..39b5a4abf 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -29,7 +29,7 @@ fastmcp --help | [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project | | [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth | | `login` | Sign in to Prefect Horizon with a browser device flow | -| `whoami` | Show the current Horizon user and organization memberships | +| `whoami` | Show the current Horizon account | | `logout` | Revoke the current Horizon key and remove the local credential | | `version` | Print version info (`--copy` to copy to clipboard) | @@ -107,7 +107,7 @@ fastmcp login --host https://horizon.example.com Login stores only the personal Horizon API key. It does not select or store a deployment organization. -`fastmcp whoami` gets the current user and organization memberships from Horizon. +`fastmcp whoami` gets the current user from Horizon. `fastmcp logout` attempts to revoke the active key and always removes the local credential. Set `HORIZON_API_KEY` to use an environment credential instead. diff --git a/docs/deployment/prefect-horizon.mdx b/docs/deployment/prefect-horizon.mdx index 565424522..cab9795d1 100644 --- a/docs/deployment/prefect-horizon.mdx +++ b/docs/deployment/prefect-horizon.mdx @@ -27,7 +27,7 @@ New users can register and create their first Horizon organization in the browse Use `fastmcp login --host ` to save a different Horizon host. A host change clears the stored credential before login. -Check the active account and its current organization memberships after login. +Check the active account after login. ```bash fastmcp whoami diff --git a/fastmcp_slim/fastmcp/cli/deploy/command.py b/fastmcp_slim/fastmcp/cli/deploy/command.py index cf8effef1..630b12e88 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/command.py +++ b/fastmcp_slim/fastmcp/cli/deploy/command.py @@ -7,6 +7,7 @@ import webbrowser from typing import Annotated, NoReturn from cyclopts import Parameter +from rich.status import Status from fastmcp.cli.deploy.authentication import ( DeviceAuthorizationDeniedError, @@ -23,8 +24,8 @@ from fastmcp.cli.deploy.credentials import ( revoke_and_clear_credential, ) from fastmcp.cli.deploy.horizon_client import ( + DeviceAuthorization, HorizonClient, - HorizonOrganization, HorizonResponseError, HorizonUnauthorizedError, HorizonUnavailableError, @@ -37,6 +38,8 @@ from fastmcp.cli.deploy.output import ( emit_error, emit_identity, emit_logout, + start_device_approval_status, + stop_device_approval_status, ) from fastmcp.cli.deploy.state import StateFileError @@ -144,14 +147,12 @@ def _fail_for_expected_error( raise error -async def _get_identity( +async def _get_user( api_origin: str, credential: ResolvedCredential, -) -> tuple[HorizonUser, tuple[HorizonOrganization, ...]]: +) -> HorizonUser: async with HorizonClient(api_origin, api_key=credential.api_key) as client: - user = await client.get_current_user() - organizations = await client.list_organizations() - return user, organizations + return await client.get_current_user() async def login( @@ -181,16 +182,23 @@ async def login( ) async def device_authorization(): - async with HorizonClient(configuration.api_origin) as client: - return await authorize_device( - client, - on_challenge=lambda challenge: emit_device_challenge( - challenge, - json_output=json_output, - ), - open_browser=not json_output and _can_open_browser(), - browser_opener=webbrowser.open, - ) + approval_status: Status | None = None + + def show_challenge(challenge: DeviceAuthorization) -> None: + nonlocal approval_status + emit_device_challenge(challenge, json_output=json_output) + approval_status = start_device_approval_status(json_output=json_output) + + try: + async with HorizonClient(configuration.api_origin) as client: + return await authorize_device( + client, + on_challenge=show_challenge, + open_browser=not json_output and _can_open_browser(), + browser_opener=webbrowser.open, + ) + finally: + stop_device_approval_status(approval_status) credential = await resolve_credential( credentials, @@ -198,7 +206,7 @@ async def login( ) try: - user, organizations = await _get_identity( + user = await _get_user( configuration.api_origin, credential, ) @@ -215,7 +223,7 @@ async def login( authorize=device_authorization, ) try: - user, organizations = await _get_identity( + user = await _get_user( configuration.api_origin, credential, ) @@ -235,7 +243,6 @@ async def login( emit_identity( "login", user, - organizations, json_output=json_output, ) @@ -244,14 +251,14 @@ async def whoami( *, json_output: JsonOption = False, ) -> None: - """Show the current Prefect Horizon user and organization memberships.""" + """Show the current Prefect Horizon user.""" credentials = CredentialStore() credential: ResolvedCredential | None = None try: configuration = ConfigurationStore().load() credential = await resolve_credential(credentials) - user, organizations = await _get_identity( + user = await _get_user( configuration.api_origin, credential, ) @@ -270,7 +277,6 @@ async def whoami( emit_identity( "whoami", user, - organizations, json_output=json_output, ) diff --git a/fastmcp_slim/fastmcp/cli/deploy/output.py b/fastmcp_slim/fastmcp/cli/deploy/output.py index 624b6d469..925184ea4 100644 --- a/fastmcp_slim/fastmcp/cli/deploy/output.py +++ b/fastmcp_slim/fastmcp/cli/deploy/output.py @@ -6,13 +6,16 @@ import json import sys from typing import Literal -from rich.console import Console +from rich import box +from rich.align import Align +from rich.console import Console, Group +from rich.padding import Padding +from rich.panel import Panel +from rich.status import Status +from rich.table import Table +from rich.text import Text -from fastmcp.cli.deploy.horizon_client import ( - DeviceAuthorization, - HorizonOrganization, - HorizonUser, -) +from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser CommandName = Literal["login", "logout", "whoami"] ErrorCategory = Literal[ @@ -37,6 +40,47 @@ def _write_json(payload: object, *, stderr: bool = False) -> None: print(json.dumps(payload, separators=(",", ":")), file=stream, flush=True) +def _banner(title: str, *, style: str) -> Panel: + return Panel( + Align.center(Text(title, style=f"bold {style}")), + box=box.ROUNDED, + border_style=style, + padding=(0, 1), + width=52, + ) + + +def _account_panel( + user: HorizonUser, + *, + title: str, + message: str, +) -> Panel: + name = Text(user.name or user.email, style="bold") + details: list[Text] = [name] + if user.name: + details.append(Text(user.email, style="cyan")) + details.extend([Text(), Text(message, style="green")]) + return Panel( + Group(*details), + title=Text(title, style="bold green"), + title_align="left", + box=box.ROUNDED, + border_style="green", + padding=(1, 2), + width=52, + ) + + +def _format_duration(seconds: int) -> str: + if seconds % 60 == 0: + minutes = seconds // 60 + unit = "minute" if minutes == 1 else "minutes" + return f"{minutes} {unit}" + unit = "second" if seconds == 1 else "seconds" + return f"{seconds} {unit}" + + def emit_device_challenge( authorization: DeviceAuthorization, *, @@ -55,44 +99,84 @@ def emit_device_challenge( ) return - console.print("Open this URL to sign in to Prefect Horizon:") - console.print(authorization.verification_uri) - console.print(f"Enter code: {authorization.user_code}") - console.print("Waiting for approval...") + console.print() + console.print(_banner("FastMCP CLI Sign In", style="cyan")) + console.print() + console.print(Text("✓ Device authorization started", style="bold green")) + console.print() + console.print(" Open this URL in your browser:") + console.print() + console.print( + Padding( + Text(authorization.verification_uri_complete, style="cyan underline"), + (0, 2), + ) + ) + console.print() + console.print(" Confirm this code:") + console.print() + code = Table.grid() + code.add_column(justify="center", width=52) + code.add_row(Text(authorization.user_code, style="bold")) + console.print(code) + console.print() + expires_in = _format_duration(authorization.expires_in) + console.print(Text(f"The request expires in {expires_in}.", style="dim")) + console.print(Text("Press Ctrl-C to cancel.", style="dim")) + console.print() + + +def start_device_approval_status(*, json_output: bool) -> Status | None: + """Start the terminal spinner while the browser approval is pending.""" + if json_output: + return None + status = console.status( + "[cyan]Waiting for approval in your browser[/cyan]", + spinner="dots", + spinner_style="cyan", + ) + status.start() + return status + + +def stop_device_approval_status(status: Status | None) -> None: + """Stop a device approval spinner when one is active.""" + if status is not None: + status.stop() def emit_identity( command: Literal["login", "whoami"], user: HorizonUser, - organizations: tuple[HorizonOrganization, ...], *, json_output: bool, ) -> None: - """Show the authenticated user and current organization memberships.""" + """Show the authenticated user.""" if json_output: _write_json( { "ok": True, "command": command, "user": user.model_dump(mode="json"), - "organizations": [ - organization.model_dump(mode="json") - for organization in organizations - ], } ) return - prefix = "Signed in" if command == "login" else "Authenticated" - display_name = f"{user.name} <{user.email}>" if user.name else user.email - console.print(f"{prefix} as {display_name}.", markup=False) - if not organizations: - console.print("Organization memberships: none") - return - - console.print("Organization memberships:") - for organization in organizations: - console.print(f"- {organization.name} ({organization.slug})", markup=False) + console.print() + if command == "login": + panel = _account_panel( + user, + title="✓ Authorization complete", + message="You are signed in to FastMCP.", + ) + else: + panel = _account_panel( + user, + title="FastMCP Account", + message="● Signed in", + ) + console.print(panel) + console.print() def emit_logout( @@ -113,9 +197,27 @@ def emit_logout( return if remote_revoked: - console.print("Signed out of Prefect Horizon.") + title = "✓ Signed out of FastMCP" + message = "The Horizon credential was revoked and removed from this device." + style = "green" else: - console.print("No active Horizon credential remains on this device.") + title = "FastMCP Account" + message = "No active Horizon credential remains on this device." + style = "cyan" + + console.print() + console.print( + Panel( + Text(message), + title=Text(title, style=f"bold {style}"), + title_align="left", + box=box.ROUNDED, + border_style=style, + padding=(1, 2), + width=60, + ) + ) + console.print() def emit_error( @@ -141,4 +243,21 @@ def emit_error( _write_json(payload) return - error_console.print(f"Error: {message}", markup=False) + titles = { + "login": "✗ Sign in failed", + "logout": "✗ Sign out failed", + "whoami": "✗ Account lookup failed", + } + error_console.print() + error_console.print( + Panel( + Text(message), + title=Text(titles[command], style="bold red"), + title_align="left", + box=box.ROUNDED, + border_style="red", + padding=(1, 2), + width=60, + ) + ) + error_console.print() diff --git a/tests/cli/deploy/test_command.py b/tests/cli/deploy/test_command.py index 6bcf32b70..b7473cc8d 100644 --- a/tests/cli/deploy/test_command.py +++ b/tests/cli/deploy/test_command.py @@ -20,13 +20,11 @@ class HorizonAuthAPI: *, token_error: str | None = None, revoke_status: int = 204, - organizations: list[dict[str, str]] | None = None, invalid_api_key: str | None = None, ) -> None: self.token_error = token_error self.revoke_status = revoke_status self.invalid_api_key = invalid_api_key - self.organizations = organizations or [] self.requests: list[httpx2.Request] = [] def __call__(self, request: httpx2.Request) -> httpx2.Response: @@ -68,14 +66,6 @@ class HorizonAuthAPI: } }, ) - if path == "/api/v0/me/organizations": - return httpx2.Response( - 200, - json={ - "items": self.organizations, - "meta": {"nextCursor": None, "limit": 100}, - }, - ) if path == "/api/v0/me/api-key": return httpx2.Response(self.revoke_status) raise AssertionError(f"Unexpected request: {request.method} {path}") @@ -117,9 +107,7 @@ async def test_json_login_writes_one_result_and_challenge_to_stderr( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: - api = HorizonAuthAPI( - organizations=[{"id": "org-1", "name": "Acme", "slug": "acme"}] - ) + api = HorizonAuthAPI() use_horizon_api(api) browser_open = Mock() monkeypatch.setattr(command_module.webbrowser, "open", browser_open) @@ -137,7 +125,6 @@ async def test_json_login_writes_one_result_and_challenge_to_stderr( "email": "ada@example.com", "name": "Ada", }, - "organizations": [{"id": "org-1", "name": "Acme", "slug": "acme"}], } assert json.loads(captured.err) == { "event": "device_authorization", @@ -198,8 +185,10 @@ async def test_tty_login_survives_browser_open_failure( output = capsys.readouterr().out assert "https://horizon.prefect.io/oauth/device" in output assert "ABCD-EFGH" in output - assert "Signed in as Ada ." in output - assert "Organization memberships: none" in output + assert "✓ Authorization complete" in output + assert "Ada" in output + assert "ada@example.com" in output + assert "Organization" not in output browser_open.assert_called_once() diff --git a/tests/cli/deploy/test_output.py b/tests/cli/deploy/test_output.py index e5258baa1..3af6f1501 100644 --- a/tests/cli/deploy/test_output.py +++ b/tests/cli/deploy/test_output.py @@ -2,11 +2,7 @@ import json import pytest -from fastmcp.cli.deploy.horizon_client import ( - DeviceAuthorization, - HorizonOrganization, - HorizonUser, -) +from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser from fastmcp.cli.deploy.output import ( emit_device_challenge, emit_error, @@ -32,13 +28,6 @@ def user() -> HorizonUser: return HorizonUser(id="user-1", email="ada@example.com", name="Ada") -def organizations() -> tuple[HorizonOrganization, ...]: - return ( - HorizonOrganization(id="org-1", name="Acme", slug="acme"), - HorizonOrganization(id="org-2", name="Research", slug="research"), - ) - - def test_json_device_challenge_uses_only_stderr( capsys: pytest.CaptureFixture[str], ) -> None: @@ -56,10 +45,24 @@ def test_json_device_challenge_uses_only_stderr( } +def test_tty_device_challenge_uses_the_sign_in_layout( + capsys: pytest.CaptureFixture[str], +) -> None: + emit_device_challenge(authorization(), json_output=False) + + output = capsys.readouterr().out + assert "╭" in output + assert "FastMCP CLI Sign In" in output + assert "✓ Device authorization started" in output + assert "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" in output + assert "ABCD-EFGH" in output + assert "The request expires in 10 minutes." in output + + def test_json_identity_has_stable_fields( capsys: pytest.CaptureFixture[str], ) -> None: - emit_identity("login", user(), organizations(), json_output=True) + emit_identity("login", user(), json_output=True) result = json.loads(capsys.readouterr().out) assert result == { @@ -70,21 +73,21 @@ def test_json_identity_has_stable_fields( "email": "ada@example.com", "name": "Ada", }, - "organizations": [ - {"id": "org-1", "name": "Acme", "slug": "acme"}, - {"id": "org-2", "name": "Research", "slug": "research"}, - ], } -def test_tty_identity_handles_no_organizations( +def test_tty_identity_uses_an_account_panel( capsys: pytest.CaptureFixture[str], ) -> None: - emit_identity("whoami", user(), (), json_output=False) + emit_identity("whoami", user(), json_output=False) output = capsys.readouterr().out - assert "Authenticated as Ada ." in output - assert "Organization memberships: none" in output + assert "╭" in output + assert "FastMCP Account" in output + assert "Ada" in output + assert "ada@example.com" in output + assert "● Signed in" in output + assert "Organization" not in output def test_json_error_has_stable_fields(