muxplex-deck 0.4.0__tar.gz → 0.5.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 (29) hide show
  1. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/PKG-INFO +18 -19
  2. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/README.md +17 -18
  3. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/pyproject.toml +1 -1
  4. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/cli.py +230 -25
  5. muxplex_deck-0.5.0/src/muxplex_deck/hidhelp.py +390 -0
  6. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/init_wizard.py +27 -3
  7. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/main.py +108 -8
  8. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/service.py +219 -32
  9. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/statusfile.py +11 -0
  10. muxplex_deck-0.5.0/src/muxplex_deck/usbnode.py +109 -0
  11. muxplex_deck-0.5.0/src/muxplex_deck/wsl.py +221 -0
  12. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/deck_probe/__init__.py +0 -0
  13. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/deck_probe/__main__.py +0 -0
  14. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/deck_probe/capabilities.py +0 -0
  15. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/deck_probe/events.py +0 -0
  16. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/deck_probe/main.py +0 -0
  17. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/deck_probe/rendering.py +0 -0
  18. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/__init__.py +0 -0
  19. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/__main__.py +0 -0
  20. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/attention.py +0 -0
  21. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/config.py +0 -0
  22. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/device.py +0 -0
  23. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/device_real.py +0 -0
  24. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/emulator.py +0 -0
  25. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/focus.py +0 -0
  26. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/interaction.py +0 -0
  27. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/layout.py +0 -0
  28. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/rendering.py +1 -1
  29. {muxplex_deck-0.4.0 → muxplex_deck-0.5.0}/src/muxplex_deck/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: muxplex-deck
