Skip to content

Commit 9b3874f

Browse files
bjoaquincclaude
andauthored
fix(cli): install venv in non-interactive terminals instead of crashing (#11)
* fix(cli): install venv in non-interactive terminals instead of crashing When stdin is not a TTY (piped input, CI, coding agents) and neither --yes nor --no-sync is passed, the venv/uv confirmation prompts called beaupy.select, which raises `termios.error: (25, 'Inappropriate ioctl for device')`. That error fell through to the generic handler and the run failed with "Unexpected error" and exit code 1, leaving no venv. confirm() now detects a non-interactive stdin and returns the prompt's default (Yes) instead of invoking beaupy, so a non-TTY run installs uv if missing and runs `uv sync` unattended. --yes and --no-sync behavior is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli): point non-interactive next steps at bundled skills In a non-TTY run the final "Next steps" line now tells the agent to use the bundled skills in .agents/skills/ instead of "open your coding agent and tell it what to build", which assumes a human at an interactive terminal. Interactive runs are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent eaf8c36 commit 9b3874f

7 files changed

Lines changed: 89 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- Non-interactive runs (piped stdin, CI, coding agents) no longer crash at the venv prompt. Without a TTY, `dlthub-init` used to fail with `Unexpected error: (25, 'Inappropriate ioctl for device')` and create no venv; it now proceeds with the prompt defaults and installs the venv unattended.
12+
13+
### Changed
14+
- In non-interactive runs the final "Next steps" line now points at the bundled skills in `.agents/skills/` instead of telling you to open a coding agent.
15+
1016
## [0.2.2] - 2026-07-07
1117

1218
### Changed

src/dlthub_init/cli.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@
2020
substep_detail,
2121
)
2222
from .errors import CollisionError, UvError, WorkspaceError
23-
from .prompts import confirm
23+
from .prompts import confirm, stdin_is_interactive
2424
from .scaffold import apply_scaffold, resolve_target
25-
from .skills import install_skills
25+
from .skills import install_skills, skills_source
2626
from .uv import execute_uv_install, find_uv, run_uv_sync
2727

2828

@@ -151,7 +151,13 @@ def run(args: argparse.Namespace) -> None:
151151
substep_detail(strings.MSG_SKILLS_INSTALLED.format(count=len(installed_skills)))
152152

153153
synced = _maybe_sync(project_dir, args, verbose=verbose)
154-
print_next_steps(project_dir, synced=synced, uv_installed=find_uv() is not None)
154+
print_next_steps(
155+
project_dir,
156+
synced=synced,
157+
uv_installed=find_uv() is not None,
158+
interactive=stdin_is_interactive(),
159+
skills_available=skills_source() is not None,
160+
)
155161

156162

157163
def _maybe_sync(project_dir: Path, args: argparse.Namespace, *, verbose: bool) -> bool:

src/dlthub_init/display.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,14 @@ def print_summary(plan: list[PlannedPath]) -> None:
6969
console.print(strings.MSG_NOTHING_WRITTEN)
7070

7171

72-
def print_next_steps(project_dir: Path, *, synced: bool, uv_installed: bool) -> None:
72+
def print_next_steps(
73+
project_dir: Path,
74+
*,
75+
synced: bool,
76+
uv_installed: bool,
77+
interactive: bool = True,
78+
skills_available: bool = False,
79+
) -> None:
7380
steps: list[tuple[str, str | None]] = []
7481
cd = _display_path(project_dir)
7582
if cd != ".":
@@ -78,7 +85,10 @@ def print_next_steps(project_dir: Path, *, synced: bool, uv_installed: bool) ->
7885
if not uv_installed:
7986
steps.append((strings.STEPS_LABEL_INSTALL_UV, strings.CMD_INSTALL_UV_UNIX))
8087
steps.append((strings.STEPS_LABEL_INSTALL_DEPS, strings.CMD_UV_SYNC))
81-
steps.append((strings.STEPS_LABEL_OPEN_AGENT, None))
88+
if not interactive and skills_available:
89+
steps.append((strings.STEPS_LABEL_USE_SKILLS, None))
90+
else:
91+
steps.append((strings.STEPS_LABEL_OPEN_AGENT, None))
8292

8393
single = len(steps) == 1
8494
header = strings.LABEL_NEXT_STEP if single else strings.LABEL_NEXT_STEPS

src/dlthub_init/prompts.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import sys
56
from typing import cast
67

78
import beaupy
@@ -17,8 +18,21 @@ def _echo_selection(value: str) -> None:
1718
console.print(f"[{CURSOR_STYLE}]{TICK_CHAR}[/{CURSOR_STYLE}] [bold]{value}[/bold]")
1819

