From 86a9a61f59cc0cb1aafe448e3a5aab9b92fe7b12 Mon Sep 17 00:00:00 2001 From: rdwj Date: Tue, 5 May 2026 11:40:48 -0500 Subject: [PATCH] fix: Skip GitHub prompt in non-interactive shells (#40) `fips-agents create gateway` and `create ui` (and the other four create subcommands, which share the same helper) hung on the `Create GitHub repository?` prompt when stdin wasn't a TTY, then exited with an empty `Unexpected error:` because click.confirm raised Abort and the catch-all swallowed its empty message. `_determine_github_mode` now checks `sys.stdin.isatty()` before prompting and falls back to local mode with a one-line hint pointing to `--github`/`--yes`. As defense in depth the confirm call is wrapped in `try/except click.exceptions.Abort` for environments where isatty lies. The six `Unexpected error: {e}` sites switch to `{e!r}` so future empty-message exceptions surface their type instead of going blank. Assisted-by: Claude Code (Opus 4.7) --- src/fips_agents_cli/commands/create.py | 31 +++++++++--- tests/test_create.py | 65 ++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/fips_agents_cli/commands/create.py b/src/fips_agents_cli/commands/create.py index 8f99a49..63dbcb1 100644 --- a/src/fips_agents_cli/commands/create.py +++ b/src/fips_agents_cli/commands/create.py @@ -317,7 +317,7 @@ def mcp_server( console.print("\n[yellow]⚠[/yellow] Operation cancelled by user") sys.exit(130) except Exception as e: - console.print(f"\n[red]✗[/red] Unexpected error: {e}") + console.print(f"\n[red]✗[/red] Unexpected error: {e!r}") sys.exit(1) @@ -603,7 +603,7 @@ def agent( console.print("\n[yellow]⚠[/yellow] Operation cancelled by user") sys.exit(130) except Exception as e: - console.print(f"\n[red]✗[/red] Unexpected error: {e}") + console.print(f"\n[red]✗[/red] Unexpected error: {e!r}") sys.exit(1) @@ -862,7 +862,7 @@ def workflow( console.print("\n[yellow]⚠[/yellow] Operation cancelled by user") sys.exit(130) except Exception as e: - console.print(f"\n[red]✗[/red] Unexpected error: {e}") + console.print(f"\n[red]✗[/red] Unexpected error: {e!r}") sys.exit(1) @@ -1121,7 +1121,7 @@ def gateway( console.print("\n[yellow]⚠[/yellow] Operation cancelled by user") sys.exit(130) except Exception as e: - console.print(f"\n[red]✗[/red] Unexpected error: {e}") + console.print(f"\n[red]✗[/red] Unexpected error: {e!r}") sys.exit(1) @@ -1380,7 +1380,7 @@ def ui( console.print("\n[yellow]⚠[/yellow] Operation cancelled by user") sys.exit(130) except Exception as e: - console.print(f"\n[red]✗[/red] Unexpected error: {e}") + console.print(f"\n[red]✗[/red] Unexpected error: {e!r}") sys.exit(1) @@ -1637,7 +1637,7 @@ def sandbox( console.print("\n[yellow]⚠[/yellow] Operation cancelled by user") sys.exit(130) except Exception as e: - console.print(f"\n[red]✗[/red] Unexpected error: {e}") + console.print(f"\n[red]✗[/red] Unexpected error: {e!r}") sys.exit(1) @@ -1677,9 +1677,26 @@ def _determine_github_mode(use_github: bool, use_local: bool, yes: bool) -> bool console.print("[dim]Mode: GitHub (--yes with gh available)[/dim]") return True + # No TTY available (CI, agent-driven shells, piped input): can't prompt. + # Default to local; users opt in to GitHub with --github or --yes. + if not sys.stdin.isatty(): + console.print( + "[dim]Mode: Local only (non-interactive shell — pass --github or --yes " + "to create a GitHub repo)[/dim]" + ) + return False + # Interactive: prompt user console.print("[cyan]GitHub CLI detected and authenticated.[/cyan]") - return click.confirm("Create GitHub repository?", default=True) + try: + return click.confirm("Create GitHub repository?", default=True) + except click.exceptions.Abort: + # Stdin closed mid-prompt (isatty lied, or input ended). Fall back to local. + console.print( + "\n[dim]Mode: Local only (no input available — pass --github or --yes " + "to create a GitHub repo)[/dim]" + ) + return False def _show_success_message( diff --git a/tests/test_create.py b/tests/test_create.py index 47809c5..d95f1b1 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -978,3 +978,68 @@ def create_minimal_structure(url, target_path, branch=None): assert result.exit_code == 0 assert "Successfully created chat UI project" in result.output assert "my-chat-ui" in result.output + + +class TestDetermineGithubMode: + """Tests for _determine_github_mode helper. + + Regression coverage for issue #40: non-TTY shells (CI, agent-driven) hung + on the `Create GitHub repository?` prompt and exited with an empty + `Unexpected error:` message. + """ + + def _call(self, **overrides): + from fips_agents_cli.commands.create import _determine_github_mode + + kwargs = {"use_github": False, "use_local": False, "yes": False} + kwargs.update(overrides) + return _determine_github_mode(**kwargs) + + def test_local_flag_short_circuits(self): + with patch("sys.stdin.isatty", return_value=False): + assert self._call(use_local=True) is False + + def test_github_flag_short_circuits(self): + assert self._call(use_github=True) is True + + @patch("fips_agents_cli.commands.create.is_gh_installed", return_value=False) + def test_falls_back_to_local_when_gh_missing(self, _mock_installed): + assert self._call() is False + + @patch("fips_agents_cli.commands.create.is_gh_authenticated", return_value=False) + @patch("fips_agents_cli.commands.create.is_gh_installed", return_value=True) + def test_falls_back_to_local_when_gh_unauthenticated(self, *_mocks): + assert self._call() is False + + @patch("fips_agents_cli.commands.create.is_gh_authenticated", return_value=True) + @patch("fips_agents_cli.commands.create.is_gh_installed", return_value=True) + def test_yes_flag_chooses_github_when_gh_available(self, *_mocks): + assert self._call(yes=True) is True + + @patch("sys.stdin.isatty", return_value=False) + @patch("fips_agents_cli.commands.create.is_gh_authenticated", return_value=True) + @patch("fips_agents_cli.commands.create.is_gh_installed", return_value=True) + def test_non_tty_falls_back_to_local_without_prompting(self, *_mocks): + # The fix for issue #40: never prompt when stdin isn't a TTY. + with patch("click.confirm") as mock_confirm: + assert self._call() is False + mock_confirm.assert_not_called() + + @patch("sys.stdin.isatty", return_value=True) + @patch("fips_agents_cli.commands.create.is_gh_authenticated", return_value=True) + @patch("fips_agents_cli.commands.create.is_gh_installed", return_value=True) + def test_tty_prompts_user(self, *_mocks): + with patch("click.confirm", return_value=True) as mock_confirm: + assert self._call() is True + mock_confirm.assert_called_once() + + @patch("sys.stdin.isatty", return_value=True) + @patch("fips_agents_cli.commands.create.is_gh_authenticated", return_value=True) + @patch("fips_agents_cli.commands.create.is_gh_installed", return_value=True) + def test_tty_with_aborted_confirm_falls_back_to_local(self, *_mocks): + # Defensive: if the prompt aborts (e.g. closed stdin while isatty + # claimed otherwise), we should not crash with `Unexpected error:`. + import click as _click + + with patch("click.confirm", side_effect=_click.exceptions.Abort()): + assert self._call() is False