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

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 (178) hide show
  1. package/README.md +99 -0
  2. package/cli/README.md +40 -4
  3. package/cli/bin/muscat-pk.mjs +33 -6
  4. package/cli/docker/Dockerfile.cpp +11 -6
  5. package/cli/docker/Dockerfile.dev +12 -0
  6. package/cli/src/build.mjs +6 -6
  7. package/cli/src/cpp.mjs +3 -1
  8. package/cli/src/dev.mjs +2 -1
  9. package/cli/src/devBack.mjs +8 -7
  10. package/cli/src/manifest.mjs +56 -0
  11. package/cli/src/mcp.mjs +382 -43
  12. package/cli/src/new.mjs +3 -0
  13. package/cli/src/ports.mjs +3 -3
  14. package/cli/src/ros2.mjs +3 -1
  15. package/cli/src/templates-embedded.mjs +3 -0
  16. package/cli/src/util.mjs +13 -0
  17. package/cli/storybook-dist/_stories-src/RBDatePicker/RBRangeDateInput.stories.tsx +13 -1
  18. package/cli/storybook-dist/assets/{Color-YHDXOIA2-YYWRJKA2.js → Color-YHDXOIA2-DixbmWSF.js} +1 -1
  19. package/cli/storybook-dist/assets/{Docs-CKJ2w--X.js → Docs-Blx_41TT.js} +1 -1
  20. package/cli/storybook-dist/assets/Docs-C0E3mHmn.js +7 -0
  21. package/cli/storybook-dist/assets/Docs-CMEiBjLz.js +20 -0
  22. package/cli/storybook-dist/assets/{Docs-UmN7NFo1.js → Docs-D6oFYDre.js} +1 -1
  23. package/cli/storybook-dist/assets/{Docs-DIJ7cbFD.js → Docs-DcUa4nGr.js} +1 -1
  24. package/cli/storybook-dist/assets/{Docs-DT9RD87x.js → Docs-Dv-5qjF4.js} +1 -1
  25. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-CyAy1mnJ.js → DocsRenderer-CFRXHY34-GwWANrWN.js} +1 -1
  26. package/cli/storybook-dist/assets/NxAvatar.stories-CyZ4AE2b.js +13 -0
  27. package/cli/storybook-dist/assets/NxButton.stories-Bx8UtGNd.js +20 -0
  28. package/cli/storybook-dist/assets/{NxIcons.stories-Dg42riT5.js → NxIcons.stories-DeqrVzVs.js} +1 -1
  29. package/cli/storybook-dist/assets/{NxNumberInput.stories-zlQJuGFP.js → NxNumberInput.stories-skizAdd5.js} +3 -3
  30. package/cli/storybook-dist/assets/NxPagination.stories-gtIxt2u0.js +11 -0
  31. package/cli/storybook-dist/assets/NxPointerHoldButton.stories-DSHMk3N5.js +14 -0
  32. package/cli/storybook-dist/assets/{NxRangeInput.stories-Cu5ZWU0w.js → NxRangeInput.stories-C1YtrFZ1.js} +3 -3
  33. package/cli/storybook-dist/assets/NxSelectBox.stories-DCySAtBp.js +17 -0
  34. package/cli/storybook-dist/assets/NxSlideRange.stories-gSaMOWo7.js +14 -0
  35. package/cli/storybook-dist/assets/NxSwitch.stories-DF0DBiyG.js +11 -0
  36. package/cli/storybook-dist/assets/NxTextArea.stories-Dill0M6z.js +11 -0
  37. package/cli/storybook-dist/assets/NxTextInput.stories-Bax14r48.js +11 -0
  38. package/cli/storybook-dist/assets/RBAccordion.stories-CCwz_pIV.js +17 -0
  39. package/cli/storybook-dist/assets/RBAccordionGroup.stories-BjDxxdPV.js +33 -0
  40. package/cli/storybook-dist/assets/RBAlert.stories-5z6GasgP.js +17 -0
  41. package/cli/storybook-dist/assets/RBBadge.stories-BwcM7i7w.js +1 -0
  42. package/cli/storybook-dist/assets/RBBattery.stories-DOeQH3rK.js +10 -0
  43. package/cli/storybook-dist/assets/RBCard.stories-DcmiOXAV.js +45 -0
  44. package/cli/storybook-dist/assets/RBCheckBox.stories-BYru87fX.js +1 -0
  45. package/cli/storybook-dist/assets/RBCheckIconButton.stories-CXUjtR1a.js +1 -0
  46. package/cli/storybook-dist/assets/RBCheckLabelButton.stories-BSOQV7Du.js +24 -0
  47. package/cli/storybook-dist/assets/RBChipTab.stories-ByBmeukU.js +21 -0
  48. package/cli/storybook-dist/assets/RBChipsBoxTab.stories-GtJ01RTr.js +1 -0
  49. package/cli/storybook-dist/assets/RBColorIcons.stories-C9saET1u.js +3 -0
  50. package/cli/storybook-dist/assets/{RBCommand.stories-C3bwPq2T.js → RBCommand.stories-CcvnWlWZ.js} +3 -3
  51. package/cli/storybook-dist/assets/RBConfirm.stories-hdBVSdBS.js +20 -0
  52. package/cli/storybook-dist/assets/RBDateInput.stories-BE9JQl-E.js +15 -0
  53. package/cli/storybook-dist/assets/RBDialog.stories-BaWuDgl_.js +38 -0
  54. package/cli/storybook-dist/assets/RBDivider.stories-D3INKm_Z.js +1 -0
  55. package/cli/storybook-dist/assets/RBDrawer.stories-BZ8V0J6-.js +14 -0
  56. package/cli/storybook-dist/assets/{RBDropdownMenu.stories-9fBcV2Ct.js → RBDropdownMenu.stories-DxLi8HRw.js} +2 -2
  57. package/cli/storybook-dist/assets/RBEmptyView.stories-DQcvDt2S.js +12 -0
  58. package/cli/storybook-dist/assets/RBIconButton.stories-DJfuTpQ0.js +1 -0
  59. package/cli/storybook-dist/assets/RBIcons.stories-BSsyb0BK.js +3 -0
  60. package/cli/storybook-dist/assets/RBInlineLabelButton.stories-Di3th--x.js +1 -0
  61. package/cli/storybook-dist/assets/RBJoyStick.stories-DfGazm4n.js +11 -0
  62. package/cli/storybook-dist/assets/RBLabelButton.stories-CLbxzW56.js +1 -0
  63. package/cli/storybook-dist/assets/RBLight.stories-DeiuDm_P.js +1 -0
  64. package/cli/storybook-dist/assets/RBMenubar.stories-BZQTV9e5.js +3 -0
  65. package/cli/storybook-dist/assets/RBMultiRangeInput.stories-DDNmdi_n.js +14 -0
  66. package/cli/storybook-dist/assets/RBPagination.stories-CLBCEyCU.js +17 -0
  67. package/cli/storybook-dist/assets/RBPopover.stories-DRI5Kfa-.js +11 -0
  68. package/cli/storybook-dist/assets/RBPrompt.stories-DIAmypoJ.js +20 -0
  69. package/cli/storybook-dist/assets/RBRadioBox.stories-BS_N4aLf.js +1 -0
  70. package/cli/storybook-dist/assets/RBRadioButton.stories-BTm8cYvm.js +11 -0
  71. package/cli/storybook-dist/assets/RBRangeDateInput.stories-D5hBsSyB.js +15 -0
  72. package/cli/storybook-dist/assets/RBResizable.stories-CxPEkbXQ.js +20 -0
  73. package/cli/storybook-dist/assets/RBSelectBox.stories-FUQ7AcUi.js +6 -0
  74. package/cli/storybook-dist/assets/RBSheet.stories-mhVHH_71.js +39 -0
  75. package/cli/storybook-dist/assets/RBSideNumberInput.stories-5FMkLUim.js +5 -0
  76. package/cli/storybook-dist/assets/RBSlider.stories-Bnhcp9pz.js +5 -0
  77. package/cli/storybook-dist/assets/RBSpinner.stories-Bf4416mP.js +14 -0
  78. package/cli/storybook-dist/assets/{RBSwitch.stories-CBYXvhkd.js → RBSwitch.stories-BNhFjRaV.js} +2 -2
  79. package/cli/storybook-dist/assets/RBTab.stories-Bsr7jlfb.js +1 -0
  80. package/cli/storybook-dist/assets/RBTable.stories-BkA6pKzs.js +62 -0
  81. package/cli/storybook-dist/assets/RBTableRow.stories-D07Z7D5-.js +11 -0
  82. package/cli/storybook-dist/assets/RBTabs.stories-DHnZiV21.js +21 -0
  83. package/cli/storybook-dist/assets/RBTextArea.stories-CtLMLuV_.js +5 -0
  84. package/cli/storybook-dist/assets/RBTextField.stories-Chz0i1-u.js +45 -0
  85. package/cli/storybook-dist/assets/RBTextInput.stories-Dk5cZfUF.js +5 -0
  86. package/cli/storybook-dist/assets/RBToast.stories-tZxbjmr8.js +11 -0
  87. package/cli/storybook-dist/assets/RBToggle.stories-VTe7a4xZ.js +12 -0
  88. package/cli/storybook-dist/assets/RBToggleGroup.stories-WiFnGGYx.js +17 -0
  89. package/cli/storybook-dist/assets/RBTooltip.stories-B_L4W7Mn.js +9 -0
  90. package/cli/storybook-dist/assets/{MapEditor-C6_H9h5t.css → SideNumberInput-SAkhydsn.css} +2 -2
  91. package/cli/storybook-dist/assets/SideNumberInput-os5wKKf7.js +1109 -0
  92. package/cli/storybook-dist/assets/browserUtils-CQM9Za7e.js +1 -0
  93. package/cli/storybook-dist/assets/{entry-preview-N9dpIJQA.js → entry-preview-NpzneD8a.js} +1 -1
  94. package/cli/storybook-dist/assets/{iframe-BwCxSq5d.js → iframe-DsvRKHwc.js} +2 -2
  95. package/cli/storybook-dist/assets/{index-CgJh_5EZ.js → index-BzvSJzrT.js} +3 -3
  96. package/cli/storybook-dist/assets/preview-BXXleNPp.css +1 -0
  97. package/cli/storybook-dist/assets/preview-BXxjLQ9M.js +3 -0
  98. package/cli/storybook-dist/assets/{preview-DsYc6ovL.js → preview-yXaYuZ78.js} +2 -2
  99. package/cli/storybook-dist/assets/react-resizable-panels.browser-CYcsrUgg.js +120 -0
  100. package/cli/storybook-dist/iframe.html +1 -1
  101. package/cli/storybook-dist/project.json +1 -1
  102. package/cli/templates/common/README.md +352 -24
  103. package/cli/templates/common/manifest.json +1 -0
  104. package/cli/templates/common/py/main.py +24 -1
  105. package/cli/templates/common/requirements.txt +3 -3
  106. package/cli/templates/cpp/cpp/CMakeLists.txt +7 -7
  107. package/cli/templates/cpp/cpp/main.cpp +13 -6
  108. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +4 -0
  109. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +2 -0
  110. package/package.json +3 -3
  111. package/cli/storybook-dist/assets/Docs-DLFwGiks.js +0 -7
  112. package/cli/storybook-dist/assets/Docs-dOc9cH1E.js +0 -20
  113. package/cli/storybook-dist/assets/MapEditor-Jj1J2sUz.js +0 -6418
  114. package/cli/storybook-dist/assets/NxAvatar.stories-B-iQoylp.js +0 -13
  115. package/cli/storybook-dist/assets/NxButton.stories-CawQZN07.js +0 -20
  116. package/cli/storybook-dist/assets/NxPagination.stories-VYXLplNl.js +0 -11
  117. package/cli/storybook-dist/assets/NxPointerHoldButton.stories-BpqIkt0X.js +0 -14
  118. package/cli/storybook-dist/assets/NxSelectBox.stories-JECi_1PM.js +0 -17
  119. package/cli/storybook-dist/assets/NxSlideRange.stories-DJe8F2Mv.js +0 -14
  120. package/cli/storybook-dist/assets/NxSwitch.stories-DX6LxxQL.js +0 -11
  121. package/cli/storybook-dist/assets/NxTextArea.stories-Cy42nYOI.js +0 -11
  122. package/cli/storybook-dist/assets/NxTextInput.stories-Bb_q6FuW.js +0 -11
  123. package/cli/storybook-dist/assets/RBAccordion.stories-ChgPXO0_.js +0 -17
  124. package/cli/storybook-dist/assets/RBAccordionGroup.stories-nmbqYYSE.js +0 -33
  125. package/cli/storybook-dist/assets/RBAlert.stories-ChOPuFxI.js +0 -17
  126. package/cli/storybook-dist/assets/RBBadge.stories-Bas7g4jb.js +0 -1
  127. package/cli/storybook-dist/assets/RBBattery.stories-C0MywWvB.js +0 -10
  128. package/cli/storybook-dist/assets/RBCard.stories-Ba7y0u6Q.js +0 -45
  129. package/cli/storybook-dist/assets/RBCheckBox.stories-CsHFsXCd.js +0 -1
  130. package/cli/storybook-dist/assets/RBCheckIconButton.stories-GERClL9R.js +0 -1
  131. package/cli/storybook-dist/assets/RBCheckLabelButton.stories-C-BrmKaW.js +0 -24
  132. package/cli/storybook-dist/assets/RBChipTab.stories-C8JCa6F9.js +0 -21
  133. package/cli/storybook-dist/assets/RBChipsBoxTab.stories-DmUaHBJ3.js +0 -1
  134. package/cli/storybook-dist/assets/RBColorIcons.stories-Bcah6eZe.js +0 -3
  135. package/cli/storybook-dist/assets/RBConfirm.stories-CT0GuE-J.js +0 -20
  136. package/cli/storybook-dist/assets/RBDateInput.stories-CMd2xRCF.js +0 -15
  137. package/cli/storybook-dist/assets/RBDialog.stories-Ui5Ni-P_.js +0 -38
  138. package/cli/storybook-dist/assets/RBDivider.stories-r8TDiO36.js +0 -1
  139. package/cli/storybook-dist/assets/RBDrawer.stories-CymmrHjI.js +0 -14
  140. package/cli/storybook-dist/assets/RBEmptyView.stories-By9vncNC.js +0 -12
  141. package/cli/storybook-dist/assets/RBIconButton.stories-DC29KMxX.js +0 -1
  142. package/cli/storybook-dist/assets/RBIcons.stories-Dpd_V9b9.js +0 -3
  143. package/cli/storybook-dist/assets/RBInlineLabelButton.stories-CfUQdAdt.js +0 -1
  144. package/cli/storybook-dist/assets/RBJoyStick.stories-Dl-uXk9x.js +0 -11
  145. package/cli/storybook-dist/assets/RBLabelButton.stories-Dx_Vn6EM.js +0 -1
  146. package/cli/storybook-dist/assets/RBLight.stories-Cm5UYJ4n.js +0 -1
  147. package/cli/storybook-dist/assets/RBMenubar.stories-CN5z7F6D.js +0 -3
  148. package/cli/storybook-dist/assets/RBMultiRangeInput.stories-DrAMI4mQ.js +0 -14
  149. package/cli/storybook-dist/assets/RBPagination.stories-BVLXuTLw.js +0 -17
  150. package/cli/storybook-dist/assets/RBPopover.stories-VCws5q80.js +0 -11
  151. package/cli/storybook-dist/assets/RBPrompt.stories-Cw5-wssh.js +0 -20
  152. package/cli/storybook-dist/assets/RBRadioBox.stories-ClqGtGA_.js +0 -1
  153. package/cli/storybook-dist/assets/RBRadioButton.stories-SxcJFioj.js +0 -11
  154. package/cli/storybook-dist/assets/RBRangeDateInput.stories-L_wCftDv.js +0 -15
  155. package/cli/storybook-dist/assets/RBResizable.stories-2VytDv6m.js +0 -20
  156. package/cli/storybook-dist/assets/RBSelectBox.stories-BoOKTSL_.js +0 -6
  157. package/cli/storybook-dist/assets/RBSheet.stories-CUClMqKK.js +0 -39
  158. package/cli/storybook-dist/assets/RBSideNumberInput.stories-BTYDDAmk.js +0 -5
  159. package/cli/storybook-dist/assets/RBSlider.stories-Bu6vD9ym.js +0 -5
  160. package/cli/storybook-dist/assets/RBSpinner.stories-XmVcaTVS.js +0 -14
  161. package/cli/storybook-dist/assets/RBTab.stories-DFFYlkCv.js +0 -1
  162. package/cli/storybook-dist/assets/RBTable.stories-CR2j2EQQ.js +0 -62
  163. package/cli/storybook-dist/assets/RBTableRow.stories-CpPIP1au.js +0 -11
  164. package/cli/storybook-dist/assets/RBTabs.stories-Bew5XVDZ.js +0 -21
  165. package/cli/storybook-dist/assets/RBTextArea.stories-JiRn-tk_.js +0 -5
  166. package/cli/storybook-dist/assets/RBTextField.stories-8LWyUBm9.js +0 -45
  167. package/cli/storybook-dist/assets/RBTextInput.stories-PLJ70xm5.js +0 -5
  168. package/cli/storybook-dist/assets/RBToast.stories-BQf_9x0k.js +0 -11
  169. package/cli/storybook-dist/assets/RBToggle.stories-C5dZnmX_.js +0 -12
  170. package/cli/storybook-dist/assets/RBToggleGroup.stories-D7fwXCxA.js +0 -17
  171. package/cli/storybook-dist/assets/RBTooltip.stories-D-sWRxbA.js +0 -9
  172. package/cli/storybook-dist/assets/RainbowUiProvider-CsMXuxM1.js +0 -31
  173. package/cli/storybook-dist/assets/browserUtils-CxJ5gea3.js +0 -1
  174. package/cli/storybook-dist/assets/keymapTileIndex.worker-BsNYNbqs.js +0 -1
  175. package/cli/storybook-dist/assets/preview-CzDPTg-k.js +0 -3
  176. package/cli/storybook-dist/assets/preview-DZWUyVBI.css +0 -1
  177. package/cli/storybook-dist/assets/react-resizable-panels.browser-CHDWt8fa.js +0 -72
  178. /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`,
@@ -50,10 +60,10 @@ and `50051` are reserved and must not be used by external plugins.
50
60
  | runtime | source | notes |
51
61
  |---|---|---|
52
62
  | `{ "type": "none" }` | — | UI-only plugin; no backend container |
53
- | `{ "type": "python", "entry": "main.py" }` | `py/` | Uses `rainbow.rb_sdk.RBPluginCall` for UI/backend messaging |
54
- | `{ "type": "cpp", "entry": "<executable name>" }` | `cpp/` | Built with CMake in Docker; current template is a run skeleton |
63
+ | `{ "type": "python", "entry": "main.py" }` | `py/` | Uses `rainbow.rb_sdk.RBPluginCall` for UI/backend messaging; `plugin.router` exposes external endpoints (`common/plugin/{id}/{path}`, needs sdk ≥ 0.0.9.dev5 on the robot; the dev bridge only registers routes — external calls need a running `common`) |
64
+ | `{ "type": "cpp", "entry": "<executable name>" }` | `cpp/` | Built with CMake in Docker against the C++ SDK (apt `rainbow-rb-sdk-dev`, `find_package(rb_sdk)`, dynamic link) |
55
65
  | `{ "type": "ros2", "rosClient": "rclpy", ... }` | `ros2_ws/` | ROS2 Python node; may use Python SDK alongside ROS2 APIs |
56
- | `{ "type": "ros2", "rosClient": "rclcpp", ... }` | `ros2_ws/` | ROS2 C++ node built with colcon/CMake |
66
+ | `{ "type": "ros2", "rosClient": "rclcpp", ... }` | `ros2_ws/` | ROS2 C++ node built with colcon/CMake, links the C++ SDK like `cpp` |
57
67
 
58
68
  All non-`none` runtimes run in Docker during `muscat-pk dev:back`, `muscat-pk
