muxplex-deck 0.6.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/PKG-INFO +1 -1
  2. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/pyproject.toml +1 -1
  3. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/cli.py +363 -100
  4. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/init_wizard.py +133 -44
  5. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/layout.py +52 -17
  6. muxplex_deck-0.7.0/src/muxplex_deck/report.py +416 -0
  7. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/service.py +38 -8
  8. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/statusfile.py +1 -1
  9. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/README.md +0 -0
  10. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/__init__.py +0 -0
  11. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/__main__.py +0 -0
  12. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/capabilities.py +0 -0
  13. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/events.py +0 -0
  14. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/main.py +0 -0
  15. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/rendering.py +0 -0
  16. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/__init__.py +0 -0
  17. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/__main__.py +0 -0
  18. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/LICENSE-bsd.txt +0 -0
  19. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/VERSION +0 -0
  20. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/x64/hidapi.dll +0 -0
  21. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/attention.py +0 -0
  22. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/config.py +0 -0
  23. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/device.py +0 -0
  24. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/device_real.py +0 -0
  25. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/emulator.py +0 -0
  26. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/focus.py +0 -0
  27. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/hidapi_win.py +0 -0
  28. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/hidhelp.py +0 -0
  29. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/interaction.py +0 -0
  30. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/main.py +0 -0
  31. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/rendering.py +0 -0
  32. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/usbnode.py +0 -0
  33. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/views.py +0 -0
  34. {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/wsl.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: muxplex-deck
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Hardware probe/diagnostic app for the Elgato Stream Deck+ (seed of the muxplex sidecar)
5
5
  Author: Amplifier
6
6
  Author-email: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "muxplex-deck"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "Hardware probe/diagnostic app for the Elgato Stream Deck+ (seed of the muxplex sidecar)"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -389,6 +389,21 @@ def check_ca_file(ca_file: Path | None) -> tuple[str, str]:
389
389
  check=False,
390
390
  )
391
391
  except FileNotFoundError:
392
+ if sys.platform == "win32":
393
+ # Windows doesn't ship openssl by default -- unlike Linux/macOS,
394
+ # where its absence is unusual and worth a "warn", here it's the
395
+ # norm. Flagging this as something the user needs to fix would
396
+ # violate the repo's own rule (never print guidance that treats
397
+ # an environment gap as the user's problem when it isn't one).
398
+ # `ok`, with an actionable-if-they-want-it alternative -- never
399
+ # invent an X.509 parser dependency for this one check.
400
+ return "ok", (
401
+ "ca_file: could not verify via openssl (not bundled with "
402
+ "Windows) -- compare the SHA-256 fingerprint printed "
403
+ "during `init` against the server's, or install openssl "
404
+ "(winget install ShiningLight.OpenSSL.Light, or use Git "
405
+ "for Windows' bundled openssl.exe) to enable this check"
406
+ )
392
407
  return "warn", "openssl not found -- cannot verify ca_file is a CA"
393
408
  except Exception as exc: # noqa: BLE001 -- doctor check must degrade to warn, never raise
394
409
  return "warn", f"Could not inspect ca_file {ca_file}: {exc}"
