From 9d1d9526da6c349c9b3eda8aac192e8da7a9076d Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Mon, 29 Jun 2026 21:19:06 +0500 Subject: [PATCH 1/5] 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 dd815b8c5d..e3d640de39 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -227,6 +227,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 25d4a7c16a..49cbab990a 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -115,6 +115,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 032e389adc4c26942fb5f5931b2fddc34485bf96 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Mon, 29 Jun 2026 23:35:07 +0500 Subject: [PATCH 2/5] 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 e3d640de39..4cb72edf09 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -227,19 +227,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 49cbab990a..56090dd6b6 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -115,16 +115,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() From dacd64b7b6d3d80299c780d7444dc403721dd217 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Tue, 30 Jun 2026 19:59:33 +0500 Subject: [PATCH 3/5] fix(init): distinguish interactive cancel from no-input; defer merge warning Address Copilot review on the --here non-empty path: (1) treat typer.Abort during an interactive confirm (e.g. Ctrl+C) as a normal cancellation (exit 0), and only emit the '--force' guidance + exit 1 when there is no TTY (empty stdin / EOF) -- no longer conflating the two; (2) move the 'will be merged / may overwrite' warning so it only shows when actually proceeding (force) or folded into the confirmation prompt, not on the fail-fast path where nothing is merged. Piped confirmation (e.g. 'echo y | specify init --here') is still honored, which is why the prompt is attempted rather than refused outright when non-interactive -- the existing test_init_here_without_force_preserves_shared_infra pipes 'y' and must succeed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/commands/init.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 4cb72edf09..b2a7d58b44 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -220,29 +220,38 @@ def init( console.print( f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)" ) - console.print( - "[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]" - ) if force: + # Proceeding: the merge/overwrite warning is accurate here. + console.print( + "[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]" + ) console.print( "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" ) else: + # Fold the merge/overwrite warning into the confirmation so it + # is shown only when we are actually about to proceed -- not on + # the fail-fast path below, where nothing is merged. try: - response = typer.confirm("Do you want to continue?") + proceed = typer.confirm( + "Template files will be merged with existing content and " + "may overwrite existing files. 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. + if _stdin_is_interactive(): + # Interactive cancel (e.g. Ctrl+C): a normal cancellation. + console.print("[yellow]Operation cancelled[/yellow]") + raise typer.Exit(0) from None + # Non-interactive with no confirmation input on stdin: fail + # fast with actionable guidance instead of a bare "Aborted." + # (Piped input such as "y" is still honored above.) 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: + if not proceed: console.print("[yellow]Operation cancelled[/yellow]") raise typer.Exit(0) else: From f5f46eabccfa596c3da7c4b1e5a798292bc13511 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Tue, 30 Jun 2026 20:29:35 +0500 Subject: [PATCH 4/5] fix(init): fail fast on non-interactive --here instead of prompting Per Copilot review: do not call typer.confirm when stdin is not a TTY -- an open-but-idle non-TTY stdin (CI/agent) could block on the prompt. When the directory is non-empty and --force is not given, fail fast with '--force' guidance unless an interactive terminal is present. Interactive confirm still offers the merge-but-preserve path (distinct from --force, which overwrites); a Ctrl+C there is treated as a normal cancellation (exit 0). The merge/overwrite warning is only printed when actually proceeding, not on the fail-fast path. Updated the preserve-merge E2E test to simulate an interactive terminal so it exercises the confirm path (non-interactive sessions now require --force). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/commands/init.py | 41 ++++++++++++++++---------------- tests/integrations/test_cli.py | 11 +++++++-- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index b2a7d58b44..54ced0bbd2 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -228,29 +228,28 @@ def init( console.print( "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" ) + elif not _stdin_is_interactive(): + # No interactive terminal: do NOT prompt. A non-TTY stdin that + # is open but idle (a common CI/agent scenario) would block on + # typer.confirm, so fail fast and require an explicit --force + # for a non-interactive merge. No merge happens here, so no + # merge/overwrite warning is printed. + 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: - # Fold the merge/overwrite warning into the confirmation so it - # is shown only when we are actually about to proceed -- not on - # the fail-fast path below, where nothing is merged. + console.print( + "[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]" + ) try: - proceed = typer.confirm( - "Template files will be merged with existing content and " - "may overwrite existing files. Continue?" - ) - except (typer.Abort, EOFError): - if _stdin_is_interactive(): - # Interactive cancel (e.g. Ctrl+C): a normal cancellation. - console.print("[yellow]Operation cancelled[/yellow]") - raise typer.Exit(0) from None - # Non-interactive with no confirmation input on stdin: fail - # fast with actionable guidance instead of a bare "Aborted." - # (Piped input such as "y" is still honored above.) - 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 + proceed = typer.confirm("Do you want to continue?") + except typer.Abort: + # Interactive cancel (e.g. Ctrl+C): a normal cancellation. + console.print("[yellow]Operation cancelled[/yellow]") + raise typer.Exit(0) from None if not proceed: 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 56090dd6b6..306b425bc2 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -860,10 +860,17 @@ def test_init_here_force_overwrites_shared_infra(self, tmp_path): # --force should overwrite the custom file assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content - def test_init_here_without_force_preserves_shared_infra(self, tmp_path): - """E2E: specify init --here (no --force) preserves existing shared infra files.""" + def test_init_here_without_force_preserves_shared_infra(self, tmp_path, monkeypatch): + """E2E: confirming the merge interactively (no --force) preserves existing + shared infra files (unlike --force, which overwrites them).""" from typer.testing import CliRunner from specify_cli import app + from specify_cli.commands import init as init_mod + + # Simulate an interactive terminal so the confirmation prompt is reached; + # the piped "y" then exercises the preserve-merge path. (Non-interactive + # sessions now fail fast and require --force.) + monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) project = tmp_path / "e2e-no-force" project.mkdir() From de42535461a474fa669da11753206079856a0569 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Tue, 30 Jun 2026 21:55:19 +0500 Subject: [PATCH 5/5] fix(init): honor piped y/n for 'init --here', error only on no-input Per maintainer review: restore the second-revision shape. Calling typer.confirm normally keeps 'echo y | specify init --here' reaching the non-destructive preserve-merge path (and piped 'n' cancels with exit 0). Only when no confirmation input is available at all (closed/empty stdin -> typer.Abort/EOFError) is it converted into the actionable error that points at --force. This drops the _stdin_is_interactive fail-fast that broke the common piped-confirm idiom and made preserve-merge interactive-only. The preserve test no longer needs to monkeypatch _stdin_is_interactive - it passes on the real contract. Co-Authored-By: Claude Opus 4.8 --- src/specify_cli/commands/init.py | 29 +++++++++++++---------------- tests/integrations/test_cli.py | 12 +++--------- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 54ced0bbd2..cb63abcdfb 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -228,28 +228,25 @@ def init( console.print( "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" ) - elif not _stdin_is_interactive(): - # No interactive terminal: do NOT prompt. A non-TTY stdin that - # is open but idle (a common CI/agent scenario) would block on - # typer.confirm, so fail fast and require an explicit --force - # for a non-interactive merge. No merge happens here, so no - # merge/overwrite warning is printed. - 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: console.print( "[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]" ) + # Call typer.confirm normally so piped y/n is honored — e.g. + # `echo y | specify init --here` keeps reaching the + # non-destructive preserve-merge path. Only when no + # confirmation input is available at all (closed/empty stdin + # → EOF/Abort) do we convert it into an actionable error that + # points at --force, rather than blocking or failing opaquely. try: proceed = typer.confirm("Do you want to continue?") - except typer.Abort: - # Interactive cancel (e.g. Ctrl+C): a normal cancellation. - console.print("[yellow]Operation cancelled[/yellow]") - raise typer.Exit(0) from None + except (typer.Abort, EOFError): + 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 proceed: 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 306b425bc2..d23957206f 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -860,17 +860,11 @@ def test_init_here_force_overwrites_shared_infra(self, tmp_path): # --force should overwrite the custom file assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content - def test_init_here_without_force_preserves_shared_infra(self, tmp_path, monkeypatch): - """E2E: confirming the merge interactively (no --force) preserves existing - shared infra files (unlike --force, which overwrites them).""" + def test_init_here_without_force_preserves_shared_infra(self, tmp_path): + """E2E: confirming the merge with piped "y" (no --force) preserves + existing shared infra files (unlike --force, which overwrites them).""" from typer.testing import CliRunner from specify_cli import app - from specify_cli.commands import init as init_mod - - # Simulate an interactive terminal so the confirmation prompt is reached; - # the piped "y" then exercises the preserve-merge path. (Non-interactive - # sessions now fail fast and require --force.) - monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) project = tmp_path / "e2e-no-force" project.mkdir()