Skip to content

Commit 8c57587

Browse files
committed
fix(init): stop specify init hanging on arrow-key pickers in agent harnesses
Agent harnesses often allocate a PTY so isatty is true, but they cannot send arrow keys. Fail fast when stdin is not a TTY, and add --non-interactive so scripted init applies defaults instead of hanging. Fixes #4152.
1 parent e4895d1 commit 8c57587

8 files changed

Lines changed: 242 additions & 11 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ specify init my-project --integration copilot
7070
cd my-project
7171
```
7272

73+
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:
74+
75+
```bash
76+
specify init my-project --non-interactive --ignore-agent-tools
77+
specify init --here --force --non-interactive --integration claude
78+
```
79+
7380
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.
7481

7582
```bash

docs/local-development.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This guide shows how to iterate on the `specify` CLI locally without publishing a release or committing to `main` first.
44

5-
> 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.
5+
> 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.
66
77
## 1. Clone and Switch Branches
88

docs/quickstart.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
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.
44

55
> [!NOTE]
6-
> 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.
6+
> 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.
77
88
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.
99

@@ -43,7 +43,7 @@ uv tool install specify-cli
4343
specify init taskify # or: specify init . to use the current directory
4444
```
4545

46-
`init` lets you pick your coding agent interactively, or pass it explicitly with `--integration` (e.g. `--integration copilot`).
46+
`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.
4747

4848
> [!NOTE]
4949
> 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.

src/specify_cli/_console.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ def select_with_arrows(
151151
options: dict[str, str],
152152
prompt_text: str = "Select an option",
153153
default_key: str | None = None,
154+
*,
155+
flag_hint: str | None = None,
154156
) -> str:
155157
"""
156158
Interactive selection using arrow keys with Rich Live display.
@@ -159,13 +161,30 @@ def select_with_arrows(
159161
options: Dict with keys as option keys and values as descriptions
160162
prompt_text: Text to show above the options
161163
default_key: Default option key to start with
164+
flag_hint: CLI flag the caller can pass instead of answering this prompt.
165+
Included in the error when stdin is not a TTY so the hang is replaced
166+
by an actionable message.
162167
163168
Returns:
164169
Selected option key
165170
"""
166171
if not options:
167172
raise ValueError("select_with_arrows() requires at least one option.")
168173

174+
# readchar.readkey() blocks forever when stdin is not a TTY. Fail immediately
175+
# instead of hanging CI jobs and agent harnesses with no keyboard.
176+
if not sys.stdin.isatty():
177+
console.print(
178+
"[red]Error:[/red] Interactive selection requires a terminal "
179+
"(stdin is not a TTY). Waiting for arrow keys would hang indefinitely."
180+
)
181+
if flag_hint:
182+
console.print(
183+
f"Re-run with [bold]{flag_hint}[/bold] to supply this choice "
184+
"non-interactively."
185+
)
186+
raise typer.Exit(1)
187+
169188
option_keys = list(options.keys())
170189
if default_key and default_key in option_keys:
171190
selected_index = option_keys.index(default_key)

src/specify_cli/commands/init.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ def _stdin_is_interactive() -> bool:
3333
return sys.stdin.isatty()
3434

3535

36+
def _prompts_allowed(non_interactive: bool) -> bool:
37+
"""Return True when interactive pickers and confirmations may be shown.
38+
39+
``--non-interactive`` suppresses prompts even when stdin is a TTY. Agent
40+
harnesses often allocate a PTY (so ``isatty()`` is True) but cannot send
41+
arrow-key input, which previously hung in ``select_with_arrows``.
42+
"""
43+
return not non_interactive and _stdin_is_interactive()
44+
45+
3646
def _ext_spec_is_url(ext_spec: str) -> bool:
3747
"""Return True when *ext_spec* is an http(s) URL rather than a name/path."""
3848
from urllib.parse import urlparse
@@ -44,7 +54,10 @@ def _ext_spec_is_url(ext_spec: str) -> bool:
4454

4555

