From 079c9a80d22eb48b7ddfd941784ea04d5eb6ef69 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Mon, 29 Jun 2026 21:19:06 +0500 Subject: [PATCH 1/2] fix(init): don't block on confirmation for 'init --here' without a TTY When 'specify init --here' targets a non-empty directory without --force, it called typer.confirm() unconditionally. In a non-interactive session (no TTY -- CI, piped, agent) there is no input, so the prompt reads EOF and aborts unhelpfully (or blocks), with no actionable message. The named-project path already fails fast and points to --force; --here was the inconsistent outlier. Guard the confirmation with the existing _stdin_is_interactive() helper: when non-interactive, print a clear 'directory not empty; re-run with --force' error and exit 1 instead of prompting. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/commands/init.py | 11 +++++++++++ tests/integrations/test_cli.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index fc82334da2..27cd5ff53e 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -229,6 +229,17 @@ def init( console.print( "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" ) + elif not _stdin_is_interactive(): + # No TTY to confirm on: fail fast with actionable guidance + # instead of blocking on typer.confirm (which would read EOF + # and abort unhelpfully). Mirrors the named-project path, + # which already errors and points to --force. + console.print( + "[red]Error:[/red] Current directory is not empty and no " + "interactive terminal is available to confirm. Re-run with " + "[bold]--force[/bold] to merge into it." + ) + raise typer.Exit(1) else: response = typer.confirm("Do you want to continue?") if not response: diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index be8aad2326..08cbfe1ac8 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -121,6 +121,34 @@ def fail_select(*_args, **_kwargs): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION + def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path, monkeypatch): + """`init --here` on a non-empty directory must not block on a confirmation + prompt when there is no interactive terminal: it should fail fast with + guidance to use --force, instead of reading EOF and aborting unhelpfully.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.commands import init as init_mod + + # Deterministically exercise the non-interactive branch. + monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: False) + + project = tmp_path / "nonempty-here" + project.mkdir() + (project / "existing.txt").write_text("keep me", encoding="utf-8") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 1, result.output + assert "--force" in result.output + # Aborted before scaffolding: the pre-existing file is untouched. + assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me" + def test_integration_copilot_auto_promotes(self, tmp_path): from typer.testing import CliRunner from specify_cli import app From b4a6fcd96ef76b71380e2cf8bf693ba1ffde5f02 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Mon, 29 Jun 2026 23:35:07 +0500 Subject: [PATCH 2/2] fix(init): honor piped confirmation for 'init --here'; only fail-fast on empty stdin The first version of this fix short-circuited on '_stdin_is_interactive()' (isatty) before typer.confirm, which broke 'init --here' when confirmation is piped (e.g. 'echo y | specify init --here' / CliRunner input='y\n') -- a non-TTY pipe with valid input was wrongly rejected, regressing test_init_here_without_force_preserves_shared_infra. Instead, call typer.confirm normally (piped 'y'/'n' is honored) and catch the Abort/EOFError it raises only when stdin is empty, converting that to the actionable '--force' guidance. This keeps the UX win for the no-input case without rejecting piped input. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/commands/init.py | 26 ++++++++++++++------------ tests/integrations/test_cli.py | 14 ++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 27cd5ff53e..1b6a73a87c 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -229,19 +229,21 @@ def init( console.print( "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" ) - elif not _stdin_is_interactive(): - # No TTY to confirm on: fail fast with actionable guidance - # instead of blocking on typer.confirm (which would read EOF - # and abort unhelpfully). Mirrors the named-project path, - # which already errors and points to --force. - console.print( - "[red]Error:[/red] Current directory is not empty and no " - "interactive terminal is available to confirm. Re-run with " - "[bold]--force[/bold] to merge into it." - ) - raise typer.Exit(1) else: - response = typer.confirm("Do you want to continue?") + try: + response = typer.confirm("Do you want to continue?") + except (typer.Abort, EOFError): + # No confirmation input available (non-interactive session + # with empty stdin): fail fast with actionable guidance + # instead of the bare "Aborted." Piped input (e.g. "y") is + # still honored above. Mirrors the named-project path, + # which already points to --force. + console.print( + "[red]Error:[/red] Current directory is not empty and no " + "confirmation input is available. Re-run with " + "[bold]--force[/bold] to merge into it." + ) + raise typer.Exit(1) from None if not response: console.print("[yellow]Operation cancelled[/yellow]") raise typer.Exit(0) diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 08cbfe1ac8..41052b8087 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -121,16 +121,14 @@ def fail_select(*_args, **_kwargs): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION - def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path, monkeypatch): - """`init --here` on a non-empty directory must not block on a confirmation - prompt when there is no interactive terminal: it should fail fast with - guidance to use --force, instead of reading EOF and aborting unhelpfully.""" + def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path): + """`init --here` on a non-empty directory with no confirmation input (empty + stdin) must fail fast with guidance to use --force, instead of the bare + 'Aborted.' from an EOF on typer.confirm. CliRunner with no `input=` provides + empty stdin, so typer.confirm raises Abort, which the command converts to the + actionable error.""" from typer.testing import CliRunner from specify_cli import app - from specify_cli.commands import init as init_mod - - # Deterministically exercise the non-interactive branch. - monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: False) project = tmp_path / "nonempty-here" project.mkdir()