@rainbow-robotics/muscat-plugin-kit 1.1.5 → 1.2.1

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 (163) hide show
  1. package/README.md +99 -0
  2. package/cli/README.md +32 -1
  3. package/cli/bin/muscat-pk.mjs +33 -6
  4. package/cli/src/dev.mjs +2 -1
  5. package/cli/src/devBack.mjs +8 -7
  6. package/cli/src/manifest.mjs +54 -0
  7. package/cli/src/mcp.mjs +58 -3
  8. package/cli/src/new.mjs +3 -0
  9. package/cli/src/ports.mjs +3 -3
  10. package/cli/src/templates-embedded.mjs +3 -0
  11. package/cli/src/util.mjs +13 -0
  12. package/cli/storybook-dist/assets/{Color-YHDXOIA2-YYWRJKA2.js → Color-YHDXOIA2-CSzM1dKs.js} +1 -1
  13. package/cli/storybook-dist/assets/{Docs-DT9RD87x.js → Docs-BIcNCkcj.js} +1 -1
  14. package/cli/storybook-dist/assets/{Docs-CKJ2w--X.js → Docs-Bhl2G8dW.js} +1 -1
  15. package/cli/storybook-dist/assets/{Docs-DIJ7cbFD.js → Docs-CxnHR1EX.js} +1 -1
  16. package/cli/storybook-dist/assets/Docs-DGfEBD4U.js +7 -0
  17. package/cli/storybook-dist/assets/{Docs-UmN7NFo1.js → Docs-Q256LO6R.js} +1 -1
  18. package/cli/storybook-dist/assets/Docs-y19ER8GV.js +20 -0
  19. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-CyAy1mnJ.js → DocsRenderer-CFRXHY34-DnUkc2jA.js} +1 -1
  20. package/cli/storybook-dist/assets/NxAvatar.stories-D8_zg1ow.js +13 -0
  21. package/cli/storybook-dist/assets/NxButton.stories-DJ27PRZJ.js +20 -0
  22. package/cli/storybook-dist/assets/{NxNumberInput.stories-zlQJuGFP.js → NxNumberInput.stories-DAhhfwBb.js} +3 -3
  23. package/cli/storybook-dist/assets/NxPagination.stories-B0B2V-we.js +11 -0
  24. package/cli/storybook-dist/assets/NxPointerHoldButton.stories-Cs9aWGqX.js +14 -0
  25. package/cli/storybook-dist/assets/{NxRangeInput.stories-Cu5ZWU0w.js → NxRangeInput.stories-A3qk290L.js} +3 -3
  26. package/cli/storybook-dist/assets/NxSelectBox.stories-BtA0bI0U.js +17 -0
  27. package/cli/storybook-dist/assets/NxSlideRange.stories-DaVwMyfI.js +14 -0
  28. package/cli/storybook-dist/assets/NxSwitch.stories-MEpYcafd.js +11 -0
  29. package/cli/storybook-dist/assets/NxTextArea.stories-8PqT83b0.js +11 -0
  30. package/cli/storybook-dist/assets/NxTextInput.stories-DwOvyFAK.js +11 -0
  31. package/cli/storybook-dist/assets/RBAccordion.stories-l_KWmznJ.js +17 -0
  32. package/cli/storybook-dist/assets/RBAccordionGroup.stories-BHwMLO_b.js +33 -0
  33. package/cli/storybook-dist/assets/RBAlert.stories-XeJozdck.js +17 -0
  34. package/cli/storybook-dist/assets/RBBadge.stories-BoIw5gFT.js +1 -0
  35. package/cli/storybook-dist/assets/RBBattery.stories-C_qyHveA.js +10 -0
  36. package/cli/storybook-dist/assets/RBCard.stories-jFfiHYKe.js +45 -0
  37. package/cli/storybook-dist/assets/RBCheckBox.stories-DTfCEweM.js +1 -0
  38. package/cli/storybook-dist/assets/RBCheckIconButton.stories-CTDnyUqG.js +1 -0
  39. package/cli/storybook-dist/assets/RBCheckLabelButton.stories-DmquL7n0.js +24 -0
  40. package/cli/storybook-dist/assets/RBChipTab.stories-B8zl9AG8.js +21 -0
  41. package/cli/storybook-dist/assets/RBChipsBoxTab.stories-BGRQ_ix8.js +1 -0
  42. package/cli/storybook-dist/assets/RBColorIcons.stories-s5QE7tL1.js +3 -0
  43. package/cli/storybook-dist/assets/{RBCommand.stories-C3bwPq2T.js → RBCommand.stories-Yrpy_B26.js} +3 -3
  44. package/cli/storybook-dist/assets/RBConfirm.stories-C9qrp3br.js +20 -0
  45. package/cli/storybook-dist/assets/RBDateInput.stories-DZpvxSqb.js +15 -0
  46. package/cli/storybook-dist/assets/RBDialog.stories-DSonk9Bx.js +38 -0
  47. package/cli/storybook-dist/assets/RBDivider.stories-CtuMDRWW.js +1 -0
  48. package/cli/storybook-dist/assets/RBDrawer.stories-BNjRz9Cf.js +14 -0
  49. package/cli/storybook-dist/assets/{RBDropdownMenu.stories-9fBcV2Ct.js → RBDropdownMenu.stories-Bjl9aOPU.js} +2 -2
  50. package/cli/storybook-dist/assets/RBEmptyView.stories-ChMkuohg.js +12 -0
  51. package/cli/storybook-dist/assets/RBIconButton.stories-B5R9unwo.js +1 -0
  52. package/cli/storybook-dist/assets/RBIcons.stories-Cp5JeZNr.js +3 -0
  53. package/cli/storybook-dist/assets/RBInlineLabelButton.stories-Dl_km9SD.js +1 -0
  54. package/cli/storybook-dist/assets/RBJoyStick.stories-g1XwqElo.js +11 -0
  55. package/cli/storybook-dist/assets/RBLabelButton.stories-iNSl-cdD.js +1 -0
  56. package/cli/storybook-dist/assets/RBLight.stories-DptPacw1.js +1 -0
  57. package/cli/storybook-dist/assets/RBMenubar.stories-CTH4jNi6.js +3 -0
  58. package/cli/storybook-dist/assets/RBMultiRangeInput.stories-BD138rOz.js +14 -0
  59. package/cli/storybook-dist/assets/RBPagination.stories-DaXD-840.js +17 -0
  60. package/cli/storybook-dist/assets/RBPopover.stories-BHA1-yYi.js +11 -0
  61. package/cli/storybook-dist/assets/RBPrompt.stories-CoEeIK6d.js +20 -0
  62. package/cli/storybook-dist/assets/RBRadioBox.stories-CePglWfe.js +1 -0
  63. package/cli/storybook-dist/assets/RBRadioButton.stories-BRNRgcJb.js +11 -0
  64. package/cli/storybook-dist/assets/RBRangeDateInput.stories-B6ukx3Pl.js +15 -0
  65. package/cli/storybook-dist/assets/RBResizable.stories-DqoaWeqW.js +20 -0
  66. package/cli/storybook-dist/assets/RBSelectBox.stories-8QBhknxC.js +6 -0
  67. package/cli/storybook-dist/assets/RBSheet.stories-Doaw8VOp.js +39 -0
  68. package/cli/storybook-dist/assets/RBSideNumberInput.stories-BmAEpYFE.js +5 -0
  69. package/cli/storybook-dist/assets/RBSlider.stories-CH-Hkgd6.js +5 -0
  70. package/cli/storybook-dist/assets/RBSpinner.stories-BEceevO_.js +14 -0
  71. package/cli/storybook-dist/assets/{RBSwitch.stories-CBYXvhkd.js → RBSwitch.stories-BoZzoyfx.js} +2 -2
  72. package/cli/storybook-dist/assets/RBTab.stories-CN3Algxc.js +1 -0
  73. package/cli/storybook-dist/assets/RBTable.stories-C1UgfrL_.js +62 -0
  74. package/cli/storybook-dist/assets/RBTableRow.stories-ASxrEiHq.js +11 -0
  75. package/cli/storybook-dist/assets/RBTabs.stories-0tMRRXl4.js +21 -0
  76. package/cli/storybook-dist/assets/RBTextArea.stories-DhWXTnBK.js +5 -0
  77. package/cli/storybook-dist/assets/RBTextField.stories-BLI0HpCu.js +45 -0
  78. package/cli/storybook-dist/assets/RBTextInput.stories-J8FkNDJo.js +5 -0
  79. package/cli/storybook-dist/assets/RBToast.stories-D8UxgdX3.js +11 -0
  80. package/cli/storybook-dist/assets/RBToggle.stories-CjwzqnV-.js +12 -0
  81. package/cli/storybook-dist/assets/RBToggleGroup.stories-3JwuIqYr.js +17 -0
  82. package/cli/storybook-dist/assets/RBTooltip.stories-Wkx6rfug.js +9 -0
  83. package/cli/storybook-dist/assets/{MapEditor-C6_H9h5t.css → SideNumberInput-SAkhydsn.css} +2 -2
  84. package/cli/storybook-dist/assets/SideNumberInput-_BKR64MG.js +1114 -0
  85. package/cli/storybook-dist/assets/{entry-preview-N9dpIJQA.js → entry-preview-VQeOKtPr.js} +1 -1
  86. package/cli/storybook-dist/assets/{iframe-BwCxSq5d.js → iframe-Ci_IjEPD.js} +2 -2
  87. package/cli/storybook-dist/assets/{index-CgJh_5EZ.js → index-DVR_O3uO.js} +3 -3
  88. package/cli/storybook-dist/assets/preview-BVJWkKiZ.css +1 -0
  89. package/cli/storybook-dist/assets/preview-CO9TxipX.js +3 -0
  90. package/cli/storybook-dist/assets/{preview-DsYc6ovL.js → preview-DAZ7L_eg.js} +2 -2
  91. package/cli/storybook-dist/assets/react-resizable-panels.browser-BpHwB_WW.js +102 -0
  92. package/cli/storybook-dist/iframe.html +1 -1
  93. package/cli/storybook-dist/project.json +1 -1
  94. package/cli/templates/common/README.md +229 -19
  95. package/cli/templates/common/manifest.json +1 -0
  96. package/cli/templates/common/requirements.txt +3 -3
  97. package/package.json +3 -3
  98. package/cli/storybook-dist/assets/Docs-DLFwGiks.js +0 -7
  99. package/cli/storybook-dist/assets/Docs-dOc9cH1E.js +0 -20
  100. package/cli/storybook-dist/assets/MapEditor-Jj1J2sUz.js +0 -6418
  101. package/cli/storybook-dist/assets/NxAvatar.stories-B-iQoylp.js +0 -13
  102. package/cli/storybook-dist/assets/NxButton.stories-CawQZN07.js +0 -20
  103. package/cli/storybook-dist/assets/NxPagination.stories-VYXLplNl.js +0 -11
  104. package/cli/storybook-dist/assets/NxPointerHoldButton.stories-BpqIkt0X.js +0 -14
  105. package/cli/storybook-dist/assets/NxSelectBox.stories-JECi_1PM.js +0 -17
  106. package/cli/storybook-dist/assets/NxSlideRange.stories-DJe8F2Mv.js +0 -14
  107. package/cli/storybook-dist/assets/NxSwitch.stories-DX6LxxQL.js +0 -11
  108. package/cli/storybook-dist/assets/NxTextArea.stories-Cy42nYOI.js +0 -11
  109. package/cli/storybook-dist/assets/NxTextInput.stories-Bb_q6FuW.js +0 -11
  110. package/cli/storybook-dist/assets/RBAccordion.stories-ChgPXO0_.js +0 -17
  111. package/cli/storybook-dist/assets/RBAccordionGroup.stories-nmbqYYSE.js +0 -33
  112. package/cli/storybook-dist/assets/RBAlert.stories-ChOPuFxI.js +0 -17
  113. package/cli/storybook-dist/assets/RBBadge.stories-Bas7g4jb.js +0 -1
  114. package/cli/storybook-dist/assets/RBBattery.stories-C0MywWvB.js +0 -10
  115. package/cli/storybook-dist/assets/RBCard.stories-Ba7y0u6Q.js +0 -45
  116. package/cli/storybook-dist/assets/RBCheckBox.stories-CsHFsXCd.js +0 -1
  117. package/cli/storybook-dist/assets/RBCheckIconButton.stories-GERClL9R.js +0 -1
  118. package/cli/storybook-dist/assets/RBCheckLabelButton.stories-C-BrmKaW.js +0 -24
  119. package/cli/storybook-dist/assets/RBChipTab.stories-C8JCa6F9.js +0 -21
  120. package/cli/storybook-dist/assets/RBChipsBoxTab.stories-DmUaHBJ3.js +0 -1
  121. package/cli/storybook-dist/assets/RBColorIcons.stories-Bcah6eZe.js +0 -3
  122. package/cli/storybook-dist/assets/RBConfirm.stories-CT0GuE-J.js +0 -20
  123. package/cli/storybook-dist/assets/RBDateInput.stories-CMd2xRCF.js +0 -15
  124. package/cli/storybook-dist/assets/RBDialog.stories-Ui5Ni-P_.js +0 -38
  125. package/cli/storybook-dist/assets/RBDivider.stories-r8TDiO36.js +0 -1
  126. package/cli/storybook-dist/assets/RBDrawer.stories-CymmrHjI.js +0 -14
  127. package/cli/storybook-dist/assets/RBEmptyView.stories-By9vncNC.js +0 -12
  128. package/cli/storybook-dist/assets/RBIconButton.stories-DC29KMxX.js +0 -1
  129. package/cli/storybook-dist/assets/RBIcons.stories-Dpd_V9b9.js +0 -3
  130. package/cli/storybook-dist/assets/RBInlineLabelButton.stories-CfUQdAdt.js +0 -1
  131. package/cli/storybook-dist/assets/RBJoyStick.stories-Dl-uXk9x.js +0 -11
  132. package/cli/storybook-dist/assets/RBLabelButton.stories-Dx_Vn6EM.js +0 -1
  133. package/cli/storybook-dist/assets/RBLight.stories-Cm5UYJ4n.js +0 -1
  134. package/cli/storybook-dist/assets/RBMenubar.stories-CN5z7F6D.js +0 -3
  135. package/cli/storybook-dist/assets/RBMultiRangeInput.stories-DrAMI4mQ.js +0 -14
  136. package/cli/storybook-dist/assets/RBPagination.stories-BVLXuTLw.js +0 -17
  137. package/cli/storybook-dist/assets/RBPopover.stories-VCws5q80.js +0 -11
  138. package/cli/storybook-dist/assets/RBPrompt.stories-Cw5-wssh.js +0 -20
  139. package/cli/storybook-dist/assets/RBRadioBox.stories-ClqGtGA_.js +0 -1
  140. package/cli/storybook-dist/assets/RBRadioButton.stories-SxcJFioj.js +0 -11
  141. package/cli/storybook-dist/assets/RBRangeDateInput.stories-L_wCftDv.js +0 -15
  142. package/cli/storybook-dist/assets/RBResizable.stories-2VytDv6m.js +0 -20
  143. package/cli/storybook-dist/assets/RBSelectBox.stories-BoOKTSL_.js +0 -6
  144. package/cli/storybook-dist/assets/RBSheet.stories-CUClMqKK.js +0 -39
  145. package/cli/storybook-dist/assets/RBSideNumberInput.stories-BTYDDAmk.js +0 -5
  146. package/cli/storybook-dist/assets/RBSlider.stories-Bu6vD9ym.js +0 -5
  147. package/cli/storybook-dist/assets/RBSpinner.stories-XmVcaTVS.js +0 -14
  148. package/cli/storybook-dist/assets/RBTab.stories-DFFYlkCv.js +0 -1
  149. package/cli/storybook-dist/assets/RBTable.stories-CR2j2EQQ.js +0 -62
  150. package/cli/storybook-dist/assets/RBTableRow.stories-CpPIP1au.js +0 -11
  151. package/cli/storybook-dist/assets/RBTabs.stories-Bew5XVDZ.js +0 -21
  152. package/cli/storybook-dist/assets/RBTextArea.stories-JiRn-tk_.js +0 -5
  153. package/cli/storybook-dist/assets/RBTextField.stories-8LWyUBm9.js +0 -45
  154. package/cli/storybook-dist/assets/RBTextInput.stories-PLJ70xm5.js +0 -5
  155. package/cli/storybook-dist/assets/RBToast.stories-BQf_9x0k.js +0 -11
  156. package/cli/storybook-dist/assets/RBToggle.stories-C5dZnmX_.js +0 -12
  157. package/cli/storybook-dist/assets/RBToggleGroup.stories-D7fwXCxA.js +0 -17
  158. package/cli/storybook-dist/assets/RBTooltip.stories-D-sWRxbA.js +0 -9
  159. package/cli/storybook-dist/assets/RainbowUiProvider-CsMXuxM1.js +0 -31
  160. package/cli/storybook-dist/assets/preview-CzDPTg-k.js +0 -3
  161. package/cli/storybook-dist/assets/preview-DZWUyVBI.css +0 -1
  162. package/cli/storybook-dist/assets/react-resizable-panels.browser-CHDWt8fa.js +0 -72
  163. /package/cli/storybook-dist/assets/{RainbowUiProvider-BaelaOGy.css → react-resizable-panels-BaelaOGy.css} +0 -0
