diff --git a/CHANGELOG.md b/CHANGELOG.md index 1472de9..ad7dcd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,9 @@ All notable changes to SyncMill are documented here. ### Fixed +- Without a terminal, bare `syncmill start` and `start` without `--task` + print their refusal and the commands to use instead on separate lines. They + used to come out as one line joined by a literal `\x0a` (#254). - The TUI no longer exits when the board's decisions or the selected item cannot be read during a Board-tab refresh; it shows `DEGRADED` and keeps the last view, as it already did for the item list. diff --git a/src/syncmill/cli.py b/src/syncmill/cli.py index f455792..25e7884 100644 --- a/src/syncmill/cli.py +++ b/src/syncmill/cli.py @@ -976,8 +976,8 @@ def _start_project( if not task: if not interactive: - raise click.ClickException( - "start needs --task without a terminal.\n" + _PROJECT_STEPS + raise _MultilineError( + "start needs --task without a terminal.", *_PROJECT_STEPS.splitlines() ) task = click.prompt("What should the agent do?").strip() if not task: @@ -1228,10 +1228,10 @@ def start( if not trial and not project_flags: if not interactive: # Never wait for an answer nobody can give. - raise click.ClickException( + raise _MultilineError( "start needs a terminal to ask what you want to do. Use " - "`syncmill start --trial` for the offline walkthrough.\n" - + _PROJECT_STEPS + "`syncmill start --trial` for the offline walkthrough.", + *_PROJECT_STEPS.splitlines(), ) choice = click.prompt( "What would you like to do?\n" diff --git a/tests/test_start.py b/tests/test_start.py index 78dbe2a..dfd6c8c 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -95,9 +95,17 @@ def test_dry_run_shows_the_screen_and_runs_nothing(monkeypatch, project, calls): assert calls["run"] == [] +def _assert_project_steps_as_lines(output: str, first: str) -> None: + """The refusal and the steps it points at each sit on their own line: the + error boundary escapes a one-line message's LF to a visible ``\\x0a``.""" + assert "\\x0a" not in output + assert output.splitlines() == [f"Error: {first}", *cli_module._PROJECT_STEPS.splitlines()] + + def test_without_a_terminal_it_needs_task_and_yes(monkeypatch, project, calls): no_task = _start(monkeypatch, ["-a", "codex"], ready=_ready("codex")) assert no_task.exit_code == 1 and "start needs --task" in no_task.output + _assert_project_steps_as_lines(no_task.output, "start needs --task without a terminal.") no_yes = _start(monkeypatch, ["--task", "t"], ready=_ready("codex")) assert no_yes.exit_code == 1 and "start needs --yes" in no_yes.output @@ -109,6 +117,18 @@ def test_without_a_terminal_it_needs_task_and_yes(monkeypatch, project, calls): assert calls["diff"] == calls["apply"] == [] # nobody to ask; run printed the commands +def test_bare_start_without_a_terminal_explains_instead_of_asking(monkeypatch, project, calls): + result = _start(monkeypatch, [], ready=_ready("codex")) + + assert result.exit_code == 1 + _assert_project_steps_as_lines( + result.output, + "start needs a terminal to ask what you want to do. Use " + "`syncmill start --trial` for the offline walkthrough.", + ) + assert calls["run"] == [] + + def test_no_ready_agent_points_at_the_trial(monkeypatch, project, calls): result = _start( monkeypatch, ["--task", "t"], ready=_ready(missing=["codex"], logged_out=["claude"])