diff --git a/AGENTS.md b/AGENTS.md
index 660189e..5a89f7b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -81,6 +81,8 @@ The rule exists because directories alone never held it: `protocols/`, `slicer/`
Accepted debt lives in `ALLOWED` in that script, each entry with a reason. Shrink it; do not grow it. There are currently no allowlisted edges: `RuntimeContext.printer()` uses an injectable factory registered downward from `bambu_cli.printer`.
+The same script ratchets **cross-unit imports of underscore-private names** (`PRIVATE_IMPORT_BUDGET`): a leading underscore is only a contract if something enforces it, and the rank table polices direction, not encapsulation. The budget is the measured count and may only go down — give a shared helper a public name or move it to a rank-10 module; do not raise the number.
+
The same script also enforces `SEALED` — package internals no outside module may import. `bambu_cli.printables.client` is sealed because an adapter is only a sandbox if callers cannot reach past it. **Third-party integrations go behind an adapter that cannot raise:** `PrintablesAdapter.resolve()` returns a `PrintablesResolution` for every outcome, converting a renamed field or a redesigned error envelope into a typed `printables_contract_changed` result instead of a traceback in the middle of `plate job`. `KeyboardInterrupt`/`SystemExit` are deliberately the only things that still propagate.
**JSON schemas are generated, never hand-written.** `docs/schemas/*.json` comes from the dataclasses in `bambu_cli/contracts/`:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d09f3f..321f801 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,35 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
+### Fixed
+
+- `plate print ` no longer fails with "unsafe name". A path with
+ a separator can never be a printer-side file, so the error now says that
+ `print` takes the name of a file already on the printer and points at the
+ fix: `plate job --confirm` for a model file, or `plate upload `
+ then `plate print --confirm` for an already-sliced 3MF/G-code. The
+ `--json` envelope carries that suggestion in `next_command`. Exit code (3)
+ and `failed_step` (`validate`) are unchanged; names without a separator keep
+ the "unsafe name" message.
+
+### Changed
+
+- `scripts/check_layers.py` now also ratchets cross-unit imports of
+ underscore-private names (`from bambu_cli.x import _helper` from another
+ layer unit). The budget is the measured count (104); it may only go down.
+ New shared helpers get a public name or move to a rank-10 module.
+- Version on `main` is `0.6.0.dev0` again after the 0.5.1 release, so a source
+ build no longer reports the released version.
+- README: the "Print something" first-run steps (which start with installing
+ OrcaSlicer) now come before the "Try it in 30 seconds" simulation section,
+ and that section says plainly that `plate --sim status` needs neither a
+ printer nor OrcaSlicer while a real print needs both. No CLI behaviour
+ changed; the empty-`HOME` first-run contract is now pinned by tests:
+ `plate preflight` reports the missing config, OrcaSlicer binary, and BBL
+ profiles as three separate checks, `plate setup --sim` without a TTY prints
+ the non-interactive `--printer-ip`/`--serial`/`--access-code-file` hint
+ instead of a traceback, and `plate --sim status` still exits `0`.
+
## [0.5.1] - 2026-08-28
### Fixed
diff --git a/README.md b/README.md
index b83eb0b..b96c1c8 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
[](https://pepy.tech/projects/platecli)
[](https://opensource.org/licenses/MIT)
-[Install](#install) · [Try it in 30 seconds](#try-it-in-30-seconds) · [Print something](#print-something) · [User guide](https://github.com/DLANSAMA/platecli/blob/main/docs/manual.md) · [Troubleshooting](https://github.com/DLANSAMA/platecli/blob/main/docs/troubleshooting.md) · [For AI agents](#built-for-ai-agents)
+[Install](#install) · [Print something](#print-something) · [Try it without a printer](#try-it-in-30-seconds) · [User guide](https://github.com/DLANSAMA/platecli/blob/main/docs/manual.md) · [Troubleshooting](https://github.com/DLANSAMA/platecli/blob/main/docs/troubleshooting.md) · [For AI agents](#built-for-ai-agents)
@@ -52,30 +52,6 @@ pip install platecli
Previously published on PyPI as `bambu-local-cli` (yanked). The project is now `platecli`; the installed command is `plate`.
-## Try it in 30 seconds
-
-No printer needed — simulation mode fakes one so you can kick the tires right away:
-
-```bash
-plate --sim status
-```
-
-```
-🖨️ Bambu Printer Status
- State: IDLE
- Bed: 25°C / 0°C
- Nozzle: 25°C / 0°C
- Fan: 0 | WiFi: -42dBm
- AMS:
- Unit 0 (humidity 5, 26.0°C)
- ▶ Slot 0: PLA #F2F2F2 | 90%
- Slot 1: PETG #0A0AC8 | 60%
- Slot 2: empty
- Slot 3: TPU #000000 | 45%
-```
-
-Timestamps and log-level prefixes trimmed for brevity.
-
## Print something
Four steps, no flags to learn:
@@ -129,6 +105,30 @@ It is a front-end, not new machinery: it slices and builds the `job` request thr
+## Try it in 30 seconds
+
+No printer yet, or OrcaSlicer not installed yet? Simulation mode fakes a printer so you can kick the tires right away — it needs neither. (A real print still needs both; see [Print something](#print-something).)
+
+```bash
+plate --sim status
+```
+
+```
+🖨️ Bambu Printer Status
+ State: IDLE
+ Bed: 25°C / 0°C
+ Nozzle: 25°C / 0°C
+ Fan: 0 | WiFi: -42dBm
+ AMS:
+ Unit 0 (humidity 5, 26.0°C)
+ ▶ Slot 0: PLA #F2F2F2 | 90%
+ Slot 1: PETG #0A0AC8 | 60%
+ Slot 2: empty
+ Slot 3: TPU #000000 | 45%
+```
+
+Timestamps and log-level prefixes trimmed for brevity.
+
## Why platecli
- **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.
diff --git a/bambu_cli/commands/print_cmd.py b/bambu_cli/commands/print_cmd.py
index 99ef447..5ae0c2d 100644
--- a/bambu_cli/commands/print_cmd.py
+++ b/bambu_cli/commands/print_cmd.py
@@ -16,6 +16,32 @@
from bambu_cli.utils import emit_json
+def _looks_like_local_path(value: str) -> bool:
+ """True when *value* carries a path separator, so it cannot be a printer-side name."""
+ return "/" in value or "\\" in value
+
+
+def _local_path_error(path: str) -> tuple[str, str]:
+ """Message + next command for a local path handed to `print`.
+
+ A model file (STL/STEP/OBJ) needs slicing, so `job` is the one-step fix.
+ An already-sliced 3MF/G-code only needs `upload` before `print `.
+ """
+ shown = _name_for_message(path)
+ if _is_print_ready_name(path):
+ next_command = f"plate upload {shown}"
+ fix = f"upload it first with `{next_command}`, then `plate print --confirm`"
+ else:
+ next_command = f"plate job {shown} --confirm"
+ fix = f"slice, upload and print it in one step with `{next_command}`"
+ message = (
+ f"`print` starts a file that is already on the printer, by name "
+ f"(for example `plate print model.gcode.3mf --confirm`). "
+ f"{shown!r} looks like a local path: {fix}."
+ )
+ return message, next_command
+
+
def cmd_print(args, ctx=None):
"""Start printing a file already on the printer."""
@@ -24,6 +50,19 @@ def cmd_print(args, ctx=None):
basename = str(args.file or "")
if _safe_remote_name(basename) is None:
+ if _looks_like_local_path(basename):
+ # A path with separators is never a printer-side name. Blaming the
+ # user for an "unsafe name" hides the real mistake: `print` takes
+ # the name of a file already on the printer, and the local file
+ # needs `job` (model) or `upload` (sliced) first.
+ message, next_command = _local_path_error(basename)
+ abort(
+ message,
+ exit_code=EXIT_FILE_ERROR,
+ failed_step="validate",
+ next_command=next_command,
+ extra={"file": basename},
+ )
message = f"Refusing to print file with unsafe name: {_name_for_message(basename)!r}"
abort(
message,
diff --git a/docs/quality-roadmap.md b/docs/quality-roadmap.md
index 8178c05..dbc17c0 100644
--- a/docs/quality-roadmap.md
+++ b/docs/quality-roadmap.md
@@ -1,5 +1,12 @@
# Quality roadmap: A+ across the board
+> **Frozen 2026-09-01.** The 2026-09 audit found the gates below already green
+> (91.1% coverage, mypy/bandit/pip-audit/layers/schemas all blocking) against
+> **zero external users** — no stars, forks, or issues after eight weeks and
+> 124 merged PRs. Further quality work here is deferred until real users have
+> run `plate go`; the open boxes that remain are listed honestly, not as
+> next steps. This file is repo-only and never ships.
+
Living plan to take `platecli` from **solid 0.1.x beta (B− overall)** to
**A+ product + A+ testing**. Derived from the 2026-07 harsh codebase audit.
@@ -358,16 +365,16 @@ pytest -W error::ResourceWarning --cov=bambu_cli --cov-fail-under=85
# plus existing smokes (agent, package, privacy, help)
```
-- [ ] Remove or shrink dedicated unittest module list; pytest collects everything.
-- [ ] `bandit -ll` **blocking** (no `|| true`) or allowlist with linked issues.
-- [ ] `pip-audit` blocking for high/critical (allowlist documented).
+- [x] Remove or shrink dedicated unittest module list; pytest collects everything (ci.yml: "Single runner: pytest").
+- [x] `bandit -ll` **blocking** (no `|| true`) — ci.yml step "bandit (blocking medium+)".
+- [x] `pip-audit` blocking for high/critical — ci.yml step "pip-audit (blocking high+)".
#### DoD
-- [ ] mqtt ≥85%, ftps ≥90%, camera ≥90%, netsafety ≥95%.
-- [ ] Total coverage ≥85% with fail-under.
-- [ ] Single primary test command documented in CONTRIBUTING + CLAUDE/AGENTS.
-- [ ] Zero flakes on 10 consecutive local full runs (or CI retries ≤0 on main for a week).
+- [ ] mqtt ≥85%, ftps ≥90%, camera ≥90%, netsafety ≥95% — 2026-09-01 local: mqtt 100%, camera 96.5%, netsafety 95.9% met; **ftps 89.8%** still short.
+- [x] Total coverage ≥85% with fail-under (floor is **86**; 91.1% measured 2026-09-01).
+- [x] Single primary test command documented in CONTRIBUTING + AGENTS.
+- [ ] Zero flakes on 10 consecutive local full runs (or CI retries ≤0 on main for a week) — never measured.
#### Score impact
@@ -406,15 +413,15 @@ pytest -W error::ResourceWarning --cov=bambu_cli --cov-fail-under=85
#### CI
-- [ ] Grep gate: `sys.exit` allowed only in `cli.py` (and maybe `bambu.py` entry). Adjust if scripts need exit.
-- [ ] Coverage fail-under → **88%**.
+- [x] Grep gate: `sys.exit` allowed only in `cli.py` — ci.yml step "sys.exit only at CLI entry (blocking)".
+- [ ] Coverage fail-under → **88%** (floor is 86; measured 91.1%, floor deliberately not raised — see freeze note).
#### DoD
-- [ ] `rg 'sys\.exit' bambu_cli` only hits entrypoints.
-- [ ] `errors.py` docstring no longer says “not yet converted”.
-- [ ] Agent JSON error payloads byte-for-byte compatible (contract tests green).
-- [ ] `@mockable` usages reduced by ≥50% (track count in this file).
+- [x] `rg 'sys\.exit' bambu_cli` only hits `cli.py` (plus docstrings that mention the rule).
+- [x] `errors.py` docstring no longer says “not yet converted”.
+- [x] Agent JSON error payloads byte-for-byte compatible (contract tests green).
+- [x] `@mockable` usages reduced by ≥50% — count is **0**.
#### Score impact
@@ -470,7 +477,7 @@ bandit + pip-audit blocking
#### DoD
-- [ ] Scorecard **A** column green for Tests and Typing (Typing A− OK if strict not yet full-package).
+- [x] Scorecard **A** column green for Tests and Typing (full-package mypy with `check_untyped_defs`).
- [x] Fake Orca available (`tests/fakes/orca_stub`) and used by the hermetic slice suite (`tests/test_slice_stub_integration.py`); remaining `test_slice_cmd.py` unit tests keep their mocks where they assert argv assembly / profile-resolution branches.
- [ ] `docs/test-backlog.md` reduced to “nice-to-have” only (or empty P1–P5).
@@ -512,9 +519,9 @@ bandit + pip-audit blocking
#### DoD
-- [ ] Every `--json` command has a schema + contract test.
-- [ ] No `@mockable` left (or documented single exception with removal date).
-- [ ] Scorecard A+ for Agent JSON, Docs, Product polish.
+- [x] Every `--json` command has a schema + contract test (27 generated schemas; `tests/json_contract_base.py` loads them).
+- [x] No `@mockable` left.
+- [ ] Scorecard A+ for Agent JSON, Docs, Product polish — Product polish is unmeasurable without users.
- [ ] Tag `v1.0.0` only when §5 checklist is complete.
#### Score impact
@@ -543,9 +550,9 @@ bandit + pip-audit blocking
#### DoD for A+ security/correctness
-- [ ] Adversarial suite green in CI on every PR.
-- [ ] Pin verification is one function, three call sites, fully tested.
-- [ ] No open P0/P1 issues labeled `security` or `correctness`.
+- [x] Adversarial suite green in CI on every PR (`tests/test_properties_safety.py` runs in the default suite).
+- [x] Pin verification is one function (`tlspin.verify_cert_fingerprint`), four call sites (camera, ftps ×2, mqtt_tls).
+- [x] No open P0/P1 issues labeled `security` or `correctness` (the tracker has no open issues at all).
---
diff --git a/pyproject.toml b/pyproject.toml
index 1955e1a..c4bf334 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
name = "platecli"
# Unreleased work carries a .devN suffix so a source build is never mistaken
# for the released wheel of the same number. Drop the suffix when tagging.
-version = "0.5.1"
+version = "0.6.0.dev0"
description = "platecli — local CLI for Bambu Lab printers (not affiliated with Bambu Lab)"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/scripts/check_layers.py b/scripts/check_layers.py
index 48b117c..e4a8277 100644
--- a/scripts/check_layers.py
+++ b/scripts/check_layers.py
@@ -98,12 +98,42 @@
"the raw Printables GraphQL wire format — import from bambu_cli.printables instead, "
"so a schema change stays contained in the adapter"
),
- "bambu_cli.printables.adapter": (
- "internal; the public names are re-exported from bambu_cli.printables"
- ),
+ "bambu_cli.printables.adapter": ("internal; the public names are re-exported from bambu_cli.printables"),
}
+# ---------------------------------------------------------------------------
+# Cross-unit imports of underscore-private names. The rank table polices the
+# *direction* of a dependency, not encapsulation: nothing stops commands/ from
+# reaching into download/naming.py for a `_helper`. A leading underscore is
+# only a contract if something enforces it, so this is a ratchet: the count may
+# go down (lower the budget in the same PR) but never up. New shared helpers get
+# a public name, or move down to a rank-10 module (see fsutil.py).
+# ---------------------------------------------------------------------------
+PRIVATE_IMPORT_BUDGET = 104
+
+
+def private_cross_unit_imports():
+ """Yield (file, lineno, module, name) for `from bambu_cli.x import _name` across units."""
+ for file in sorted(PKG.rglob("*.py")):
+ if "__pycache__" in file.parts:
+ continue
+ src = source_unit(file)
+ tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.ImportFrom) or node.level or not node.module:
+ continue
+ if not node.module.startswith("bambu_cli"):
+ continue
+ dst = unit_of(node.module)
+ if dst is None or dst == src:
+ continue
+ for alias in node.names:
+ name = alias.name
+ if name.startswith("_") and not name.startswith("__"):
+ yield file, node.lineno, node.module, name
+
+
def unit_of(module: str) -> str | None:
"""Map a dotted module path to the layer unit that owns it."""
parts = module.split(".")
@@ -225,7 +255,25 @@ def main() -> int:
for src, dst in sorted(stale):
print(f"STALE allowance {src} -> {dst} is no longer needed; remove it from ALLOWED")
- failures = len(violations) + len(unknown) + len(stale) + len(sealed)
+ private = list(private_cross_unit_imports())
+ budget_problems = 0
+ if len(private) > PRIVATE_IMPORT_BUDGET:
+ budget_problems = 1
+ print(
+ f"PRIVATE {len(private)} cross-unit imports of underscore-private names "
+ f"exceed the budget of {PRIVATE_IMPORT_BUDGET}. Give the helper a public "
+ f"name or move it to a rank-10 module; do not raise the budget."
+ )
+ for file, lineno, module, name in private:
+ print(f" {file.relative_to(ROOT)}:{lineno}: from {module} import {name}")
+ elif len(private) < PRIVATE_IMPORT_BUDGET:
+ budget_problems = 1
+ print(
+ f"RATCHET only {len(private)} private cross-unit imports remain; lower "
+ f"PRIVATE_IMPORT_BUDGET from {PRIVATE_IMPORT_BUDGET} to {len(private)} so it cannot grow back."
+ )
+
+ failures = len(violations) + len(unknown) + len(stale) + len(sealed) + budget_problems
if failures:
print(f"\n{failures} layering problem(s). See the rank table in {Path(__file__).name}.")
return 1
diff --git a/tests/test_cmd_print.py b/tests/test_cmd_print.py
index 1976d44..81cbd9e 100644
--- a/tests/test_cmd_print.py
+++ b/tests/test_cmd_print.py
@@ -1,7 +1,10 @@
"""Print command: the physical-action path and its --confirm gate."""
+import argparse
+
from tests.bambu_test_base import * # noqa: F401,F403
+
class TestBambuCmdPrint(unittest.TestCase):
@patch("bambu_cli.protocols.mqtt.get_status")
@patch("bambu_cli.logging_utils._BACKEND")
@@ -239,3 +242,44 @@ def test_cmd_print_with_confirm(self, mock_execute, mock_generate):
"test.gcode", use_ams=False, ams_mapping=None, timelapse=False, bed_leveling=False, flow_cali=False
)
mock_execute.assert_called_once_with(ANY, "test_payload", "test.gcode", dry_run=False)
+
+
+class TestCmdPrintLocalPathHint(unittest.TestCase):
+ """`plate print ` must explain what `print` takes, not blame the name."""
+
+ def _run(self, file_arg):
+ from bambu_cli.commands.print_cmd import cmd_print
+
+ args = argparse.Namespace(file=file_arg, confirm=False, dry_run=False, json=False)
+ with self.assertRaises(BambuError) as cm:
+ cmd_print(args, ctx=MagicMock())
+ return cm.exception
+
+ @patch("bambu_cli.logging_utils._BACKEND")
+ def test_model_path_points_at_job(self, _logger):
+ exc = self._run("tests/fixtures/cube.stl")
+ self.assertEqual(exc.exit_code, 3)
+ self.assertEqual(exc.failed_step, "validate")
+ self.assertIn("looks like a local path", str(exc))
+ self.assertIn("already on the printer", str(exc))
+ self.assertNotIn("unsafe name", str(exc))
+ self.assertEqual(exc.next_command, "plate job tests/fixtures/cube.stl --confirm")
+
+ @patch("bambu_cli.logging_utils._BACKEND")
+ def test_sliced_path_points_at_upload(self, _logger):
+ exc = self._run("out/cube.gcode.3mf")
+ self.assertEqual(exc.exit_code, 3)
+ self.assertEqual(exc.next_command, "plate upload out/cube.gcode.3mf")
+ self.assertIn("plate print --confirm", str(exc))
+
+ @patch("bambu_cli.logging_utils._BACKEND")
+ def test_windows_separator_counts_as_path(self, _logger):
+ exc = self._run("models\\cube.stl")
+ self.assertIn("looks like a local path", str(exc))
+
+ @patch("bambu_cli.logging_utils._BACKEND")
+ def test_plain_unsafe_name_keeps_old_message(self, _logger):
+ exc = self._run("bad:name.3mf")
+ self.assertEqual(exc.exit_code, 3)
+ self.assertIn("unsafe name", str(exc))
+ self.assertIsNone(exc.next_command)
diff --git a/tests/test_first_run_path.py b/tests/test_first_run_path.py
index 8660519..31b2b66 100644
--- a/tests/test_first_run_path.py
+++ b/tests/test_first_run_path.py
@@ -344,3 +344,116 @@ def test_manual_leads_with_first_print_and_demotes_agents():
assert phrase in first, phrase
assert "uploaded_not_printed" in manual
assert "without touching a slicer" not in manual
+
+
+# ---------------------------------------------------------------------------
+# 6. First run with an empty HOME: preflight, `setup --sim` off a TTY, `--sim status`
+# ---------------------------------------------------------------------------
+
+
+def _empty_home(monkeypatch, tmp_path, argv):
+ """Route every config lookup at a fresh, empty HOME and pin the context to unconfigured.
+
+ ``setup_cmd.common`` holds its own from-import copy of ``CONFIG_PATH``, so
+ the ``bambu_cli.config`` patch in ``_cli`` alone would leave preflight
+ reading the developer's real config.
+ """
+ home = tmp_path / "home"
+ home.mkdir()
+ monkeypatch.setenv("HOME", str(home))
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
+ config_path = str(home / ".config" / "bambu" / "config.json")
+ _cli(monkeypatch, argv, config_path)
+ monkeypatch.setattr("bambu_cli.setup_cmd.common.CONFIG_PATH", config_path)
+ # Nothing detected anywhere, whatever the CI host has installed.
+ monkeypatch.setattr("bambu_cli.config.detect_orca_slicer", lambda: None)
+ monkeypatch.setattr("bambu_cli.config.detect_profiles_dir", lambda: None)
+ return config_path
+
+
+def test_readme_names_orcaslicer_before_the_thirty_second_claim():
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
+ # A newcomer reads about the second slicer before any "30 seconds" / sim promise.
+ assert readme.index("OrcaSlicer") < readme.index("30 seconds")
+ assert readme.index("OrcaSlicer") < readme.index("plate --sim status")
+ # The first-run steps (which start with installing OrcaSlicer) come before the sim section.
+ assert readme.index("## Print something") < readme.index("## Try it in 30 seconds")
+ print_something = _section(readme, "## Print something")
+ assert re.search(r"^1\. \*\*Install OrcaSlicer\*\*", print_something, flags=re.MULTILINE)
+ # The sim section itself says what it does and does not need.
+ sim = _section(readme, "## Try it in 30 seconds")
+ assert "OrcaSlicer" in sim and "neither" in sim
+ assert sim.index("OrcaSlicer") < sim.index("plate --sim status")
+
+
+def test_preflight_empty_home_names_each_missing_piece_separately(monkeypatch, tmp_path):
+ from tests.bambu_test_base import config_ctx, settings_ctx
+
+ _empty_home(monkeypatch, tmp_path, ["preflight"])
+ # config_ctx({}) drops the test base's mock config (which points at /tmp/mock_orca);
+ # settings_ctx then blanks the platform-default slicer paths on top of it.
+ with (
+ config_ctx({}),
+ settings_ctx(printer_ip="0.0.0.0", orca_slicer="", profiles_dir=""),
+ patch("bambu_cli.logging_utils._BACKEND") as log,
+ pytest.raises(SystemExit) as ei,
+ ):
+ main()
+ assert ei.value.code == 1
+ failed = [m for m in _messages(log, "info") if "❌" in m]
+ names = [m.split("❌", 1)[1].split(":", 1)[0].strip() for m in failed]
+ # One line per missing piece, not one generic "run setup".
+ assert names == ["config", "orca-slicer", "profiles-dir"], failed
+ by_name = dict(zip(names, failed, strict=True))
+ assert "Config not found" in by_name["config"]
+ assert "OrcaSlicer path is not configured" in by_name["orca-slicer"]
+ assert "OrcaSlicer profile directory is not configured" in by_name["profiles-dir"]
+ # Each names an install / config step of its own, and the summary counts all three.
+ for name in ("orca-slicer", "profiles-dir"):
+ assert "Install it with" in by_name[name] and "config.json" in by_name[name], by_name[name]
+ assert any("Preflight failed: 3 error(s)" in m for m in _messages(log, "error"))
+
+
+def test_preflight_empty_home_json_lists_each_missing_piece(monkeypatch, tmp_path, capsys):
+ from tests.bambu_test_base import config_ctx, settings_ctx
+
+ _empty_home(monkeypatch, tmp_path, ["--json", "preflight"])
+ with (
+ config_ctx({}),
+ settings_ctx(printer_ip="0.0.0.0", orca_slicer="", profiles_dir=""),
+ pytest.raises(SystemExit) as ei,
+ ):
+ main()
+ assert ei.value.code == 1
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["status"] == "error"
+ errors = {c["name"]: c["message"] for c in payload["checks"] if c["status"] == "error"}
+ assert set(errors) == {"config", "orca-slicer", "profiles-dir"}, errors
+ assert payload["errors"] == 3
+
+
+def test_setup_sim_without_tty_is_a_usable_error_not_a_crash(monkeypatch, tmp_path, capsys):
+ _empty_home(monkeypatch, tmp_path, ["setup", "--sim"])
+ with 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, errors
+ assert "cannot run in a headless environment" in errors[0]
+ for flag in ("--printer-ip", "--serial", "--access-code-file"):
+ assert flag in errors[0], flag
+ assert "plate setup --printer-ip" in errors[0]
+ captured = capsys.readouterr()
+ assert "Traceback" not in captured.err and "Traceback" not in captured.out
+
+
+def test_sim_status_with_empty_home_exits_zero_and_reports_idle(monkeypatch, tmp_path):
+ _empty_home(monkeypatch, tmp_path, ["--sim", "status"])
+ with patch("bambu_cli.logging_utils._BACKEND") as log:
+ try:
+ main()
+ except SystemExit as exc: # pragma: no cover - main() may return or exit 0
+ assert exc.code in (None, 0)
+ assert _messages(log, "error") == []
+ info = _messages(log, "info")
+ assert any("State: IDLE" in m for m in info), info
diff --git a/uv.lock b/uv.lock
index 00f9722..04a1d5d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -154,7 +154,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "typing-extensions" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -411,7 +411,7 @@ wheels = [
[[package]]
name = "platecli"
-version = "0.5.0"
+version = "0.6.0.dev0"
source = { editable = "." }
dependencies = [
{ name = "paho-mqtt" },