package/README.md CHANGED
@@ -119,6 +119,54 @@ muscat-pk build # build dist/ and produce a distributable ZIP (sk
119
119
  | `muscat-pk version` | Print the installed CLI version (`-v` / `--version` also work) |
120
120
  | `muscat-pk completion [shell]` | Print a shell completion script (`bash` / `zsh` / `fish` / `powershell`; defaults to `$SHELL`) |
121
121
 
122
+ ### Update policy
123
+
124
+ `muscat-pk` can be installed globally (npm `-g` or the S3 binary) and/or per
125
+ plugin project (`node_modules`). Two rules keep those from drifting apart:
126
+
127
+ **The project's local install wins.** When you run `muscat-pk` inside a plugin
128
+ project that has its own install, the CLI hands off to that copy. So the CLI,
129
+ the `vite` preset your `vite.config.ts` imports, and the `preview-host` that
130
+ `dev:ui` launches are always the single version the project pinned — a global
131
+ CLI that is newer or older than the project can't half-apply itself.
132
+
133
+ **`update` only touches what you're in.**
134
+
135
+ | Where you run it | What it updates |
136
+ |---|---|
137
+ | Inside a project with a local install | that project's `muscat-plugin-kit` + `plugin-ui` only |
138
+ | Same, with `--global` | the above, plus the global CLI |
139
+ | Anywhere else | the global CLI (and local deps if you're in a project) |
140
+
141
+ ```bash
142
+ muscat-pk update # this project only
143
+ muscat-pk update --global # this project + the global CLI
144
+ muscat-pk update dev # follow the dev channel instead of latest
145
+ muscat-pk version # which version is actually running here
146
+ ```
147
+
148
+ `dev:ui` also prints a one-line notice when a newer version is published. It is
149
+ skipped by `MUSCAT_PK_NO_UPDATE_CHECK=1` and fails silently offline.
150
+
151
+ ### Offline / air-gapped use
152
+
153
+ Docker images are only built when they are missing. `docker build` contacts the
154
+ registry to resolve the base image tag on every run — even when every layer is
155
+ cached — so rebuilding an image you already have would break `dev` with no
156
+ network. Once an image exists locally, `muscat-pk` skips the build entirely.
157
+
158
+ Move images to a machine with no internet:
159
+
160
+ ```bash
161
+ # on a machine with network
162
+ docker save muscat-pk-python-dev muscat-pk-bridge | gzip > muscat-pk-images.tar.gz
163
+
164
+ # on the offline machine
165
+ gunzip -c muscat-pk-images.tar.gz | docker load
166
+ ```
167
+
168
+ Force a rebuild with `MUSCAT_PK_REBUILD_IMAGE=1`.
169
+
122
170
  ### Shell completion
123
171
 
124
172
  | OS / shell | Command |
@@ -130,6 +178,57 @@ muscat-pk build # build dist/ and produce a distributable ZIP (sk
130
178
 
131
179
  Windows `cmd.exe` has no completion mechanism to hook into — use PowerShell, Git Bash, or WSL.
132
180
 
181
+ ## Backend port policy
182
+
183
+ A plugin backend that opens a TCP listener must declare every such port in
184
+ `manifest.json`:
185
+
186
+ ```json
187
+ { "ports": [28600, 28601] }
188
+ ```
189
+
190
+ | Rule | Enforced by |
191
+ |---|---|
192
+ | Ports must be within `28600-28999` | `muscat-pk build` / `dev` (manifest validation) |
193
+ | Reserved robot/industrial ports are rejected | same — `502`, `2222`, `4840`, `5170-5179`, `8000-8999`, `10000`, `30004`, `34964`, `44818`, `50000-50005`, `50051` |
194
+ | **Every listening port must be declared** | `dev` / `dev:back` runtime monitor — stops the backend on the first undeclared port |
195
+ | No two enabled plugins may claim the same port | the robot, at install time (HTTP 409) |
196
+
197
+ The runtime monitor is strict on purpose: an undeclared port outside the plugin
198
+ range is invisible to both the monitor and the robot's conflict check, so it
199
+ would only surface as a collision after deployment.
200
+
201
+ `muscat-pk`'s own dev plumbing (the Socket.IO bridge on `10000`, the Zenoh dev
202
+ peer port) is excluded from the check — it isn't the plugin's traffic.
203
+
204
+ Declared ports are published to `127.0.0.1` on macOS and Windows so you can
205
+ reach the backend from the host; native Linux uses host networking, where they
206
+ already are.
207
+
208
+ ## Variables (pyfm)
209
+
210
+ Variables the plugin wants to expose to pyfm programs must be pre-declared in
211
+ `manifest.json` — the Muscat UI lists only declared variables in its variable
212
+ picker:
213
+
214
+ ```json
215
+ {
216
+ "variables": [
217
+ { "name": "RBP_grip_force", "type": "number", "init": 0.5, "description": { "ko": "그리퍼 힘 (0~1)", "en": "Gripper force (0–1)" } },
218
+ { "name": "RBP_target_pose", "type": "array", "init": [0, 0, 0] }
219
+ ]
220
+ }
221
+ ```
222
+
223
+ | Field | Required | Rule |
224
+ |---|---|---|
225
+ | `name` | yes | must start with `RBP_`, then letters/digits/underscore; unique per plugin |
226
+ | `type` | yes | `"number"` \| `"string"` \| `"array"` |
227
+ | `init` | yes | initial value, must match `type` |
228
+ | `description` | no | string or i18n object (`en` required in object form). Without it the UI shows the variable with no explanation — write one. |
229
+
230
+ Validated by `muscat-pk build` / `dev` alongside the rest of the manifest.
231
+
133
232
  ## IDE support (Python)
