diff --git a/CHANGELOG.md b/CHANGELOG.md index d86e469..9304f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Fixed + +- **First-run honesty for people, not agents.** Bare `plate` outside a terminal + (a pipe, CI, `plate | less`) now prints a short four-step first-run guide — + install OrcaSlicer (a second slicer, not Bambu Studio), turn on LAN mode and + read the IP / serial / LAN access code off the touchscreen (not the account + password; it rotates), `plate setup`, `plate go` — instead of the full + argparse dump. It still exits `5`, keeps stdout empty, and never launches the + wizard or the TUI. The README and user guide lead with that same path; + `plate job --confirm` moves to the scripts-and-agents section. +- `--confirm` copy now says what happens without it, per command: `job` / + `send` still download, slice, and **upload** (exit `0`, + `"uploaded_not_printed"`); only `print` / `stop` / `pause` / `resume` / + `gcode` / `delete` refuse. `plate go` help no longer claims "no slicer" — it + still runs OrcaSlicer, you just never learn its flags. `--sim` help says it + is a fake printer (no hardware, no config, not a protocol test). The `go` / + `tui` non-TTY refusal points scripts at `plate job --json` and says + that `--confirm` is what starts a print. +- Expected failures were logged twice — once by the failing step and again by + the CLI's error handler. They now print once. The missing-OrcaSlicer error no + longer adds a contradictory "update config.json / tools/" hint when nothing + is installed, and the "HTML page did not contain a direct model file link" + error names what to use instead (a direct `.stl` / `.3mf` URL, a Printables + model page, or a local file) — previously that hint was unreachable code. + `plate doctor` with no printer configured now points at `plate preflight` + and `plate --sim status`. + ## [0.5.0] - 2026-08-26 ### Added diff --git a/README.md b/README.md index 1345f2e..b83eb0b 100644 --- a/README.md +++ b/README.md @@ -21,18 +21,18 @@ platecli demo: live printer status and slicing from the terminal -Paste a Printables link, get a physical print. `plate` downloads the model, slices it with OrcaSlicer, and sends it to your Bambu Lab printer — one command, entirely on your local network. No cloud account, no telemetry. Runs on **Linux, macOS, and Windows**, driven by hand or by AI agents. +Paste a Printables link, get a physical print. `plate` downloads the model, slices it with OrcaSlicer, and sends it to your Bambu Lab printer — one guided command, entirely on your local network. No cloud account, no telemetry. Runs on **Linux, macOS, and Windows**, driven by hand or by AI agents. ```text model URL or file → download → slice (OrcaSlicer) → upload → print - one command: plate job --confirm + one command: plate go ``` **Supports:** any Bambu Lab printer with LAN mode — P1P, P1S, X1C, X1E, A1, A1 Mini. **Hardware-tested on the P1 series (P1P/P1S) only.** The rest speak the same LAN protocols and are expected to work, but are unverified on real hardware — treat them as best-effort and please [open an issue](https://github.com/DLANSAMA/platecli/issues) with what you hit. One caveat: `plate snapshot` grabs the camera directly (no extra software) on P1/A1-class printers. X1-series cameras need a locally-running Docker streamer, and that path is opt-in (`camera_allow_streamer` or `--allow-camera-streamer`) because the streamer does not honour `cert_fingerprint`. ## Install -**Requirements:** Python 3.10+, and [OrcaSlicer](https://github.com/OrcaSlicer/OrcaSlicer/releases) installed locally if you want to slice. `plate slice` and `plate job` shell out to the OrcaSlicer binary; `download`, `status`, `upload`, and `print` do not need it. `plate setup` auto-detects the usual install locations (macOS app bundle, Windows Program Files, and on Linux a `$PATH` binary, Flatpak export, or AppImage), and `plate preflight` (or `plate config validate`) tells you if it can't find one. +**Requirements:** Python 3.10+, and [OrcaSlicer](https://github.com/OrcaSlicer/OrcaSlicer/releases) installed locally to slice — it is a **second slicer app, not Bambu Studio**, and `plate` cannot slice without it. `plate go`, `plate job`, and `plate slice` shell out to the OrcaSlicer binary; `download`, `status`, `upload`, and `print` do not need it. `plate setup` auto-detects the usual install locations (macOS app bundle, Windows Program Files, and on Linux a `$PATH` binary, Flatpak export, or AppImage), and `plate preflight` (or `plate config validate`) tells you if it can't find one. Fastest way to get OrcaSlicer, if you don't have it: @@ -78,11 +78,17 @@ plate --sim status ## Print something -Enable LAN mode on your printer, grab the IP, serial, and access code from its touchscreen, then let the interactive setup walk you through the rest: +Four steps, no flags to learn: + +1. **Install OrcaSlicer** (see [Install](#install)). `plate` runs it to slice; it is a second slicer app, not Bambu Studio. +2. **Turn on LAN mode** on the printer's touchscreen and note the **IP address**, **serial number**, and **LAN access code** shown next to it. The access code is *not* your Bambu account password, and it changes every time LAN mode is toggled or the printer is factory-reset — re-run `plate setup` if it stops connecting. +3. **`plate setup`** asks for those three values and finds OrcaSlicer for you. `plate doctor` then checks the connection end to end (optional). +4. **`plate go`** — paste a model link (or a local file), pick a material and quality, look at the time and filament estimate, and say yes. Plain `plate` in a terminal opens the same wizard. ```bash plate setup plate doctor # optional: verify the connection end to end +plate go # or: plate go "https://www.printables.com/model/3161-3d-benchy" ``` @@ -91,24 +97,17 @@ plate doctor # optional: verify the connection end to end plate doctor: config, MQTT, and FTPS health checks with TLS-pin verification against a real printer -Now go from a link on the internet to plastic on the bed: +The wizard walks you from a model URL (or local file) to a running print: paste a source, confirm the printer, pick a material and quality preset, answer one supports question, then see a time and filament preview before a final confirm. OrcaSlicer still does the slicing underneath — you just never touch its settings. If your printer has an AMS, the material step defaults to whatever filament is loaded. Declining the final question offers upload-only, and cancelling keeps the sliced file. -```bash -plate job "https://www.printables.com/model/3161-3d-benchy" --confirm -``` +### Scripts and agents: `plate job` -`--confirm` is required for anything that moves the printer or destroys data on it: `print`, `stop`, `pause`, `resume`, `gcode`, and `delete`. Leave it off and the command refuses with exit code `5` — nothing on the printer moves. For `job` / `send`, omitting `--confirm` still runs the download → slice → upload pipeline and exits `0` with `"status": "uploaded_not_printed"`; only the print step is withheld. (`light` is exempt; an LED is not a physical action.) - -### Prefer a guided walk-through? - -If you'd rather not think about flags, run the wizard — or just type `plate` on its own: +`plate go` needs an interactive terminal. Everything it asks maps to a flag on `plate job`, which runs the same `download` → `slice` → `upload` → `print` pipeline in one shot, so the result is identical: ```bash -plate go # or: plate go "https://www.printables.com/model/3161-3d-benchy" -plate # bare `plate` on a terminal launches the same wizard +plate job "https://www.printables.com/model/3161-3d-benchy" --confirm ``` -It walks you from a model URL (or local file) to a running print without touching a slicer: paste a source, confirm the printer, pick a material and quality preset, answer one supports question, then see a time and filament preview before a final confirm. If your printer has an AMS, the material step defaults to whatever filament is loaded. It drives the same `download` → `slice` → `job` pipeline as `plate job`, so the result is identical — it just asks the questions for you. `plate go` needs an interactive terminal; for scripts and agents, use `plate job --confirm`. +`--confirm` is what starts the print. **Without it, `job` / `send` still download, slice, and upload the file to the printer** — they exit `0` with `"status": "uploaded_not_printed"`, and the file sits on the printer unstarted. The commands that act on the printer directly are stricter: `print`, `stop`, `pause`, `resume`, `gcode`, and `delete` refuse without `--confirm` (exit code `5`) and do nothing. (`light` is exempt; an LED is not a physical action.) ### Watch the printer while it works @@ -132,7 +131,7 @@ It is a front-end, not new machinery: it slices and builds the `job` request thr ## Why platecli -- **One command, whole pipeline** — `plate job ` downloads, slices, uploads, and prints in one shot; or run `download` / `slice` / `upload` / `print` individually. +- **One command, whole pipeline** — `plate go` asks the questions; `plate job --confirm` takes flags. Both download, slice, upload, and print in one shot; or run `download` / `slice` / `upload` / `print` individually. - **Fully local & private** — talks straight to the printer over your LAN; no Bambu cloud account, ever. - **Deliberate-action gate** — physical commands refuse without `--confirm` (exit `5`), so a typo, a truncated argument list, or a replayed read-only command can't start a print. It is a gate against *accidents*, not an authorization boundary: `plate` cannot tell your `--confirm` from an agent's, so anything you let run `plate` can pass the flag. Sandbox agents accordingly. - **AI-agent ready** — every command speaks `--json` with published schemas, plus a `--sim` mode for hardware-free automation. @@ -164,7 +163,7 @@ you can put in a shell script or hand to an agent, use this. ## Built for AI agents -Every command emits machine-readable `--json` output backed by published [JSON Schemas](https://github.com/DLANSAMA/platecli/tree/main/docs/schemas/), `--sim` provides a canned printer (not a protocol test) for development without hardware, and the `--confirm` gate means physical actions never happen by accident. Two commands are deliberately human-only — the `go` wizard and the `tui` full-screen UI refuse `--json` and a non-TTY stdin with exit `5`; `plate job --confirm` is the machine path that does the same work. See the [user guide](https://github.com/DLANSAMA/platecli/blob/main/docs/manual.md) and [docs/api.md](https://github.com/DLANSAMA/platecli/blob/main/docs/api.md) for the JSON contracts and stability policy. +Every command emits machine-readable `--json` output backed by published [JSON Schemas](https://github.com/DLANSAMA/platecli/tree/main/docs/schemas/), `--sim` provides a canned printer (not a protocol test) for development without hardware, and the `--confirm` gate means a print never starts by accident — note that `job` / `send` without `--confirm` still upload (`uploaded_not_printed`); only the six direct printer commands refuse outright. Two commands are deliberately human-only — the `go` wizard and the `tui` full-screen UI refuse `--json` and a non-TTY stdin with exit `5`; `plate job --confirm` is the machine path that does the same work. See the [user guide](https://github.com/DLANSAMA/platecli/blob/main/docs/manual.md) and [docs/api.md](https://github.com/DLANSAMA/platecli/blob/main/docs/api.md) for the JSON contracts and stability policy. ## Documentation diff --git a/bambu_cli/cli.py b/bambu_cli/cli.py index 72ab6fd..03c58a0 100644 --- a/bambu_cli/cli.py +++ b/bambu_cli/cli.py @@ -23,6 +23,7 @@ _SilentArgumentParser, _SilentParseError, build_parser, + first_run_text, get_global_parser, ) from .constants import ( @@ -205,7 +206,9 @@ def _handle_bambu_error(exc, command_name): failed_step=exc.failed_step, **extra, ) - if msg and not msg.startswith("Command failed (exit "): + # Sites that raise through emit_json_error have already logged the line + # (exc.logged); printing it here again was the "every error twice" bug. + if msg and not msg.startswith("Command failed (exit ") and not getattr(exc, "logged", False): _safe_log_error(msg) sys.exit(exc.exit_code) @@ -237,6 +240,13 @@ def _handle_interrupt(interrupt_args, command_name): printer_ip = _context.current_settings().printer_ip if printer_ip == "0.0.0.0": message = "Printer IP is not configured. Please run `plate setup` first." + if args.cmd == "doctor": + # doctor is the "is my setup OK?" command; without a printer the + # useful checks are the local ones, so name them. + message += ( + " No printer yet? `plate preflight` checks the local setup (Python, OrcaSlicer, profiles)" + " without one, and `plate --sim status` fakes a printer." + ) write_error_envelope(args, args.cmd or "main", EXIT_CONFIG_ERROR, message, failed_step="config") logger.error(message) sys.exit(EXIT_CONFIG_ERROR) @@ -282,11 +292,12 @@ def _handle_interrupt(interrupt_args, command_name): # and without --json launches the guided wizard — the highest-leverage # ease-of-use win for someone who just installed `plate` and typed it to # see what happens (plan §11 Q1). Any machine-use flag (--json) or a - # non-TTY stream (CI, pipes, subprocess, `plate | less`) keeps today's - # exact behavior below: help to stderr, EXIT_COMMAND_ERROR. + # non-TTY stream (CI, pipes, subprocess, `plate | less`) gets the short + # first-run text below on stderr and EXIT_COMMAND_ERROR — never the + # wizard or the TUI. _go = _resolve_command("go") if _go is None: # pragma: no cover -- go is always registered - parser.print_help(sys.stderr) + print(first_run_text(), file=sys.stderr) sys.exit(EXIT_COMMAND_ERROR) args.cmd = "go" try: @@ -296,7 +307,10 @@ def _handle_interrupt(interrupt_args, command_name): except BambuError as exc: _handle_bambu_error(exc, "go") else: - parser.print_help(sys.stderr) + # Bare `plate` without a terminal: a person reading this has probably + # just installed it, so say what to do next instead of dumping every + # subcommand. Still a usage error for scripts (exit 5, stdout untouched). + print(first_run_text(), file=sys.stderr) sys.exit(EXIT_COMMAND_ERROR) diff --git a/bambu_cli/cliparse.py b/bambu_cli/cliparse.py index dcbcf22..17b4f08 100644 --- a/bambu_cli/cliparse.py +++ b/bambu_cli/cliparse.py @@ -164,7 +164,14 @@ def _add_slice_override_args(parser): def _add_job_arguments(parser): parser.add_argument("source", help="URL or local path to .stl/.step/.stp/.obj/.3mf/.gcode/.zip") - parser.add_argument("--confirm", action="store_true", help="Confirm print start after upload") + parser.add_argument( + "--confirm", + action="store_true", + help=( + "Start the print after upload. Without it the model is still downloaded, sliced, and " + "uploaded to the printer (status uploaded_not_printed); only the print start is withheld" + ), + ) parser.add_argument( "--dry-run", action="store_true", help="No-side-effect validation; skip download/slice/upload/print" ) @@ -217,12 +224,54 @@ def _add_job_arguments(parser): _add_slice_override_args(parser) +def first_run_text() -> str: + """The short guide bare ``plate`` prints when it cannot open the wizard. + + Written for a Bambu owner who has never used a CLI: the four steps from a + fresh install to a first print, in order, with the two things that trip + people up said out loud (OrcaSlicer is a second slicer, not Bambu Studio; + the LAN access code is not the account password and it rotates). + Deliberately not the argparse dump — that is one ``plate --help`` away. + """ + # Local import: config probes the filesystem for defaults at import time, + # and parser construction should stay side-effect free. + from bambu_cli.config import orca_install_command + + return ( + "plate — print to a Bambu Lab printer from your terminal, no cloud account.\n" + "\n" + "First time? Four steps, in order:\n" + "\n" + " 1. Install OrcaSlicer. plate uses it to slice. It is a separate slicer app,\n" + " not Bambu Studio:\n" + f" {orca_install_command()}\n" + " 2. On the printer's touchscreen turn on LAN mode, then note the IP address,\n" + " serial number, and LAN access code shown there. That code is not your\n" + " Bambu account password, and it changes whenever LAN mode is toggled or\n" + " the printer is factory-reset.\n" + " 3. Run: plate setup\n" + " 4. Run: plate go (paste a model link; in a terminal, plain `plate` does the same)\n" + "\n" + "Every command: plate --help\n" + "No printer yet: plate --sim status (a fake printer, no hardware needed)\n" + "Scripts and agents: plate job --json (add --confirm only to start the print)\n" + ) + + def get_global_parser(): global_parser = argparse.ArgumentParser(add_help=False) global_parser.add_argument( "-v", "--verbose", action="store_true", default=argparse.SUPPRESS, help="Enable debug logging" ) - global_parser.add_argument("--sim", action="store_true", default=argparse.SUPPRESS, help="Enable simulation mode") + global_parser.add_argument( + "--sim", + action="store_true", + default=argparse.SUPPRESS, + help=( + "Use a fake printer with canned responses: no hardware and no printer config needed. " + "It is not a protocol test of MQTT/FTPS" + ), + ) global_parser.add_argument( "--json", action="store_true", @@ -332,7 +381,10 @@ def build_parser(): p_go = sub.add_parser( "go", parents=[get_global_parser()], - help="Interactive guided print: URL in, plastic out — no slicer knowledge needed", + help=( + "Guided print: paste a model URL or file, answer a few questions, print. " + "OrcaSlicer still does the slicing; you just never learn its flags" + ), ) p_go.add_argument("source", nargs="?", help="Model URL or local file (skips the first prompt)") diff --git a/bambu_cli/commands/go.py b/bambu_cli/commands/go.py index 0ae33f3..1fa1d9d 100644 --- a/bambu_cli/commands/go.py +++ b/bambu_cli/commands/go.py @@ -11,7 +11,7 @@ def cmd_go(args: argparse.Namespace) -> None: - """Interactive guided print: URL in, plastic out — no slicer knowledge needed.""" + """Guided print: URL or file in, plastic out — OrcaSlicer runs underneath, no flags to learn.""" from bambu_cli.interactive.session import cmd_go as _cmd_go _cmd_go(args) diff --git a/bambu_cli/config.py b/bambu_cli/config.py index 9a5b7a7..50ef6ed 100644 --- a/bambu_cli/config.py +++ b/bambu_cli/config.py @@ -294,6 +294,11 @@ def detect_profiles_dir(): } +def orca_install_command(): + """Return the fastest one-line OrcaSlicer install command for this platform.""" + return _ORCA_INSTALL_COMMANDS.get(sys.platform, _ORCA_INSTALL_COMMANDS["linux"]) + + def orca_install_hint(): """Return a one-line, platform-appropriate 'how to install OrcaSlicer' hint. @@ -301,8 +306,7 @@ def orca_install_hint(): on the machine — at that point suggesting a config edit is useless, because there is nothing to point the config at. """ - command = _ORCA_INSTALL_COMMANDS.get(sys.platform, _ORCA_INSTALL_COMMANDS["linux"]) - return f"Install it with `{command}` (or download from {ORCA_RELEASES_URL}), then run `plate setup`." + return f"Install it with `{orca_install_command()}` (or download from {ORCA_RELEASES_URL}), then run `plate setup`." _DEFAULT_ORCA = _default_orca_path() diff --git a/bambu_cli/download/downloader.py b/bambu_cli/download/downloader.py index 537c0bf..c48094c 100644 --- a/bambu_cli/download/downloader.py +++ b/bambu_cli/download/downloader.py @@ -43,6 +43,13 @@ from bambu_cli.printables import is_printables_url, resolve_printables_url from bambu_cli.utils import _ensure_output_dir, _record_download_success, emit_json_error +# Appended to the two "this HTML page is not a model" errors so the next step is +# in the ERROR line itself (one concrete example of a source that works). +_HTML_SOURCE_HINT = ( + "Use a direct model URL such as https://example.com/model.stl (.stl/.step/.stp/.obj/.3mf/.gcode/.zip), " + "a Printables model page, or a local file." +) + def _response_header(resp, name): value = resp.getheader(name) @@ -278,7 +285,9 @@ def _cleanup_reserved(): args, source_url, normalized_source, url, urlparse(url).path, failed_step="resolve" ) continue - message = "HTML page did not contain a direct model file link." + # emit_json_error logs once and raises, so the next step has to + # live in the message itself. + message = f"HTML page did not contain a direct model file link. {_HTML_SOURCE_HINT}" emit_json_error( args, "download", @@ -289,11 +298,6 @@ def _cleanup_reserved(): normalized_source=normalized_source_report, download_url=_redact_url_credentials(url), ) - safe_log_error(message) - logger.info( - " Use a Printables model page, a direct .stl/.step/.stp/.obj/.3mf/.gcode/.zip download URL, or a page with a direct model-file link." - ) - abort("", exit_code=EXIT_FILE_ERROR) if not archive_download: _reject_unsupported_content_type(args, source_url, normalized_source, url, content_type) @@ -554,7 +558,7 @@ def _cleanup_reserved(): ) return outpath - message = "Could not resolve HTML page to a direct model file." + message = f"Could not resolve HTML page to a direct model file. {_HTML_SOURCE_HINT}" emit_json_error( args, "download", @@ -565,8 +569,6 @@ def _cleanup_reserved(): normalized_source=normalized_source_report, download_url=_redact_url_credentials(url), ) - safe_log_error(message) - abort("", exit_code=EXIT_FILE_ERROR) except urllib.error.HTTPError as e: _remove_partial_file(partial_path) _cleanup_reserved() diff --git a/bambu_cli/errors.py b/bambu_cli/errors.py index ba8b2a2..0ed9d48 100644 --- a/bambu_cli/errors.py +++ b/bambu_cli/errors.py @@ -42,6 +42,8 @@ class BambuError(Exception): next_command: An optional suggested follow-up command for the user. exit_code: Process exit code to use when this error escapes ``main()``. failed_step: Which pipeline stage failed (e.g. "config", "connect"). + logged: True when the raising site already wrote the human-readable + ERROR line, so ``cli.main`` must not print the same message again. """ exit_code: int = EXIT_COMMAND_ERROR @@ -56,11 +58,13 @@ def __init__( exit_code=None, failed_step=None, extra=None, + logged=False, ): super().__init__(message) self.detail = detail or {} self.next_command = next_command self.extra = extra or {} + self.logged = bool(logged) if exit_code is not None: self.exit_code = exit_code if failed_step is not None: @@ -180,12 +184,14 @@ def abort( next_command=None, extra=None, command: str | None = None, + logged: bool = False, ) -> NoReturn: """Raise the appropriate structured error for ``exit_code`` (domain code never calls ``sys.exit``). ``command`` is accepted so leftover ``emit_json_error`` call sites can pass it through; the exception does not store it — callers that need a payload - use ``BambuError.to_error_payload(command)``. + use ``BambuError.to_error_payload(command)``. Pass ``logged=True`` when the + caller has already written the ERROR line so it is not printed twice. """ extra = dict(extra or {}) resolved = message or f"Command failed (exit {exit_code})" @@ -197,4 +203,5 @@ def abort( detail=detail, next_command=next_command, extra=extra, + logged=logged, ) diff --git a/bambu_cli/interactive/session.py b/bambu_cli/interactive/session.py index e85efd4..9a20ecf 100644 --- a/bambu_cli/interactive/session.py +++ b/bambu_cli/interactive/session.py @@ -65,7 +65,11 @@ "cmd_go", ] -_NON_TTY_MESSAGE = "plate go is interactive; use 'plate job --confirm' for scripts." +_NON_TTY_MESSAGE = ( + "plate go is interactive and needs a terminal. Scripts and agents: use 'plate job --json'" + " (it downloads, slices, and uploads; add --confirm only to start the print)," + " or 'plate --sim status' to try things with a fake printer." +) _MAX_URL_ATTEMPTS = 3 @@ -332,7 +336,7 @@ def _run_go(args: argparse.Namespace, deps: GoDeps) -> None: def cmd_go(args: argparse.Namespace, deps: GoDeps | None = None) -> None: - """Interactive guided print: URL in, plastic out — no slicer knowledge needed. + """Guided print: URL or file in, plastic out — OrcaSlicer runs underneath, no flags to learn. ``deps`` is injectable for tests; production callers pass nothing and get the real prompt layer + pipeline collaborators. diff --git a/bambu_cli/slicer/cmd.py b/bambu_cli/slicer/cmd.py index 6bc12a3..d442903 100644 --- a/bambu_cli/slicer/cmd.py +++ b/bambu_cli/slicer/cmd.py @@ -239,8 +239,8 @@ def cmd_slice( logger.info( f'Detected OrcaSlicer at {_display_path(detected_orca)} — set "orca_slicer" to this in config.json.' ) - else: - logger.info("Please update 'orca_slicer' in your config.json or place it in the tools/ directory.") + # With no install anywhere, `message` already says to install it and run + # `plate setup`; a "update config.json / tools/" hint would contradict that. emit_json_error( args, "slice", diff --git a/bambu_cli/tui/entry.py b/bambu_cli/tui/entry.py index 8855e2d..af3adde 100644 --- a/bambu_cli/tui/entry.py +++ b/bambu_cli/tui/entry.py @@ -21,7 +21,11 @@ from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_CONFIG_ERROR from bambu_cli.errors import abort -_NON_TTY_MESSAGE = "plate tui is interactive; use 'plate job --confirm' for scripts." +_NON_TTY_MESSAGE = ( + "plate tui is interactive and needs a terminal. Scripts and agents: use 'plate job --json'" + " (it downloads, slices, and uploads; add --confirm only to start the print)," + " or 'plate --sim status' to try things with a fake printer." +) _MISSING_EXTRA_MESSAGE = "plate tui requires the TUI extra: pip install 'platecli[tui]'" diff --git a/bambu_cli/utils.py b/bambu_cli/utils.py index 4d0c78e..b1fcd26 100644 --- a/bambu_cli/utils.py +++ b/bambu_cli/utils.py @@ -200,7 +200,8 @@ def emit_json_error(args, command, exit_code, error, failed_step=None, **extra) ) if error: safe_log_error(error) - abort(error, exit_code=exit_code, failed_step=failed_step, extra=extra, command=command) + # The ERROR line was just written; tell cli.main so it does not print it again. + abort(error, exit_code=exit_code, failed_step=failed_step, extra=extra, command=command, logged=bool(error)) def record_error_detail(command, exit_code, error, failed_step=None, **extra): diff --git a/docs/manual.md b/docs/manual.md index fd9a3b1..5564455 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -4,9 +4,7 @@ The complete reference for `plate` — setup, configuration, slicing, monitoring **Contents** -- [Installing from source](#installing-from-source) -- [Use with AI agents](#use-with-ai-agents) -- [Features in depth](#features-in-depth) +- [Your first print](#your-first-print) - [Setup](#setup) - [OrcaSlicer](#orcaslicer) - [Usage](#usage) @@ -17,53 +15,53 @@ The complete reference for `plate` — setup, configuration, slicing, monitoring - [Global flags](#global-flags) - [Slicing & AMS](#slicing--ams) - [Config reference](#config-reference) +- [Use with AI agents](#use-with-ai-agents) +- [Features in depth](#features-in-depth) +- [Installing from source](#installing-from-source) - [Troubleshooting](https://github.com/DLANSAMA/platecli/blob/main/docs/troubleshooting.md) +- [Support matrix](#support-matrix) - [Project layout](#project-layout) - [Documentation map](#documentation-map) -- [Support matrix](#support-matrix) - -## Installing from source - -```bash -pip install . -# or: uv sync -``` - -## Use with AI agents - -`--json` is a global flag accepted by every command that produces structured output. Responses follow published JSON Schema files under [`docs/schemas/`](https://github.com/DLANSAMA/platecli/tree/main/docs/schemas/) — agents can validate against them or use them to understand the exact shape of each response. -`--sim` (simulation mode) replaces the real printer with a **canned** local stub — fixed status, files, and camera bytes. It is not a protocol test of MQTT/FTPS. Use it to develop agents and scripts without hardware. - -Destructive and physical actions — starting a print, pausing or resuming a print, stopping a job, deleting a file, or sending raw G-code — are gated behind an explicit `--confirm` flag. `print`, `stop`, `pause`, `resume`, `delete`, and `gcode` refuse without it (exit code `5`, `"status": "confirmation_required"`) and the printer is untouched. `job` / `send` without `--confirm` still download, slice, and upload, then exit `0` with `"status": "uploaded_not_printed"` — only the print step is withheld. Note this is a gate against accidents, not an authorization boundary: anything that can run `plate` can also pass `--confirm`. - -```bash -# Inspect printer state without hardware -plate --sim status --json - -# Start a full print workflow — requires --confirm to actually begin printing -plate job --json --confirm -``` - -## Features in depth - -- **Jobs & URL support** — Use `job` when an agent or user gives either a website URL or a local file path. It handles everything in one shot. -- **Printables downloads** — platecli fetches files from Printables *on your behalf*, from your own machine and network — the same file you would get by clicking Download. It identifies itself honestly as `platecli/`, keeps at least one second between requests to the same host, and honors `Retry-After`. Your use is subject to [Printables' terms of service](https://www.printables.com/legal/terms-of-use) and to the individual model's own licence (often a Creative Commons variant with attribution, non-commercial, or no-derivatives conditions). platecli grants you no rights to any downloaded model — check the licence on the model page before printing, remixing, redistributing, or selling. The Printables API used for resolution is undocumented and may change or stop working without notice. -- **Safe extraction** — ZIP archives containing model files are fully supported. Existing files are kept safe by creating a numbered sibling such as `model-1.stl`. URL downloads and ZIP extraction have a 2048 MB safety limit, adjustable via `--max-download-mb`. -- **Modularity** — Run steps individually using `download`, `slice`, `upload`, or `print`. -- **Safety first** — `print`, `pause`, `resume`, `stop`, `delete`, and raw `gcode` refuse without `--confirm` (exit code `5`). `job` / `send` without `--confirm` still upload and exit `0` with `"status": "uploaded_not_printed"`; only the print step is withheld. -- **TLS pinning** — Pin the printer’s self-signed cert with `cert_fingerprint` (setup/doctor can capture it). Prefer this over `insecure_tls`. -- **SSRF-hardened downloads** — Private/loopback targets are refused unless you pass `--allow-private-ips` for that invocation. -- **Diagnostics** — Network, FTPS, and MQTT health checking with `doctor` and `preflight`. -- **Agent JSON** — Structured `--json` output with published schemas under `docs/schemas/`. +## Your first print + +Four steps, in order. None of them needs a slicer setting or a flag. + +1. **Install OrcaSlicer.** `plate` slices with it, and cannot slice without it. + It is a *second* slicer app, separate from Bambu Studio — see + [OrcaSlicer](#orcaslicer) for the one-line install on each platform. +2. **Turn on LAN mode** on the printer's touchscreen and note the three values + shown next to it: the **IP address**, the **serial number**, and the + **LAN access code**. The access code is *not* your Bambu account password, + and it changes whenever LAN mode is toggled or the printer is factory-reset. +3. **Run `plate setup`.** It asks for those three values and auto-detects + OrcaSlicer. `plate doctor` afterwards checks the connection end to end + (see [Setup](#setup)). +4. **Run `plate go`** and paste a model link or a local file. It asks for a + material and a quality, shows the time and filament estimate, and starts + the print only after you say yes. Plain `plate` in a terminal opens the same + wizard (see [Guided mode](#guided-mode-plate-go)). + +No printer within reach yet? `plate --sim status` fakes one so you can look +around, and `plate preflight` checks the local pieces (Python, OrcaSlicer, +profiles) without touching a printer. + +Writing a script or driving `plate` from an agent instead? Use `plate job` +— see [Use with AI agents](#use-with-ai-agents) for the flags and for what +`--confirm` does and does not gate. ## Setup -Before running `setup`, gather your printer's LAN IP address, serial number, and -LAN-only access code (all shown on the printer's touchscreen under network/LAN -settings), and make sure LAN mode is enabled on the printer. +Before running `setup`, turn on LAN mode on the printer and gather the three +values its touchscreen shows under network/LAN settings: the LAN IP address, +the serial number, and the LAN access code. The access code is **not** your +Bambu account password, and it is regenerated whenever LAN mode is toggled or +the printer is factory-reset — if `plate` suddenly cannot connect, read it off +the screen again and re-run `setup`. -Use the interactive `setup` command to create your config securely: +Use the interactive `setup` command to create your config securely (it also +auto-detects [OrcaSlicer](#orcaslicer), which must be installed first if you +want to slice): ```bash plate setup @@ -196,6 +194,10 @@ If slicing still fails, see ## Usage ```bash +# Guided: paste a link, answer a few questions, print (asks before starting) +plate go +plate go "https://www.printables.com/model/3161-3d-benchy" + # Read-only: check connectivity and printer state (safe, no printer state changes) plate status plate doctor @@ -204,14 +206,15 @@ plate doctor For programmatic checks, `plate --json --version` emits JSON version details. ```bash -# Full workflow (download, slice, upload, and START A PHYSICAL PRINT) -# --confirm is required for any command that begins printing. +# Scripted full workflow: download, slice, upload, and START A PHYSICAL PRINT. +# --confirm is what starts the print; without it job/send still download, +# slice, and upload, then exit 0 with "status": "uploaded_not_printed". plate job "https://www.printables.com/model/3161-3d-benchy" --confirm --json ``` ## Guided mode (plate go) -`plate go` is an interactive wizard for printing from a URL without touching a slicer. It is a front-end over the same pipeline as `plate job` — it collects your answers, builds the same request `job` would, and drives `download` → `slice` → `job` — so the result is identical; it just asks the questions instead of taking flags. +`plate go` is an interactive wizard for printing from a URL without learning any slicer settings — OrcaSlicer still does the slicing underneath, so it must be installed. It is a front-end over the same pipeline as `plate job` — it collects your answers, builds the same request `job` would, and drives `download` → `slice` → `job` — so the result is identical; it just asks the questions instead of taking flags. ```bash plate go # prompts for everything @@ -393,6 +396,42 @@ or manually. `allow_private_ips` is **not** a config key — use the CLI flag `--allow-private-ips` per invocation. +## Use with AI agents + +`--json` is a global flag accepted by every command that produces structured output. Responses follow published JSON Schema files under [`docs/schemas/`](https://github.com/DLANSAMA/platecli/tree/main/docs/schemas/) — agents can validate against them or use them to understand the exact shape of each response. + +`--sim` (simulation mode) replaces the real printer with a **canned** local stub — fixed status, files, and camera bytes. It is not a protocol test of MQTT/FTPS. Use it to develop agents and scripts without hardware. + +Destructive and physical actions — starting a print, pausing or resuming a print, stopping a job, deleting a file, or sending raw G-code — are gated behind an explicit `--confirm` flag. `print`, `stop`, `pause`, `resume`, `delete`, and `gcode` refuse without it (exit code `5`, `"status": "confirmation_required"`) and the printer is untouched. `job` / `send` without `--confirm` still download, slice, and upload, then exit `0` with `"status": "uploaded_not_printed"` — only the print step is withheld. Note this is a gate against accidents, not an authorization boundary: anything that can run `plate` can also pass `--confirm`. + +```bash +# Inspect printer state without hardware +plate --sim status --json + +# Full pipeline. Without --confirm this still downloads, slices, and UPLOADS +# (exit 0, "uploaded_not_printed"); with it, the print starts. +plate job --json --confirm +``` + +## Features in depth + +- **Jobs & URL support** — Use `job` when an agent or user gives either a website URL or a local file path. It handles everything in one shot. +- **Printables downloads** — platecli fetches files from Printables *on your behalf*, from your own machine and network — the same file you would get by clicking Download. It identifies itself honestly as `platecli/`, keeps at least one second between requests to the same host, and honors `Retry-After`. Your use is subject to [Printables' terms of service](https://www.printables.com/legal/terms-of-use) and to the individual model's own licence (often a Creative Commons variant with attribution, non-commercial, or no-derivatives conditions). platecli grants you no rights to any downloaded model — check the licence on the model page before printing, remixing, redistributing, or selling. The Printables API used for resolution is undocumented and may change or stop working without notice. +- **Safe extraction** — ZIP archives containing model files are fully supported. Existing files are kept safe by creating a numbered sibling such as `model-1.stl`. URL downloads and ZIP extraction have a 2048 MB safety limit, adjustable via `--max-download-mb`. +- **Modularity** — Run steps individually using `download`, `slice`, `upload`, or `print`. +- **Safety first** — `print`, `pause`, `resume`, `stop`, `delete`, and raw `gcode` refuse without `--confirm` (exit code `5`). `job` / `send` without `--confirm` still upload and exit `0` with `"status": "uploaded_not_printed"`; only the print step is withheld. +- **TLS pinning** — Pin the printer’s self-signed cert with `cert_fingerprint` (setup/doctor can capture it). Prefer this over `insecure_tls`. +- **SSRF-hardened downloads** — Private/loopback targets are refused unless you pass `--allow-private-ips` for that invocation. +- **Diagnostics** — Network, FTPS, and MQTT health checking with `doctor` and `preflight`. +- **Agent JSON** — Structured `--json` output with published schemas under `docs/schemas/`. + +## Installing from source + +```bash +pip install . +# or: uv sync +``` + ## Support matrix OS × Python is what CI runs. Printer models are honest: P1 series on real hardware, everything else best-effort. The full table (including camera caveats and firmware) lives in [docs/api.md](api.md#support-matrix). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a9eaf03..62a6cad 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -545,19 +545,21 @@ the extra, and every non-interactive command works exactly as before. ## "plate tui is interactive" — exit 5 in a script, pipe, or with --json ``` -plate tui is interactive; use 'plate job --confirm' for scripts. +plate tui is interactive and needs a terminal. Scripts and agents: use 'plate job --json' (it downloads, slices, and uploads; add --confirm only to start the print), or 'plate --sim status' to try things with a fake printer. ``` -Exit code `5`. Working as designed, and it fires in three situations: you passed -`--json`, stdin is not a terminal (a pipe, a cron job, a CI step, some editor -terminals), or both. The TUI has no machine contract — there is no stable -document to parse out of a full-screen app — so instead of emitting half a -contract it refuses and points at the command that *does* have one. +Exit code `5` (`plate go` says the same thing about itself). Working as +designed, and it fires in three situations: you passed `--json`, stdin is not a +terminal (a pipe, a cron job, a CI step, some editor terminals), or both. The +TUI has no machine contract — there is no stable document to parse out of a +full-screen app — so instead of emitting half a contract it refuses and points +at the command that *does* have one. For automation use the one-shot pipeline: ```bash -plate job --json --confirm +plate job --json # download + slice + upload, nothing printed +plate job --json --confirm # ...and start the print ``` If you meant to run it interactively and still got this, your stdin is not a TTY. diff --git a/tests/agent_cli_smoke.py b/tests/agent_cli_smoke.py index c16c55a..1c7cc5e 100644 --- a/tests/agent_cli_smoke.py +++ b/tests/agent_cli_smoke.py @@ -193,8 +193,13 @@ def smoke_help_surface(root): if version_json != {"status": "ok", "command": "version", "version": project_version()}: assert False, f"--json --version payload was unexpected: {version_json}" no_command = run_cli([], env, expected_returncode=5) - if "usage:" not in no_command.stderr.lower() or no_command.stdout.strip(): - assert False, "missing subcommand should print usage to stderr and keep stdout empty" + # Bare `plate` off a TTY is the human first-run text (install OrcaSlicer, LAN + # mode, `plate setup`, `plate go`) on stderr — not the argparse dump, and never + # the wizard/TUI. stdout stays empty so it is still a clean usage error. + if "plate setup" not in no_command.stderr or "OrcaSlicer" not in no_command.stderr: + assert False, "missing subcommand should print the first-run guide to stderr" + if "positional arguments" in no_command.stderr or no_command.stdout.strip(): + assert False, "missing subcommand must not dump argparse help or write to stdout" unknown_command = run_cli(["definitely-not-a-command"], env, expected_returncode=5) if "invalid choice" not in unknown_command.stderr or unknown_command.stdout.strip(): assert False, "unknown subcommand should be a command error on stderr with empty stdout" diff --git a/tests/test_download_cmd.py b/tests/test_download_cmd.py index 2fb1e7d..778914b 100644 --- a/tests/test_download_cmd.py +++ b/tests/test_download_cmd.py @@ -485,6 +485,26 @@ def test_html_page_without_model_link_fails(self, mock_logger): any("did not contain a direct model file link" in c[0][0] for c in mock_logger.error.call_args_list) ) + @patch("bambu_cli.logging_utils._BACKEND") + def test_html_page_without_model_link_prints_once_with_a_next_step(self, mock_logger): + """One ERROR line (marked logged so cli.main does not repeat it) that names a good source.""" + self._respond( + _FakeResp( + [b"no model links here", b""], + headers={"Content-Type": "text/html"}, + ) + ) + with self.assertRaises(BambuError) as cm: + self._run_download(self._args(url="https://example.com/page")) + message = str(cm.exception) + self.assertIn("did not contain a direct model file link", message) + self.assertIn("https://example.com/model.stl", message) + self.assertIn("Printables model page", message) + self.assertIn("local file", message) + self.assertTrue(cm.exception.logged) + errors = [c[0][0] for c in mock_logger.error.call_args_list if "direct model file link" in c[0][0]] + self.assertEqual(errors, [message]) + @patch("bambu_cli.logging_utils._BACKEND") def test_redirect_url_is_revalidated_and_used(self, mock_logger): from bambu_cli.commands import cmd_download diff --git a/tests/test_first_run_path.py b/tests/test_first_run_path.py new file mode 100644 index 0000000..8660519 --- /dev/null +++ b/tests/test_first_run_path.py @@ -0,0 +1,346 @@ +"""The first-run person path: what a Bambu owner who has never used a CLI sees. + +Covers the copy contract end to end — bare ``plate`` off a TTY, the ``--confirm`` +/ ``--sim`` / ``go`` help strings, the ``go`` / ``tui`` non-TTY refusal, the +missing-OrcaSlicer and HTML-page errors printing exactly once with a next step, +and the README / manual leading with the same path. No printer, no network, no +real slicer: ``--sim`` plus injected collaborators throughout. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from bambu_cli.cli import build_parser, main +from bambu_cli.cliparse import first_run_text +from bambu_cli.errors import BambuError + +ROOT = Path(__file__).resolve().parents[1] +CUBE = str(ROOT / "tests" / "fixtures" / "cube.stl") + + +def _subparser(name): + for action in build_parser()._actions: + if getattr(action, "choices", None) and name in action.choices: + return action.choices[name] + raise AssertionError(f"no subparser {name!r}") + + +def _write_config(tmp_path, **overrides): + cfg = { + "printer_ip": "192.0.2.1", + "serial": "01P00A000000000", + "access_code": "12345678", + "model": "P1S", + } + cfg.update(overrides) + path = tmp_path / "config.json" + path.write_text(json.dumps(cfg), encoding="utf-8") + os.chmod(path, 0o600) + return str(path) + + +def _cli(monkeypatch, argv, config_path): + """Point main() at ``config_path`` with a silent, mockable logger.""" + monkeypatch.setattr(sys, "argv", ["plate", *argv]) + monkeypatch.setattr("bambu_cli.config.CONFIG_PATH", config_path) + monkeypatch.setattr("bambu_cli.cli.setup_logging", lambda *a, **k: None) + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + + +def _messages(mock_logger, level): + return [str(c.args[0]) for c in getattr(mock_logger, level).call_args_list] + + +# --------------------------------------------------------------------------- +# 1. bare `plate` off a TTY: the short person path, not the argparse dump +# --------------------------------------------------------------------------- + + +def test_first_run_text_is_the_person_path_in_order(): + text = first_run_text() + steps = [ + "Install OrcaSlicer", + "not Bambu Studio", + "LAN mode", + "not your\n Bambu account password", + "factory-reset", + "plate setup", + "plate go", + ] + positions = [text.index(step) for step in steps] + assert positions == sorted(positions), "first-run steps must appear in the order a person needs them" + # Short by construction: a screenful, not twenty subcommands. + assert len(text.splitlines()) < 25 + assert "positional arguments" not in text + # Scripts are pointed at job, with --confirm named as the thing that starts a print. + assert "plate job --json" in text and "--confirm" in text + + +def test_first_run_text_names_the_platform_install_command(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + assert "brew install --cask orcaslicer" in first_run_text() + monkeypatch.setattr(sys, "platform", "win32") + assert "winget install --id SoftFever.OrcaSlicer" in first_run_text() + + +def test_bare_plate_non_tty_prints_first_run_text_not_help(monkeypatch, tmp_path, capsys): + from bambu_cli import commands as commands_mod + + _cli(monkeypatch, [], str(tmp_path / "missing" / "config.json")) + monkeypatch.setattr(commands_mod, "cmd_go", lambda args: (_ for _ in ()).throw(AssertionError("wizard ran"))) + monkeypatch.setattr(commands_mod, "cmd_tui", lambda args: (_ for _ in ()).throw(AssertionError("tui ran"))) + + with pytest.raises(SystemExit) as ei: + main() + assert ei.value.code == 5 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err.strip() == first_run_text().strip() + + +def test_bare_plate_json_keeps_error_envelope(monkeypatch, tmp_path, capsys): + """--json is a machine flag: the envelope contract wins over the first-run text.""" + _cli(monkeypatch, ["--json"], str(tmp_path / "missing" / "config.json")) + with pytest.raises(SystemExit) as ei: + main() + assert ei.value.code == 5 + captured = capsys.readouterr() + assert json.loads(captured.out)["status"] == "error" + assert "plate setup" not in captured.err + + +# --------------------------------------------------------------------------- +# 2. help strings: --confirm (job/send), --sim, go +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("command", ["job", "send"]) +def test_job_confirm_help_admits_upload_without_print(command): + help_text = " ".join(_subparser(command).format_help().split()) + assert "uploaded_not_printed" in help_text + assert "uploaded to the printer" in help_text + + +@pytest.mark.parametrize("command", ["print", "stop", "pause", "resume", "gcode", "delete"]) +def test_gated_commands_still_take_confirm(command): + assert "--confirm" in _subparser(command).format_help() + + +def test_sim_help_says_fake_printer_not_protocol_test(): + help_text = " ".join(build_parser().format_help().split()) + assert "fake printer" in help_text + assert "no hardware and no printer config" in help_text + assert "not a protocol test" in help_text + assert "Enable simulation mode" not in help_text + + +def test_go_help_does_not_promise_no_slicer(): + top = " ".join(build_parser().format_help().split()) + go = " ".join(_subparser("go").format_help().split()) + for text in (top, go): + assert "no slicer knowledge" not in text + assert "without touching a slicer" not in text + assert "OrcaSlicer still does the slicing" in top + + +# --------------------------------------------------------------------------- +# 3. go / tui non-TTY refusal copy +# --------------------------------------------------------------------------- + + +def _assert_refusal_copy(message, command): + assert message.startswith(f"plate {command} is interactive") + assert "plate job --json" in message + assert "add --confirm only to start the print" in message + assert "plate --sim status" in message + # The old copy handed scripts a loaded gun. + assert "use 'plate job --confirm' for scripts" not in message + + +def test_go_non_tty_refusal_names_confirm_as_the_print_trigger(monkeypatch): + from bambu_cli.interactive.session import _NON_TTY_MESSAGE, cmd_go + + _assert_refusal_copy(_NON_TTY_MESSAGE, "go") + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + with pytest.raises(BambuError) as ei: + cmd_go(MagicMock(json=False, sim=False)) + assert str(ei.value) == _NON_TTY_MESSAGE + assert ei.value.exit_code == 5 + + +def test_tui_non_tty_refusal_names_confirm_as_the_print_trigger(monkeypatch): + from bambu_cli.tui.entry import _NON_TTY_MESSAGE, cmd_tui + + _assert_refusal_copy(_NON_TTY_MESSAGE, "tui") + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + with pytest.raises(BambuError) as ei: + cmd_tui(MagicMock(json=False)) + assert str(ei.value) == _NON_TTY_MESSAGE + assert ei.value.exit_code == 5 + + +def test_troubleshooting_quotes_the_current_tui_refusal(): + from bambu_cli.tui.entry import _NON_TTY_MESSAGE + + doc = (ROOT / "docs" / "troubleshooting.md").read_text(encoding="utf-8") + assert _NON_TTY_MESSAGE in doc + + +# --------------------------------------------------------------------------- +# 4. errors print once and name the next step +# --------------------------------------------------------------------------- + + +def test_expected_failure_is_logged_once_through_main(monkeypatch, tmp_path): + """emit_json_error logs and raises; cli.main must not log the same line again.""" + from bambu_cli import commands as commands_mod + from bambu_cli.utils import emit_json_error + + _cli(monkeypatch, ["--sim", "status"], _write_config(tmp_path)) + + def failing_status(args, **_kw): + emit_json_error(args, "status", 3, "the one and only line", failed_step="probe") + + monkeypatch.setattr(commands_mod, "cmd_status", failing_status) + with patch("bambu_cli.logging_utils._BACKEND") as log, pytest.raises(SystemExit) as ei: + main() + assert ei.value.code == 3 + assert _messages(log, "error").count("the one and only line") == 1 + + +def test_plain_bambu_error_is_still_logged_by_main(monkeypatch, tmp_path): + """A raise that did NOT log itself keeps getting its one line from cli.main.""" + from bambu_cli import commands as commands_mod + + _cli(monkeypatch, ["--sim", "status"], _write_config(tmp_path)) + + def failing_status(args, **_kw): + raise BambuError("unlogged failure", exit_code=3, failed_step="probe") + + monkeypatch.setattr(commands_mod, "cmd_status", failing_status) + with patch("bambu_cli.logging_utils._BACKEND") as log, pytest.raises(SystemExit) as ei: + main() + assert ei.value.code == 3 + assert _messages(log, "error").count("unlogged failure") == 1 + + +def test_missing_orca_prints_once_and_says_install_then_setup(monkeypatch, tmp_path): + config = _write_config(tmp_path, orca_slicer=str(tmp_path / "nope" / "orca-slicer"), profiles_dir=str(tmp_path)) + _cli(monkeypatch, ["--sim", "slice", CUBE], config) + # Nothing installed anywhere on this machine, whatever the CI host has. + monkeypatch.setattr("bambu_cli.config.detect_orca_slicer", lambda: None) + + with patch("bambu_cli.logging_utils._BACKEND") as log, pytest.raises(SystemExit) as ei: + main() + assert ei.value.code == 1 + errors = [m for m in _messages(log, "error") if "OrcaSlicer not found" in m] + assert len(errors) == 1, errors + assert "then run `plate setup`" in errors[0] + # The contradictory "edit config.json / tools/" hint is gone when there is nothing to point at. + assert not any("update 'orca_slicer'" in m for m in _messages(log, "info")) + + +def test_missing_orca_still_names_a_detected_install(monkeypatch, tmp_path): + detected = tmp_path / "real" / "orca-slicer" + detected.parent.mkdir() + detected.write_text("", encoding="utf-8") + detected.chmod(0o755) + config = _write_config(tmp_path, orca_slicer=str(tmp_path / "nope" / "orca-slicer"), profiles_dir=str(tmp_path)) + _cli(monkeypatch, ["--sim", "slice", CUBE], config) + monkeypatch.setattr("bambu_cli.config.detect_orca_slicer", lambda: str(detected)) + + with patch("bambu_cli.logging_utils._BACKEND") as log, pytest.raises(SystemExit): + main() + assert any("Detected OrcaSlicer at" in m for m in _messages(log, "info")) + assert sum("OrcaSlicer not found" in m for m in _messages(log, "error")) == 1 + + +def test_doctor_without_printer_points_at_the_no_printer_checks(monkeypatch, tmp_path): + from tests.bambu_test_base import settings_ctx + + # No config file, and the baseline context pinned to "unconfigured" (a missing + # file leaves whatever context is installed in place). + _cli(monkeypatch, ["doctor"], str(tmp_path / "missing" / "config.json")) + with ( + settings_ctx(printer_ip="0.0.0.0"), + patch("bambu_cli.logging_utils._BACKEND") as log, + pytest.raises(SystemExit) as ei, + ): + main() + assert ei.value.code == 1 + errors = _messages(log, "error") + assert len(errors) == 1 + assert "plate setup" in errors[0] + assert "plate preflight" in errors[0] and "plate --sim status" in errors[0] + + +def test_other_printer_commands_keep_the_short_not_configured_line(monkeypatch, tmp_path): + from tests.bambu_test_base import settings_ctx + + _cli(monkeypatch, ["status"], str(tmp_path / "missing" / "config.json")) + with ( + settings_ctx(printer_ip="0.0.0.0"), + patch("bambu_cli.logging_utils._BACKEND") as log, + pytest.raises(SystemExit), + ): + main() + errors = _messages(log, "error") + assert errors == ["Printer IP is not configured. Please run `plate setup` first."] + + +# --------------------------------------------------------------------------- +# 5. README and manual lead with the person path; job --confirm is demoted +# --------------------------------------------------------------------------- + + +def _section(markdown, heading): + match = re.search(rf"^{re.escape(heading)}\n(.*?)(?=^## |\Z)", markdown, flags=re.MULTILINE | re.DOTALL) + assert match, f"missing section {heading!r}" + return match.group(1) + + +def test_readme_hero_is_the_person_path(): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + print_something = _section(readme, "## Print something") + for phrase in ("Bambu Studio", "LAN mode", "account password", "factory-reset", "plate setup", "plate go"): + assert phrase in print_something, phrase + # The human path comes before the agent one-liner. + assert print_something.index("plate go") < print_something.index("plate job") + # The pipeline hero line no longer sells `job --confirm` as *the* command. + hero = readme[: readme.index("## Install")] + assert "plate job --confirm" not in hero + # OrcaSlicer is called out as a second slicer before the pip install line. + install = _section(readme, "## Install") + assert install.index("not Bambu Studio") < install.index("pip install platecli") + + +def test_readme_confirm_copy_is_honest_about_job_and_send(): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + assert "uploaded_not_printed" in readme + assert "still download, slice, and upload" in readme + for stale in ("nothing on the printer moves", "without touching a slicer", "no slicer knowledge"): + assert stale not in readme, stale + + +def test_manual_leads_with_first_print_and_demotes_agents(): + manual = (ROOT / "docs" / "manual.md").read_text(encoding="utf-8") + toc = re.findall(r"^- \[([^\]]+)\]\(#", manual, flags=re.MULTILINE) + assert toc[0] == "Your first print" + assert toc.index("Setup") < toc.index("Use with AI agents") + assert toc.index("Setup") < toc.index("Installing from source") + headings = re.findall(r"^## (.+)$", manual, flags=re.MULTILINE) + assert headings[0] == "Your first print" + first = _section(manual, "## Your first print") + for phrase in ("Bambu Studio", "LAN mode", "account password", "plate setup", "plate go"): + assert phrase in first, phrase + assert "uploaded_not_printed" in manual + assert "without touching a slicer" not in manual diff --git a/tests/test_interactive_session.py b/tests/test_interactive_session.py index 0037d90..e36c270 100644 --- a/tests/test_interactive_session.py +++ b/tests/test_interactive_session.py @@ -795,7 +795,7 @@ def fake_cmd_go(args): main() # returns cleanly; the wizard handler ran instead of help assert called["cmd"] == "go" -def test_bare_plate_non_tty_prints_help_and_exits_5(monkeypatch, tmp_path): +def test_bare_plate_non_tty_prints_first_run_text_and_exits_5(monkeypatch, tmp_path, capsys): from bambu_cli import commands as commands_mod from bambu_cli.cli import main @@ -810,6 +810,11 @@ def boom(args): # the wizard must NOT run on a non-TTY with pytest.raises(SystemExit) as ei: main() assert ei.value.code == 5 + captured = capsys.readouterr() + assert captured.out == "" # still a usage error for scripts: stdout untouched + # The short person path, not the argparse dump. + assert "plate setup" in captured.err and "plate go" in captured.err + assert "positional arguments" not in captured.err def test_bare_plate_tty_stdin_but_redirected_stdout_prints_help(monkeypatch, tmp_path): """A TTY stdin with a redirected stdout is a script pattern -> keep help path.""" diff --git a/tests/test_json_contract_cli.py b/tests/test_json_contract_cli.py index a3e1e15..1e26b97 100644 --- a/tests/test_json_contract_cli.py +++ b/tests/test_json_contract_cli.py @@ -63,12 +63,15 @@ def test_missing_subcommand_json_shape(monkeypatch, tmp_path, capsys): ) -def test_missing_subcommand_without_json_prints_usage_not_json(monkeypatch, tmp_path, capsys): +def test_missing_subcommand_without_json_prints_first_run_text_not_json(monkeypatch, tmp_path, capsys): exc = run_main(monkeypatch, tmp_path, []) assert exc is not None and exc.code == 5 out, err = capsys.readouterr() assert out.strip() == "" - assert "usage:" in err.lower() + # Off a TTY, bare `plate` is the short human first-run guide on stderr (not the + # argparse dump, and never JSON on stdout). + assert "plate setup" in err and "plate --help" in err + assert "{" not in out # ---------------------------------------------------------------------------