1920

21+
def stdin_is_interactive() -> bool:
22+
stream = sys.stdin
23+
try:
24+
return stream is not None and stream.isatty()
25+
except (OSError, ValueError):
26+
return False
27+
28+
2029
def confirm(message: str, *, default: bool = True) -> bool:
2130
console.print(f"\n[bold]{message}[/bold]")
31+
if not stdin_is_interactive():
32+
# No TTY to read a selection from (piped stdin, CI, agents). beaupy would
33+
# crash with a termios error, so fall back to the default answer instead.
34+
_echo_selection("Yes" if default else "No")
35+
return default
2236
choice = cast(
2337
str,
2438
beaupy.select(

src/dlthub_init/strings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@
8080
STEPS_LABEL_OPEN_AGENT = (
8181
"Open your coding agent (Claude Code, Cursor, Codex, …) in this workspace and tell it what to build."
8282
)
83+
STEPS_LABEL_USE_SKILLS = (
84+
"This workspace ships dltHub skills in [bold].agents/skills/[/bold] — "
85+
"use them to guide building your dltHub workspace."
86+
)
8387

8488
CMD_INSTALL_UV_UNIX = "curl -LsSf https://astral.sh/uv/install.sh | sh"
8589
CMD_UV_SYNC = "uv sync"

tests/test_display.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,16 @@ def test_nothing_written_message_for_empty_plan(self):
4040

4141

4242
class NextStepsTest(unittest.TestCase):
43-
def _render(self, project_dir, *, synced, uv_installed=True):
44-
return _capture(lambda: display.print_next_steps(project_dir, synced=synced, uv_installed=uv_installed))
43+
def _render(self, project_dir, *, synced, uv_installed=True, interactive=True, skills_available=False):
44+
return _capture(
45+
lambda: display.print_next_steps(
46+
project_dir,
47+
synced=synced,
48+
uv_installed=uv_installed,
49+
interactive=interactive,
50+
skills_available=skills_available,
51+
)
52+
)
4553

4654
def test_single_step_in_place_synced(self):
4755
out = self._render(Path.cwd(), synced=True)
@@ -57,6 +65,20 @@ def test_multiple_steps_numbered_for_subdir(self):
5765
self.assertIn("2.", out)
5866
self.assertIn("uv sync", out)
5967

68+
def test_interactive_points_at_coding_agent(self):
69+
out = self._render(Path.cwd(), synced=True, interactive=True, skills_available=True)
70+
self.assertIn("Open your coding agent", out)
71+
self.assertNotIn(".agents/skills", out)
72+
73+
def test_non_interactive_points_at_skills(self):
74+
out = self._render(Path.cwd(), synced=True, interactive=False, skills_available=True)
75+
self.assertIn(".agents/skills", out)
76+
self.assertNotIn("Open your coding agent", out)
77+
78+
def test_non_interactive_without_skills_falls_back(self):
79+
out = self._render(Path.cwd(), synced=True, interactive=False, skills_available=False)
80+
self.assertIn("Open your coding agent", out)
81+
6082

6183
class DisplayPathTest(unittest.TestCase):
6284
def test_cwd_is_dot(self):

tests/test_prompts.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
class ConfirmTest(unittest.TestCase):
99
def setUp(self):
1010
display.console.quiet = True
11+
interactive = patch("dlthub_init.prompts.stdin_is_interactive", return_value=True)
12+
interactive.start()
13+
self.addCleanup(interactive.stop)
1114

1215
def tearDown(self):
1316
display.console.quiet = False
@@ -26,5 +29,22 @@ def test_default_controls_initial_cursor(self, select):
2629
self.assertEqual(select.call_args.kwargs["cursor_index"], 1)
2730

2831

32+
class NonInteractiveConfirmTest(unittest.TestCase):
33+
def setUp(self):
34+
display.console.quiet = True
35+
non_interactive = patch("dlthub_init.prompts.stdin_is_interactive", return_value=False)
36+
non_interactive.start()
37+
self.addCleanup(non_interactive.stop)
38+
39+
def tearDown(self):
40+
display.console.quiet = False
41+
42+
@patch("dlthub_init.prompts.beaupy.select")
43+
def test_returns_default_without_prompting(self, select):
44+
self.assertTrue(confirm("Proceed?", default=True))
45+
self.assertFalse(confirm("Proceed?", default=False))
46+
select.assert_not_called()
47+
48+
2949
if __name__ == "__main__":
3050
unittest.main()

0 commit comments

Comments
 (0)