134
233
 
135
234
  `muscat-pk new` creates a `.venv` at the project root with `requirements.txt` installed. The real backend still runs in a Docker container — this `.venv` exists purely for editor linting (pylint/pyright).
package/cli/README.md CHANGED
@@ -37,7 +37,17 @@ If a plugin backend opens a TCP server, declare it in `manifest.json`:
37
37
  `ports` is optional and accepts only `number[]`; ranges or objects are not
38
38
  accepted. External plugin backend ports are reserved for `28600-28999`.
39
39
  `muscat-pk dev` and `muscat-pk dev:back` validate the manifest before start and
40
- stop the backend if it opens an undeclared managed TCP listen port.
40
+ then watch the container: **any listening TCP port that is not declared stops
41
+ the backend**, whatever its number. Since a port outside `28600-28999` cannot be
42
+ declared, it cannot be opened either — otherwise it would slip past both this
43
+ check and the robot's install-time conflict check.
44
+
45
+ The kit's own dev ports (Socket.IO bridge `10000`, the Zenoh dev peer port) are
46
+ excluded from that check.
47
+
48
+ Declared ports are published to `127.0.0.1` so the host can reach the backend on
49
+ macOS/Windows; native Linux dev containers use host networking and already have
50
+ them. A declared port that is already busy on the host is skipped with a warning.
41
51
 
