@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.
- package/README.md +99 -0
- package/cli/README.md +32 -1
- package/cli/bin/muscat-pk.mjs +49 -4
- package/cli/src/bridge.mjs +26 -2
- package/cli/src/build.mjs +11 -1
- package/cli/src/cpp.mjs +6 -2
- package/cli/src/dev.mjs +4 -1
- package/cli/src/devBack.mjs +14 -8
- package/cli/src/manifest.mjs +54 -0
- package/cli/src/mcp.mjs +58 -3
- package/cli/src/new.mjs +3 -0
- package/cli/src/ports.mjs +24 -10
- package/cli/src/python.mjs +7 -1
- package/cli/src/ros2.mjs +5 -1
- package/cli/src/templates-embedded.mjs +3 -0
- package/cli/src/update.mjs +34 -26
- package/cli/src/util.mjs +59 -1
- package/cli/storybook-dist/assets/{Color-YHDXOIA2-YYdZOPJH.js → Color-YHDXOIA2-CSzM1dKs.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-xa7vkY1N.js → Docs-BIcNCkcj.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-3w-iBQPm.js → Docs-Bhl2G8dW.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-qI6e7Wy0.js → Docs-CxnHR1EX.js} +1 -1
- package/cli/storybook-dist/assets/Docs-DGfEBD4U.js +7 -0
- package/cli/storybook-dist/assets/{Docs-D1XY1-wF.js → Docs-Q256LO6R.js} +1 -1
- package/cli/storybook-dist/assets/Docs-y19ER8GV.js +20 -0
- package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-BE26HVj4.js → DocsRenderer-CFRXHY34-DnUkc2jA.js} +1 -1
- package/cli/storybook-dist/assets/NxAvatar.stories-D8_zg1ow.js +13 -0
- package/cli/storybook-dist/assets/NxButton.stories-DJ27PRZJ.js +20 -0
- package/cli/storybook-dist/assets/{NxNumberInput.stories-YuPp7Wu0.js → NxNumberInput.stories-DAhhfwBb.js} +3 -3
- package/cli/storybook-dist/assets/NxPagination.stories-B0B2V-we.js +11 -0
- package/cli/storybook-dist/assets/NxPointerHoldButton.stories-Cs9aWGqX.js +14 -0
- package/cli/storybook-dist/assets/{NxRangeInput.stories-Bpv0nJOc.js → NxRangeInput.stories-A3qk290L.js} +3 -3
- package/cli/storybook-dist/assets/NxSelectBox.stories-BtA0bI0U.js +17 -0
- package/cli/storybook-dist/assets/NxSlideRange.stories-DaVwMyfI.js +14 -0
- package/cli/storybook-dist/assets/NxSwitch.stories-MEpYcafd.js +11 -0
- package/cli/storybook-dist/assets/NxTextArea.stories-8PqT83b0.js +11 -0
- package/cli/storybook-dist/assets/NxTextInput.stories-DwOvyFAK.js +11 -0
- package/cli/storybook-dist/assets/RBAccordion.stories-l_KWmznJ.js +17 -0
- package/cli/storybook-dist/assets/RBAccordionGroup.stories-BHwMLO_b.js +33 -0
- package/cli/storybook-dist/assets/RBAlert.stories-XeJozdck.js +17 -0
- package/cli/storybook-dist/assets/RBBadge.stories-BoIw5gFT.js +1 -0
- package/cli/storybook-dist/assets/RBBattery.stories-C_qyHveA.js +10 -0
- package/cli/storybook-dist/assets/RBCard.stories-jFfiHYKe.js +45 -0
- package/cli/storybook-dist/assets/RBCheckBox.stories-DTfCEweM.js +1 -0
- package/cli/storybook-dist/assets/RBCheckIconButton.stories-CTDnyUqG.js +1 -0
- package/cli/storybook-dist/assets/RBCheckLabelButton.stories-DmquL7n0.js +24 -0
- package/cli/storybook-dist/assets/RBChipTab.stories-B8zl9AG8.js +21 -0
- package/cli/storybook-dist/assets/RBChipsBoxTab.stories-BGRQ_ix8.js +1 -0
- package/cli/storybook-dist/assets/RBColorIcons.stories-s5QE7tL1.js +3 -0
- package/cli/storybook-dist/assets/{RBCommand.stories-DWSPAw4x.js → RBCommand.stories-Yrpy_B26.js} +3 -3
- package/cli/storybook-dist/assets/RBConfirm.stories-C9qrp3br.js +20 -0
- package/cli/storybook-dist/assets/RBDateInput.stories-DZpvxSqb.js +15 -0
- package/cli/storybook-dist/assets/RBDialog.stories-DSonk9Bx.js +38 -0
- package/cli/storybook-dist/assets/RBDivider.stories-CtuMDRWW.js +1 -0
- package/cli/storybook-dist/assets/RBDrawer.stories-BNjRz9Cf.js +14 -0
- package/cli/storybook-dist/assets/{RBDropdownMenu.stories-CuzDr6dG.js → RBDropdownMenu.stories-Bjl9aOPU.js} +2 -2
- package/cli/storybook-dist/assets/RBEmptyView.stories-ChMkuohg.js +12 -0
- package/cli/storybook-dist/assets/RBIconButton.stories-B5R9unwo.js +1 -0
- package/cli/storybook-dist/assets/RBIcons.stories-Cp5JeZNr.js +3 -0
- package/cli/storybook-dist/assets/RBInlineLabelButton.stories-Dl_km9SD.js +1 -0
- package/cli/storybook-dist/assets/RBJoyStick.stories-g1XwqElo.js +11 -0
- package/cli/storybook-dist/assets/RBLabelButton.stories-iNSl-cdD.js +1 -0
- package/cli/storybook-dist/assets/RBLight.stories-DptPacw1.js +1 -0
- package/cli/storybook-dist/assets/RBMenubar.stories-CTH4jNi6.js +3 -0
- package/cli/storybook-dist/assets/RBMultiRangeInput.stories-BD138rOz.js +14 -0
- package/cli/storybook-dist/assets/RBPagination.stories-DaXD-840.js +17 -0
- package/cli/storybook-dist/assets/RBPopover.stories-BHA1-yYi.js +11 -0
- package/cli/storybook-dist/assets/RBPrompt.stories-CoEeIK6d.js +20 -0
- package/cli/storybook-dist/assets/RBRadioBox.stories-CePglWfe.js +1 -0
- package/cli/storybook-dist/assets/RBRadioButton.stories-BRNRgcJb.js +11 -0
- package/cli/storybook-dist/assets/RBRangeDateInput.stories-B6ukx3Pl.js +15 -0
- package/cli/storybook-dist/assets/RBResizable.stories-DqoaWeqW.js +20 -0
- package/cli/storybook-dist/assets/RBSelectBox.stories-8QBhknxC.js +6 -0
- package/cli/storybook-dist/assets/RBSheet.stories-Doaw8VOp.js +39 -0
- package/cli/storybook-dist/assets/RBSideNumberInput.stories-BmAEpYFE.js +5 -0
- package/cli/storybook-dist/assets/RBSlider.stories-CH-Hkgd6.js +5 -0
- package/cli/storybook-dist/assets/RBSpinner.stories-BEceevO_.js +14 -0
- package/cli/storybook-dist/assets/{RBSwitch.stories-DUJP7uXw.js → RBSwitch.stories-BoZzoyfx.js} +2 -2
- package/cli/storybook-dist/assets/RBTab.stories-CN3Algxc.js +1 -0
- package/cli/storybook-dist/assets/RBTable.stories-C1UgfrL_.js +62 -0
- package/cli/storybook-dist/assets/RBTableRow.stories-ASxrEiHq.js +11 -0
- package/cli/storybook-dist/assets/RBTabs.stories-0tMRRXl4.js +21 -0
- package/cli/storybook-dist/assets/RBTextArea.stories-DhWXTnBK.js +5 -0
- package/cli/storybook-dist/assets/RBTextField.stories-BLI0HpCu.js +45 -0
- package/cli/storybook-dist/assets/RBTextInput.stories-J8FkNDJo.js +5 -0
- package/cli/storybook-dist/assets/RBToast.stories-D8UxgdX3.js +11 -0
- package/cli/storybook-dist/assets/RBToggle.stories-CjwzqnV-.js +12 -0
- package/cli/storybook-dist/assets/RBToggleGroup.stories-3JwuIqYr.js +17 -0
- package/cli/storybook-dist/assets/RBTooltip.stories-Wkx6rfug.js +9 -0
- package/cli/storybook-dist/assets/{MapEditor-C6_H9h5t.css → SideNumberInput-SAkhydsn.css} +2 -2
- package/cli/storybook-dist/assets/SideNumberInput-_BKR64MG.js +1114 -0
- package/cli/storybook-dist/assets/{entry-preview-G2ctDavw.js → entry-preview-VQeOKtPr.js} +1 -1
- package/cli/storybook-dist/assets/{iframe-BOhIypRE.js → iframe-Ci_IjEPD.js} +2 -2
- package/cli/storybook-dist/assets/{index-BgcrmuE1.js → index-DVR_O3uO.js} +3 -3
- package/cli/storybook-dist/assets/preview-BVJWkKiZ.css +1 -0
- package/cli/storybook-dist/assets/preview-CO9TxipX.js +3 -0
- package/cli/storybook-dist/assets/{preview-ChKiP_bM.js → preview-DAZ7L_eg.js} +2 -2
- package/cli/storybook-dist/assets/react-resizable-panels.browser-BpHwB_WW.js +102 -0
- package/cli/storybook-dist/iframe.html +1 -1
- package/cli/storybook-dist/project.json +1 -1
- package/cli/templates/common/README.md +229 -19
- package/cli/templates/common/manifest.json +1 -0
- package/cli/templates/common/requirements.txt +3 -3
- package/package.json +3 -3
- package/cli/storybook-dist/assets/Docs-CZPzbRJI.js +0 -20
- package/cli/storybook-dist/assets/Docs-DVG-oWp3.js +0 -7
- package/cli/storybook-dist/assets/MapEditor-D4C2XwU6.js +0 -6418
- package/cli/storybook-dist/assets/NxAvatar.stories-5xlylW8S.js +0 -13
- package/cli/storybook-dist/assets/NxButton.stories-YRxoorOO.js +0 -20
- package/cli/storybook-dist/assets/NxPagination.stories-HpA9nBE3.js +0 -11
- package/cli/storybook-dist/assets/NxPointerHoldButton.stories-RkuBsu7A.js +0 -14
- package/cli/storybook-dist/assets/NxSelectBox.stories-CG1h2bp3.js +0 -17
- package/cli/storybook-dist/assets/NxSlideRange.stories-UdK8ZHHK.js +0 -14
- package/cli/storybook-dist/assets/NxSwitch.stories-BM7d32cZ.js +0 -11
- package/cli/storybook-dist/assets/NxTextArea.stories-DL6uoMyN.js +0 -11
- package/cli/storybook-dist/assets/NxTextInput.stories-Cy8UZ2j4.js +0 -11
- package/cli/storybook-dist/assets/RBAccordion.stories-tShXBGM7.js +0 -17
- package/cli/storybook-dist/assets/RBAccordionGroup.stories-BdWxR0_T.js +0 -33
- package/cli/storybook-dist/assets/RBAlert.stories-qXR-YP3W.js +0 -17
- package/cli/storybook-dist/assets/RBBadge.stories-cEzAMHib.js +0 -1
- package/cli/storybook-dist/assets/RBBattery.stories-eGnAHW-z.js +0 -10
- package/cli/storybook-dist/assets/RBCard.stories-CycdrGRs.js +0 -45
- package/cli/storybook-dist/assets/RBCheckBox.stories-BSFFOcOq.js +0 -1
- package/cli/storybook-dist/assets/RBCheckIconButton.stories-CFJnuMjZ.js +0 -1
- package/cli/storybook-dist/assets/RBCheckLabelButton.stories-CAS-EfIx.js +0 -24
- package/cli/storybook-dist/assets/RBChipTab.stories-DWsx5nBl.js +0 -21
- package/cli/storybook-dist/assets/RBChipsBoxTab.stories-7DmzDv1u.js +0 -1
- package/cli/storybook-dist/assets/RBColorIcons.stories-BYhvvnJM.js +0 -3
- package/cli/storybook-dist/assets/RBConfirm.stories-CtXYYbJ1.js +0 -20
- package/cli/storybook-dist/assets/RBDateInput.stories-C8vfSeoE.js +0 -15
- package/cli/storybook-dist/assets/RBDialog.stories-BsljNu-X.js +0 -38
- package/cli/storybook-dist/assets/RBDivider.stories-BaS6rsk7.js +0 -1
- package/cli/storybook-dist/assets/RBDrawer.stories-Dadf_QAx.js +0 -14
- package/cli/storybook-dist/assets/RBEmptyView.stories-CL8YKZ4z.js +0 -12
- package/cli/storybook-dist/assets/RBIconButton.stories-C9uCobtq.js +0 -1
- package/cli/storybook-dist/assets/RBIcons.stories-Do543jrJ.js +0 -3
- package/cli/storybook-dist/assets/RBInlineLabelButton.stories-fo5Mkohq.js +0 -1
- package/cli/storybook-dist/assets/RBJoyStick.stories-DluMn4KO.js +0 -11
- package/cli/storybook-dist/assets/RBLabelButton.stories-BisTX_da.js +0 -1
- package/cli/storybook-dist/assets/RBLight.stories-xs5nahD4.js +0 -1
- package/cli/storybook-dist/assets/RBMenubar.stories-Cn-6_-AJ.js +0 -3
- package/cli/storybook-dist/assets/RBMultiRangeInput.stories-5mfoXGC6.js +0 -14
- package/cli/storybook-dist/assets/RBPagination.stories-bcc-iOO2.js +0 -17
- package/cli/storybook-dist/assets/RBPopover.stories-DCMSSLeN.js +0 -11
- package/cli/storybook-dist/assets/RBPrompt.stories-D2qyZEoz.js +0 -20
- package/cli/storybook-dist/assets/RBRadioBox.stories-CDc_WkjI.js +0 -1
- package/cli/storybook-dist/assets/RBRadioButton.stories-BCu10IWf.js +0 -11
- package/cli/storybook-dist/assets/RBRangeDateInput.stories-DoRRHADM.js +0 -15
- package/cli/storybook-dist/assets/RBResizable.stories-ClPOvGPZ.js +0 -20
- package/cli/storybook-dist/assets/RBSelectBox.stories-Chc3359A.js +0 -6
- package/cli/storybook-dist/assets/RBSheet.stories-GG_2M19S.js +0 -39
- package/cli/storybook-dist/assets/RBSideNumberInput.stories-wZw5a4Cc.js +0 -5
- package/cli/storybook-dist/assets/RBSlider.stories-B5KtjUqu.js +0 -5
- package/cli/storybook-dist/assets/RBSpinner.stories-D1dZ-X8O.js +0 -14
- package/cli/storybook-dist/assets/RBTab.stories-DQNP4u6i.js +0 -1
- package/cli/storybook-dist/assets/RBTable.stories-Bncn0LRp.js +0 -62
- package/cli/storybook-dist/assets/RBTableRow.stories-DGfPZAVt.js +0 -11
- package/cli/storybook-dist/assets/RBTabs.stories-KM5-HtJk.js +0 -21
- package/cli/storybook-dist/assets/RBTextArea.stories-SBLKQy3l.js +0 -5
- package/cli/storybook-dist/assets/RBTextField.stories-BtD9AYDp.js +0 -45
- package/cli/storybook-dist/assets/RBTextInput.stories-BAV7UURd.js +0 -5
- package/cli/storybook-dist/assets/RBToast.stories-D9Hx1Gqv.js +0 -11
- package/cli/storybook-dist/assets/RBToggle.stories-BsxBo-P4.js +0 -12
- package/cli/storybook-dist/assets/RBToggleGroup.stories-DtpdGy8j.js +0 -17
- package/cli/storybook-dist/assets/RBTooltip.stories-CxLxUTJD.js +0 -9
- package/cli/storybook-dist/assets/RainbowUiProvider-CsMXuxM1.js +0 -31
- package/cli/storybook-dist/assets/preview-CvPmpqMH.css +0 -1
- package/cli/storybook-dist/assets/preview-D_vClpb_.js +0 -3
- package/cli/storybook-dist/assets/react-resizable-panels.browser-CvAJUcrm.js +0 -72
- /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
|
-
|
|
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).
|
package/cli/bin/muscat-pk.mjs
CHANGED
|
@@ -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 {
|
|
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":
|
package/cli/src/bridge.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/cli/src/devBack.mjs
CHANGED
|
@@ -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")
|
package/cli/src/manifest.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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.
|