diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b2257d..c4c617c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replaced leftover "local DuckDB" copy (README, agent-picker prompt, sample pipeline docstring) with the managed `playground` destination, and dropped the misleading "on your local machine" claim from the first-run summary. - Standardized the product name to "dltHub platform" in the CLI copy (was "dltHub Pro" in a couple of strings). - The startup banner is now suppressed when output isn't a TTY (piped/CI/agent-run), where it was just noise in captured logs. +- Reworked the post-setup panel so its framing matches the outcome: it now reads "You're all set" only after a successful first run, and "Almost there" when the run failed or setup stopped early. When setup is unfinished, the panel also points forward — listing the remaining install steps followed by the sample-run steps — instead of dead-ending at `uv sync`. +- The coding-agent prompt now adapts to the outcome too: it invites you to "create your own pipeline" only after the demo run succeeded, and shows a neutral "set up your coding agent" line otherwise (e.g. after a failed run), so it no longer presumes a run that didn't happen. ## [0.8.2] - 2026-06-18 diff --git a/src/create_dlthub_workspace/cli.py b/src/create_dlthub_workspace/cli.py index 1dd2acb..fbcc770 100644 --- a/src/create_dlthub_workspace/cli.py +++ b/src/create_dlthub_workspace/cli.py @@ -19,7 +19,6 @@ print_created_tree, print_dir_not_empty, print_next_steps, - print_resume_steps, substep, substep_detail, substep_streaming, @@ -163,15 +162,15 @@ def run(args: argparse.Namespace) -> None: if args.setup_only or confirm(strings.PROMPT_INSTALL_UV, recommended=RECOMMENDED.install_uv): uv_executable = execute_uv_install(verbose=verbose) else: - _finalize_agent(project_dir, scaffold, args, verbose=verbose) + _finalize_agent(project_dir, scaffold, args, ran=False, verbose=verbose) console.print(strings.MSG_SKIPPED_UV_AND_SYNC) - print_resume_steps(project_dir, uv_installed=False) + print_next_steps(project_dir, scaffold=scaffold, ran=False, needs_uv_install=True, needs_deps=True) return if args.scaffold_only: - _finalize_agent(project_dir, scaffold, args, verbose=verbose) + _finalize_agent(project_dir, scaffold, args, ran=False, verbose=verbose) console.print(strings.MSG_SKIPPED_SYNC) - print_resume_steps(project_dir, uv_installed=True) + print_next_steps(project_dir, scaffold=scaffold, ran=False, needs_deps=True) return with substep(strings.MSG_INSTALLING_DEPS, strings.MSG_INSTALLED_DEPS, verbose=verbose): @@ -190,19 +189,19 @@ def run(args: argparse.Namespace) -> None: first_pipeline_ran = False console.print(strings.MSG_FIRST_RUN_FAILED.format(message=exc)) - agent = _finalize_agent(project_dir, scaffold, args, verbose=verbose) + agent = _finalize_agent(project_dir, scaffold, args, ran=first_pipeline_ran, verbose=verbose) if first_pipeline_ran and _launch_agent(project_dir, agent, prompt=strings.CMD_BUILD_OWN_SOURCE_PROMPT): return console.print() prompt_copied = first_pipeline_ran and copy_to_clipboard(strings.CMD_BUILD_OWN_SOURCE_PROMPT) - print_next_steps(project_dir, scaffold=scaffold, first_pipeline_ran=first_pipeline_ran, prompt_copied=prompt_copied) + print_next_steps(project_dir, scaffold=scaffold, ran=first_pipeline_ran, prompt_copied=prompt_copied) -def _finalize_agent(project_dir: Path, scaffold: str, args: argparse.Namespace, *, verbose: bool) -> str: +def _finalize_agent(project_dir: Path, scaffold: str, args: argparse.Namespace, *, ran: bool, verbose: bool) -> str: """Resolve the agent (prompting unless --agent/--setup-only set it) and lay down its AI files.""" - agent = args.agent or (RECOMMENDED.agent if args.setup_only else choose_agent()) + agent = args.agent or (RECOMMENDED.agent if args.setup_only else choose_agent(ran=ran)) with substep( strings.MSG_ADDING_AGENT_FILES.format(agent=agent), strings.MSG_ADDED_AGENT_FILES.format(agent=agent), diff --git a/src/create_dlthub_workspace/display.py b/src/create_dlthub_workspace/display.py index 9103c0c..ede31cb 100644 --- a/src/create_dlthub_workspace/display.py +++ b/src/create_dlthub_workspace/display.py @@ -301,77 +301,74 @@ def print_created_tree(scaffold: str) -> None: console.print(f"[dim]{branch}{entry}[/dim]") +def _print_steps_panel(body: Text, *, title: str) -> None: + console.print( + Panel( + body, + title=title, + title_align="left", + border_style="#C6D300", + padding=(1, 2), + ) + ) + + def print_next_steps( project_dir: Path, *, scaffold: str, - first_pipeline_ran: bool = False, + ran: bool = False, + needs_uv_install: bool = False, + needs_deps: bool = False, prompt_copied: bool = False, ) -> None: - """Post-setup panel. After the first run, it's just the prompt to hand to the - agent; otherwise it lists the run/edit steps for the scaffold.""" + """The build-your-own prompt when ``ran``, else any remaining setup commands + followed by the steps to run the sample pipeline.""" body = Text() - if first_pipeline_ran: + + if ran: body.append(f"{strings.STEPS_LABEL_BUILD_OWN_SOURCE}\n\n") body.append(f" {strings.CMD_BUILD_OWN_SOURCE_PROMPT}", style="bold #59C1D5") if prompt_copied: body.append(f"\n\n {strings.HINT_PROMPT_COPIED}", style="bold #C6D300") body.append(f"\n\n {strings.LABEL_DOCS} ", style="dim") body.append(strings.LINK_DOCS_LABEL, style=f"underline #59C1D5 link {strings.LINK_DOCS_URL}") - else: - # Lead with `cd` only for a subdirectory; in the cwd it's noise (`cd .`). - cd = _cd_target(project_dir) - cd_step: tuple[tuple[str, str | None], ...] = ( - () if cd == "." else ((strings.STEPS_LABEL_CD, strings.CMD_CD.format(project_dir=cd)),) - ) - steps: tuple[tuple[str, str | None], ...] = (*cd_step, *NEXT_STEPS[scaffold]) - body.append(f"{strings.LABEL_WHAT_TO_TRY}\n\n", style="bold #C6D300") - for index, (label, command) in enumerate(steps, start=1): - body.append(f" {index}. {label}\n", style="dim") - if command is not None: - body.append(f" {command}\n", style="bold #59C1D5") - body.append("\n") + _print_steps_panel(body, title=strings.TITLE_ALL_SET) + return - console.print( - Panel( - body, - title=strings.TITLE_NEXT_STEPS_PANEL, - title_align="left", - border_style="#C6D300", - padding=(1, 2), - ) + cd = _cd_target(project_dir) + cd_step: tuple[tuple[str, str | None], ...] = ( + () if cd == "." else ((strings.STEPS_LABEL_CD, strings.CMD_CD.format(project_dir=cd)),) ) + sections: list[tuple[str, tuple[tuple[str, str | None], ...]]] = [] + if needs_uv_install or needs_deps: + finish: list[tuple[str, str | None]] = [*cd_step] + if needs_uv_install: + finish.append((strings.STEPS_LABEL_INSTALL_UV, strings.CMD_INSTALL_UV_UNIX)) + if needs_deps: + finish.append((strings.STEPS_LABEL_INSTALL_DEPS, strings.CMD_UV_SYNC)) + sections.append((strings.LABEL_FINISH_SETUP, tuple(finish))) + sections.append((strings.LABEL_WHAT_TO_TRY, NEXT_STEPS[scaffold])) + else: + sections.append((strings.LABEL_WHAT_TO_TRY, (*cd_step, *NEXT_STEPS[scaffold]))) -def print_resume_steps(project_dir: Path, *, uv_installed: bool) -> None: - """Remaining setup commands. AI workbench files are already in the - workspace (vendored into the scaffold), so the only thing the user still - needs to do is finish the uv setup.""" - steps: list[tuple[str, str]] = [] - cd = _cd_target(project_dir) - if cd != ".": - steps.append((strings.STEPS_LABEL_CD, strings.CMD_CD.format(project_dir=cd))) - 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)) + step = 1 + for index, (header, steps) in enumerate(sections): + if index: + body.append("\n") + body.append(f"{header}\n\n", style="bold #C6D300") + for label, command in steps: + body.append(f" {step}. {label}\n", style="dim") + if command is not None: + body.append(f" {command}\n", style="bold #59C1D5") + body.append("\n") + step += 1 - body = Text() - body.append(f"{strings.LABEL_FINISH_SETUP}\n\n", style="bold #C6D300") - for index, (label, command) in enumerate(steps, start=1): - body.append(f" {index}. {label}\n", style="dim") - body.append(f" {command}\n\n", style="bold #59C1D5") if cd != ".": body.append(f" {strings.MSG_AGENT_WORKSPACE_NOTE}", style="dim") - console.print( - Panel( - body, - title=strings.TITLE_RESUME_PANEL, - title_align="left", - border_style="#C6D300", - padding=(1, 2), - ) - ) + _print_steps_panel(body, title=strings.TITLE_ALMOST_THERE) def print_dir_not_empty(project_dir: Path) -> None: diff --git a/src/create_dlthub_workspace/prompts.py b/src/create_dlthub_workspace/prompts.py index 475f35b..594e923 100644 --- a/src/create_dlthub_workspace/prompts.py +++ b/src/create_dlthub_workspace/prompts.py @@ -30,7 +30,7 @@ def _echo_selection(value: str) -> None: console.print(f"[{CURSOR_STYLE}]{TICK_CHAR}[/{CURSOR_STYLE}] [bold]{value}[/bold]") -def choose_agent(default: str = RECOMMENDED.agent) -> str: +def choose_agent(default: str = RECOMMENDED.agent, *, ran: bool = False) -> str: """Arrow-key select for the coding agent. Exactly one is chosen.""" agents = list(AGENTS) options = [ @@ -41,7 +41,8 @@ def choose_agent(default: str = RECOMMENDED.agent) -> str: ] default_index = agents.index(default) if default in agents else 0 - console.print(strings.PROMPT_AGENT_HEADER) + lead = strings.PROMPT_AGENT_LEAD_RAN if ran else strings.PROMPT_AGENT_LEAD_SETUP + console.print(strings.PROMPT_AGENT_HEADER.format(lead=lead)) index = cast( int, beaupy.select( diff --git a/src/create_dlthub_workspace/strings.py b/src/create_dlthub_workspace/strings.py index 829cb0d..b515364 100644 --- a/src/create_dlthub_workspace/strings.py +++ b/src/create_dlthub_workspace/strings.py @@ -22,10 +22,13 @@ # Prompts --------------------------------------------------------------- PROMPT_AGENT_HEADER = ( - "\n[bold]2. Claude/Cursor/Codex-native dlt pipeline building with dltHub platform[/bold]" - "\nNow create your own dlt pipeline to load data from a REST API source into the dltHub platform." + "\n[bold]Claude/Cursor/Codex-native dlt pipeline building with dltHub platform[/bold]" + "\n{lead}" "\n\n[bold]Which coding agent do you want to use?[/bold] [dim](↑/↓ to move, enter to confirm)[/dim]" ) +# The lead line swaps on whether the demo run succeeded. +PROMPT_AGENT_LEAD_RAN = "Now create your own dlt pipeline to load data from a REST API source into your playground." +PROMPT_AGENT_LEAD_SETUP = "Set up your coding agent to build dlt pipelines." PROMPT_INSTALL_UV = "uv is required but was not found. Install uv now?" @@ -78,7 +81,7 @@ MSG_SHOWED_RUN = "Showed your pipeline run" # "What just happened" summary, shown after the first run and before the agent picker. MSG_PLAYGROUND_READY = ( - "\n[bold]1. The dltHub platform playground experience is set[/bold]" + "\n[bold]The dltHub platform playground experience is set[/bold]" "\nWe created a demo dlt pipeline and set up your dltHub playground." ) MSG_FIRST_RUN_FAILED = ( @@ -89,8 +92,8 @@ # Panel titles ---------------------------------------------------------- TITLE_BANNER = "dlthub-start v{version} [bold #C6D300](beta)[/bold #C6D300]" -TITLE_NEXT_STEPS_PANEL = "You're all set" -TITLE_RESUME_PANEL = "Almost there" +TITLE_ALL_SET = "You're all set" +TITLE_ALMOST_THERE = "Almost there" TITLE_DIR_NOT_EMPTY = "Directory not empty" # Shown on the next-steps / resume panels only when scaffolding into a diff --git a/tests/test_cli.py b/tests/test_cli.py index 0f3bece..666312f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -134,7 +134,6 @@ def _make_args(**overrides: object) -> argparse.Namespace: "print_banner", "print_created_tree", "print_next_steps", - "print_resume_steps", ) @@ -177,7 +176,7 @@ def test_agent_files_added_after_the_first_run(self) -> None: self.m["overlay_agent"].assert_called_once() self.m["choose_agent"].assert_called_once() self.m["print_next_steps"].assert_called_once() - self.assertTrue(self.m["print_next_steps"].call_args.kwargs["first_pipeline_ran"]) + self.assertTrue(self.m["print_next_steps"].call_args.kwargs["ran"]) def test_launched_agent_replaces_the_next_steps_panel(self) -> None: self.m["_launch_agent"].return_value = True @@ -198,7 +197,7 @@ def test_first_run_failure_degrades_but_finishes_setup(self) -> None: self.m["overlay_agent"].assert_called_once() self.m["_launch_agent"].assert_not_called() - self.assertFalse(self.m["print_next_steps"].call_args.kwargs["first_pipeline_ran"]) + self.assertFalse(self.m["print_next_steps"].call_args.kwargs["ran"]) def test_explicit_agent_skips_the_prompt(self) -> None: self._run(agent="codex") @@ -210,7 +209,7 @@ def test_setup_only_skips_first_run_and_prompt_but_still_adds_agent_files(self) self.m["run_uv_command"].assert_not_called() self.m["choose_agent"].assert_not_called() self.assertEqual(self.m["overlay_agent"].call_args.kwargs["agent"], RECOMMENDED.agent) - self.assertFalse(self.m["print_next_steps"].call_args.kwargs["first_pipeline_ran"]) + self.assertFalse(self.m["print_next_steps"].call_args.kwargs["ran"]) def test_uv_declined_stops_at_scaffold_only_but_adds_agent_files(self) -> None: self.m["find_uv"].return_value = None @@ -221,15 +220,21 @@ def test_uv_declined_stops_at_scaffold_only_but_adds_agent_files(self) -> None: self.m["execute_uv_install"].assert_not_called() self.m["run_uv_sync"].assert_not_called() self.m["overlay_agent"].assert_called_once() - self.m["print_next_steps"].assert_not_called() - self.m["print_resume_steps"].assert_called_once_with(Path("/tmp/test_workspace"), uv_installed=False) + self.m["print_next_steps"].assert_called_once() + kwargs = self.m["print_next_steps"].call_args.kwargs + self.assertFalse(kwargs["ran"]) + self.assertTrue(kwargs["needs_uv_install"]) + self.assertTrue(kwargs["needs_deps"]) def test_scaffold_only_stops_after_install_but_adds_agent_files(self) -> None: self._run(scaffold_only=True) self.m["run_uv_sync"].assert_not_called() self.m["overlay_agent"].assert_called_once() - self.m["print_next_steps"].assert_not_called() - self.m["print_resume_steps"].assert_called_once_with(Path("/tmp/test_workspace"), uv_installed=True) + self.m["print_next_steps"].assert_called_once() + kwargs = self.m["print_next_steps"].call_args.kwargs + self.assertFalse(kwargs["ran"]) + self.assertTrue(kwargs["needs_deps"]) + self.assertFalse(kwargs.get("needs_uv_install", False)) def test_first_run_creates_playground_when_absent(self) -> None: self.m["capture_uv_command"].return_value = "Name\n----\nMy Workspace\n" @@ -297,7 +302,6 @@ def _run_with(self, **flags: bool) -> MagicMock: patch("create_dlthub_workspace.cli._launch_agent", return_value=False), patch("create_dlthub_workspace.cli.choose_agent", return_value="claude"), patch("create_dlthub_workspace.cli.print_next_steps"), - patch("create_dlthub_workspace.cli.print_resume_steps"), patch("create_dlthub_workspace.cli.err_console") as err_console, _silenced(), ): diff --git a/tests/test_display.py b/tests/test_display.py index c42a51c..6d76572 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -1,4 +1,4 @@ -"""Tests for display panels (next-steps + resume-steps + banner). +"""Tests for display panels (next-steps + banner). Uses `console.capture()` to grab rendered text so we can assert on the content of rich panels. @@ -21,7 +21,6 @@ copy_to_clipboard, print_banner, print_next_steps, - print_resume_steps, ) from create_dlthub_workspace.scaffold import SCAFFOLDS_DIR @@ -40,7 +39,7 @@ def test_first_pipeline_ran_shows_only_the_agent_prompt(self) -> None: # After the first run, the panel drops the run/edit steps and shows just # the instruction + the verbatim prompt to hand to the agent. with console.capture() as cap: - print_next_steps(Path.cwd(), scaffold="minimal_workspace", first_pipeline_ran=True, prompt_copied=True) + print_next_steps(Path.cwd(), scaffold="minimal_workspace", ran=True, prompt_copied=True) # Strip panel borders (Unicode │ on POSIX, ASCII | on the Windows console) # and collapse line-wrapping so the prompt is one contiguous string. output = " ".join(cap.get().replace("│", " ").replace("|", " ").split()) @@ -111,10 +110,16 @@ def test_created_tree_covers_every_next_steps_scaffold(self) -> None: self.assertEqual(set(CREATED_TREE), set(NEXT_STEPS)) -class PrintResumeStepsTests(unittest.TestCase): +class ResumeStepsTests(unittest.TestCase): def test_uv_not_installed_includes_install_command(self) -> None: with console.capture() as cap: - print_resume_steps(Path("/tmp/my_workspace"), uv_installed=False) + print_next_steps( + Path("/tmp/my_workspace"), + scaffold="minimal_workspace", + ran=False, + needs_uv_install=True, + needs_deps=True, + ) output = cap.get() self.assertIn("Install uv", output) @@ -123,12 +128,29 @@ def test_uv_not_installed_includes_install_command(self) -> None: def test_uv_installed_omits_install_command(self) -> None: with console.capture() as cap: - print_resume_steps(Path("/tmp/my_workspace"), uv_installed=True) + print_next_steps( + Path("/tmp/my_workspace"), + scaffold="minimal_workspace", + ran=False, + needs_deps=True, + ) output = cap.get() self.assertNotIn("curl -LsSf", output) self.assertIn("uv sync", output) + def test_finish_setup_still_points_to_the_sample_run(self) -> None: + with console.capture() as cap: + print_next_steps( + Path("/tmp/my_workspace"), + scaffold="minimal_workspace", + ran=False, + needs_deps=True, + ) + output = cap.get() + + self.assertIn("uv run dlthub run load_sample_shop", output) + class PrintBannerTests(unittest.TestCase): def test_renders_with_version_in_title(self) -> None: diff --git a/tests/test_prompts.py b/tests/test_prompts.py index f7e894c..a5d4aae 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -5,6 +5,7 @@ import unittest from unittest.mock import MagicMock, patch +from create_dlthub_workspace import strings from create_dlthub_workspace.config import AGENTS, RECOMMENDED from create_dlthub_workspace.display import console from create_dlthub_workspace.prompts import ( @@ -59,6 +60,22 @@ def test_defaults_cursor_to_recommended_agent( choose_agent() self.assertEqual(select.call_args.kwargs["cursor_index"], list(AGENTS).index(RECOMMENDED.agent)) + @patch("create_dlthub_workspace.prompts.beaupy.select", return_value=0) + def test_ran_header_uses_build_your_own_lead(self, _select: MagicMock) -> None: + with console.capture() as cap: + choose_agent(ran=True) + output = " ".join(cap.get().split()) + self.assertIn(strings.PROMPT_AGENT_LEAD_RAN, output) + self.assertNotIn(strings.PROMPT_AGENT_LEAD_SETUP, output) + + @patch("create_dlthub_workspace.prompts.beaupy.select", return_value=0) + def test_no_run_header_uses_neutral_lead(self, _select: MagicMock) -> None: + with console.capture() as cap: + choose_agent(ran=False) + output = " ".join(cap.get().split()) + self.assertIn(strings.PROMPT_AGENT_LEAD_SETUP, output) + self.assertNotIn(strings.PROMPT_AGENT_LEAD_RAN, output) + class ConfirmTests(unittest.TestCase): @patch("create_dlthub_workspace.prompts.console.print")