4656
def _confirm_extension_url_trust(
47-
url_specs: list[str], *, trust_override: bool
57+
url_specs: list[str],
58+
*,
59+
trust_override: bool,
60+
allow_prompt: bool | None = None,
4861
) -> dict[str, bool]:
4962
"""Resolve trust for each URL-based extension before the Live display.
5063
@@ -58,7 +71,7 @@ def _confirm_extension_url_trust(
5871
from rich.panel import Panel
5972

6073
approvals: dict[str, bool] = {}
61-
interactive = _stdin_is_interactive()
74+
interactive = _stdin_is_interactive() if allow_prompt is None else allow_prompt
6275
for spec in url_specs:
6376
if trust_override:
6477
approvals[spec] = True
@@ -264,6 +277,16 @@ def init(
264277
"--force",
265278
help="Force merge/overwrite when using --here (skip confirmation)",
266279
),
280+
non_interactive: bool = typer.Option(
281+
False,
282+
"--non-interactive",
283+
help=(
284+
"Never prompt. Use documented defaults for unspecified "
285+
"selections and fail instead of hanging when a choice has no "
286+
"safe default. Required for agent harnesses that allocate a "
287+
"PTY but cannot send arrow-key input."
288+
),
289+
),
267290
skip_tls: bool = typer.Option(
268291
False,
269292
"--skip-tls",
@@ -324,7 +347,7 @@ def init(
324347
This command will:
325348
1. Check that required tools are installed
326349
2. Let you choose your coding agent integration, or default to Copilot
327-
in non-interactive sessions
350+
in non-interactive sessions (no TTY, or --non-interactive)
328351
3. Install bundled Spec Kit templates, scripts, workflow, and shared
329352
project infrastructure
330353
4. Set up coding agent integration commands and optional presets
@@ -341,6 +364,8 @@ def init(
341364
specify init --here --integration vibe # Initialize with Mistral Vibe support
342365
specify init --here
343366
specify init --here --force # Skip confirmation when current directory not empty
367+
specify init my-project --non-interactive # CI/agent: defaults, no prompts
368+
specify init --here --force --non-interactive --integration claude # Scripted init, no hang
344369
specify init my-project --integration claude # Claude installs skills by default
345370
specify init --here --integration gemini
346371
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(
416441
console.print(
417442
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
418443
)
444+
elif non_interactive:
445+
console.print(
446+
"[red]Error:[/red] Current directory is not empty and "
447+
"--non-interactive was set. Re-run with "
448+
"[bold]--force[/bold] to merge into it."
449+
)
450+
raise typer.Exit(1)
419451
else:
420452
# Fold the merge risk into the confirmation prompt rather than
421453
# printing it unconditionally first: on the EOF/no-input path
@@ -491,7 +523,7 @@ def init(
491523
)
492524
raise typer.Exit(1)
493525
selected_ai = integration
494-
elif not _stdin_is_interactive():
526+
elif not _prompts_allowed(non_interactive):
495527
default_integration = resolve_default_init_integration()
496528
console.print(
497529
f"[dim]Non-interactive session detected: defaulting to '{default_integration}'. "
@@ -504,6 +536,7 @@ def init(
504536
ai_choices,
505537
"Choose your coding agent integration:",
506538
resolve_default_init_integration(),
539+
flag_hint="--integration <agent>",
507540
)
508541

509542
if not integration:
@@ -567,11 +600,12 @@ def init(
567600
else:
568601
default_script = "ps" if os.name == "nt" else "sh"
569602

570-
if _stdin_is_interactive():
603+
if _prompts_allowed(non_interactive):
571604
selected_script = select_with_arrows(
572605
SCRIPT_TYPE_CHOICES,
573606
"Choose script type (or press Enter)",
574607
default_script,
608+
flag_hint="--script sh|ps|py",
575609
)
576610
else:
577611
selected_script = default_script
@@ -615,7 +649,9 @@ def init(
615649
url_specs = [e for e in extensions if _ext_spec_is_url(e)]
616650
if url_specs:
617651
extension_url_approvals = _confirm_extension_url_trust(
618-
url_specs, trust_override=trust_extension_urls
652+
url_specs,
653+
trust_override=trust_extension_urls,
654+
allow_prompt=_prompts_allowed(non_interactive),
619655
)
620656

621657
# Disable transient mode on Windows: PowerShell 5.1's legacy console

tests/integrations/test_cli.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,127 @@ def fail_select(*_args, **_kwargs):
122122
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
123123
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
124124

125+
def test_noninteractive_flag_skips_pickers_when_stdin_is_a_tty(
126+
self, tmp_path, monkeypatch
127+
):
128+
"""Agent harnesses often allocate a PTY (isatty True) but cannot send
129+
arrow keys. ``--non-interactive`` must still skip both pickers and apply
130+
documented defaults — the hang reported in #4152.
131+
"""
132+
from typer.testing import CliRunner
133+
from specify_cli import app
134+
import specify_cli
135+
import specify_cli.commands.init as init_mod
136+
137+
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
138+
139+
def fail_select(*_args, **_kwargs):
140+
raise AssertionError(
141+
"--non-interactive must not open select_with_arrows even on a TTY"
142+
)
143+
144+
monkeypatch.setattr(init_mod, "select_with_arrows", fail_select)
145+
146+
runner = CliRunner()
147+
project = tmp_path / "agent-pty"
148+
result = runner.invoke(
149+
app,
150+
["init", str(project), "--non-interactive", "--ignore-agent-tools"],
151+
catch_exceptions=False,
152+
)
153+
154+
assert result.exit_code == 0, result.output
155+
assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output
156+
157+
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
158+
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
159+
160+
def test_noninteractive_flag_here_nonempty_requires_force(
161+
self, tmp_path, monkeypatch
162+
):
163+
"""``--non-interactive`` on a non-empty --here directory must fail fast
164+
asking for --force, even when stdin looks like a TTY.
165+
"""
166+
from typer.testing import CliRunner
167+
from specify_cli import app
168+
import specify_cli.commands.init as init_mod
169+
170+
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
171+
172+
def fail_select(*_args, **_kwargs):
173+
raise AssertionError("picker must not run under --non-interactive")
174+
175+
monkeypatch.setattr(init_mod, "select_with_arrows", fail_select)
176+
177+
project = tmp_path / "nonempty-here-flag"
178+
project.mkdir()
179+
(project / "existing.txt").write_text("keep me", encoding="utf-8")
180+
old_cwd = os.getcwd()
181+
try:
182+
os.chdir(project)
183+
result = CliRunner().invoke(
184+
app,
185+
[
186+
"init",
187+
"--here",
188+
"--non-interactive",
189+
"--integration",
190+
"copilot",
191+
"--ignore-agent-tools",
192+
],
193+
catch_exceptions=False,
194+
)
195+
finally:
196+
os.chdir(old_cwd)
197+
198+
assert result.exit_code == 1, result.output
199+
assert "--force" in result.output
200+
assert "--non-interactive" in result.output
201+
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
202+
203+
def test_noninteractive_flag_here_force_completes_without_script_flag(
204+
self, tmp_path, monkeypatch
205+
):
206+
"""The #4152 reproduction: ``--here --force --integration`` without
207+
``--script`` must not hang on the script picker when --non-interactive
208+
is set, even if stdin is a TTY.
209+
"""
210+
from typer.testing import CliRunner
211+
from specify_cli import app
212+
import specify_cli.commands.init as init_mod
213+
214+
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
215+
216+
def fail_select(*_args, **_kwargs):
217+
raise AssertionError("script picker must not run under --non-interactive")
218+
219+
monkeypatch.setattr(init_mod, "select_with_arrows", fail_select)
220+
221+
project = tmp_path / "here-force-agent"
222+
project.mkdir()
223+
(project / "existing.txt").write_text("keep me", encoding="utf-8")
224+
old_cwd = os.getcwd()
225+
try:
226+
os.chdir(project)
227+
result = CliRunner().invoke(
228+
app,
229+
[
230+
"init",
231+
"--here",
232+
"--force",
233+
"--non-interactive",
234+
"--integration",
235+
"claude",
236+
"--ignore-agent-tools",
237+
],
238+
catch_exceptions=False,
239+
)
240+
finally:
241+
os.chdir(old_cwd)
242+
243+
assert result.exit_code == 0, result.output
244+
assert (project / ".specify" / "init-options.json").exists()
245+
125246
def test_noninteractive_init_honors_default_integration_env_var(
126247
self, tmp_path, monkeypatch
127248
):
@@ -164,7 +285,7 @@ def test_interactive_init_picker_default_honors_env_var(
164285

165286
captured = {}
166287

167-
def fake_select(options, prompt_text=None, default_key=None):
288+
def fake_select(options, prompt_text=None, default_key=None, **_kwargs):
168289
# Only capture the integration picker (not the script picker).
169290
if "Choose your coding agent integration" in (prompt_text or ""):
170291
captured["default_key"] = default_key

tests/test_console_imports.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,51 @@ def test_select_with_arrows_raises_on_empty_options():
4444
select_with_arrows({})
4545

4646

47+
def test_select_with_arrows_fails_fast_when_stdin_is_not_a_tty(monkeypatch, capsys):
48+
"""Regression for #4152: a missing TTY must error, not block on readchar."""
49+
import sys
50+
51+
import pytest
52+
import typer
53+
54+
def fail_readkey():
55+
raise AssertionError("readkey must not be called when stdin is not a TTY")
56+
57+
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
58+
monkeypatch.setattr("specify_cli._console.readchar.readkey", fail_readkey)
59+
60+
with pytest.raises(typer.Exit) as exc:
61+
select_with_arrows(
62+
{"copilot": "GitHub Copilot"},
63+
"Choose your coding agent integration:",
64+
"copilot",
65+
flag_hint="--integration <agent>",
66+
)
67+
68+
assert exc.value.exit_code == 1
69+
captured = capsys.readouterr().out
70+
assert "stdin is not a TTY" in captured
71+
assert "--integration <agent>" in captured
72+
73+
74+
def test_select_with_arrows_tty_check_does_not_call_readkey_without_hint(monkeypatch):
75+
import sys
76+
77+
import pytest
78+
import typer
79+
80+
def fail_readkey():
81+
raise AssertionError("readkey must not be called when stdin is not a TTY")
82+
83+
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
84+
monkeypatch.setattr("specify_cli._console.readchar.readkey", fail_readkey)
85+
86+
with pytest.raises(typer.Exit) as exc:
87+
select_with_arrows({"a": "Option A"}, "Pick one")
88+
89+
assert exc.value.exit_code == 1
90+
91+
4792
def test_step_tracker_refresh_error_is_logged(caplog):
4893
"""Regression: _maybe_refresh must log exceptions instead of silently swallowing."""
4994
tracker = StepTracker("test")

0 commit comments

Comments
 (0)