42
52
  Known robot/industrial/internal ports such as `502`, `4840`, `5170-5179`,
43
53
  `8000-8999`, `10000`, `2222`, `30004`, `34964`, `44818`, `50000-50005`,
@@ -229,4 +239,25 @@ offline.
229
239
  - `RB_REGISTRY` — registry base URL (default `https://registry.npmjs.org`).
230
240
  - `MUSCAT_PK_NO_UPDATE_CHECK=1` — skip the check.
231
241
 
242
+ ## Local vs global install
243
+
244
+ If the plugin project has its own `muscat-plugin-kit` in `node_modules`, the CLI
245
+ delegates to it — so the CLI, the `vite` preset, and `preview-host` are always
246
+ the one version the project pinned, no matter which `muscat-pk` you typed.
247
+
248
+ `muscat-pk update` follows the same rule: run from a project's local install it
249
+ updates only that project; add `--global` to also update the machine-wide CLI.
250
+ `muscat-pk version` prints whichever copy actually ran.
251
+
252
+ ## Docker images
253
+
254
+ Images are built only when missing, because `docker build` resolves the base
255
+ image tag over the network on every run — even with all layers cached — which
256
+ would break `dev` offline. Pre-seed an air-gapped machine with
257
+ `docker save … | gzip` + `docker load`, or force a rebuild with
258
+ `MUSCAT_PK_REBUILD_IMAGE=1`.
259
+
260
+ On Linux the dev containers run as the invoking user (`--user`), so build output
261
+ under `dist/` and `cpp/build-*` is not left root-owned.
262
+
232
263
  Full spec: [`docs/plugin-system/CREATE-CLI.md`](../../docs/plugin-system/CREATE-CLI.md).
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // muscat-pk — Muscat plugin developer CLI.
3
3
  // Subcommands: new, build, dev, dev:ui, dev:back, storybook, mcp, update, uninstall, sdk, version.