3
- Version: 0.4.0
3
+ Version: 0.5.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>
@@ -108,10 +108,14 @@ without a udev rule granting your user access to the device (vendor id
108
108
  prints a copy-pasteable remediation block (the same rule shown in
109
109
  "Permissions" under the hardware probe section above) instead of silently
110
110
  installing a service that won't work. It never writes to `/etc` itself --
111
- only detects and reports. Run the printed `sudo tee ... && sudo udevadm
111
+ only detects and reports. This remediation is only ever printed when udev
112
+ is actually running (`/run/udev/control` present) -- on WSL without
113
+ systemd, or in a container, it never fires anyway, so `muxplex-deck`
114
+ detects that and prints a different, WSL/container-appropriate fix instead
115
+ (see `docs/WSL.md`). Run the printed `sudo tee ... && sudo udevadm
112
116
  control --reload-rules && sudo udevadm trigger` commands, then unplug and
113
- replug the deck (or re-`usbipd attach` under WSL) and `muxplex-deck service
114
- restart`.
117
+ replug the deck (or `muxplex-deck wsl attach` under WSL) and `muxplex-deck
118
+ service restart`.
115
119
 
116
120
  On Linux, `service install` also attempts `loginctl enable-linger
117
121
  <you>` (best-effort, non-fatal) so the service keeps running after you log
@@ -159,25 +163,20 @@ uv run deck-probe
159
163
 
160
164
  #### Windows + WSL
161
165
 
162
- USB devices are not visible inside WSL until attached from the Windows
163
- side. In an **admin PowerShell**:
164
-
165
- ```powershell
166
- usbipd list # find the Stream Deck's BUSID (VID 0fd9)
167
- usbipd bind --busid <BUSID> # first time only
168
- usbipd attach --wsl --busid <BUSID>
169
- ```
170
-
171
- Then inside WSL, grant HID access via a udev rule (first time only):
166
+ USB devices are invisible to WSL until Windows hands them over. Rather
167
+ than driving `usbipd`/udev rules by hand for this PoC probe, install and
168
+ use the actual product instead -- it handles this for you:
172
169
 
173
170
  ```sh
174
- sudo tee /etc/udev/rules.d/70-streamdeck.rules >/dev/null \
175
- <<< 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", MODE="0666"'
176
- sudo udevadm control --reload-rules
177
- # then detach/re-attach the device (usbipd detach + attach)
178
- sudo apt install libhidapi-libusb0 # native HIDAPI, if not present
171
+ uv tool install muxplex-deck
172
+ muxplex-deck wsl attach # finds the deck, attaches it, tells you what's left
173
+ muxplex-deck doctor # diagnoses every remaining step, with real values
179
174
  ```
180
175
 
176
+ Neither needs the details -- but see `docs/WSL.md` if you want them (BUSID/
177
+ device-node churn, the `usbipd` vs `usbipd.exe` trap, durable vs per-attach
178
+ permission options).
179
+
181
180
  **Close the Elgato Stream Deck app on Windows first** -- it holds
182
181
  exclusive HID access. The probe prints this same guidance when it finds
183
182
  no device.
@@ -95,10 +95,14 @@ without a udev rule granting your user access to the device (vendor id
95
95
  prints a copy-pasteable remediation block (the same rule shown in
96
96
  "Permissions" under the hardware probe section above) instead of silently
97
97
  installing a service that won't work. It never writes to `/etc` itself --
98
- only detects and reports. Run the printed `sudo tee ... && sudo udevadm
98
+ only detects and reports. This remediation is only ever printed when udev
99
+ is actually running (`/run/udev/control` present) -- on WSL without
100
+ systemd, or in a container, it never fires anyway, so `muxplex-deck`
101
+ detects that and prints a different, WSL/container-appropriate fix instead
102
+ (see `docs/WSL.md`). Run the printed `sudo tee ... && sudo udevadm
99
103
  control --reload-rules && sudo udevadm trigger` commands, then unplug and
100
- replug the deck (or re-`usbipd attach` under WSL) and `muxplex-deck service
101
- restart`.
104
+ replug the deck (or `muxplex-deck wsl attach` under WSL) and `muxplex-deck
105
+ service restart`.
102
106
 
103
107
  On Linux, `service install` also attempts `loginctl enable-linger
104
108
  <you>` (best-effort, non-fatal) so the service keeps running after you log
@@ -146,25 +150,20 @@ uv run deck-probe
146
150
 
147
151
  #### Windows + WSL
148
152
 
149
- USB devices are not visible inside WSL until attached from the Windows
150
- side. In an **admin PowerShell**:
151
-
152
- ```powershell
153
- usbipd list # find the Stream Deck's BUSID (VID 0fd9)
154
- usbipd bind --busid <BUSID> # first time only
155
- usbipd attach --wsl --busid <BUSID>
156
- ```
157
-
158
- Then inside WSL, grant HID access via a udev rule (first time only):
153
+ USB devices are invisible to WSL until Windows hands them over. Rather
154
+ than driving `usbipd`/udev rules by hand for this PoC probe, install and
155
+ use the actual product instead -- it handles this for you:
159
156
 
160
157
  ```sh
161
- sudo tee /etc/udev/rules.d/70-streamdeck.rules >/dev/null \
162
- <<< 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", MODE="0666"'
163
- sudo udevadm control --reload-rules
164
- # then detach/re-attach the device (usbipd detach + attach)
165
- sudo apt install libhidapi-libusb0 # native HIDAPI, if not present
158
+ uv tool install muxplex-deck
159
+ muxplex-deck wsl attach # finds the deck, attaches it, tells you what's left
160
+ muxplex-deck doctor # diagnoses every remaining step, with real values
166
161
  ```
167
162
 
163
+ Neither needs the details -- but see `docs/WSL.md` if you want them (BUSID/
164
+ device-node churn, the `usbipd` vs `usbipd.exe` trap, durable vs per-attach
165
+ permission options).
166
+
168
167
  **Close the Elgato Stream Deck app on Windows first** -- it holds
169
168
  exclusive HID access. The probe prints this same guidance when it finds
170
169
  no device.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "muxplex-deck"
3
- version = "0.4.0"
3
+ version = "0.5.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 = [
@@ -251,11 +251,14 @@ def _get_install_info() -> dict:
251
251
  return info
252
252
 
253
253
 
254
+ _PYPI_PROJECT_URL = "https://pypi.org/pypi/muxplex-deck/json"
255
+
256
+
254
257
  def _check_for_update(info: dict) -> tuple[bool, str]:
255
258
  """Check if an update is available.
256
259
 
257
- muxplex-deck is git-only (no PyPI release) -- only the git comparison
258
- path applies. Editable installs are never flagged.
260
+ muxplex-deck 0.4.0+ is published to PyPI, so both the git and pypi
261
+ comparison paths apply now. Editable installs are never flagged.
259
262
  """
260
263
  if info["source"] == "editable":
261
264
  return False, "editable install -- manage updates manually"
@@ -283,6 +286,24 @@ def _check_for_update(info: dict) -> tuple[bool, str]:
283
286
  except Exception: # noqa: BLE001 -- any git/network failure means "upgrade to be safe"
284
287
  return True, "check failed -- upgrading to be safe"
285
288
 
289
+ if info["source"] == "pypi":
290
+ try:
291
+ import httpx
292
+
293
+ response = httpx.get(
294
+ _PYPI_PROJECT_URL,
295
+ headers={"Accept": "application/json"},
296
+ timeout=10,
297
+ )
298
+ response.raise_for_status()
299
+ latest = response.json()["info"]["version"]
300
+ current = info["version"]
301
+ if latest == current:
302
+ return False, f"up to date (v{current})"
303
+ return True, f"update available (v{current} -> v{latest})"
304
+ except Exception: # noqa: BLE001 -- any network/parse failure means "upgrade to be safe"
305
+ return True, "could not check PyPI -- upgrading to be safe"
306
+
286
307
  return True, "unknown install source -- could not check"
287
308
 
288
309
 
@@ -400,16 +421,9 @@ def probe_deck_status(manager: Any) -> dict:
400
421
 
401
422
  _NO_DEVICE_GUIDANCE = (
402
423
  "No Stream Deck found. Things to check:\n"
403
- " - All platforms: close the official Elgato Stream Deck app -- it holds\n"
404
- " exclusive HID access, so muxplex-deck cannot open the device while it runs.\n"
405
- " - Linux (incl. WSL): a udev rule must grant access to Elgato devices\n"
406
- " (vendor id 0x0fd9). Run 'muxplex-deck service install' for the exact\n"
407
- " remediation block, or see AGENTS.md.\n"
408
- " - WSL specifically: USB devices are not visible until attached from\n"
409
- " Windows via usbipd -- in an admin PowerShell:\n"
410
- " usbipd list (find the Stream Deck's BUSID)\n"
411
- " usbipd bind --busid <BUSID> (first time only)\n"
412
- " usbipd attach --wsl --busid <BUSID>\n"
424
+ " - Close the official Elgato Stream Deck app -- it holds exclusive HID access,\n"
425
+ " so muxplex-deck cannot open the device while it is running.\n"
426
+ " - Check the USB cable and try a different port.\n"
413
427
  )
414
428
 
415
429
 
@@ -481,11 +495,13 @@ def check_hid_openable() -> tuple[str, str]:
481
495
  if service_is_active():
482
496
  return "ok", "HID: device in use by the muxplex-deck service (expected)"
483
497
 
498
+ from . import hidhelp
499
+
484
500
  hint = ""
485
501
  if sys.platform not in ("darwin", "win32") and not udev_rule_exists():
486
- hint = " Run: muxplex-deck service install (prints the udev remediation)."
502
+ hint = hidhelp.HID_HINT_RUN_SERVICE_INSTALL
487
503
  elif sys.platform not in ("darwin", "win32"):
488
- hint = " A udev rule exists but the device still could not be opened."
504
+ hint = hidhelp.HID_HINT_RULE_EXISTS_BUT_STILL_FAILED
489
505
  return "warn", f"HID: could not open device ({status['error']}).{hint}"
490
506
 
491
507
 
@@ -647,6 +663,15 @@ def doctor(config_path: str | None = None) -> int:
647
663
  ca_file = config_mod._expand(raw["ca_file"])
648
664
  checks.append(check_ca_file(ca_file))
649
665
 
666
+ # Environment guidance (WSL/usbipd/udev-liveness) BEFORE the device
667
+ # checks -- it explains why the next line is about to warn. Returns []
668
+ # on a healthy platform (macOS, or native Linux with udev running), so
669
+ # this adds no output there -- see hidhelp.explain_environment().
670
+ from . import hidhelp
671
+
672
+ for guidance in hidhelp.explain_environment():
673
+ checks.append((guidance.status, guidance.message))
674
+
650
675
  checks.append(check_deck_detected(config_path))
651
676
  checks.append(check_hid_openable())
652
677
 
@@ -681,6 +706,9 @@ def _format_device_line(device: dict[str, Any]) -> tuple[str, str]:
681
706
  if not device.get("connected"):
682
707
  return "warn", "Device: not connected"
683
708
  caps = device.get("capabilities") or {}
709
+ hint = device.get("hint")
710
+ if not caps and hint:
711
+ return "warn", "Device: connected (capabilities unavailable)"
684
712
  if not caps:
685
713
  return "ok", "Device: connected (capabilities unavailable)"
686
714
  touchscreen = "yes" if caps.get("has_touchscreen") else "no"
@@ -780,7 +808,15 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
780
808
  else:
781
809
  print_check("ok", f"Status updated {age:.0f}s ago (pid {data.get('pid', '?')})")
782
810
 
783
- print_check(*_format_device_line(data.get("device", {})))
811
+ device_data = data.get("device", {})
812
+ print_check(*_format_device_line(device_data))
813
+ hint = device_data.get("hint")
814
+ if hint and not device_data.get("connected"):
815
+ # Populated by the sidecar's open-failure branch (see main.py /
816
+ # hidhelp.explain_open_failure) -- this is what turns a stale
817
+ # status file into the primary teaching surface instead of a
818
+ # bare "not connected" with no explanation.
819
+ print_check("warn", hint)
784
820
  print_check(*_format_server_line(data.get("server", {})))
785
821
  print_check("ok", _format_state_line(data.get("state", {})))
786
822
 
@@ -855,19 +891,54 @@ def _service_is_active() -> bool:
855
891
  return service_is_active()
856
892
 
857
893
 
858
- def update() -> None:
894
+ def update(*, force: bool = False) -> None:
859
895
  """Update muxplex-deck to the latest version and restart the service (if installed).
860
896
 
861
- Simplified relative to muxplex's own `upgrade()`: muxplex-deck has no
862
- PyPI release, so there is no "already up to date, use --force" version
863
- gate -- it always reinstalls from git HEAD (matching `uv tool install
864
- --force`'s own semantics). Reporting style (plain ` ERROR: ...` on
865
- stderr equivalent) matches muxplex's.
897
+ Respects how the tool was installed (see `_get_install_info`), mirroring
898
+ muxplex's own `upgrade()`: a `pypi` install upgrades from PyPI (package
899
+ name, so `uv tool install --force muxplex-deck` / `pip install --upgrade
900
+ muxplex-deck`); a `git` (or `unknown`) install keeps reinstalling from
901
+ git HEAD via `_REPO_URL`, exactly as before -- this honors an explicit
902
+ git install rather than migrating it. An `editable` install is left
903
+ alone entirely (dev checkout -- manage it via git yourself).
904
+
905
+ Now that a real PyPI release exists, the "already up to date" version
906
+ gate this module previously lacked (see AGENTS.md history) is real:
907
+ unless `force=True`, an install already at the latest version/commit
908
+ is reported and left untouched rather than reinstalled and restarted
909
+ for no code change. Reporting style (plain ` ERROR: ...` on stderr
910
+ equivalent) matches muxplex's.
866
911
  """
867
912
  from .service import service_install
868
913
 
869
914
  print("\nmuxplex-deck update\n")
870
915
 
916
+ info = _get_install_info()
917
+ commit_suffix = f" @ {info['commit'][:8]}" if info["commit"] else ""
918
+ print(
919
+ f" Installed: muxplex-deck {info['version']} (via {info['source']}{commit_suffix})"
920
+ )
921
+
922
+ if info["source"] == "editable":
923
+ print(
924
+ "\n Editable install detected -- manage updates via git yourself (no action taken).\n"
925
+ )
926
+ return
927
+
928
+ if force:
929
+ print(" Status: --force specified -- skipping version check")
930
+ else:
931
+ update_available, message = _check_for_update(info)
932
+ print(f" Status: {message}")
933
+ if not update_available:
934
+ print(
935
+ "\n Already up to date."
936
+ " Use 'muxplex-deck update --force' to reinstall anyway.\n"
937
+ )
938
+ return
939
+
940
+ install_target = "muxplex-deck" if info["source"] == "pypi" else f"git+{_REPO_URL}"
941
+
871
942
  was_active = _service_is_active()
872
943
  if was_active:
873
944
  print(" Stopping service...")
@@ -896,7 +967,7 @@ def update() -> None:
896
967
  uv_path = _find_uv()
897
968
  if uv_path:
898
969
  result = subprocess.run(
899
- [uv_path, "tool", "install", "--force", f"git+{_REPO_URL}"],
970
+ [uv_path, "tool", "install", "--force", install_target],
900
971
  capture_output=True,
901
972
  text=True,
902
973
  check=False,
@@ -910,7 +981,7 @@ def update() -> None:
910
981
  pip_path = _find_pip()
911
982
  if pip_path:
912
983
  result = subprocess.run(
913
- [pip_path, "install", "--upgrade", f"git+{_REPO_URL}"],
984
+ [pip_path, "install", "--upgrade", install_target],
914
985
  capture_output=True,
915
986
  text=True,
916
987
  check=False,
@@ -960,6 +1031,122 @@ def update() -> None:
960
1031
  doctor()
961
1032
 
962
1033
 
1034
+ # ---------------------------------------------------------------------------
1035
+ # wsl attach -- find, share-check, and attach the Stream Deck under WSL2.
1036
+ #
1037
+ # This is the ONLY command whose entire purpose is to mutate host state
1038
+ # (WSL_COLD_START_SPEC.md section 6.1): it's the sole caller of
1039
+ # `wsl.attach()`. Its deciding rationale isn't convenience -- it removes
1040
+ # the `usbipd` vs `usbipd.exe` trap from the recurring path entirely (a
1041
+ # user who never types either name cannot get the wrong binary; see
1042
+ # section 6.4). Never invokes `sudo` (P3) -- when a permission fix is
1043
+ # still needed after attaching, it prints the command, it doesn't run it.
1044
+ # ---------------------------------------------------------------------------
1045
+
1046
+
1047
+ def wsl_attach(*, vendor_id: str = "0fd9") -> int:
1048
+ """`muxplex-deck wsl attach` -- attach the Stream Deck to this WSL2 distro.
1049
+
1050
+ Exit 0 on a successful attach (even if a follow-up permission fix is
1051
+ still needed -- the attach itself succeeded). Exit 1 for every state
1052
+ that stops short of attaching (not WSL, WSL1, usbipd.exe missing, not
1053
+ found, not shared) so scripts can branch on it.
1054
+ """
1055
+ from . import hidhelp
1056
+ from . import usbnode as usbnode_mod
1057
+ from . import wsl as wsl_mod
1058
+
1059
+ print("\nmuxplex-deck wsl attach\n")
1060
+
1061
+ info = wsl_mod.detect()
1062
+ if not info.is_wsl:
1063
+ print_check(
1064
+ "fail", "Not running under WSL -- this command only makes sense there."
1065
+ )
1066
+ print()
1067
+ return 1
1068
+ print_check("ok", f"WSL{info.version} detected ({info.kernel})")
1069
+
1070
+ if info.version == 1:
1071
+ print_check("fail", hidhelp.W1_MESSAGE)
1072
+ print()
1073
+ return 1
1074
+
1075
+ paths = wsl_mod.find_usbipd()
1076
+ if paths.linux_impostor is not None and paths.windows is not None:
1077
+ print_check("warn", hidhelp.impostor_message(paths))
1078
+
1079
+ if paths.windows is None:
1080
+ print_check("warn", hidhelp.W2_MESSAGE)
1081
+ print()
1082
+ return 1
1083
+ print_check("ok", f"usbipd.exe: {paths.windows}")
1084
+
1085
+ devices = wsl_mod.list_devices(paths.windows, vendor_id=vendor_id)
1086
+ if devices is None:
1087
+ print_check("warn", hidhelp.W2_MESSAGE)
1088
+ print()
1089
+ return 1
1090
+ if not devices:
1091
+ print_check("warn", hidhelp.NO_MATCHING_WINDOWS_DEVICE_MESSAGE)
1092
+ print()
1093
+ return 1
1094
+
1095
+ device = devices[0]
1096
+ print_check(
1097
+ "ok",
1098
+ f"Found on Windows: BUSID {device.busid} {device.vid_pid} {device.description}",
1099
+ )
1100
+
1101
+ if device.state == "not_shared":
1102
+ print_check("warn", hidhelp.w4_message(device))
1103
+ print()
1104
+ return 1
1105
+
1106
+ if device.state == "unknown":
1107
+ print_check("warn", hidhelp.w3_unknown_state_message(device))
1108
+ print()
1109
+ return 1
1110
+
1111
+ if device.state == "shared":
1112
+ print_check("ok", "Shared -- attaching...")
1113
+ success, message = wsl_mod.attach(paths.windows, device.busid)
1114
+ if not success:
1115
+ print_check("fail", f"Attach failed: {message}")
1116
+ print()
1117
+ return 1
1118
+ print_check("ok", "Attached")
1119
+ else:
1120
+ print_check("ok", f"Already attached (BUSID {device.busid})")
1121
+
1122
+ node = usbnode_mod.find_usb_node(vendor_id)
1123
+ if node is None:
1124
+ print_check("warn", hidhelp.w6_message(device))
1125
+ print()
1126
+ return 1
1127
+ print_check("ok", f"Visible to Linux: {node.path}")
1128
+
1129
+ if not node.readable_writable:
1130
+ print_check("warn", hidhelp.w7_message(node))
1131
+ if not usbnode_mod.udev_is_live():
1132
+ print_check(
1133
+ "warn",
1134
+ hidhelp.u_dead_wsl_message(wsl_mod.wsl_conf_systemd_state()),
1135
+ )
1136
+
1137
+ print()
1138
+ print(" Next:")
1139
+ print(" muxplex-deck service restart")
1140
+ print(" muxplex-deck status")
1141
+ print()
1142
+ print(
1143
+ " The device number changes on every attach -- re-run "
1144
+ "`muxplex-deck wsl attach`\n after any unplug or Windows reboot."
1145
+ )
1146
+ print()
1147
+ return 0
1148
+
1149
+
963
1150
  # ---------------------------------------------------------------------------
964
1151
  # main
965
1152
  # ---------------------------------------------------------------------------
@@ -989,11 +1176,16 @@ def main() -> None:
989
1176
  "--json", action="store_true", help="Emit raw status as JSON"
990
1177
  )
991
1178
 
992
- sub.add_parser(
1179
+ update_parser = sub.add_parser(
993
1180
  "update",
994
1181
  aliases=["upgrade"],
995
1182
  help="Update muxplex-deck to the latest version and restart the service",
996
1183
  )
1184
+ update_parser.add_argument(
1185
+ "--force",
1186
+ action="store_true",
1187
+ help="Reinstall even if already at the latest version/commit",
1188
+ )
997
1189
 
998
1190
  init_parser = sub.add_parser(
999
1191
  "init",
@@ -1024,6 +1216,13 @@ def main() -> None:
1024
1216
  "key", nargs="?", help="Config key (omit to reset all)"
1025
1217
  )
1026
1218
 
1219
+ wsl_parser = sub.add_parser("wsl", help="WSL2 USB/IP helpers")
1220
+ wsl_sub = wsl_parser.add_subparsers(dest="wsl_command")
1221
+ wsl_sub.add_parser(
1222
+ "attach",
1223
+ help="Find, share-check, and attach the Stream Deck to this WSL2 distro",
1224
+ )
1225
+
1027
1226
  service_parser = sub.add_parser(
1028
1227
  "service", help="Manage the muxplex-deck background service"
1029
1228
  )
@@ -1057,7 +1256,7 @@ def main() -> None:
1057
1256
  status(getattr(args, "config", None), as_json=getattr(args, "json", False))
1058
1257
  )
1059
1258
  elif args.command in ("update", "upgrade"):
1060
- update()
1259
+ update(force=getattr(args, "force", False))
1061
1260
  elif args.command == "init":
1062
1261
  from .init_wizard import run_init
1063
1262
 
@@ -1068,6 +1267,12 @@ def main() -> None:
1068
1267
  non_interactive=getattr(args, "non_interactive", False),
1069
1268
  )
1070
1269
  )
1270
+ elif args.command == "wsl":
1271
+ cmd = getattr(args, "wsl_command", None)
1272
+ if cmd == "attach":
1273
+ sys.exit(wsl_attach())
1274
+ else:
1275
+ wsl_parser.print_help()
1071
1276
  elif args.command == "config":
1072
1277
  config_path = getattr(args, "config", None)
1073
1278
  cmd = getattr(args, "config_command", None)