Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions src/fips_agents_cli/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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(
Expand Down
65 changes: 65 additions & 0 deletions tests/test_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading