diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ebebf..b9654dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- 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. + +### Changed +- 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. + ## [0.2.2] - 2026-07-07 ### Changed diff --git a/src/dlthub_init/cli.py b/src/dlthub_init/cli.py index 1ad9af9..e7d38d6 100644 --- a/src/dlthub_init/cli.py +++ b/src/dlthub_init/cli.py @@ -20,9 +20,9 @@ substep_detail, ) from .errors import CollisionError, UvError, WorkspaceError -from .prompts import confirm +from .prompts import confirm, stdin_is_interactive from .scaffold import apply_scaffold, resolve_target -from .skills import install_skills +from .skills import install_skills, skills_source from .uv import execute_uv_install, find_uv, run_uv_sync @@ -151,7 +151,13 @@ def run(args: argparse.Namespace) -> None: substep_detail(strings.MSG_SKILLS_INSTALLED.format(count=len(installed_skills))) synced = _maybe_sync(project_dir, args, verbose=verbose) - print_next_steps(project_dir, synced=synced, uv_installed=find_uv() is not None) + print_next_steps( + project_dir, + synced=synced, + uv_installed=find_uv() is not None, + interactive=stdin_is_interactive(), + skills_available=skills_source() is not None, + ) def _maybe_sync(project_dir: Path, args: argparse.Namespace, *, verbose: bool) -> bool: diff --git a/src/dlthub_init/display.py b/src/dlthub_init/display.py index ca07b58..a8754aa 100644 --- a/src/dlthub_init/display.py +++ b/src/dlthub_init/display.py @@ -69,7 +69,14 @@ def print_summary(plan: list[PlannedPath]) -> None: console.print(strings.MSG_NOTHING_WRITTEN) -def print_next_steps(project_dir: Path, *, synced: bool, uv_installed: bool) -> None: +def print_next_steps( + project_dir: Path, + *, + synced: bool, + uv_installed: bool, + interactive: bool = True, + skills_available: bool = False, +) -> None: steps: list[tuple[str, str | None]] = [] cd = _display_path(project_dir) if cd != ".": @@ -78,7 +85,10 @@ def print_next_steps(project_dir: Path, *, synced: bool, uv_installed: bool) -> if not uv_installed: steps.append((strings.STEPS_LABEL_INSTALL_UV, strings.CMD_INSTALL_UV_UNIX)) steps.append((strings.STEPS_LABEL_INSTALL_DEPS, strings.CMD_UV_SYNC)) - steps.append((strings.STEPS_LABEL_OPEN_AGENT, None)) + if not interactive and skills_available: + steps.append((strings.STEPS_LABEL_USE_SKILLS, None)) + else: + steps.append((strings.STEPS_LABEL_OPEN_AGENT, None)) single = len(steps) == 1 header = strings.LABEL_NEXT_STEP if single else strings.LABEL_NEXT_STEPS diff --git a/src/dlthub_init/prompts.py b/src/dlthub_init/prompts.py index bc050af..48aab89 100644 --- a/src/dlthub_init/prompts.py +++ b/src/dlthub_init/prompts.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys from typing import cast import beaupy @@ -17,8 +18,21 @@ def _echo_selection(value: str) -> None: console.print(f"[{CURSOR_STYLE}]{TICK_CHAR}[/{CURSOR_STYLE}] [bold]{value}[/bold]") +def stdin_is_interactive() -> bool: + stream = sys.stdin + try: + return stream is not None and stream.isatty() + except (OSError, ValueError): + return False + + def confirm(message: str, *, default: bool = True) -> bool: console.print(f"\n[bold]{message}[/bold]") + if not stdin_is_interactive(): + # No TTY to read a selection from (piped stdin, CI, agents). beaupy would + # crash with a termios error, so fall back to the default answer instead. + _echo_selection("Yes" if default else "No") + return default choice = cast( str, beaupy.select( diff --git a/src/dlthub_init/strings.py b/src/dlthub_init/strings.py index 6c0ff07..fb33000 100644 --- a/src/dlthub_init/strings.py +++ b/src/dlthub_init/strings.py @@ -80,6 +80,10 @@ STEPS_LABEL_OPEN_AGENT = ( "Open your coding agent (Claude Code, Cursor, Codex, …) in this workspace and tell it what to build." ) +STEPS_LABEL_USE_SKILLS = ( + "This workspace ships dltHub skills in [bold].agents/skills/[/bold] — " + "use them to guide building your dltHub workspace." +) CMD_INSTALL_UV_UNIX = "curl -LsSf https://astral.sh/uv/install.sh | sh" CMD_UV_SYNC = "uv sync" diff --git a/tests/test_display.py b/tests/test_display.py index 355919f..7acded0 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -40,8 +40,16 @@ def test_nothing_written_message_for_empty_plan(self): class NextStepsTest(unittest.TestCase): - def _render(self, project_dir, *, synced, uv_installed=True): - return _capture(lambda: display.print_next_steps(project_dir, synced=synced, uv_installed=uv_installed)) + def _render(self, project_dir, *, synced, uv_installed=True, interactive=True, skills_available=False): + return _capture( + lambda: display.print_next_steps( + project_dir, + synced=synced, + uv_installed=uv_installed, + interactive=interactive, + skills_available=skills_available, + ) + ) def test_single_step_in_place_synced(self): out = self._render(Path.cwd(), synced=True) @@ -57,6 +65,20 @@ def test_multiple_steps_numbered_for_subdir(self): self.assertIn("2.", out) self.assertIn("uv sync", out) + def test_interactive_points_at_coding_agent(self): + out = self._render(Path.cwd(), synced=True, interactive=True, skills_available=True) + self.assertIn("Open your coding agent", out) + self.assertNotIn(".agents/skills", out) + + def test_non_interactive_points_at_skills(self): + out = self._render(Path.cwd(), synced=True, interactive=False, skills_available=True) + self.assertIn(".agents/skills", out) + self.assertNotIn("Open your coding agent", out) + + def test_non_interactive_without_skills_falls_back(self): + out = self._render(Path.cwd(), synced=True, interactive=False, skills_available=False) + self.assertIn("Open your coding agent", out) + class DisplayPathTest(unittest.TestCase): def test_cwd_is_dot(self): diff --git a/tests/test_prompts.py b/tests/test_prompts.py index cebf7f3..f32a9d6 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -8,6 +8,9 @@ class ConfirmTest(unittest.TestCase): def setUp(self): display.console.quiet = True + interactive = patch("dlthub_init.prompts.stdin_is_interactive", return_value=True) + interactive.start() + self.addCleanup(interactive.stop) def tearDown(self): display.console.quiet = False @@ -26,5 +29,22 @@ def test_default_controls_initial_cursor(self, select): self.assertEqual(select.call_args.kwargs["cursor_index"], 1) +class NonInteractiveConfirmTest(unittest.TestCase): + def setUp(self): + display.console.quiet = True + non_interactive = patch("dlthub_init.prompts.stdin_is_interactive", return_value=False) + non_interactive.start() + self.addCleanup(non_interactive.stop) + + def tearDown(self): + display.console.quiet = False + + @patch("dlthub_init.prompts.beaupy.select") + def test_returns_default_without_prompting(self, select): + self.assertTrue(confirm("Proceed?", default=True)) + self.assertFalse(confirm("Proceed?", default=False)) + select.assert_not_called() + + if __name__ == "__main__": unittest.main()