@rainbow-robotics/muscat-plugin-kit 1.1.4 → 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 (169) hide show
  1. package/README.md +99 -0
  2. package/cli/README.md +32 -1
  3. package/cli/bin/muscat-pk.mjs +49 -4
  4. package/cli/src/bridge.mjs +26 -2
  5. package/cli/src/build.mjs +11 -1
  6. package/cli/src/cpp.mjs +6 -2
  7. package/cli/src/dev.mjs +4 -1
  8. package/cli/src/devBack.mjs +14 -8
  9. package/cli/src/manifest.mjs +54 -0
  10. package/cli/src/mcp.mjs +58 -3
  11. package/cli/src/new.mjs +3 -0
  12. package/cli/src/ports.mjs +24 -10
  13. package/cli/src/python.mjs +7 -1
  14. package/cli/src/ros2.mjs +5 -1
  15. package/cli/src/templates-embedded.mjs +3 -0
  16. package/cli/src/update.mjs +34 -26
  17. package/cli/src/util.mjs +59 -1
  18. package/cli/storybook-dist/assets/{Color-YHDXOIA2-YYdZOPJH.js → Color-YHDXOIA2-CSzM1dKs.js} +1 -1
  19. package/cli/storybook-dist/assets/{Docs-xa7vkY1N.js → Docs-BIcNCkcj.js} +1 -1
  20. package/cli/storybook-dist/assets/{Docs-3w-iBQPm.js → Docs-Bhl2G8dW.js} +1 -1
  21. package/cli/storybook-dist/assets/{Docs-qI6e7Wy0.js → Docs-CxnHR1EX.js} +1 -1
  22. package/cli/storybook-dist/assets/Docs-DGfEBD4U.js +7 -0
  23. package/cli/storybook-dist/assets/{Docs-D1XY1-wF.js → Docs-Q256LO6R.js} +1 -1
  24. package/cli/storybook-dist/assets/Docs-y19ER8GV.js +20 -0
  25. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-BE26HVj4.js → DocsRenderer-CFRXHY34-DnUkc2jA.js} +1 -1
  26. package/cli/storybook-dist/assets/NxAvatar.stories-D8_zg1ow.js +13 -0
  27. package/cli/storybook-dist/assets/NxButton.stories-DJ27PRZJ.js +20 -0
  28. package/cli/storybook-dist/assets/{NxNumberInput.stories-YuPp7Wu0.js → NxNumberInput.stories-DAhhfwBb.js} +3 -3
  29. package/cli/storybook-dist/assets/NxPagination.stories-B0B2V-we.js +11 -0
  30. package/cli/storybook-dist/assets/NxPointerHoldButton.stories-Cs9aWGqX.js +14 -0
  31. package/cli/storybook-dist/assets/{NxRangeInput.stories-Bpv0nJOc.js → NxRangeInput.stories-A3qk290L.js} +3 -3
  32. package/cli/storybook-dist/assets/NxSelectBox.stories-BtA0bI0U.js +17 -0
  33. package/cli/storybook-dist/assets/NxSlideRange.stories-DaVwMyfI.js +14 -0
  34. package/cli/storybook-dist/assets/NxSwitch.stories-MEpYcafd.js +11 -0
  35. package/cli/storybook-dist/assets/NxTextArea.stories-8PqT83b0.js +11 -0
  36. package/cli/storybook-dist/assets/NxTextInput.stories-DwOvyFAK.js +11 -0
  37. package/cli/storybook-dist/assets/RBAccordion.stories-l_KWmznJ.js +17 -0
  38. package/cli/storybook-dist/assets/RBAccordionGroup.stories-BHwMLO_b.js +33 -0
  39. package/cli/storybook-dist/assets/RBAlert.stories-XeJozdck.js +17 -0
  40. package/cli/storybook-dist/assets/RBBadge.stories-BoIw5gFT.js +1 -0
  41. package/cli/storybook-dist/assets/RBBattery.stories-C_qyHveA.js +10 -0
  42. package/cli/storybook-dist/assets/RBCard.stories-jFfiHYKe.js +45 -0
  43. package/cli/storybook-dist/assets/RBCheckBox.stories-DTfCEweM.js +1 -0
  44. package/cli/storybook-dist/assets/RBCheckIconButton.stories-CTDnyUqG.js +1 -0
  45. package/cli/storybook-dist/assets/RBCheckLabelButton.stories-DmquL7n0.js +24 -0
  46. package/cli/storybook-dist/assets/RBChipTab.stories-B8zl9AG8.js +21 -0
  47. package/cli/storybook-dist/assets/RBChipsBoxTab.stories-BGRQ_ix8.js +1 -0
  48. package/cli/storybook-dist/assets/RBColorIcons.stories-s5QE7tL1.js +3 -0
  49. package/cli/storybook-dist/assets/{RBCommand.stories-DWSPAw4x.js → RBCommand.stories-Yrpy_B26.js} +3 -3
  50. package/cli/storybook-dist/assets/RBConfirm.stories-C9qrp3br.js +20 -0
  51. package/cli/storybook-dist/assets/RBDateInput.stories-DZpvxSqb.js +15 -0
  52. package/cli/storybook-dist/assets/RBDialog.stories-DSonk9Bx.js +38 -0
  53. package/cli/storybook-dist/assets/RBDivider.stories-CtuMDRWW.js +1 -0
  54. package/cli/storybook-dist/assets/RBDrawer.stories-BNjRz9Cf.js +14 -0
  55. package/cli/storybook-dist/assets/{RBDropdownMenu.stories-CuzDr6dG.js → RBDropdownMenu.stories-Bjl9aOPU.js} +2 -2
  56. package/cli/storybook-dist/assets/RBEmptyView.stories-ChMkuohg.js +12 -0
  57. package/cli/storybook-dist/assets/RBIconButton.stories-B5R9unwo.js +1 -0
  58. package/cli/storybook-dist/assets/RBIcons.stories-Cp5JeZNr.js +3 -0
  59. package/cli/storybook-dist/assets/RBInlineLabelButton.stories-Dl_km9SD.js +1 -0
  60. package/cli/storybook-dist/assets/RBJoyStick.stories-g1XwqElo.js +11 -0
  61. package/cli/storybook-dist/assets/RBLabelButton.stories-iNSl-cdD.js +1 -0
  62. package/cli/storybook-dist/assets/RBLight.stories-DptPacw1.js +1 -0
  63. package/cli/storybook-dist/assets/RBMenubar.stories-CTH4jNi6.js +3 -0
  64. package/cli/storybook-dist/assets/RBMultiRangeInput.stories-BD138rOz.js +14 -0
  65. package/cli/storybook-dist/assets/RBPagination.stories-DaXD-840.js +17 -0
  66. package/cli/storybook-dist/assets/RBPopover.stories-BHA1-yYi.js +11 -0
  67. package/cli/storybook-dist/assets/RBPrompt.stories-CoEeIK6d.js +20 -0
  68. package/cli/storybook-dist/assets/RBRadioBox.stories-CePglWfe.js +1 -0
  69. package/cli/storybook-dist/assets/RBRadioButton.stories-BRNRgcJb.js +11 -0
  70. package/cli/storybook-dist/assets/RBRangeDateInput.stories-B6ukx3Pl.js +15 -0
  71. package/cli/storybook-dist/assets/RBResizable.stories-DqoaWeqW.js +20 -0
  72. package/cli/storybook-dist/assets/RBSelectBox.stories-8QBhknxC.js +6 -0
  73. package/cli/storybook-dist/assets/RBSheet.stories-Doaw8VOp.js +39 -0
  74. package/cli/storybook-dist/assets/RBSideNumberInput.stories-BmAEpYFE.js +5 -0
  75. package/cli/storybook-dist/assets/RBSlider.stories-CH-Hkgd6.js +5 -0
  76. package/cli/storybook-dist/assets/RBSpinner.stories-BEceevO_.js +14 -0
  77. package/cli/storybook-dist/assets/{RBSwitch.stories-DUJP7uXw.js → RBSwitch.stories-BoZzoyfx.js} +2 -2
  78. package/cli/storybook-dist/assets/RBTab.stories-CN3Algxc.js +1 -0
  79. package/cli/storybook-dist/assets/RBTable.stories-C1UgfrL_.js +62 -0
  80. package/cli/storybook-dist/assets/RBTableRow.stories-ASxrEiHq.js +11 -0
  81. package/cli/storybook-dist/assets/RBTabs.stories-0tMRRXl4.js +21 -0
  82. package/cli/storybook-dist/assets/RBTextArea.stories-DhWXTnBK.js +5 -0
  83. package/cli/storybook-dist/assets/RBTextField.stories-BLI0HpCu.js +45 -0
  84. package/cli/storybook-dist/assets/RBTextInput.stories-J8FkNDJo.js +5 -0
  85. package/cli/storybook-dist/assets/RBToast.stories-D8UxgdX3.js +11 -0
  86. package/cli/storybook-dist/assets/RBToggle.stories-CjwzqnV-.js +12 -0
  87. package/cli/storybook-dist/assets/RBToggleGroup.stories-3JwuIqYr.js +17 -0
  88. package/cli/storybook-dist/assets/RBTooltip.stories-Wkx6rfug.js +9 -0
  89. package/cli/storybook-dist/assets/{MapEditor-C6_H9h5t.css → SideNumberInput-SAkhydsn.css} +2 -2
  90. package/cli/storybook-dist/assets/SideNumberInput-_BKR64MG.js +1114 -0
  91. package/cli/storybook-dist/assets/{entry-preview-G2ctDavw.js → entry-preview-VQeOKtPr.js} +1 -1
  92. package/cli/storybook-dist/assets/{iframe-BOhIypRE.js → iframe-Ci_IjEPD.js} +2 -2
  93. package/cli/storybook-dist/assets/{index-BgcrmuE1.js → index-DVR_O3uO.js} +3 -3
  94. package/cli/storybook-dist/assets/preview-BVJWkKiZ.css +1 -0
  95. package/cli/storybook-dist/assets/preview-CO9TxipX.js +3 -0
  96. package/cli/storybook-dist/assets/{preview-ChKiP_bM.js → preview-DAZ7L_eg.js} +2 -2
  97. package/cli/storybook-dist/assets/react-resizable-panels.browser-BpHwB_WW.js +102 -0
  98. package/cli/storybook-dist/iframe.html +1 -1
  99. package/cli/storybook-dist/project.json +1 -1
  100. package/cli/templates/common/README.md +229 -19
  101. package/cli/templates/common/manifest.json +1 -0
  102. package/cli/templates/common/requirements.txt +3 -3
  103. package/package.json +3 -3
  104. package/cli/storybook-dist/assets/Docs-CZPzbRJI.js +0 -20
  105. package/cli/storybook-dist/assets/Docs-DVG-oWp3.js +0 -7
  106. package/cli/storybook-dist/assets/MapEditor-D4C2XwU6.js +0 -6418
  107. package/cli/storybook-dist/assets/NxAvatar.stories-5xlylW8S.js +0 -13
  108. package/cli/storybook-dist/assets/NxButton.stories-YRxoorOO.js +0 -20
  109. package/cli/storybook-dist/assets/NxPagination.stories-HpA9nBE3.js +0 -11
  110. package/cli/storybook-dist/assets/NxPointerHoldButton.stories-RkuBsu7A.js +0 -14
  111. package/cli/storybook-dist/assets/NxSelectBox.stories-CG1h2bp3.js +0 -17
  112. package/cli/storybook-dist/assets/NxSlideRange.stories-UdK8ZHHK.js +0 -14
  113. package/cli/storybook-dist/assets/NxSwitch.stories-BM7d32cZ.js +0 -11
  114. package/cli/storybook-dist/assets/NxTextArea.stories-DL6uoMyN.js +0 -11
  115. package/cli/storybook-dist/assets/NxTextInput.stories-Cy8UZ2j4.js +0 -11
  116. package/cli/storybook-dist/assets/RBAccordion.stories-tShXBGM7.js +0 -17
  117. package/cli/storybook-dist/assets/RBAccordionGroup.stories-BdWxR0_T.js +0 -33
  118. package/cli/storybook-dist/assets/RBAlert.stories-qXR-YP3W.js +0 -17
  119. package/cli/storybook-dist/assets/RBBadge.stories-cEzAMHib.js +0 -1
  120. package/cli/storybook-dist/assets/RBBattery.stories-eGnAHW-z.js +0 -10
  121. package/cli/storybook-dist/assets/RBCard.stories-CycdrGRs.js +0 -45
  122. package/cli/storybook-dist/assets/RBCheckBox.stories-BSFFOcOq.js +0 -1
  123. package/cli/storybook-dist/assets/RBCheckIconButton.stories-CFJnuMjZ.js +0 -1
  124. package/cli/storybook-dist/assets/RBCheckLabelButton.stories-CAS-EfIx.js +0 -24
  125. package/cli/storybook-dist/assets/RBChipTab.stories-DWsx5nBl.js +0 -21
  126. package/cli/storybook-dist/assets/RBChipsBoxTab.stories-7DmzDv1u.js +0 -1
  127. package/cli/storybook-dist/assets/RBColorIcons.stories-BYhvvnJM.js +0 -3
  128. package/cli/storybook-dist/assets/RBConfirm.stories-CtXYYbJ1.js +0 -20
  129. package/cli/storybook-dist/assets/RBDateInput.stories-C8vfSeoE.js +0 -15
  130. package/cli/storybook-dist/assets/RBDialog.stories-BsljNu-X.js +0 -38
  131. package/cli/storybook-dist/assets/RBDivider.stories-BaS6rsk7.js +0 -1
  132. package/cli/storybook-dist/assets/RBDrawer.stories-Dadf_QAx.js +0 -14
  133. package/cli/storybook-dist/assets/RBEmptyView.stories-CL8YKZ4z.js +0 -12
  134. package/cli/storybook-dist/assets/RBIconButton.stories-C9uCobtq.js +0 -1
  135. package/cli/storybook-dist/assets/RBIcons.stories-Do543jrJ.js +0 -3
  136. package/cli/storybook-dist/assets/RBInlineLabelButton.stories-fo5Mkohq.js +0 -1
  137. package/cli/storybook-dist/assets/RBJoyStick.stories-DluMn4KO.js +0 -11
  138. package/cli/storybook-dist/assets/RBLabelButton.stories-BisTX_da.js +0 -1
  139. package/cli/storybook-dist/assets/RBLight.stories-xs5nahD4.js +0 -1
  140. package/cli/storybook-dist/assets/RBMenubar.stories-Cn-6_-AJ.js +0 -3
  141. package/cli/storybook-dist/assets/RBMultiRangeInput.stories-5mfoXGC6.js +0 -14
  142. package/cli/storybook-dist/assets/RBPagination.stories-bcc-iOO2.js +0 -17
  143. package/cli/storybook-dist/assets/RBPopover.stories-DCMSSLeN.js +0 -11
  144. package/cli/storybook-dist/assets/RBPrompt.stories-D2qyZEoz.js +0 -20
  145. package/cli/storybook-dist/assets/RBRadioBox.stories-CDc_WkjI.js +0 -1
  146. package/cli/storybook-dist/assets/RBRadioButton.stories-BCu10IWf.js +0 -11
  147. package/cli/storybook-dist/assets/RBRangeDateInput.stories-DoRRHADM.js +0 -15
  148. package/cli/storybook-dist/assets/RBResizable.stories-ClPOvGPZ.js +0 -20
  149. package/cli/storybook-dist/assets/RBSelectBox.stories-Chc3359A.js +0 -6
  150. package/cli/storybook-dist/assets/RBSheet.stories-GG_2M19S.js +0 -39
  151. package/cli/storybook-dist/assets/RBSideNumberInput.stories-wZw5a4Cc.js +0 -5
  152. package/cli/storybook-dist/assets/RBSlider.stories-B5KtjUqu.js +0 -5
  153. package/cli/storybook-dist/assets/RBSpinner.stories-D1dZ-X8O.js +0 -14
  154. package/cli/storybook-dist/assets/RBTab.stories-DQNP4u6i.js +0 -1
  155. package/cli/storybook-dist/assets/RBTable.stories-Bncn0LRp.js +0 -62
  156. package/cli/storybook-dist/assets/RBTableRow.stories-DGfPZAVt.js +0 -11
  157. package/cli/storybook-dist/assets/RBTabs.stories-KM5-HtJk.js +0 -21
  158. package/cli/storybook-dist/assets/RBTextArea.stories-SBLKQy3l.js +0 -5
  159. package/cli/storybook-dist/assets/RBTextField.stories-BtD9AYDp.js +0 -45
  160. package/cli/storybook-dist/assets/RBTextInput.stories-BAV7UURd.js +0 -5
  161. package/cli/storybook-dist/assets/RBToast.stories-D9Hx1Gqv.js +0 -11
  162. package/cli/storybook-dist/assets/RBToggle.stories-BsxBo-P4.js +0 -12
  163. package/cli/storybook-dist/assets/RBToggleGroup.stories-DtpdGy8j.js +0 -17
  164. package/cli/storybook-dist/assets/RBTooltip.stories-CxLxUTJD.js +0 -9
  165. package/cli/storybook-dist/assets/RainbowUiProvider-CsMXuxM1.js +0 -31
  166. package/cli/storybook-dist/assets/preview-CvPmpqMH.css +0 -1
  167. package/cli/storybook-dist/assets/preview-D_vClpb_.js +0 -3
  168. package/cli/storybook-dist/assets/react-resizable-panels.browser-CvAJUcrm.js +0 -72
  169. /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,11 @@
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 { color, kitVersion, log } from "../src/util.mjs";
4
+ import { readFileSync, realpathSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { spawnSync } from "node:child_process";
8
+ import { color, kitVersion, log, resolveLocalCliBin } from "../src/util.mjs";
5
9
  import { runNew } from "../src/new.mjs";
6
10
  import { runBuild } from "../src/build.mjs";
7
11
  import { runDevUi } from "../src/devUi.mjs";
@@ -18,6 +22,45 @@ import { runCompletion } from "../src/completion.mjs";
18
22
  // on every invocation otherwise (see ros2.mjs/python.mjs/cpp.mjs for the same
19
23
  // pattern in build.mjs).
20
24
 
25
+ // 실행 중인 게 글로벌/바이너리인데 cwd 상위에 로컬 설치가 있으면 그쪽으로 넘긴다.
26
+ // vite preset / preview-host / manifest 검증이 항상 프로젝트가 고정한 버전과
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
+
38
+ (function delegateToLocalInstall() {
39
+ try {
40
+ const localBin = resolveLocalCliBin();
41
+ if (!localBin) return;
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
+ }
58
+ process.exit(r.status ?? 0);
59
+ } catch {
60
+ // realpath 실패 등 — 그냥 지금 프로세스로 계속.
61
+ }
62
+ })();
63
+
21
64
  const [, , cmd, ...args] = process.argv;