@@ -685,11 +700,27 @@ def check_federation_key_auth(
685
700
  callers must not print a false success, but also
686
701
  must not treat an unrelated network hiccup as a
687
702
  rejected key.
703
+
704
+ **Must send `Accept: application/json`.** This is the root cause of a
705
+ real regression: without it, muxplex's auth middleware treats the
706
+ request as a browser navigation and answers an unauthenticated
707
+ request with a 307 redirect to `/login` instead of a 401 -- which this
708
+ client (deliberately, like `muxplex_client.MuxplexClient`, see its
709
+ docstring) does not follow, so the redirect surfaced as an
710
+ "unexpected response HTTP 307" that read as "can't verify" and let a
711
+ bad key through unvalidated. `follow_redirects` is NOT the fix --
712
+ following the redirect would land on the login page and return a
713
+ misleading 200. Sending the header the server's auth layer actually
714
+ branches on is what makes this request exercise the credential at
715
+ all, exactly like the sidecar's own `MuxplexClient` already does.
688
716
  """
689
717
  import httpx
690
718
 
691
719
  url = f"{server_url.rstrip('/')}/api/sessions"
692
- headers = {"Authorization": f"Bearer {federation_key}"}
720
+ headers = {
721
+ "Accept": "application/json",
722
+ "Authorization": f"Bearer {federation_key}",
723
+ }
693
724
  try:
694
725
  with httpx.Client(verify=verify, timeout=5.0) as client:
695
726
  resp = client.get(url, headers=headers)
@@ -726,6 +757,16 @@ def check_service_status() -> tuple[str, str]:
726
757
 
727
758
  if sys.platform == "darwin":
728
759
  manager, tool = "launchd", "launchctl"
760
+ elif sys.platform == "win32":
761
+ # Accurate-but-misleading is still misleading: "systemctl not
762
+ # found" frames this as a missing Linux tool, when the real story
763
+ # is that background-service support (Task Scheduler) is a
764
+ # not-yet-built increment on this platform -- not something the
765
+ # user is missing or can install their way out of.
766
+ return "warn", (
767
+ "Service: background-service install isn't supported on "
768
+ "Windows yet -- run: muxplex-deck"
769
+ )
729
770
  else:
730
771
  manager, tool = "systemd", "systemctl"
731
772
 
@@ -771,15 +812,118 @@ def print_check(status: str, message: str) -> None:
771
812
  print(f"{prefix}{line}")
772
813
 
773
814
 
774
- def doctor(config_path: str | None = None) -> int:
815
+ def _status_glyph(status: str) -> str:
816
+ """Map a check helper's ("ok"|"warn"|"fail") status to a report glyph.
817
+
818
+ "ok" -> fine. "fail" (a definitive rejection) and "warn" (almost always
819
+ paired with a "run: ..." remediation already in the message) both ->
820
+ act-now. Checks that are genuinely blocked on an upstream item (e.g.
821
+ server/service before config exists) are never routed through this
822
+ mapper -- callers construct those `Check`s directly with
823
+ `report.BLOCKED` instead.
824
+ """
825
+ from . import report
826
+
827
+ return report.FINE if status == "ok" else report.ACT
828
+
829
+
830
+ def _hid_tail(hid_message: str) -> str:
831
+ """Short phrase summarizing a successful HID-open, for the merged device line."""
832
+ text = hid_message
833
+ text = text.removeprefix("HID: ")
834
+ if text.startswith("device opened successfully"):
835
+ return "HID opens"
836
+ if text.startswith("device in use by the muxplex-deck service"):
837
+ return "HID in use by service"
838
+ return text if text.startswith("HID") else f"HID: {text}"
839
+
840
+
841
+ def _device_hid_checks(
842
+ deck_status: str, deck_message: str, hid_status: str, hid_message: str
843
+ ) -> list[Any]:
844
+ """Merge device-detection + HID-open into one line when they agree.
845
+
846
+ When both share a glyph there's nothing to hide by combining them --
847
+ one readable "device" line beats two boring ones. When they diverge
848
+ (e.g. the device is visible on Windows but WSL can't open it yet),
849
+ collapsing to a single worst-member line would silently drop whichever
850
+ fact ACTION needs to reference (like a busid) -- so they render as two
851
+ independent, dependency-ordered lines instead: "device" (is it
852
+ physically present) then "hid" (can this process open it).
853
+ """
854
+ from . import report
855
+
856
+ deck_glyph = _status_glyph(deck_status)
857
+ hid_glyph = _status_glyph(hid_status)
858
+ if deck_glyph == hid_glyph:
859
+ tail = _hid_tail(hid_message)
860
+ value = f"{deck_message}, {tail}" if tail else deck_message
861
+ return [report.Check("device", deck_glyph, value)]
862
+ return [
863
+ report.Check("device", deck_glyph, deck_message),
864
+ report.Check("hid", hid_glyph, hid_message),
865
+ ]
866
+
867
+
868
+ def _doctor_decision(check: Any) -> Any:
869
+ """Build the ACTION decision for the first act-now check, in dependency order."""
870
+ from . import report
871
+
872
+ if check.subject == "config":
873
+ return report.Decision(
874
+ commands=["muxplex-deck init"],
875
+ prose=(
876
+ "Creates the config, fetches and fingerprints the server CA, "
877
+ "stores the federation key, and offers to install the service."
878
+ ),
879
+ )
880
+ command = report.extract_run_command(check.value)
881
+ if command:
882
+ return report.Decision(commands=[command])
883
+ return report.Decision(commands=[], prose=check.value)
884
+
885
+
886
+ def _build_doctor_action(collapsed: list[Any]) -> list[str] | None:
887
+ """The ACTION band: the first act-now item's decision, plus an overflow
888
+
889
+ note when more than one independent act-now item exists (see
890
+ report.Action.overflow_note) -- never dump every fix at once.
891
+ """
892
+ from . import report
893
+
894
+ act_items = [c for c in collapsed if c.glyph == report.ACT]
895
+ if not act_items:
896
+ return None
897
+ overflow = None
898
+ if len(act_items) > 1:
899
+ overflow = f"{len(act_items) - 1} more after this -- rerun doctor."
900
+ action = report.Action(
901
+ decision=_doctor_decision(act_items[0]), overflow_note=overflow
902
+ )
903
+ return report.render_action(action)
904
+
905
+
906
+ def doctor(config_path: str | None = None, *, show_all: bool = False) -> int:
775
907
  """Run diagnostic checks and report system status. Always returns 0 (informational)."""
776
- print("\nmuxplex-deck doctor\n")
908
+ from . import hidhelp, report
777
909
 
778
- checks: list[tuple[str, str]] = []
779
- checks.append(check_python_version())
780
- checks.append(check_install_and_update())
781
- checks.append(check_config_file(config_path))
910
+ utf8 = report.utf8_capable()
782
911
 
912
+ py_status, py_message = check_python_version()
913
+ inst_status, inst_message = check_install_and_update()
914
+ env_members = [
915
+ report.Check("python", _status_glyph(py_status), py_message),
916
+ report.Check("install", _status_glyph(inst_status), inst_message),
917
+ ]
918
+ hidapi_check = check_hidapi_dll() # Windows-only; None everywhere else.
919
+ if hidapi_check is not None:
920
+ hidapi_status, hidapi_message = hidapi_check
921
+ env_members.append(
922
+ report.Check("hidapi", _status_glyph(hidapi_status), hidapi_message)
923
+ )
924
+ environment_group = report.Group("environment", env_members)
925
+
926
+ cfg_status, cfg_message = check_config_file(config_path)
783
927
  try:
784
928
  cfg = config_mod.load_config(config_path)
785
929
  except ConfigError:
@@ -787,29 +931,44 @@ def doctor(config_path: str | None = None) -> int:
787
931
 
788
932
  raw = config_mod.load_raw_config(config_path)
789
933
  key_file = config_mod._expand(raw.get("key_file", config_mod.DEFAULT_KEY_FILE))
790
- checks.append(check_federation_key(key_file))
934
+ key_status, key_message = check_federation_key(key_file)
791
935
 
792
936
  ca_file = cfg.ca_file if cfg is not None else None
793
937
  if cfg is None and raw.get("ca_file"):
794
938
  ca_file = config_mod._expand(raw["ca_file"])
795
- checks.append(check_ca_file(ca_file))
939
+ ca_status, ca_message = check_ca_file(ca_file)
940
+
941
+ config_group = report.Group(
942
+ "config",
943
+ [
944
+ report.Check("file", _status_glyph(cfg_status), cfg_message),
945
+ report.Check("key", _status_glyph(key_status), key_message),
946
+ report.Check("ca", _status_glyph(ca_status), ca_message),
947
+ ],
948
+ )
949
+ config_created = cfg_status == "ok"
796
950
 
797
- # Windows-only; None (skipped, not appended) on every other platform.
798
- hidapi_check = check_hidapi_dll()
799
- if hidapi_check is not None:
800
- checks.append(hidapi_check)
951
+ items: list[Any] = [environment_group, config_group]
801
952
 
802
953
  # Environment guidance (WSL/usbipd/udev-liveness) BEFORE the device
803
954
  # checks -- it explains why the next line is about to warn. Returns []
804
955
  # on a healthy platform (macOS, or native Linux with udev running), so
805
956
  # this adds no output there -- see hidhelp.explain_environment().
806
- from . import hidhelp
807
-
808
957
  env_guidances = hidhelp.explain_environment()
809
958
  for guidance in env_guidances:
810
- checks.append((guidance.status, guidance.message))
959
+ items.append(
960
+ report.Check("device", _status_glyph(guidance.status), guidance.message)
961
+ )
811
962
  env_states = {g.state for g in env_guidances}
812
963
 
964
+ if not config_created:
965
+ # Server/service structurally need server_url/config -- blocked,
966
+ # not independently evaluated. Device/HID don't depend on config
967
+ # at all (a bare USB probe works with no config file), so they are
968
+ # still evaluated for real below.
969
+ items.append(report.Check("server", report.BLOCKED, "waiting on config"))
970
+ items.append(report.Check("service", report.BLOCKED, "waiting on config"))
971
+
813
972
  if "W7" in env_states:
814
973
  # The W7 guidance just appended above already says the device is
815
974
  # attached but can't be opened, plus the proven per-attach `chown`
@@ -833,22 +992,27 @@ def doctor(config_path: str | None = None) -> int:
833
992
  # yet) -- the generic "check the cable" guidance would
834
993
  # flatly contradict it (see bug report: located @ BUSID 1-4,
835
994
  # immediately followed by "check your cable").
836
- deck_message = (
837
- "Stream Deck: not detected on this OS yet -- see the WSL "
838
- "guidance above."
839
- )
840
- checks.append((deck_status, deck_message))
841
- checks.append(check_hid_openable())
995
+ deck_message = "not detected on this OS yet -- see the WSL guidance above."
996
+ hid_status, hid_message = check_hid_openable()
997
+ items.extend(
998
+ _device_hid_checks(deck_status, deck_message, hid_status, hid_message)
999
+ )
842
1000
 
843
- server_url = cfg.server_url if cfg is not None else raw.get("server_url", "")
844
- checks.append(check_server_reachable(server_url, ca_file))
1001
+ if config_created:
1002
+ server_url = cfg.server_url if cfg is not None else raw.get("server_url", "")
1003
+ srv_status, srv_message = check_server_reachable(server_url, ca_file)
1004
+ items.append(report.Check("server", _status_glyph(srv_status), srv_message))
845
1005
 
846
- checks.append(check_service_status())
1006
+ svc_status, svc_message = check_service_status()
1007
+ items.append(report.Check("service", _status_glyph(svc_status), svc_message))
847
1008
 
848
- for status, message in checks:
849
- print_check(status, message)
1009
+ collapsed = report.collapsed_checks(items)
1010
+ action_count = report.count_actions([c.glyph for c in collapsed])
1011
+ verdict = report.verdict_readiness(action_count)
1012
+ state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
1013
+ action_lines = _build_doctor_action(collapsed)
850
1014
 
851
- print()
1015
+ sys.stdout.write(report.render(verdict, state_lines, action_lines))
852
1016
  return 0
853
1017
 
854
1018
 
@@ -867,48 +1031,62 @@ def doctor(config_path: str | None = None) -> int:
867
1031
  _STATUS_STALE_THRESHOLD_SECONDS = 15.0
868
1032
 
869
1033
 
870
- def _format_device_line(device: dict[str, Any]) -> tuple[str, str]:
1034
+ def _status_device_item(device: dict[str, Any]) -> Any:
1035
+ """Build the "device" Check from the sidecar's published status snapshot.
1036
+
1037
+ "-" is the correct rendering for "I could not determine this" -- during
1038
+ a restart race the caller returns before this is ever called (see the
1039
+ pid-freshness guard in `status()`), so this function only ever runs
1040
+ when the snapshot is genuinely current.
1041
+ """
1042
+ from . import report
1043
+
871
1044
  if not device.get("connected"):
872
- return "warn", "Device: not connected"
1045
+ return report.Check("device", report.ACT, "not connected")
873
1046
  caps = device.get("capabilities") or {}
874
1047
  hint = device.get("hint")
875
- if not caps and hint:
876
- return "warn", "Device: connected (capabilities unavailable)"
877
1048
  if not caps:
878
- return "ok", "Device: connected (capabilities unavailable)"
1049
+ glyph = report.ACT if hint else report.FINE
1050
+ return report.Check("device", glyph, "connected (capabilities unavailable)")
879
1051
  touchscreen = "yes" if caps.get("has_touchscreen") else "no"
880
- return "ok", (
881
- f"Device: {caps.get('model', '?')} -- {caps.get('key_count', '?')} keys "
1052
+ value = (
1053
+ f"{caps.get('model', '?')} -- {caps.get('key_count', '?')} keys "
882
1054
  f"({caps.get('key_rows', '?')}x{caps.get('key_cols', '?')}), "
883
1055
  f"{caps.get('dial_count', '?')} dials, touchscreen={touchscreen}"
884
1056
  )
1057
+ return report.Check("device", report.FINE, value)
1058
+
885
1059
 
1060
+ def _status_server_item(server: dict[str, Any]) -> Any:
1061
+ from . import report
886
1062
 
887
- def _format_server_line(server: dict[str, Any]) -> tuple[str, str]:
888
1063
  url = server.get("url") or "(not configured)"
889
1064
  if server.get("connected"):
890
- return "ok", f"Server: {url} (reachable)"
1065
+ return report.Check("server", report.FINE, f"{url} (reachable)")
891
1066
  err = server.get("last_error")
892
- message = f"Server: {url} (unreachable)"
1067
+ value = f"{url} (unreachable)"
893
1068
  if err:
894
- message += f" -- {err}"
895
- return "warn", message
1069
+ value += f" -- {err}"
1070
+ return report.Check("server", report.ACT, value)
896
1071
 
897
1072
 
898
- def _format_state_line(state: dict[str, Any]) -> str:
899
- session = state.get("active_session") or "none"
900
- view = state.get("active_view") or "all"
1073
+ def _status_view_value(state: dict[str, Any]) -> str:
1074
+ view_name = state.get("active_view") or "all"
901
1075
  page = state.get("page")
902
- page_text = str(page) if page is not None else "-"
903
- return f"Active session: {session} | view: {view} | page: {page_text}"
1076
+ return f"{view_name} (page {page})" if page is not None else view_name
904
1077
 
905
1078
 
906
- def _print_direct_probe(config_path: str | None) -> None:
1079
+ def _direct_probe_item(config_path: str | None) -> Any:
907
1080
  """Fallback when the service isn't running -- nothing holds the device."""
908
- print_check(*check_deck_detected(config_path))
1081
+ from . import report
1082
+
1083
+ deck_status, deck_message = check_deck_detected(config_path)
1084
+ return report.Check("device", _status_glyph(deck_status), deck_message)
909
1085
 
910
1086
 
911
- def status(config_path: str | None = None, *, as_json: bool = False) -> int:
1087
+ def status(
1088
+ config_path: str | None = None, *, as_json: bool = False, show_all: bool = False
1089
+ ) -> int:
912
1090
  """Print the sidecar's hardware + connection status.
913
1091
 
914
1092
  Reads the status file the running sidecar publishes (see
@@ -920,6 +1098,7 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
920
1098
  fall back to a direct probe -- this keeps `status` useful even before
921
1099
  the service has ever been installed.
922
1100
  """
1101
+ from . import report
923
1102
  from .service import service_is_active, service_main_pid
924
1103
  from .statusfile import read_status
925
1104
 
@@ -931,36 +1110,45 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
931
1110
  print(json.dumps(payload, indent=2))
932
1111
  return 0 if data is not None else 1
933
1112
 
934
- print("\nmuxplex-deck status\n")
935
- print_check(
936
- "ok" if running else "warn",
937
- f"Service: {'running' if running else 'not running'}",
938
- )
1113
+ utf8 = report.utf8_capable()
939
1114
 
940
1115
  if not running:
941
- if data is None:
942
- print_check(
943
- "warn",
944
- "No status file found -- probing the device directly instead "
945
- "(safe: nothing holds it while the service is stopped).",
946
- )
947
- else:
948
- print_check(
949
- "warn",
950
- "Service not running -- probing the device directly instead "
951
- "of trusting the (possibly stale) status file.",
952
- )
953
- _print_direct_probe(config_path)
954
- print()
1116
+ probe_note = (
1117
+ "no status file -- probing the device directly instead (safe: "
1118
+ "nothing holds it while the service is stopped)"
1119
+ if data is None
1120
+ else "not trusting the (possibly stale) status file -- probing "
1121
+ "the device directly instead"
1122
+ )
1123
+ items: list[Any] = [
1124
+ report.Check("service", report.BLOCKED, "not running"),
1125
+ report.Check("probe", report.BLOCKED, probe_note),
1126
+ _direct_probe_item(config_path),
1127
+ ]
1128
+ collapsed = report.collapsed_checks(items)
1129
+ action_count = report.count_actions([c.glyph for c in collapsed])
1130
+ verdict = (
1131
+ "Not connected -- service not running."
1132
+ if action_count == 0
1133
+ else report.verdict_readiness(action_count)
1134
+ )
1135
+ state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
1136
+ sys.stdout.write(report.render(verdict, state_lines, None))
955
1137
  return 0
956
1138
 
957
1139
  if data is None:
958
- print_check(
959
- "unknown",
960
- "No status file found even though the service is running -- it "
961
- "may have just started. Try: muxplex-deck service logs",
1140
+ items = [
1141
+ report.Check(
1142
+ "status",
1143
+ report.BLOCKED,
1144
+ "no status file yet -- the sidecar may have just started. "
1145
+ "Try: muxplex-deck service logs",
1146
+ )
1147
+ ]
1148
+ state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
1149
+ sys.stdout.write(
1150
+ report.render("Not connected -- starting up.", state_lines, None)
962
1151
  )
963
- print()
964
1152
  return 0
965
1153
 
966
1154
  # Is this snapshot from the process running RIGHT NOW, or from a
@@ -983,40 +1171,94 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
983
1171
  ) <= _STATUS_STALE_THRESHOLD_SECONDS
984
1172
 
985
1173
  if not is_current:
986
- print_check(
987
- "unknown",
988
- f"Status not yet available for the running process (pid "
989
- f"{current_pid if current_pid is not None else '?'}) -- the "
990
- f"published status is from a previous run (pid {recorded_pid}). "
991
- "This is expected right after (re)starting; try again in a "
992
- "moment, or: muxplex-deck service logs",
1174
+ # "-" (BLOCKED), never "!" (act-now): we did not actually observe
1175
+ # this instant, so nothing here has earned a verdict yet -- see the
1176
+ # restart-race incident this guards against (AGENTS.md).
1177
+ items = [
1178
+ report.Check(
1179
+ "status",
1180
+ report.BLOCKED,
1181
+ f"not yet available for the running process (pid "
1182
+ f"{current_pid if current_pid is not None else '?'}) -- the "
1183
+ f"published status is from a previous run (pid {recorded_pid}). "
1184
+ "This is expected right after (re)starting; try again in a "
1185
+ "moment, or: muxplex-deck service logs",
1186
+ )
1187
+ ]
1188
+ state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
1189
+ sys.stdout.write(
1190
+ report.render("Not connected -- previous run's data.", state_lines, None)
993
1191
  )
994
- print()
995
1192
  return 0
996
1193
 
997
1194
  age = time.time() - data.get("updated_at", 0)
998
- if age > _STATUS_STALE_THRESHOLD_SECONDS:
999
- print_check(
1000
- "warn",
1001
- f"Status file is stale (last updated {age:.0f}s ago) -- the "
1002
- "sidecar may be stuck. Try: muxplex-deck service logs",
1003
- )
1004
- else:
1005
- print_check("ok", f"Status updated {age:.0f}s ago (pid {data.get('pid', '?')})")
1195
+ stale = age > _STATUS_STALE_THRESHOLD_SECONDS
1196
+ device_item = _status_device_item(data.get("device", {}))
1197
+ server_item = _status_server_item(data.get("server", {}))
1198
+ state = data.get("state", {})
1199
+
1200
+ session_readout = report.Readout("session", state.get("active_session") or "none")
1201
+ view_readout = report.Readout("view", _status_view_value(state))
1202
+ pid_readout = report.Readout(
1203
+ "pid", f"{data.get('pid', '?')} (updated {age:.0f}s ago)"
1204
+ )
1205
+
1206
+ all_fine = (
1207
+ not stale
1208
+ and device_item.glyph == report.FINE
1209
+ and server_item.glyph == report.FINE
1210
+ )
1211
+
1212
+ if all_fine:
1213
+ readouts = [
1214
+ session_readout,
1215
+ view_readout,
1216
+ report.Readout("device", device_item.value),
1217
+ report.Readout("server", server_item.value),
1218
+ pid_readout,
1219
+ ]
1220
+ state_lines = report.render_readouts(readouts)
1221
+ sys.stdout.write(report.render("Running.", state_lines, None))
1222
+ return 0
1006
1223
 
1007
- device_data = data.get("device", {})
1008
- print_check(*_format_device_line(device_data))
1009
- hint = device_data.get("hint")
1010
- if hint and not device_data.get("connected"):
1224
+ lines: list[str] = [
1225
+ report.format_readout_line(session_readout.name, session_readout.value),
1226
+ report.format_readout_line(view_readout.name, view_readout.value),
1227
+ report.format_check_line(
1228
+ device_item.glyph, device_item.subject, device_item.value, utf8=utf8
1229
+ ),
1230
+ ]
1231
+ hint = data.get("device", {}).get("hint")
1232
+ if hint and device_item.glyph != report.FINE:
1011
1233
  # Populated by the sidecar's open-failure branch (see main.py /
1012
1234
  # hidhelp.explain_open_failure) -- this is what turns a stale
1013
1235
  # status file into the primary teaching surface instead of a
1014
1236
  # bare "not connected" with no explanation.
1015
- print_check("warn", hint)
1016
- print_check(*_format_server_line(data.get("server", {})))
1017
- print_check("ok", _format_state_line(data.get("state", {})))
1018
-
1019
- print()
1237
+ lines.append(report.format_check_line(report.ACT, "hint", hint, utf8=utf8))
1238
+ lines.append(
1239
+ report.format_check_line(
1240
+ server_item.glyph, server_item.subject, server_item.value, utf8=utf8
1241
+ )
1242
+ )
1243
+ if stale:
1244
+ lines.append(
1245
+ report.format_check_line(
1246
+ report.ACT,
1247
+ "status",
1248
+ f"stale (last updated {age:.0f}s ago) -- the sidecar may be "
1249
+ "stuck. Try: muxplex-deck service logs",
1250
+ utf8=utf8,
1251
+ )
1252
+ )
1253
+ lines.append(report.format_readout_line(pid_readout.name, pid_readout.value))
1254
+
1255
+ glyphs = [device_item.glyph, server_item.glyph]
1256
+ if hint and device_item.glyph != report.FINE:
1257
+ glyphs.append(report.ACT)
1258
+ if stale:
1259
+ glyphs.append(report.ACT)
1260
+ verdict = report.verdict_readiness(report.count_actions(glyphs))
1261
+ sys.stdout.write(report.render(verdict, lines, None))
1020
1262
  return 0
1021
1263
 
1022
1264
 
@@ -1387,7 +1629,17 @@ def main() -> None:
1387
1629
 
1388
1630
  sub.add_parser("version", help="Show the muxplex-deck version")
1389
1631
 
1390
- sub.add_parser("doctor", help="Check dependencies and system status")
1632
+ doctor_parser = sub.add_parser(
1633
+ "doctor", help="Check dependencies and system status"
1634
+ )
1635
+ doctor_parser.add_argument(
1636
+ "--all",
1637
+ "--verbose",
1638
+ dest="show_all",
1639
+ action="store_true",
1640
+ help="Show every underlying check (VID:PID, full paths, etc.) instead "
1641
+ "of the collapsed summary -- a strict superset, never a different answer",
1642
+ )
1391
1643
 
1392
1644
  status_parser = sub.add_parser(
1393
1645
  "status", help="Show connected hardware + connection state"
@@ -1395,6 +1647,13 @@ def main() -> None:
1395
1647
  status_parser.add_argument(
1396
1648
  "--json", action="store_true", help="Emit raw status as JSON"
1397
1649
  )
1650
+ status_parser.add_argument(
1651
+ "--all",
1652
+ "--verbose",
1653
+ dest="show_all",
1654
+ action="store_true",
1655
+ help="Show every underlying check instead of the collapsed summary",
1656
+ )
1398
1657
 
1399
1658
  update_parser = sub.add_parser(
1400
1659
  "update",
@@ -1470,10 +1729,14 @@ def main() -> None:
1470
1729
  if args.command == "version":
1471
1730
  print_version()
1472
1731
  elif args.command == "doctor":
1473
- doctor(getattr(args, "config", None))
1732
+ doctor(getattr(args, "config", None), show_all=getattr(args, "show_all", False))
1474
1733
  elif args.command == "status":
1475
1734
  sys.exit(
1476
- status(getattr(args, "config", None), as_json=getattr(args, "json", False))
1735
+ status(
1736
+ getattr(args, "config", None),
1737
+ as_json=getattr(args, "json", False),
1738
+ show_all=getattr(args, "show_all", False),
1739
+ )
1477
1740
  )
1478
1741
  elif args.command in ("update", "upgrade"):
1479
1742
  update(force=getattr(args, "force", False))