59
69
  dev`, and build/export flows so development matches the robot environment.
@@ -229,4 +239,30 @@ 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
+ The C++ images (`Dockerfile.cpp`, `Dockerfile.dev`) install `rainbow-rb-sdk-dev`
261
+ from `https://rainbowrobotics.github.io/rb-sdk` (suite `dev`), pinned by the
262
+ `RB_SDK_VERSION` build arg. Set `MUSCAT_PK_RB_SDK_VERSION=<version>` (with
263
+ `MUSCAT_PK_REBUILD_IMAGE=1`) to match a robot running a different SDK version.
264
+
265
+ On Linux the dev containers run as the invoking user (`--user`), so build output
266
+ under `dist/` and `cpp/build-*` is not left root-owned.
267
+
232
268
  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":
@@ -10,12 +10,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
10
10
  cmake \
11
11
  && rm -rf /var/lib/apt/lists/*
12
12
 
13
- # TODO(cpp-sdk): install the rainbow cpp SDK (dev package) here, pinned to the
14
- # robot's version, so plugins build/link against the same SDK as production.
15
- # Plan: apt package + dynamic link (the robot provides the .so at runtime;
16
- # we do NOT bundle it into the plugin binary). e.g.:
17
- # RUN apt-get update && apt-get install -y rainbow-cpp-sdk-dev=<robot-version> \
18
- # && rm -rf /var/lib/apt/lists/*
13
+ # Rainbow Robotics C++ SDK (rainbow-rb-sdk-dev): headers + CMake config for
14
+ # find_package(rb_sdk). Pinned to the robot's SDK version — the binary links
15
+ # librb_sdk.so.<ver> by SONAME and the robot provides that exact .so at runtime.
16
+ # Override with MUSCAT_PK_RB_SDK_VERSION=<ver> (+ MUSCAT_PK_REBUILD_IMAGE=1).
17
+ ARG RB_SDK_VERSION=0.1.2~dev1
18
+ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
19
+ && install -d /etc/apt/keyrings \
20
+ && curl -fsSL https://rainbowrobotics.github.io/rb-sdk/rb-sdk-apt-repo.gpg.key -o /etc/apt/keyrings/rb-sdk.asc \
21
+ && echo 'deb [signed-by=/etc/apt/keyrings/rb-sdk.asc] https://rainbowrobotics.github.io/rb-sdk dev main' > /etc/apt/sources.list.d/rb-sdk.list \
22
+ && apt-get update && apt-get install -y --no-install-recommends "rainbow-rb-sdk-dev=${RB_SDK_VERSION}" \
23
+ && rm -rf /var/lib/apt/lists/*
19
24
 
20
25
  WORKDIR /plugin
21
26
 
@@ -9,6 +9,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
9
9
  python3-colcon-common-extensions \
10
10
  && rm -rf /var/lib/apt/lists/*
11
11
 
12
+ # Rainbow Robotics C++ SDK (rainbow-rb-sdk-dev): headers + CMake config for
13
+ # find_package(rb_sdk). Pinned to the robot's SDK version — the binary links
14
+ # librb_sdk.so.<ver> by SONAME and the robot provides that exact .so at runtime.
15
+ # Override with MUSCAT_PK_RB_SDK_VERSION=<ver> (+ MUSCAT_PK_REBUILD_IMAGE=1).
16
+ ARG RB_SDK_VERSION=0.1.2~dev1
17
+ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
18
+ && install -d /etc/apt/keyrings \
19
+ && curl -fsSL https://rainbowrobotics.github.io/rb-sdk/rb-sdk-apt-repo.gpg.key -o /etc/apt/keyrings/rb-sdk.asc \
20
+ && echo 'deb [signed-by=/etc/apt/keyrings/rb-sdk.asc] https://rainbowrobotics.github.io/rb-sdk dev main' > /etc/apt/sources.list.d/rb-sdk.list \
21
+ && apt-get update && apt-get install -y --no-install-recommends "rainbow-rb-sdk-dev=${RB_SDK_VERSION}" \
22
+ && rm -rf /var/lib/apt/lists/*
23
+
12
24
  WORKDIR /plugin
13
25
 
14
26
  CMD ["bash"]
package/cli/src/build.mjs CHANGED
@@ -65,11 +65,11 @@ function runViteBuild(pluginRoot, { mode = "production" } = {}) {
65
65
  if (r.status !== 0) throw new Error("vite build failed");
66
66
  }
67
67
 
68
- function isMainBranch() {
69
- const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
70
- const branch = r.stdout?.trim();
71
- return branch === "main" || branch === "master";
72
- }
68
+ // function isMainBranch() {
69
+ // const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
70
+ // const branch = r.stdout?.trim();
71
+ // return branch === "main" || branch === "master";
72
+ // }
73
73
 
74
74
  // For ros2 plugins, compile the colcon workspace inside the Jazzy + Python 3.12 container.
75
75
  // rclpy: arch-agnostic (pure Python) → dist/ros2_ws/install/ (built once)
@@ -169,7 +169,7 @@ function isPrerelease(version) {
169
169
  // stableOnly=true → skips pre-release (dev/a/b/rc) versions.
170
170
  async function latestPypiVersion(pkg, { stableOnly = false } = {}) {
171
171
  const res = await fetch(`https://pypi.org/pypi/${pkg}/json`, {
172
- signal: AbortSignal.timeout(5000),
172
+ signal: new AbortController().signal.timeout(5000),
173
173
  });
174
174
  if (!res.ok) throw new Error(`pypi ${pkg}: ${res.status}`);
175
175
  const data = await res.json();
package/cli/src/cpp.mjs CHANGED
@@ -22,9 +22,11 @@ export async function ensureImage(image = CPP_DEV_IMAGE, { platform = null } = {
22
22
  const dockerfile = await resolveDockerfile("Dockerfile.cpp");
23
23
  log.step(`docker build -t ${image}${platform ? ` (${platform})` : ""} (first run is slow, then cached)…`);
24
24
  const platformFlags = platform ? ["--platform", platform] : [];
25
+ const sdkVer = process.env.MUSCAT_PK_RB_SDK_VERSION;
26
+ const buildArgs = sdkVer ? ["--build-arg", `RB_SDK_VERSION=${sdkVer}`] : [];
25
27
  const r = spawnSync(
26
28
  "docker",
27
- ["build", ...platformFlags, "-t", image, "-f", dockerfile, dirname(dockerfile)],
29
+ ["build", ...platformFlags, ...buildArgs, "-t", image, "-f", dockerfile, dirname(dockerfile)],
28
30
  { stdio: "inherit" },
29
31
  );
30
32
  if (r.status !== 0) throw new Error("docker build failed.");
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", "boolean", "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,46 @@ 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 === "boolean" && typeof v.init !== "boolean")
148
+ errors.push(`variables[${i}].init must be a boolean when type is "boolean"`);
149
+ else if (v.type === "array" && !Array.isArray(v.init))
150
+ errors.push(`variables[${i}].init must be an array when type is "array"`);
151
+ if (v.description !== undefined && v.description !== null)
152
+ validateI18nLabel(v.description, `variables[${i}].description`, errors);
153
+ });
154
+ }
155
+
113
156
  function validateContributes(contributes, errors) {
114
157
  if (!Array.isArray(contributes) || contributes.length === 0) {
115
158
  errors.push("contributes is required and must be a non-empty array");
@@ -183,6 +226,7 @@ export function validateManifest(manifest) {
183
226
  validatePorts(manifest.ports, errors);
184
227
  validateRuntime(manifest.runtime, errors);
185
228
  validateContributes(manifest.contributes, errors);
229
+ validateVariables(manifest.variables, errors);
186
230
 
187
231
  return errors;
188
232
  }
@@ -202,6 +246,18 @@ export function assertManifest(root) {
202
246
  return manifest;
203
247
  }
204
248
 
249
+ export function assertManifestVariables(root) {
250
+ const manifest = readManifest(root);
251
+ if (manifest === null)
252
+ throw new Error("manifest.json parse failed — check JSON syntax");
253
+ if (Object.keys(manifest).length === 0)
254
+ return;
255
+ const errors = [];
256
+ validateVariables(manifest.variables, errors);
257
+ if (errors.length > 0)
258
+ throw new Error(`manifest.json variable errors:\n${errors.map((e) => ` • ${e}`).join("\n")}`);
259
+ }
260
+
205
261
  export function assertManifestPorts(root) {
206
262
  const manifest = readManifest(root);
207
263
  if (manifest === null)