22
65
 
23
66
  const commandRow = (command, description) => ` ${color.cyan(command.padEnd(30))}${description}`;
@@ -37,8 +80,8 @@ function formatHelp() {
37
80
  commandRow("dev:ui", `Preview the plugin in a mini-Muscat ${color.dim("(preview-host)")}`),
38
81
  commandRow("dev:back", `Run the plugin back end ${color.dim("(python: watchfiles reload / ros2: Docker)")}`),
39
82
  commandRow("storybook", `Browse the bundled rb-components Storybook ${color.dim(`(${option("--port")} <n>, ${option("--rebuild")})`)}`),
40
- commandRow("mcp", `Start an MCP server ${color.dim("(stdio)")} exposing rb-components/rb SDK docs to AI agents`),
41
- commandRow("update", `Update ${color.magenta("@rainbow-robotics/muscat-plugin-kit")} and ${color.magenta("@rainbow-robotics/plugin-ui")}`),
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)`)}`),
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)`)}`),
42
85
  commandRow("uninstall", `Remove the installed ${color.cyan("muscat-pk")} CLI ${color.dim("(npm global or S3 binary)")}`),
43
86
  commandRow("sdk <list|update [version]>", "Manage the python rainbow-rb-sdk version"),
44
87
  commandRow("version", `Print the installed ${color.cyan("muscat-pk")} version`),
