feat: refine Horizon account output

This commit is contained in:
Edward Park 2026-08-07 23:02:29 -07:00
commit 0c955d4828
6 changed files with 208 additions and 91 deletions

View file

@ -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.

View file

@ -27,7 +27,7 @@ New users can register and create their first Horizon organization in the browse
Use `fastmcp login --host <url>` 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

View file

@ -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,
)

View file

@ -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()

View file

@ -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 <ada@example.com>." 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()

View file

@ -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 <ada@example.com>." 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(