From b402c0ff6988177d8db48cc5a7971fd59060c9d3 Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Sat, 27 Jun 2026 19:34:35 -0700 Subject: [PATCH 1/4] Add post-login banner for shared workflows and pending invitations After interactive login succeeds on a TTY, print a one-line nudge showing how many workflows are shared with the user and how many team invitations are pending, pointing at the relevant list commands. The banner is silent and non-blocking: any error, network timeout, or non-TTY session (piped output, API-key automation) results in no output and login still exits 0. Timeout is set to 0.9 s to stay sub-second. Co-Authored-By: Claude Sonnet 4.6 --- src/goodeye_cli/commands/login.py | 34 ++++++ tests/test_login_banner.py | 167 ++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 tests/test_login_banner.py diff --git a/src/goodeye_cli/commands/login.py b/src/goodeye_cli/commands/login.py index e8905f5..75ba9fe 100644 --- a/src/goodeye_cli/commands/login.py +++ b/src/goodeye_cli/commands/login.py @@ -3,6 +3,7 @@ from __future__ import annotations import platform +import sys import typer from rich.console import Console @@ -13,6 +14,38 @@ from goodeye_cli.config import get_server, save_client_config, save_credentials +def _is_tty() -> bool: + """Return True when stdout is an interactive terminal.""" + return sys.stdout.isatty() + + +def _print_post_login_banner(server: str, api_key: str, console: Console) -> None: + """Print a one-line nudge about shared workflows and pending invitations. + + Silent and non-blocking: any error, non-TTY session, or offline state + results in nothing printed. Login always succeeds regardless. + """ + if not _is_tty(): + return + try: + with GoodeyeClient(server, api_key=api_key, timeout=0.9) as client: + workflows = client.list_workflows(filter_="shared-with-me") + invitations = client.list_invitations(filter_="received", state="pending") + n = len(workflows.items) + m = len(invitations.items) + except Exception: + return + if n == 0 and m == 0: + return + wf_noun = "workflow" if n == 1 else "workflows" + inv_noun = "invitation" if m == 1 else "invitations" + console.print( + f"You have {n} {wf_noun} shared with you and {m} pending {inv_noun}. " + "Run 'goodeye workflows list --filter shared-with-me' or " + "'goodeye invitations list' to see them." + ) + + def run_interactive_login(server: str, console: Console, referral_code: str | None) -> None: """Run the browser/device-code sign-in flow and save local credentials. @@ -37,6 +70,7 @@ def run_interactive_login(server: str, console: Console, referral_code: str | No path = save_credentials({"api_key": api_key, "server": server}) console.print(f"[green]Signed in.[/green] Credentials saved to {path}") _maybe_redeem_referral(server, api_key, referral_code, console) + _print_post_login_banner(server, api_key, console) def login( diff --git a/tests/test_login_banner.py b/tests/test_login_banner.py new file mode 100644 index 0000000..454dedc --- /dev/null +++ b/tests/test_login_banner.py @@ -0,0 +1,167 @@ +"""Tests for the post-login shared-with-you banner. + +After a successful interactive login the CLI prints a one-line nudge +when workflows are shared with the user or invitations are pending. +The banner is silent on non-TTY sessions and on any list-endpoint error. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import httpx +import respx +from typer.testing import CliRunner + +from goodeye_cli.app import app +from goodeye_cli.config import ConfigPaths + +SERVER = "https://example.test" +DEVICE_URI = "https://api.workos.com/user_management/authorize/device" +TOKEN_URI = "https://api.workos.com/user_management/authenticate" + +_CLIENT_CONFIG_BODY = { + "workos_client_id": "client_X", + "workos_device_authorization_uri": DEVICE_URI, + "workos_token_uri": TOKEN_URI, +} + +_WORKFLOW_ITEMS = [ + {"id": "wf-01", "name": "alpha", "current_version": 1}, + {"id": "wf-02", "name": "beta", "current_version": 2}, +] + +_INVITATION_ITEMS = [ + { + "id": "inv-01", + "kind": "team_membership", + "target_id": "t-01", + "target_label": "@acme", + "proposed_by_handle": "alice", + "proposed_to_handle": "bob", + "created_at": "2026-01-01T00:00:00Z", + "expires_at": "2026-02-01T00:00:00Z", + }, +] + + +def _env(monkeypatch, tmp_config_paths: ConfigPaths) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_config_paths.config_dir.parent)) + monkeypatch.setenv("GOODEYE_SERVER", SERVER) + monkeypatch.delenv("GOODEYE_API_KEY", raising=False) + + +def test_login_banner_interactive_shows_counts( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """Interactive login prints shared-workflow and invitation counts when both are nonzero.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_banner1", + ), + patch("goodeye_cli.commands.login.save_client_config"), + patch("goodeye_cli.commands.login._is_tty", return_value=True), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + respx.get(f"{SERVER}/v1/workflows").mock( + return_value=httpx.Response( + 200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None} + ) + ) + respx.get(f"{SERVER}/v1/invitations").mock( + return_value=httpx.Response( + 200, json={"items": _INVITATION_ITEMS, "next_cursor": None} + ) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0, result.output + # Normalize whitespace to handle Rich's line wrapping in the captured output. + flat = " ".join(result.output.split()) + assert "shared with you" in flat + assert "pending invitation" in flat + assert "goodeye workflows list --filter shared-with-me" in flat + assert "goodeye invitations list" in flat + # Confirm the counts appear + assert "2" in flat # 2 workflows + assert "1" in flat # 1 invitation + + +def test_login_banner_no_tty_prints_nothing( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """Non-TTY session produces no banner and does not call the list endpoints.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_banner2", + ), + patch("goodeye_cli.commands.login.save_client_config"), + patch("goodeye_cli.commands.login._is_tty", return_value=False), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + wf_route = respx.get(f"{SERVER}/v1/workflows").mock( + return_value=httpx.Response( + 200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None} + ) + ) + inv_route = respx.get(f"{SERVER}/v1/invitations").mock( + return_value=httpx.Response( + 200, json={"items": _INVITATION_ITEMS, "next_cursor": None} + ) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0, result.output + assert "shared with you" not in result.output + # List endpoints must not be called when not a TTY + assert wf_route.call_count == 0 + assert inv_route.call_count == 0 + + +def test_login_banner_api_error_prints_nothing_and_login_succeeds( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """When a list endpoint returns an error the banner is suppressed and login exits 0.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_banner3", + ), + patch("goodeye_cli.commands.login.save_client_config"), + patch("goodeye_cli.commands.login._is_tty", return_value=True), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + respx.get(f"{SERVER}/v1/workflows").mock( + return_value=httpx.Response( + 500, + json={"error": "server_error", "message": "Internal error."}, + ) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0, result.output + assert "shared with you" not in result.output + assert "Signed in" in result.output From 2e4fc13df444a9250c3a0ea15b95c99861e876cd Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Sat, 27 Jun 2026 23:04:22 -0700 Subject: [PATCH 2/4] Sync uv.lock to the committed 0.20.0 package version The version bump left the lockfile self-version stale; reconcile it so a frozen sync matches pyproject. Co-Authored-By: Claude Opus 4.8 (1M context) --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 431ec83..7f649aa 100644 --- a/uv.lock +++ b/uv.lock @@ -176,7 +176,7 @@ wheels = [ [[package]] name = "goodeye" -version = "0.19.3" +version = "0.20.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From c166214b1fdea47aaaff8ec09c262a04fd0c86d6 Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Sun, 28 Jun 2026 16:01:15 -0700 Subject: [PATCH 3/4] Fix login banner formatting and show plus suffix when counts are paged Apply ruff format to the new banner test so the lint-and-test check passes, and render the shared-workflow and pending-invitation counts as "N+" when a next page exists so the banner no longer undercounts users with more than one page of results. Co-Authored-By: Claude Opus 4.8 --- src/goodeye_cli/commands/login.py | 12 +++++++++--- tests/test_login_banner.py | 24 ++++++------------------ 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/goodeye_cli/commands/login.py b/src/goodeye_cli/commands/login.py index 75ba9fe..2b16a87 100644 --- a/src/goodeye_cli/commands/login.py +++ b/src/goodeye_cli/commands/login.py @@ -33,14 +33,20 @@ def _print_post_login_banner(server: str, api_key: str, console: Console) -> Non invitations = client.list_invitations(filter_="received", state="pending") n = len(workflows.items) m = len(invitations.items) + # A present next_cursor means the first page did not exhaust the + # results, so the counts are lower bounds; render them as "N+". + wf_more = workflows.next_cursor is not None + inv_more = invitations.next_cursor is not None except Exception: return if n == 0 and m == 0: return - wf_noun = "workflow" if n == 1 else "workflows" - inv_noun = "invitation" if m == 1 else "invitations" + wf_count = f"{n}+" if wf_more else str(n) + inv_count = f"{m}+" if inv_more else str(m) + wf_noun = "workflow" if n == 1 and not wf_more else "workflows" + inv_noun = "invitation" if m == 1 and not inv_more else "invitations" console.print( - f"You have {n} {wf_noun} shared with you and {m} pending {inv_noun}. " + f"You have {wf_count} {wf_noun} shared with you and {inv_count} pending {inv_noun}. " "Run 'goodeye workflows list --filter shared-with-me' or " "'goodeye invitations list' to see them." ) diff --git a/tests/test_login_banner.py b/tests/test_login_banner.py index 454dedc..557d312 100644 --- a/tests/test_login_banner.py +++ b/tests/test_login_banner.py @@ -51,9 +51,7 @@ def _env(monkeypatch, tmp_config_paths: ConfigPaths) -> None: monkeypatch.delenv("GOODEYE_API_KEY", raising=False) -def test_login_banner_interactive_shows_counts( - tmp_config_paths: ConfigPaths, monkeypatch -) -> None: +def test_login_banner_interactive_shows_counts(tmp_config_paths: ConfigPaths, monkeypatch) -> None: """Interactive login prints shared-workflow and invitation counts when both are nonzero.""" _env(monkeypatch, tmp_config_paths) @@ -70,14 +68,10 @@ def test_login_banner_interactive_shows_counts( return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) ) respx.get(f"{SERVER}/v1/workflows").mock( - return_value=httpx.Response( - 200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None} - ) + return_value=httpx.Response(200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None}) ) respx.get(f"{SERVER}/v1/invitations").mock( - return_value=httpx.Response( - 200, json={"items": _INVITATION_ITEMS, "next_cursor": None} - ) + return_value=httpx.Response(200, json={"items": _INVITATION_ITEMS, "next_cursor": None}) ) runner = CliRunner() @@ -95,9 +89,7 @@ def test_login_banner_interactive_shows_counts( assert "1" in flat # 1 invitation -def test_login_banner_no_tty_prints_nothing( - tmp_config_paths: ConfigPaths, monkeypatch -) -> None: +def test_login_banner_no_tty_prints_nothing(tmp_config_paths: ConfigPaths, monkeypatch) -> None: """Non-TTY session produces no banner and does not call the list endpoints.""" _env(monkeypatch, tmp_config_paths) @@ -114,14 +106,10 @@ def test_login_banner_no_tty_prints_nothing( return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) ) wf_route = respx.get(f"{SERVER}/v1/workflows").mock( - return_value=httpx.Response( - 200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None} - ) + return_value=httpx.Response(200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None}) ) inv_route = respx.get(f"{SERVER}/v1/invitations").mock( - return_value=httpx.Response( - 200, json={"items": _INVITATION_ITEMS, "next_cursor": None} - ) + return_value=httpx.Response(200, json={"items": _INVITATION_ITEMS, "next_cursor": None}) ) runner = CliRunner() From 883445180b8a4915e966d843ca55193f1b7ea867 Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Sun, 28 Jun 2026 16:25:14 -0700 Subject: [PATCH 4/4] Name only the nonzero category in the post-login banner The shared-with-you banner could read "0 pending invitations" (or "0 workflows shared with you") whenever one category was empty. Build the sentence and its run commands from only the nonzero categories so an empty category is omitted entirely. Co-Authored-By: Claude Opus 4.8 --- src/goodeye_cli/commands/login.py | 16 ++++--- tests/test_login_banner.py | 74 +++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/goodeye_cli/commands/login.py b/src/goodeye_cli/commands/login.py index 2b16a87..dc45ecd 100644 --- a/src/goodeye_cli/commands/login.py +++ b/src/goodeye_cli/commands/login.py @@ -45,11 +45,17 @@ def _print_post_login_banner(server: str, api_key: str, console: Console) -> Non inv_count = f"{m}+" if inv_more else str(m) wf_noun = "workflow" if n == 1 and not wf_more else "workflows" inv_noun = "invitation" if m == 1 and not inv_more else "invitations" - console.print( - f"You have {wf_count} {wf_noun} shared with you and {inv_count} pending {inv_noun}. " - "Run 'goodeye workflows list --filter shared-with-me' or " - "'goodeye invitations list' to see them." - ) + # Name only the nonzero categories so a user with shared workflows but no + # invitations (or vice versa) never sees an awkward "0 ..." count. + clauses: list[str] = [] + commands: list[str] = [] + if n > 0: + clauses.append(f"{wf_count} {wf_noun} shared with you") + commands.append("'goodeye workflows list --filter shared-with-me'") + if m > 0: + clauses.append(f"{inv_count} pending {inv_noun}") + commands.append("'goodeye invitations list'") + console.print(f"You have {' and '.join(clauses)}. Run {' or '.join(commands)} to see them.") def run_interactive_login(server: str, console: Console, referral_code: str | None) -> None: diff --git a/tests/test_login_banner.py b/tests/test_login_banner.py index 557d312..62e9573 100644 --- a/tests/test_login_banner.py +++ b/tests/test_login_banner.py @@ -89,6 +89,80 @@ def test_login_banner_interactive_shows_counts(tmp_config_paths: ConfigPaths, mo assert "1" in flat # 1 invitation +def test_login_banner_only_workflows_omits_zero_invitations( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """With shared workflows but no pending invitations, the banner names only workflows.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_banner4", + ), + patch("goodeye_cli.commands.login.save_client_config"), + patch("goodeye_cli.commands.login._is_tty", return_value=True), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + respx.get(f"{SERVER}/v1/workflows").mock( + return_value=httpx.Response(200, json={"items": _WORKFLOW_ITEMS, "next_cursor": None}) + ) + respx.get(f"{SERVER}/v1/invitations").mock( + return_value=httpx.Response(200, json={"items": [], "next_cursor": None}) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0, result.output + flat = " ".join(result.output.split()) + assert "shared with you" in flat + assert "goodeye workflows list --filter shared-with-me" in flat + # The zero category is omitted entirely: no "0 ..." count, no invitation copy. + assert "pending invitation" not in flat + assert "goodeye invitations list" not in flat + + +def test_login_banner_only_invitations_omits_zero_workflows( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """With pending invitations but no shared workflows, the banner names only invitations.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_banner5", + ), + patch("goodeye_cli.commands.login.save_client_config"), + patch("goodeye_cli.commands.login._is_tty", return_value=True), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + respx.get(f"{SERVER}/v1/workflows").mock( + return_value=httpx.Response(200, json={"items": [], "next_cursor": None}) + ) + respx.get(f"{SERVER}/v1/invitations").mock( + return_value=httpx.Response(200, json={"items": _INVITATION_ITEMS, "next_cursor": None}) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0, result.output + flat = " ".join(result.output.split()) + assert "pending invitation" in flat + assert "goodeye invitations list" in flat + # The zero category is omitted entirely: no "0 ..." count, no workflow copy. + assert "shared with you" not in flat + assert "goodeye workflows list --filter shared-with-me" not in flat + + def test_login_banner_no_tty_prints_nothing(tmp_config_paths: ConfigPaths, monkeypatch) -> None: """Non-TTY session produces no banner and does not call the list endpoints.""" _env(monkeypatch, tmp_config_paths)