@@ -66,7 +109,9 @@ async function main() {
66
109
  case "storybook":
67
110
  return await runStorybook(args);
68
111
  case "mcp": {
69
- 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]);
70
115
  return await runMcp(args);
71
116
  }
72
117
  case "update":
@@ -7,7 +7,7 @@ import { spawnSync } from "node:child_process";
7
7
  import { createServer, connect } from "node:net";
8
8
  import { existsSync } from "node:fs";
9
9
  import { resolve } from "node:path";
10
- import { hasBin, kitRoot, log, pruneMuskatImages } from "./util.mjs";
10
+ import { canSkipImageBuild, hasBin, kitRoot, log, pruneMuskatImages } from "./util.mjs";
11
11
 
12
12
  export const BRIDGE_IMAGE = "muscat-pk-bridge";
13
13
  export const BRIDGE_CONTAINER = "muscat-pk-bridge-run";
@@ -53,6 +53,10 @@ function waitForPort(port, timeoutMs = 15_000) {
53
53
  }
54
54
 
55
55
  function ensureImage() {
56
+ if (canSkipImageBuild(BRIDGE_IMAGE)) {
57
+ log.step(`${BRIDGE_IMAGE} already built — skipping docker build`);
58
+ return;
59
+ }
56
60
  log.step(`docker build -t ${BRIDGE_IMAGE} (first run is slow, then cached)…`);
57
61
  const r = spawnSync("docker", ["build", "-t", BRIDGE_IMAGE, "-f", DOCKERFILE, BUILD_CONTEXT], { stdio: "inherit" });
58
62
  if (r.status !== 0) throw new Error("docker build failed (bridge image).");
@@ -63,19 +67,37 @@ function ensureImage() {
63
67
  * Build the image, preflight the port, and start the bridge container.
64
68
  * Returns true if bridge started, false if port was already in use (caller decides).
65
69
  */
66
- export async function startBridge({ zenohPort = null } = {}) {
70
+ export async function startBridge({ zenohPort = null, pluginPorts = [] } = {}) {
67
71
  assertDockerReady();
68
72
 
69
73
  // remove our own stale container first, then check the host port is free
70
74
  spawnSync("docker", ["rm", "-f", BRIDGE_CONTAINER], { stdio: "ignore" });
71
75
  if (await isPortInUse(BRIDGE_PORT)) return false;
72
76
 
77
+ // Publishing a busy port makes `docker run` fail with a raw daemon error —
78
+ // say which port and keep the bridge up instead.
79
+ const busy = [];
80
+ for (const port of pluginPorts) {
81
+ if (await isPortInUse(port)) busy.push(port);
82
+ }
83
+ if (busy.length > 0) {
84
+ log.warn(
85
+ `declared plugin port(s) already in use on the host: ${busy.join(", ")} — ` +
86
+ `not published (the backend still listens inside the container).`,
87
+ );
88
+ }
89
+ const publishable = pluginPorts.filter((port) => !busy.includes(port));
90
+
73
91
  ensureImage();
74
92
 
75
93
  log.step("starting Socket.IO bridge container…");
94
+ // The backend joins THIS container's netns (--network container:), and a
95
+ // netns can only publish ports when its owner starts — so the plugin's
96
+ // declared ports have to be published here, not on the backend container.
76
97
  const portFlags = [
77
98
  "-p", `127.0.0.1:${BRIDGE_PORT}:${BRIDGE_PORT}`,
78
99
  ...(zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : []),
100
+ ...publishable.flatMap((port) => ["-p", `127.0.0.1:${port}:${port}`]),
79
101
  ];
80
102
  const up = spawnSync(
81
103
  "docker",
@@ -87,6 +109,8 @@ export async function startBridge({ zenohPort = null } = {}) {
87
109
  await waitForPort(BRIDGE_PORT);
88
110
  log.step(`Socket.IO bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
89
111
  if (zenohPort) log.step(`Zenoh dev peer port published on :${zenohPort}`);
112
+ if (publishable.length > 0)
113
+ log.step(`plugin backend ports published on 127.0.0.1: ${publishable.join(", ")}`);
90
114
  return true;
91
115
  }
92
116
 
package/cli/src/build.mjs CHANGED
@@ -227,7 +227,17 @@ export async function buildPlugin({ root, minify = false } = {}) {
227
227
  const pluginRoot = root ?? findPluginRoot();
228
228
  assertManifest(pluginRoot);
229
229
  const distDir = resolve(pluginRoot, "dist");
230
- rmSync(distDir, { recursive: true, force: true });
230
+ try {
231
+ rmSync(distDir, { recursive: true, force: true });
232
+ } catch (err) {
233
+ if (err?.code !== "EACCES" && err?.code !== "EPERM") throw err;
234
+ // 이전 버전이 컨테이너를 root로 돌려 남긴 dist/ — 한 번만 정리하면 된다
235
+ throw new Error(
236
+ `Permission denied when deleting dist/ (${err.path ?? distDir}).\n` +
237
+ `The previous build left files owned by root. Please clean it up once:\n` +
238
+ ` sudo rm -rf ${distDir}`,
239
+ );
240
+ }
231
241
  mkdirSync(distDir, { recursive: true });
232
242
 
233
243
  const manifest = JSON.parse(readFileSync(resolve(pluginRoot, "manifest.json"), "utf8"));
package/cli/src/cpp.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // Used by dev:back (interactive dev loop) and build (export the binary into the ZIP).
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { dirname } from "node:path";
6
- import { hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
6
+ import { canSkipImageBuild, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
7
7
 
8
8
  export const CPP_DEV_IMAGE = "muscat-pk-cpp-dev";
9
9
 
@@ -15,6 +15,10 @@ export function assertDockerReady() {
15
15
 
16
16
  /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
17
17
  export async function ensureImage(image = CPP_DEV_IMAGE, { platform = null } = {}) {
18
+ if (canSkipImageBuild(image)) {
19
+ log.step(`${image} already built — skipping docker build`);
20
+ return;
21
+ }
18
22
  const dockerfile = await resolveDockerfile("Dockerfile.cpp");
19
23
  log.step(`docker build -t ${image}${platform ? ` (${platform})` : ""} (first run is slow, then cached)…`);
20
24
  const platformFlags = platform ? ["--platform", platform] : [];
@@ -54,7 +58,7 @@ export function cmakeBuild(root, { image = CPP_DEV_IMAGE, buildType = "Debug", a
54
58
  log.step(label);
55
59
  const r = spawnSync(
56
60
  "docker",
57
- ["run", "--rm", ...platformFlags, "-v", `${root}:/plugin`, "-w", "/plugin", actualImage, "bash", "-lc", script],
61
+ ["run", "--rm", ...dockerUserFlags(), ...platformFlags, "-v", `${root}:/plugin`, "-w", "/plugin", actualImage, "bash", "-lc", script],
58
62
  { cwd: root, stdio: "inherit" },
59
63
  );
60
64
  if (r.status !== 0) throw new Error(`cmake build failed${arch ? ` (${arch})` : ""}.`);
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)}`);
@@ -133,6 +134,8 @@ export async function runDev(args = []) {
133
134
  zenohPort: remoteRobot && !proxyEndpoint && !usesHostNetwork()
134
135
  ? Number(process.env.MUSCAT_ZENOH_PORT)
135
136
  : null,
137
+ // Linux uses --network host, so the ports are already on the host.
138
+ pluginPorts: usesHostNetwork() ? [] : portRanges,
136
139
  });
137
140
 
138
141
  if (!bridgeStarted) {
@@ -10,12 +10,13 @@
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,
19
+ publishPortFlags,
19
20
  startPortContractMonitor,
20
21
  } from "./ports.mjs";
21
22
  import {
@@ -108,6 +109,10 @@ async function runPython(root, portRanges) {
108
109
  !useHostNet && !bridgeContainer && !proxyEndpoint && zenohPort
109
110
  ? ["-p", `${zenohPort}:${zenohPort}`]
110
111
  : [];
112
+ // Standalone dev:back owns its netns, so it publishes its own declared ports.
113
+ // Under `dev` the bridge owns the netns and published them already.
114
+ const pluginPortFlags =
115
+ !useHostNet && !bridgeContainer ? publishPortFlags(portRanges) : [];
111
116
  const hostGatewayFlags = useHostNet || bridgeContainer
112
117
  ? []
113
118
  : ["--add-host", "host.docker.internal:host-gateway"];
@@ -142,7 +147,7 @@ async function runPython(root, portRanges) {
142
147
  docker(["rm", "-f", container], { stdio: "ignore" });
143
148
  log.step("starting Python dev container…");
144
149
  const up = docker(
145
- ["run", "-d", "--name", container, ...netFlags, ...standaloneZenohPortFlags,
150
+ ["run", "-d", "--name", container, ...netFlags, ...standaloneZenohPortFlags, ...pluginPortFlags, ...realtimeFlags(),
146
151
  // host.docker.internal is automatic on Docker Desktop (macOS/Windows);
147
152
  // this makes it resolve on Linux too, for the no-bridge gateway fallback above.
148
153
  ...hostGatewayFlags,
@@ -151,7 +156,7 @@ async function runPython(root, portRanges) {
151
156
  { stdio: "inherit" },
152
157
  );
153
158
  if (up.status !== 0) throw new Error("failed to start dev container.");
154
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log });
159
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [zenohPort] });
155
160
 
156
161
  log.step("pip install requirements + watchfiles (in container)…");
157
162
  docker(["exec", container, "bash", "-lc",
@@ -440,11 +445,11 @@ async function runRos2(root, portRanges) {
440
445
  docker(["rm", "-f", container], { stdio: "ignore" });
441
446
  log.step("starting ROS2 dev container…");
442
447
  const up = docker(
443
- ["run", "-d", "--name", container, "-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"],
444
449
  { stdio: "inherit" },
445
450
  );
446
451
  if (up.status !== 0) throw new Error("failed to start dev container.");
447
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log });
452
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
448
453
 
449
454
  // one-time deps + symlink build (edits to .py reflect without rebuilding)
450
455
  log.step("pip install + colcon build --symlink-install (once)…");
@@ -521,11 +526,11 @@ async function runCpp(root, portRanges) {
521
526
  docker(["rm", "-f", container], { stdio: "ignore" });
522
527
  log.step("starting C++ dev container…");
523
528
  const up = docker(
524
- ["run", "-d", "--name", container, "-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"],
525
530
  { stdio: "inherit" },
526
531
  );
527
532
  if (up.status !== 0) throw new Error("failed to start dev container.");
528
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log });
533
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
529
534
 
530
535
  const build = () => {
531
536
  log.step("cmake build…");
@@ -580,6 +585,7 @@ function warnManifest(root) {
580
585
 
581
586
  export async function runDevBack(args = []) {
582
587
  const root = findPluginRoot();
588
+ assertManifestVariables(root);
583
589
  warnManifest(root);
584
590
  const portRanges = assertManifestPorts(root);
585
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.