diff --git a/README.md b/README.md index 3452eb4a3f..de92639cec 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,13 @@ specify init my-project --integration copilot cd my-project ``` +For CI or AI agent harnesses (no keyboard, or a PTY that cannot send arrow keys), pass `--non-interactive` so init never hangs on a picker. Combine with `--force` when initializing into a non-empty directory: + +```bash +specify init my-project --non-interactive --ignore-agent-tools +specify init --here --force --non-interactive --integration claude +``` + To check for updates or upgrade the installed CLI, use the self-management commands. See the [Upgrade Guide](./docs/upgrade.md) for detailed scenarios and customization options. ```bash diff --git a/docs/local-development.md b/docs/local-development.md index 22e08fbbe7..34070451fc 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -2,7 +2,7 @@ This guide shows how to iterate on the `specify` CLI locally without publishing a release or committing to `main` first. -> Scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly. +> Scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs (no TTY, or `--non-interactive`) default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly. ## 1. Clone and Switch Branches diff --git a/docs/quickstart.md b/docs/quickstart.md index 4d4eaf89e0..2813118b5f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -3,7 +3,7 @@ This guide will help you get started with Spec-Driven Development using Spec Kit. Throughout, we illustrate each step with a running example: **Taskify**, a small team productivity platform. > [!NOTE] -> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly. +> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs (no TTY, or `--non-interactive`) default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly. Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical. @@ -43,7 +43,7 @@ uv tool install specify-cli specify init taskify # or: specify init . to use the current directory ``` -`init` lets you pick your coding agent interactively, or pass it explicitly with `--integration` (e.g. `--integration copilot`). +`init` lets you pick your coding agent interactively, or pass it explicitly with `--integration` (e.g. `--integration copilot`). For CI and AI agent harnesses, add `--non-interactive` so unspecified choices use documented defaults instead of hanging on an arrow-key picker. > [!NOTE] > Prefer `pipx`, one-time `uvx` runs, a pinned release, or an offline/air-gapped setup? See the [Installation Guide](installation.md) for all supported methods. diff --git a/src/specify_cli/_console.py b/src/specify_cli/_console.py index 0e448780ad..f540f2d3f2 100644 --- a/src/specify_cli/_console.py +++ b/src/specify_cli/_console.py @@ -151,6 +151,8 @@ def select_with_arrows( options: dict[str, str], prompt_text: str = "Select an option", default_key: str | None = None, + *, + flag_hint: str | None = None, ) -> str: """ Interactive selection using arrow keys with Rich Live display. @@ -159,6 +161,9 @@ def select_with_arrows( options: Dict with keys as option keys and values as descriptions prompt_text: Text to show above the options default_key: Default option key to start with + flag_hint: CLI flag the caller can pass instead of answering this prompt. + Included in the error when stdin is not a TTY so the hang is replaced + by an actionable message. Returns: Selected option key @@ -166,6 +171,20 @@ def select_with_arrows( if not options: raise ValueError("select_with_arrows() requires at least one option.") + # readchar.readkey() blocks forever when stdin is not a TTY. Fail immediately + # instead of hanging CI jobs and agent harnesses with no keyboard. + if not sys.stdin.isatty(): + console.print( + "[red]Error:[/red] Interactive selection requires a terminal " + "(stdin is not a TTY). Waiting for arrow keys would hang indefinitely." + ) + if flag_hint: + console.print( + f"Re-run with [bold]{flag_hint}[/bold] to supply this choice " + "non-interactively." + ) + raise typer.Exit(1) + option_keys = list(options.keys()) if default_key and default_key in option_keys: selected_index = option_keys.index(default_key) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 2bb8452025..4af9427bfa 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -33,6 +33,16 @@ def _stdin_is_interactive() -> bool: return sys.stdin.isatty() +def _prompts_allowed(non_interactive: bool) -> bool: + """Return True when interactive pickers and confirmations may be shown. + + ``--non-interactive`` suppresses prompts even when stdin is a TTY. Agent + harnesses often allocate a PTY (so ``isatty()`` is True) but cannot send + arrow-key input, which previously hung in ``select_with_arrows``. + """ + return not non_interactive and _stdin_is_interactive() + + def _ext_spec_is_url(ext_spec: str) -> bool: """Return True when *ext_spec* is an http(s) URL rather than a name/path.""" from urllib.parse import urlparse @@ -44,7 +54,10 @@ def _ext_spec_is_url(ext_spec: str) -> bool: def _confirm_extension_url_trust( - url_specs: list[str], *, trust_override: bool + url_specs: list[str], + *, + trust_override: bool, + allow_prompt: bool | None = None, ) -> dict[str, bool]: """Resolve trust for each URL-based extension before the Live display. @@ -58,7 +71,7 @@ def _confirm_extension_url_trust( from rich.panel import Panel approvals: dict[str, bool] = {} - interactive = _stdin_is_interactive() + interactive = _stdin_is_interactive() if allow_prompt is None else allow_prompt for spec in url_specs: if trust_override: approvals[spec] = True @@ -264,6 +277,16 @@ def init( "--force", help="Force merge/overwrite when using --here (skip confirmation)", ), + non_interactive: bool = typer.Option( + False, + "--non-interactive", + help=( + "Never prompt. Use documented defaults for unspecified " + "selections and fail instead of hanging when a choice has no " + "safe default. Required for agent harnesses that allocate a " + "PTY but cannot send arrow-key input." + ), + ), skip_tls: bool = typer.Option( False, "--skip-tls", @@ -324,7 +347,7 @@ def init( This command will: 1. Check that required tools are installed 2. Let you choose your coding agent integration, or default to Copilot - in non-interactive sessions + in non-interactive sessions (no TTY, or --non-interactive) 3. Install bundled Spec Kit templates, scripts, workflow, and shared project infrastructure 4. Set up coding agent integration commands and optional presets @@ -341,6 +364,8 @@ def init( specify init --here --integration vibe # Initialize with Mistral Vibe support specify init --here specify init --here --force # Skip confirmation when current directory not empty + specify init my-project --non-interactive # CI/agent: defaults, no prompts + specify init --here --force --non-interactive --integration claude # Scripted init, no hang specify init my-project --integration claude # Claude installs skills by default specify init --here --integration gemini specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir @@ -416,6 +441,13 @@ def init( console.print( "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" ) + elif non_interactive: + console.print( + "[red]Error:[/red] Current directory is not empty and " + "--non-interactive was set. Re-run with " + "[bold]--force[/bold] to merge into it." + ) + raise typer.Exit(1) else: # Fold the merge risk into the confirmation prompt rather than # printing it unconditionally first: on the EOF/no-input path @@ -491,7 +523,7 @@ def init( ) raise typer.Exit(1) selected_ai = integration - elif not _stdin_is_interactive(): + elif not _prompts_allowed(non_interactive): default_integration = resolve_default_init_integration() console.print( f"[dim]Non-interactive session detected: defaulting to '{default_integration}'. " @@ -504,6 +536,7 @@ def init( ai_choices, "Choose your coding agent integration:", resolve_default_init_integration(), + flag_hint="--integration ", ) if not integration: @@ -567,11 +600,12 @@ def init( else: default_script = "ps" if os.name == "nt" else "sh" - if _stdin_is_interactive(): + if _prompts_allowed(non_interactive): selected_script = select_with_arrows( SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script, + flag_hint="--script sh|ps|py", ) else: selected_script = default_script @@ -615,7 +649,9 @@ def init( url_specs = [e for e in extensions if _ext_spec_is_url(e)] if url_specs: extension_url_approvals = _confirm_extension_url_trust( - url_specs, trust_override=trust_extension_urls + url_specs, + trust_override=trust_extension_urls, + allow_prompt=_prompts_allowed(non_interactive), ) # Disable transient mode on Windows: PowerShell 5.1's legacy console diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 2bb68129b5..640d12a5fc 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -122,6 +122,134 @@ 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_noninteractive_flag_skips_pickers_when_stdin_is_a_tty( + self, tmp_path, monkeypatch + ): + """Agent harnesses often allocate a PTY (isatty True) but cannot send + arrow keys. ``--non-interactive`` must still skip both pickers and apply + documented defaults — the hang reported in #4152. + """ + from typer.testing import CliRunner + from specify_cli import app + import specify_cli + import specify_cli.commands.init as init_mod + + monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) + + def fail_select(*_args, **_kwargs): + raise AssertionError( + "--non-interactive must not open select_with_arrows even on a TTY" + ) + + monkeypatch.setattr(init_mod, "select_with_arrows", fail_select) + + runner = CliRunner() + project = tmp_path / "agent-pty" + result = runner.invoke( + app, + ["init", str(project), "--non-interactive", "--ignore-agent-tools"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION + + def test_noninteractive_flag_here_nonempty_requires_force( + self, tmp_path, monkeypatch + ): + """``--non-interactive`` on a non-empty --here directory must fail fast + asking for --force, even when stdin looks like a TTY. + """ + from typer.testing import CliRunner + from specify_cli import app + import specify_cli.commands.init as init_mod + + monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) + + def fail_select(*_args, **_kwargs): + raise AssertionError("picker must not run under --non-interactive") + + monkeypatch.setattr(init_mod, "select_with_arrows", fail_select) + + def fail_confirm(*_args, **_kwargs): + raise AssertionError( + "--non-interactive must not call typer.confirm for a non-empty --here directory" + ) + + monkeypatch.setattr("typer.confirm", fail_confirm) + + project = tmp_path / "nonempty-here-flag" + 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", + "--non-interactive", + "--integration", + "copilot", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 1, result.output + assert "--force" in result.output + assert "--non-interactive" in result.output + assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me" + + def test_noninteractive_flag_here_force_completes_without_script_flag( + self, tmp_path, monkeypatch + ): + """The #4152 reproduction: ``--here --force --integration`` without + ``--script`` must not hang on the script picker when --non-interactive + is set, even if stdin is a TTY. + """ + from typer.testing import CliRunner + from specify_cli import app + import specify_cli.commands.init as init_mod + + monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) + + def fail_select(*_args, **_kwargs): + raise AssertionError("script picker must not run under --non-interactive") + + monkeypatch.setattr(init_mod, "select_with_arrows", fail_select) + + project = tmp_path / "here-force-agent" + 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", + "--force", + "--non-interactive", + "--integration", + "claude", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert (project / ".specify" / "init-options.json").exists() + def test_noninteractive_init_honors_default_integration_env_var( self, tmp_path, monkeypatch ): @@ -164,7 +292,7 @@ def test_interactive_init_picker_default_honors_env_var( captured = {} - def fake_select(options, prompt_text=None, default_key=None): + def fake_select(options, prompt_text=None, default_key=None, **_kwargs): # Only capture the integration picker (not the script picker). if "Choose your coding agent integration" in (prompt_text or ""): captured["default_key"] = default_key @@ -2689,6 +2817,130 @@ def test_url_extension_skipped_without_trust(self, tmp_path): assert "untrusted url" in normalized.lower() assert not (project / ".specify" / "extensions" / "git").exists() + def test_noninteractive_flag_skips_url_trust_prompt_when_stdin_is_a_tty( + self, tmp_path, monkeypatch + ): + """``--non-interactive`` must not call ``typer.confirm`` for an HTTPS + ``--extension`` even when stdin is a TTY. Without + ``--trust-extension-urls`` the URL is denied (default-deny). Guards the + ``allow_prompt`` wiring added for #4152. + """ + from unittest.mock import patch + + import specify_cli.commands.init as init_mod + + monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) + + def fail_select(*_args, **_kwargs): + raise AssertionError("--non-interactive must not open select_with_arrows") + + def fail_confirm(*_args, **_kwargs): + raise AssertionError( + "--non-interactive must not prompt for URL extension trust" + ) + + monkeypatch.setattr(init_mod, "select_with_arrows", fail_select) + + with patch("typer.confirm", side_effect=fail_confirm), patch( + "specify_cli.authentication.http.open_url" + ) as mock_open: + project, result = self._run_init( + tmp_path, + [ + "--non-interactive", + "--extension", + "https://example.com/git.zip", + ], + project_name="ext-url-noninteractive-tty", + ) + + assert result.exit_code == 0, f"init failed:\n{result.output}" + mock_open.assert_not_called() + normalized = _normalize_cli_output(result.output) + assert "untrusted url" in normalized.lower() + assert "--trust-extension-urls" in result.output + assert not (project / ".specify" / "extensions" / "git").exists() + + def test_noninteractive_flag_trust_urls_installs_without_confirm( + self, tmp_path, monkeypatch + ): + """``--non-interactive --trust-extension-urls`` installs an HTTPS + extension without calling ``typer.confirm``, even when stdin is a TTY. + """ + import io + + from unittest.mock import patch + + from specify_cli import _locate_bundled_extension + import specify_cli.commands.init as init_mod + + bundled_git = _locate_bundled_extension("git") + assert bundled_git is not None, "bundled git extension not found" + zip_bytes = self._zip_bytes_from_dir(bundled_git) + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def _cache_dir_stand_in(project_root): + d = project_root / ".specify" / "extensions" / ".cache" / "downloads" + d.mkdir(parents=True, exist_ok=True) + return d + + def _open_download_zip(project_root, download_dir, zip_filename): + target = download_dir / zip_filename + o_temporary = getattr(os, "O_TEMPORARY", 0) + if o_temporary: + return os.open( + target, os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary, 0o600 + ) + fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600) + try: + os.unlink(target) + except OSError: + os.close(fd) + raise + return fd + + monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) + + def fail_select(*_args, **_kwargs): + raise AssertionError("--non-interactive must not open select_with_arrows") + + def fail_confirm(*_args, **_kwargs): + raise AssertionError( + "--non-interactive must not prompt for URL extension trust" + ) + + monkeypatch.setattr(init_mod, "select_with_arrows", fail_select) + + with patch("typer.confirm", side_effect=fail_confirm), patch( + "specify_cli.authentication.http.open_url", + return_value=FakeResponse(zip_bytes), + ), patch( + "specify_cli.extensions._commands._validate_safe_cache_dir", + side_effect=_cache_dir_stand_in, + ), patch( + "specify_cli.extensions._commands._safe_open_download_zip", + side_effect=_open_download_zip, + ): + project, result = self._run_init( + tmp_path, + [ + "--non-interactive", + "--extension", + "https://example.com/git.zip", + "--trust-extension-urls", + ], + project_name="ext-url-noninteractive-trust", + ) + + assert result.exit_code == 0, f"init failed:\n{result.output}" + assert (project / ".specify" / "extensions" / "git").exists() + def test_url_extension_interactive_confirm_installs(self, tmp_path): """An interactive 'yes' to the trust prompt allows the URL install.""" import io diff --git a/tests/test_console_imports.py b/tests/test_console_imports.py index 9ecb49cf3b..f7e058f89a 100644 --- a/tests/test_console_imports.py +++ b/tests/test_console_imports.py @@ -44,6 +44,51 @@ def test_select_with_arrows_raises_on_empty_options(): select_with_arrows({}) +def test_select_with_arrows_fails_fast_when_stdin_is_not_a_tty(monkeypatch, capsys): + """Regression for #4152: a missing TTY must error, not block on readchar.""" + import sys + + import pytest + import typer + + def fail_readkey(): + raise AssertionError("readkey must not be called when stdin is not a TTY") + + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + monkeypatch.setattr("specify_cli._console.readchar.readkey", fail_readkey) + + with pytest.raises(typer.Exit) as exc: + select_with_arrows( + {"copilot": "GitHub Copilot"}, + "Choose your coding agent integration:", + "copilot", + flag_hint="--integration ", + ) + + assert exc.value.exit_code == 1 + captured = capsys.readouterr().out + assert "stdin is not a TTY" in captured + assert "--integration " in captured + + +def test_select_with_arrows_tty_check_does_not_call_readkey_without_hint(monkeypatch): + import sys + + import pytest + import typer + + def fail_readkey(): + raise AssertionError("readkey must not be called when stdin is not a TTY") + + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + monkeypatch.setattr("specify_cli._console.readchar.readkey", fail_readkey) + + with pytest.raises(typer.Exit) as exc: + select_with_arrows({"a": "Option A"}, "Pick one") + + assert exc.value.exit_code == 1 + + def test_step_tracker_refresh_error_is_logged(caplog): """Regression: _maybe_refresh must log exceptions instead of silently swallowing.""" tracker = StepTracker("test") diff --git a/tests/test_live_transient_windows.py b/tests/test_live_transient_windows.py index b79c3be88f..4a45fb0cf2 100644 --- a/tests/test_live_transient_windows.py +++ b/tests/test_live_transient_windows.py @@ -32,13 +32,16 @@ def fake_live(*args, **kwargs): captured.update(kwargs) return mock_live_instance - # Patch readchar so the loop immediately returns "enter" + # Patch readchar so the loop immediately returns "enter". Tests run without + # a TTY, so also pretend stdin is interactive — otherwise the helper now + # fails fast instead of opening Live. import readchar with ( patch("sys.platform", platform), patch("specify_cli._console.Live", side_effect=fake_live), patch("specify_cli._console.readchar.readkey", return_value=readchar.key.ENTER), + patch("sys.stdin.isatty", return_value=True), ): from specify_cli._console import select_with_arrows