4
- import { realpathSync } from "node:fs";
4
+ import { readFileSync, realpathSync } from "node:fs";
5
+ import { resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { spawnSync } from "node:child_process";
7
8
  import { color, kitVersion, log, resolveLocalCliBin } from "../src/util.mjs";
@@ -24,15 +25,39 @@ import { runCompletion } from "../src/completion.mjs";
24
25
  // 실행 중인 게 글로벌/바이너리인데 cwd 상위에 로컬 설치가 있으면 그쪽으로 넘긴다.
25
26
  // vite preset / preview-host / manifest 검증이 항상 프로젝트가 고정한 버전과
26
27
  // 맞아야 해서다 (버전이 안 맞으면 "module not found" 류 에러로 터진다).
28
+ function readVersion(binPath) {
29
+ try {
30
+ return JSON.parse(readFileSync(resolve(binPath, "../../../package.json"), "utf8")).version;
31
+ } catch {
32
+ return "";
33
+ }
34
+ }
35
+
36
+ const isBinaryMode = typeof Bun !== "undefined";
37
+
27
38
  (function delegateToLocalInstall() {
28
39
  try {
29
40
  const localBin = resolveLocalCliBin();
30
41
  if (!localBin) return;
31
- if (realpathSync(localBin) === realpathSync(fileURLToPath(import.meta.url))) return;
32
- const r = spawnSync(process.execPath, [localBin, ...process.argv.slice(2)], { stdio: "inherit" });
42
+ // 바이너리는 node_modules 안의 mjs일 수 없으니 자기 자신 비교 생략.
43
+ // (import.meta.url이 /$bunfs/ 가상 경로라 realpath가 throw하기도 한다.)
44
+ if (!isBinaryMode && realpathSync(localBin) === realpathSync(fileURLToPath(import.meta.url))) return;
45
+ // Say so — otherwise an outdated local copy silently answers instead, and
46
+ // its missing/renamed subcommands look like the CLI is broken.
47
+ const localVersion = readVersion(localBin);
48
+ console.error(
49
+ color.dim(`[muscat-pk] using this project's local install${localVersion ? ` (v${localVersion})` : ""}: ${localBin}`),
50
+ );
51
+ // Bun 바이너리의 execPath는 스크립트 인자를 실행하지 않으니 PATH의 node로 넘긴다.
52
+ const runtime = isBinaryMode ? "node" : process.execPath;
53
+ const r = spawnSync(runtime, [localBin, ...process.argv.slice(2)], { stdio: "inherit" });
54
+ if (r.error) {
55
+ log.warn(`Failed to run local install via ${runtime} (${r.error.code}); continuing with this binary.`);
56
+ return;
57
+ }
33
58
  process.exit(r.status ?? 0);
34
59
  } catch {
35
- // Bun 단일 바이너리 등 realpath가 안 통하는 환경 — 그냥 지금 프로세스로 계속.
60
+ // realpath 실패 등 — 그냥 지금 프로세스로 계속.
36
61
  }
37
62
  })();
38
63
 
@@ -55,7 +80,7 @@ function formatHelp() {
55
80
  commandRow("dev:ui", `Preview the plugin in a mini-Muscat ${color.dim("(preview-host)")}`),
56
81
  commandRow("dev:back", `Run the plugin back end ${color.dim("(python: watchfiles reload / ros2: Docker)")}`),
57
82
  commandRow("storybook", `Browse the bundled rb-components Storybook ${color.dim(`(${option("--port")} <n>, ${option("--rebuild")})`)}`),
58
- commandRow("mcp", `Start an MCP server ${color.dim("(stdio)")} exposing rb-components/rb SDK docs to AI agents`),
83
+ commandRow("mcp", `Start an MCP server ${color.dim("(stdio)")} exposing rb-components/rb SDK docs to AI agents ${color.dim(`(${option("--install")} codex: register in ~/.codex/config.toml)`)}`),
59
84
  commandRow("update", `Update ${color.magenta("@rainbow-robotics/muscat-plugin-kit")} and ${color.magenta("@rainbow-robotics/plugin-ui")} ${color.dim(`(local-only if run from a project's local install; ${option("--global")} also updates the global CLI)`)}`),
60
85
  commandRow("uninstall", `Remove the installed ${color.cyan("muscat-pk")} CLI ${color.dim("(npm global or S3 binary)")}`),
61
86
  commandRow("sdk <list|update [version]>", "Manage the python rainbow-rb-sdk version"),
@@ -84,7 +109,9 @@ async function main() {
84
109
  case "storybook":
85
110
  return await runStorybook(args);
86
111
  case "mcp": {
87
- const { runMcp } = await import("../src/mcp.mjs");
112
+ const { runMcp, installMcp } = await import("../src/mcp.mjs");
113
+ if (args[0] === "--install" || args[0] === "install")
114
+ return installMcp(args[1]);
88
115
  return await runMcp(args);
89
116
  }
90
117
  case "update":
package/cli/src/dev.mjs CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { runDevUi } from "./devUi.mjs";
8
8
  import { runDevBack, stopDevBackContainers } from "./devBack.mjs";
9
9
  import { startBridge, stopBridge, BRIDGE_CONTAINER, BRIDGE_PORT } from "./bridge.mjs";
10
- import { assertManifestPorts } from "./manifest.mjs";
10
+ import { assertManifestPorts, assertManifestVariables } from "./manifest.mjs";
11
11
  import { formatPortRanges } from "./ports.mjs";
12
12
  import {
13
13
  ensureDocker,
@@ -111,6 +111,7 @@ async function maybeStartHostAgent(root, { robotIp, robotToken }) {
111
111
 
112
112
  export async function runDev(args = []) {
113
113
  const root = findPluginRoot();
114
+ assertManifestVariables(root);
114
115
  const portRanges = assertManifestPorts(root);
115
116
  if (portRanges.length > 0) {
116
117
  log.step(`plugin backend ports declared: ${formatPortRanges(portRanges)}`);
@@ -10,9 +10,9 @@
10
10
  import { spawn, spawnSync } from "node:child_process";
11
11
  import { existsSync, watch } from "node:fs";
12
12
  import { resolve } from "node:path";
13
- import { ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection } from "./util.mjs";
13
+ import { ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
14
14
  import { ensureRemoteZenohEnv, usesHostNetwork } from "./zenohDev.mjs";
15
- import { assertManifestPorts, manifestRuntime, readManifest, validateManifest } from "./manifest.mjs";
15
+ import { assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, validateManifest } from "./manifest.mjs";
16
16
  import {
17
17
  formatPortRanges,
18
18
  portEnvFlags,
@@ -147,7 +147,7 @@ async function runPython(root, portRanges) {
147
147
  docker(["rm", "-f", container], { stdio: "ignore" });
148
148
  log.step("starting Python dev container…");
149
149
  const up = docker(
150
- ["run", "-d", "--name", container, ...netFlags, ...standaloneZenohPortFlags, ...pluginPortFlags,
150
+ ["run", "-d", "--name", container, ...netFlags, ...standaloneZenohPortFlags, ...pluginPortFlags, ...realtimeFlags(),
151
151
  // host.docker.internal is automatic on Docker Desktop (macOS/Windows);
152
152
  // this makes it resolve on Linux too, for the no-bridge gateway fallback above.
153
153
  ...hostGatewayFlags,
@@ -445,11 +445,11 @@ async function runRos2(root, portRanges) {
445
445
  docker(["rm", "-f", container], { stdio: "ignore" });
446
446
  log.step("starting ROS2 dev container…");
447
447
  const up = docker(
448
- ["run", "-d", "--name", container, ...publishPortFlags(portRanges), "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
448
+ ["run", "-d", "--name", container, ...publishPortFlags(portRanges), ...realtimeFlags(), "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
449
449
  { stdio: "inherit" },
450
450
  );
451
451
  if (up.status !== 0) throw new Error("failed to start dev container.");
452
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log });
452
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
453
453
 
454
454
  // one-time deps + symlink build (edits to .py reflect without rebuilding)
455
455
  log.step("pip install + colcon build --symlink-install (once)…");
@@ -526,11 +526,11 @@ async function runCpp(root, portRanges) {
526
526
  docker(["rm", "-f", container], { stdio: "ignore" });
527
527
  log.step("starting C++ dev container…");
528
528
  const up = docker(
529
- ["run", "-d", "--name", container, ...publishPortFlags(portRanges), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
529
+ ["run", "-d", "--name", container, ...publishPortFlags(portRanges), ...realtimeFlags(), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
530
530
  { stdio: "inherit" },
531
531
  );
532
532
  if (up.status !== 0) throw new Error("failed to start dev container.");
533
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log });
533
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
534
534
 
535
535
  const build = () => {
536
536
  log.step("cmake build…");
@@ -585,6 +585,7 @@ function warnManifest(root) {
585
585
 
586
586
  export async function runDevBack(args = []) {
587
587
  const root = findPluginRoot();
588
+ assertManifestVariables(root);
588
589
  warnManifest(root);
589
590
  const portRanges = assertManifestPorts(root);
590
591
  if (portRanges.length > 0 && process.env.MUSCAT_PK_PORTS_PRECHECKED !== "1")
@@ -15,6 +15,9 @@ const RUNTIME_TYPES = ["none", "python", "cpp", "ros2"];
15
15
  const ROS_CLIENTS = ["rclpy", "rclcpp"];
16
16
  const I18N_LANGS = ["ko", "en", "ja", "zh", "vi", "th", "es"];
17
17
  const TARGET_CATEGORIES = ["manipulate", "amr", "rby1", "vision"];
18
+ const VARIABLE_TYPES = ["number", "string", "array"];
19
+ const VARIABLE_NAME_PREFIX = "RBP_";
20
+ const VARIABLE_NAME_RE = /^RBP_[A-Za-z0-9_]+$/;
18
21
 
19
22
  function validateI18nLabel(value, field, errors) {
20
23
  if (value === undefined || value === null) {
@@ -110,6 +113,44 @@ function validateCommandInfo(info, prefix, errors) {
110
113
  errors.push(`${prefix}.commandInfo.childrenCommand must be a string`);
111
114
  }
112
115
 
116
+ // variables: pyfm에 노출할 변수 선언. 없으면 UI가 사용 가능한 변수를 보여줄 수 없다.
117
+ function validateVariables(variables, errors) {
118
+ if (variables === undefined || variables === null) return;
119
+ if (!Array.isArray(variables)) {
120
+ errors.push(`"variables" must be an array`);
121
+ return;
122
+ }
123
+ const names = variables.map((v) => v?.name).filter(Boolean);
124
+ const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
125
+ if (duplicates.length > 0)
126
+ errors.push(`variables: "name" must be unique (duplicates: ${[...new Set(duplicates)].map((n) => `"${n}"`).join(", ")})`);
127
+
128
+ variables.forEach((v, i) => {
129
+ if (typeof v !== "object" || v === null || Array.isArray(v)) {
130
+ errors.push(`variables[${i}] must be an object ({ name, type, init, description? })`);
131
+ return;
132
+ }
133
+ if (typeof v.name !== "string" || v.name.length === 0)
134
+ errors.push(`variables[${i}].name is required`);
135
+ else if (!v.name.startsWith(VARIABLE_NAME_PREFIX))
136
+ errors.push(`variables[${i}].name: "${v.name}" must start with "${VARIABLE_NAME_PREFIX}" (e.g. "${VARIABLE_NAME_PREFIX}${v.name}")`);
137
+ else if (!VARIABLE_NAME_RE.test(v.name))
138
+ errors.push(`variables[${i}].name: "${v.name}" is not a valid identifier (after "${VARIABLE_NAME_PREFIX}": letters, digits, underscore only, at least one character)`);
139
+ if (!VARIABLE_TYPES.includes(v.type))
140
+ errors.push(`variables[${i}].type must be one of ${VARIABLE_TYPES.map((t) => `"${t}"`).join(" | ")}`);
141
+ if (v.init === undefined)
142
+ errors.push(`variables[${i}].init is required (initial value)`);
143
+ else if (v.type === "number" && typeof v.init !== "number")
144
+ errors.push(`variables[${i}].init must be a number when type is "number"`);
145
+ else if (v.type === "string" && typeof v.init !== "string")
146
+ errors.push(`variables[${i}].init must be a string when type is "string"`);
147
+ else if (v.type === "array" && !Array.isArray(v.init))
148
+ errors.push(`variables[${i}].init must be an array when type is "array"`);
149
+ if (v.description !== undefined && v.description !== null)
150
+ validateI18nLabel(v.description, `variables[${i}].description`, errors);
151
+ });
152
+ }
153
+
113
154
  function validateContributes(contributes, errors) {
114
155
  if (!Array.isArray(contributes) || contributes.length === 0) {
115
156
  errors.push("contributes is required and must be a non-empty array");
@@ -183,6 +224,7 @@ export function validateManifest(manifest) {
183
224
  validatePorts(manifest.ports, errors);
184
225
  validateRuntime(manifest.runtime, errors);
185
226
  validateContributes(manifest.contributes, errors);
227
+ validateVariables(manifest.variables, errors);
186
228
 
187
229
  return errors;
188
230
  }
@@ -202,6 +244,18 @@ export function assertManifest(root) {
202
244
  return manifest;
203
245
  }
204
246
 
247
+ export function assertManifestVariables(root) {
248
+ const manifest = readManifest(root);
249
+ if (manifest === null)
250
+ throw new Error("manifest.json parse failed — check JSON syntax");
251
+ if (Object.keys(manifest).length === 0)
252
+ return;
253
+ const errors = [];
254
+ validateVariables(manifest.variables, errors);
255
+ if (errors.length > 0)
256
+ throw new Error(`manifest.json variable errors:\n${errors.map((e) => ` • ${e}`).join("\n")}`);
257
+ }
258
+
205
259
  export function assertManifestPorts(root) {
206
260
  const manifest = readManifest(root);
207
261
  if (manifest === null)
package/cli/src/mcp.mjs CHANGED
@@ -22,8 +22,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
23
  import { z } from "zod";
24
24
  import ts from "typescript";
25
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
25
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
26
26
  import { spawnSync } from "node:child_process";
27
+ import { homedir } from "node:os";
27
28
  import { join, resolve } from "node:path";
28
29
  import { fileURLToPath } from "node:url";
29
30
  import { kitRoot, kitVersion, findPluginRoot, venvPython } from "./util.mjs";
@@ -111,10 +112,28 @@ function findStorySource(name) {
111
112
  /* -------------------------------------------------------------------------- */
112
113
 
113
114
  // Split the README on headings and keyword-score each section.
115
+ // Headings inside fenced code blocks (```…```) don't count — a column-0
116
+ // Python comment would otherwise start a bogus section.
117
+ function splitSections(text) {
118
+ const sections = [];
119
+ let current = [];
120
+ let inFence = false;
121
+ for (const line of text.split("\n")) {
122
+ if (/^\s*```/.test(line)) inFence = !inFence;
123
+ if (!inFence && /^#{1,4} /.test(line) && current.length) {
124
+ sections.push(current.join("\n"));
125
+ current = [];
126
+ }
127
+ current.push(line);
128
+ }
129
+ if (current.length) sections.push(current.join("\n"));
130
+ return sections;
131
+ }
132
+
114
133
  function searchReadme(query) {
115
134
  if (!existsSync(TEMPLATE_README)) return [];
116
135
  const text = readFileSync(TEMPLATE_README, "utf8");
117
- const sections = text.split(/\n(?=#{1,4} )/);
136
+ const sections = splitSections(text);
118
137
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
119
138
 
120
139
  return sections
@@ -243,6 +262,39 @@ ${script}
243
262
  return { data };
244
263
  }
245
264
 
265
+ /* -------------------------------------------------------------------------- */
266
+ /* client registration — `muscat-pk mcp --install <client>` */
267
+ /* -------------------------------------------------------------------------- */
268
+
269
+ // Claude Code needs no install step: `muscat-pk new` scaffolds a project-level
270
+ // .mcp.json. Codex only reads the global ~/.codex/config.toml, so we append
271
+ // the server block there (idempotent).
272
+ export function installMcp(target) {
273
+ if (target !== "codex") {
274
+ throw new Error(
275
+ `Unknown --install target ${JSON.stringify(target ?? "")} — supported: "codex". ` +
276
+ "(Claude Code works out of the box via the scaffolded .mcp.json.)",
277
+ );
278
+ }
279
+ const cfgDir = join(homedir(), ".codex");
280
+ const cfgPath = join(cfgDir, "config.toml");
281
+ const existing = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
282
+ if (/^\s*\[mcp_servers\.muscat\]/m.test(existing)) {
283
+ console.log(`Codex already has the muscat MCP server registered (${cfgPath}) — nothing to do.`);
284
+ return;
285
+ }
286
+ // ponytail: Windows Codex spawns without a shell, so it needs the .cmd shim.
287
+ const command = process.platform === "win32" ? "muscat-pk.cmd" : "muscat-pk";
288
+ mkdirSync(cfgDir, { recursive: true });
289
+ const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
290
+ appendFileSync(
291
+ cfgPath,
292
+ `${sep}[mcp_servers.muscat]\ncommand = "${command}"\nargs = ["mcp"]\n`,
293
+ );
294
+ console.log(`Registered the muscat MCP server for Codex → ${cfgPath}`);
295
+ console.log("Restart Codex (or start a new session) to pick it up.");
296
+ }
297
+
246
298
  /* -------------------------------------------------------------------------- */
247
299
  /* server */
248
300
  /* -------------------------------------------------------------------------- */
@@ -348,7 +400,10 @@ export async function runMcp() {
348
400
  description:
349
401
  "Search the Muscat plugin-author README for how to use useRb(), rb.pub/sub/action/serve, " +
350
402
  "rb.toast/alert/confirm/prompt, useRbFormData/useRbSave (save/restore form data through the " +
351
- "host), manifest.json, contribution points, and backend runtimes (Python/C++/ROS2). IMPORTANT: " +
403
+ "host), manifest.json (including the \"variables\" declaration for the pyfm variable picker — " +
404
+ "names require the RBP_ prefix), program variables & executor state (ctx.get_variable/" +
405
+ "set_variable/executor_state/check_stop), contribution points, and backend runtimes " +
406
+ "(Python/C++/ROS2). IMPORTANT: " +
352
407
  "the backend pattern depends on the contribution point — search 'which backend pattern' before " +
353
408
  "writing py/main.py. Only a program.panel command uses PluginProgramContext; a toolbar/move.panel " +
354
409
  "plugin controls the robot with the SDK (RBManipulateSDK/RBAmrSDK) directly.",
package/cli/src/new.mjs CHANGED
@@ -65,6 +65,9 @@ function printNewSuccess({ targetName, type, pm }) {
65
65
  log.info(` ${color.cyan("muscat-pk dev")} ${color.dim("# preview in mini-Muscat + back end")}`);
66
66
  log.info(` ${color.cyan("muscat-pk build")} ${color.dim("# build + distributable ZIP")}`);
67
67
  log.info("");
68
+ log.info(color.dim("AI assistant? Claude Code reads the scaffolded .mcp.json;"));
69
+ log.info(color.dim(`Codex users run ${color.cyan("muscat-pk mcp --install codex")} once.`));
70
+ log.info("");
68
71
  }
69
72
 
70
73
  // Build the manifest runtime object for a given type choice + plugin slug.
package/cli/src/ports.mjs CHANGED
@@ -28,9 +28,9 @@ function reservedPortReason(port) {
28
28
  return `${range.name} (${range.from}-${range.to})`;
29
29
  }
30
30
 
31
- function isPluginManagedPort(port) {
32
- return reservedPortReason(port) || (port >= PLUGIN_PORT_MIN && port <= PLUGIN_PORT_MAX);
33
- }
31
+ // function isPluginManagedPort(port) {
32
+ // return reservedPortReason(port) || (port >= PLUGIN_PORT_MIN && port <= PLUGIN_PORT_MAX);
33
+ // }
34
34
 
35
35
  function isInt(value) {
36
36
  return Number.isInteger(value);