@rainbow-robotics/muscat-plugin-kit 1.10.3-dev.2 → 1.10.3-dev.3

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 (31) hide show
  1. package/README.ko.md +1059 -0
  2. package/README.md +927 -150
  3. package/cli/README.md +1 -1
  4. package/cli/bin/muscat-pk.mjs +1 -1
  5. package/cli/src/build.mjs +9 -13
  6. package/cli/src/dev.mjs +4 -2
  7. package/cli/src/mcp.mjs +17 -3
  8. package/cli/src/muscatLocal.mjs +2 -2
  9. package/cli/src/pluginDevServer.mjs +1 -1
  10. package/cli/storybook-dist/assets/{Color-YHDXOIA2-JZmvrBu2.js → Color-YHDXOIA2-Cp8LHsF-.js} +1 -1
  11. package/cli/storybook-dist/assets/{Docs-CHh5amiz.js → Docs-B2ox3o_G.js} +1 -1
  12. package/cli/storybook-dist/assets/{Docs-DdU-wsGN.js → Docs-By11aG_M.js} +1 -1
  13. package/cli/storybook-dist/assets/{Docs-DjjxchlZ.js → Docs-Kg1Im4XT.js} +1 -1
  14. package/cli/storybook-dist/assets/{Docs-GWKBJz_w.js → Docs-LeW_BIsw.js} +1 -1
  15. package/cli/storybook-dist/assets/{Docs-DrMHaIyt.js → Docs-UJwiVwO2.js} +1 -1
  16. package/cli/storybook-dist/assets/{Docs-DXJ25Kqq.js → Docs-ik2AhpV0.js} +1 -1
  17. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-DNcXzd9o.js → DocsRenderer-CFRXHY34-DPFneFUi.js} +1 -1
  18. package/cli/storybook-dist/assets/{entry-preview-Du01HPfn.js → entry-preview-BTw3Jh8E.js} +1 -1
  19. package/cli/storybook-dist/assets/{iframe-DFBATQRS.js → iframe-BqPQIkPQ.js} +2 -2
  20. package/cli/storybook-dist/assets/{index-DG8XddNr.js → index-k_VkBflK.js} +3 -3
  21. package/cli/storybook-dist/assets/{preview-donYBfDJ.js → preview-C3ctYQQj.js} +2 -2
  22. package/cli/storybook-dist/assets/preview-Cog7BnbD.js +3 -0
  23. package/cli/storybook-dist/iframe.html +1 -1
  24. package/cli/storybook-dist/project.json +1 -1
  25. package/cli/templates/common/README.md +2 -3
  26. package/cli/templates/common/requirements.txt +3 -3
  27. package/cli/templates/cpp/cpp/CMakeLists.txt +1 -1
  28. package/cli/templates/readme-cpp/README.md +3 -4
  29. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +1 -1
  30. package/package.json +5 -4
  31. package/cli/storybook-dist/assets/preview-e0NAgyy_.js +0 -3
package/README.md CHANGED
@@ -1,50 +1,57 @@
1
1
  # @rainbow-robotics/muscat-plugin-kit
2
2
 
3
- CLI for developing Muscat plugins. Provides scaffolding, build/pack, and dev servers (UI + backend).
3
+ CLI for developing Muscat plugins (`muscat-pk`): scaffolding, dev servers (UI + backend), build/pack, and the SDK references a plugin author needs.
4
+
5
+ [한국어 문서 (README.ko.md)](./README.ko.md)
6
+
7
+ ## Contents
8
+
9
+ 1. [Install](#1-install)
10
+ 2. [Quick start](#2-quick-start)
11
+ 3. [Commands](#3-commands)
12
+ 4. [Project layout](#4-project-layout)
13
+ 5. [manifest.json](#5-manifestjson)
14
+ 6. [Plugin UI SDK (`useRb`)](#6-plugin-ui-sdk-userb)
15
+ 7. [Python plugin SDK](#7-python-plugin-sdk)
16
+ 8. [Other backend runtimes (C++ / ROS 2)](#8-other-backend-runtimes-c--ros-2)
17
+ 9. [Development environment](#9-development-environment)
18
+ 10. [Styles](#10-styles)
19
+ 11. [AI coding agent setup (`muscat-pk mcp`)](#11-ai-coding-agent-setup-muscat-pk-mcp)
20
+ 12. [Environment variables](#12-environment-variables)
21
+ 13. [Troubleshooting](#13-troubleshooting)
4
22
 
5
- ## Install
6
-
7
- ### Option 1 — npm (requires Node.js 20+)
23
+ ---
8
24
 
9
- ```bash
10
- # latest stable
11
- npm install -g @rainbow-robotics/muscat-plugin-kit
25
+ ## 1. Install
12
26
 
13
- # dev channel (latest dev build)
14
- npm install -g @rainbow-robotics/muscat-plugin-kit@dev
27
+ ### npm (Node.js 20+)
15
28
 
16
- # specific version
17
- npm install -g @rainbow-robotics/muscat-plugin-kit@1.2.3
29
+ ```bash
30
+ npm install -g @rainbow-robotics/muscat-plugin-kit # latest stable
31
+ npm install -g @rainbow-robotics/muscat-plugin-kit@dev # dev channel
32
+ npm install -g @rainbow-robotics/muscat-plugin-kit@1.2.3 # specific version
18
33
  ```
19
34
 
20
- ### Option 2 — S3 install script (no Node.js required)
35
+ ### Install script (no Node.js required)
21
36
 
22
37
  macOS / Linux / WSL:
23
38
 
24
39
  ```bash
25
- # latest stable
26
40
  curl -fsSL https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/install.sh | sh
27
-
28
- # dev channel
29
41
  curl -fsSL https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/install.sh | MUSCAT_PK_TAG=dev sh
30
-
31
- # specific version
32
42
  curl -fsSL https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/install.sh | MUSCAT_PK_TAG=1.2.3 sh
33
43
  ```
34
44
 
35
45
  Windows PowerShell:
36
46
 
37
47
  ```powershell
38
- # latest stable
39
48
  irm https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/install.ps1 | iex
40
-
41
- # dev channel
42
49
  $env:MUSCAT_PK_TAG="dev"; irm https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/install.ps1 | iex
43
50
  ```
44
51
 
45
- > The install script uses npm when Node.js 20+ is present, otherwise it downloads a self-contained binary from S3.
52
+ The script uses npm when Node.js 20+ is present, otherwise it downloads a self-contained binary.
46
53
 
47
- ### Option 3 — direct binary download
54
+ ### Direct binary download
48
55
 
49
56
  ```
50
57
  https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/v{version}/{binary}
@@ -59,129 +66,317 @@ https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/dev/{bi
59
66
  | macOS x64 | `muscat-pk-darwin-x64` |
60
67
  | Windows x64 | `muscat-pk-win-x64.exe` |
61
68
 
62
- Channel pointers:
69
+ Channel pointers: `.../muscat-plugin-kit/latest-version.txt` (stable), `.../muscat-plugin-kit/dev-version.txt` (dev).
63
70
 
64
- ```
65
- # latest stable version string
66
- https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/latest-version.txt
71
+ ### Requirements
67
72
 
68
- # latest dev version string
69
- https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/dev-version.txt
70
- ```
73
+ | Tool | Needed for |
74
+ |---|---|
75
+ | Node.js 20+ (or the binary) | the CLI, UI build |
76
+ | Docker | every backend runtime except `none` (`dev:back`, `dev`, `build`) |
77
+ | `uv` or Python 3.12 (optional) | IDE-only `.venv` for Python linting |
71
78
 
72
79
  ### Uninstall
73
80
 
74
- Regardless of how it was installed, `muscat-pk` can remove itself:
75
-
76
81
  ```bash
77
- muscat-pk uninstall
82
+ muscat-pk uninstall # works for npm and binary installs
83
+ npm uninstall -g @rainbow-robotics/muscat-plugin-kit # manual, npm
84
+ sudo rm -f /usr/local/bin/muscat-pk # manual, binary
78
85
  ```
79
86
 
80
- Manual removal:
87
+ ---
81
88
 
82
- ```bash
83
- # if installed via npm
84
- npm uninstall -g @rainbow-robotics/muscat-plugin-kit
89
+ ## 2. Quick start
85
90
 
86
- # if installed via the S3 binary (default macOS/Linux path)
87
- sudo rm -f /usr/local/bin/muscat-pk
91
+ ```bash
92
+ muscat-pk new my-plugin # scaffold (asks author, email, description, type, package manager)
93
+ cd my-plugin
94
+ muscat-pk dev # UI preview + backend together
95
+ muscat-pk build # dist/ + distributable ZIP
88
96
  ```
89
97
 
98
+ Install the ZIP from the robot's Muscat plugin page.
99
+
90
100
  ---
91
101
 
92
- ## Quick start
102
+ ## 3. Commands
103
+
104
+ | Command | Description |
105
+ |---|---|
106
+ | `muscat-pk new [dir]` | Scaffold a plugin project |
107
+ | `muscat-pk type change` | Switch the backend type |
108
+ | `muscat-pk dev` | Local Muscat runtime + `dev:ui` + `dev:back` |
109
+ | `muscat-pk dev:ui` | UI preview in a mini-Muscat (preview-host) |
110
+ | `muscat-pk dev:back` | Backend only, in Docker, with hot reload |
111
+ | `muscat-pk build` | Build, lock Python deps, vendor wheels, ZIP |
112
+ | `muscat-pk update` | Update the kit, `plugin-ui` and `preview-host` |
113
+ | `muscat-pk sdk <list\|update [version]>` | Manage the `rainbow-rb-sdk` version |
114
+ | `muscat-pk storybook` | Browse the bundled rb-components Storybook |
115
+ | `muscat-pk mcp` | MCP server for AI coding agents |
116
+ | `muscat-pk uninstall` | Remove the CLI |
117
+ | `muscat-pk version` | Print the running version (`-v`, `--version`) |
118
+ | `muscat-pk completion [shell]` | Print a shell completion script |
119
+
120
+ `muscat-pk <command> --help` prints the options of a command.
121
+
122
+ ### 3.1 `new`
93
123
 
94
124
  ```bash
95
- muscat-pk new my-plugin # scaffold a plugin project (choose python / cpp / ros2 / ros2-cpp)
96
- cd my-plugin
125
+ muscat-pk new [dir] [--type python|cpp|ros2|ros2-cpp] [--pm npm|yarn|pnpm|bun]
126
+ [--author <name>] [--email <email>] [--description <text>]
127
+ [--yes] [--no-install]
128
+ ```
129
+
130
+ | Option | Meaning |
131
+ |---|---|
132
+ | `dir` | target directory (must be empty); asked when omitted |
133
+ | `--type` | backend type: `python` (default), `cpp`, `ros2` (rclpy), `ros2-cpp` (rclcpp) |
134
+ | `--pm` | front-end package manager; defaults to `yarn` inside a Yarn workspace, else `npm` |
135
+ | `--author` / `--email` / `--description` | manifest fields (email is required by the manifest) |
136
+ | `--yes` | no prompts; unspecified values use defaults |
137
+ | `--no-install` | skip `npm install` |
138
+
139
+ Python projects also get an IDE-only `.venv` (see [9.6](#96-ide-support-python)).
97
140
 
98
- muscat-pk dev # run UI + backend together
99
- muscat-pk dev:ui # UI preview only
100
- muscat-pk dev:back # backend only (Docker: python/cpp/ros2)
141
+ ### 3.2 `type change`
101
142
 
102
- muscat-pk build # build dist/ and produce a distributable ZIP (skip the ZIP with --no-zip)
143
+ ```bash
144
+ muscat-pk type change [--to python|cpp|ros2|ros2-cpp] [--readme]
103
145
  ```
104
146
 
105
- ## Command list
147
+ Rewrites `manifest.json` → `runtime` and adds the new backend's files. Files of the old type (`cpp/`, `ros2_ws/`, …) are left in place. `--readme` also replaces the README with the new type's.
106
148
 
107
- | Command | Description |
149
+ ### 3.3 `dev`, `dev:ui`, `dev:back`
150
+
151
+ ```bash
152
+ muscat-pk dev [--robot-ip <ip>] [--keep-id]
153
+ muscat-pk dev:ui [--robot-ip <ip>] [--port <n>] [--keep-id]
154
+ muscat-pk dev:back [--keep-id]
155
+ ```
156
+
157
+ | Command | What runs |
108
158
  |---|---|
109
- | `muscat-pk new [dir]` | Scaffold a plugin project (also creates an IDE-only python `.venv`) |
110
- | `muscat-pk type change` | Switch backend type (`python` / `cpp` / `ros2` / `ros2-cpp`) |
111
- | `muscat-pk build` | Build `ui/*.tsx` → `dist/*.js` (one per `contributes[].ui` entry in the manifest) and produce a distributable ZIP |
112
- | `muscat-pk dev` | UI + backend dev server together |
113
- | `muscat-pk dev:ui` | Mini-Muscat preview server |
114
- | `muscat-pk dev:back` | Backend dev server (Docker, hot-reload) |
115
- | `muscat-pk storybook` | Browse the bundled rb-components Storybook |
116
- | `muscat-pk mcp` | Run an MCP server for AI coding agents (exposes rb-components/design-token/SDK docs) |
117
- | `muscat-pk update` | Update the kit + plugin-ui + preview-host to the latest version; lists old-API UI code to fix |
118
- | `muscat-pk sdk <list\|update>` | Manage the `rainbow-rb-sdk` version — python: `requirements.txt`, C++ (cpp / ros2-cpp): `find_package(rb_sdk <ver> EXACT REQUIRED)` in `CMakeLists.txt` |
119
- | `muscat-pk version` | Print the installed CLI version (`-v` / `--version` also work) |
120
- | `muscat-pk completion [shell]` | Print a shell completion script (`bash` / `zsh` / `fish` / `powershell`; defaults to `$SHELL`) |
159
+ | `dev` | a local Muscat runtime when no robot is given, then `dev:ui` and `dev:back` |
160
+ | `dev:ui` | the plugin's Vite dev server + preview-host at `http://localhost:4180/plugin-dev` (next free port) |
161
+ | `dev:back` | the backend in a Docker container matching the robot. Python restarts on `py/**/*.py` changes (watchfiles) |
121
162
 
122
- ### Update policy
163
+ - Robot IP comes from `--robot-ip`, `MUSCAT_ROBOT_HOST`, or a prompt (leave blank for local).
164
+ - Against a remote robot the plugin runs under a dev id (see [9.3](#93-several-developers-on-one-robot)); `--keep-id` uses the manifest id.
165
+ - The manifest is validated before start; undeclared listening ports stop the backend (see [5.6](#56-ports)).
123
166
 
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:
167
+ ### 3.4 `build`
168
+
169
+ ```bash
170
+ muscat-pk build [--arch amd64|arm64|both]
171
+ ```
126
172
 
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.
173
+ Steps:
132
174
 
133
- **`update` only touches what you're in.**
175
+ 1. Asks for a new `manifest.json` version (blank keeps the current one).
176
+ 2. Validates the manifest.
177
+ 3. `vite build` of every `contributes[].ui` entry → `dist/<entry>.js`.
178
+ 4. Backend:
179
+ - python: pins unpinned lines of `requirements.txt` to the latest PyPI version, writes `dist/requirements.lock` (`uv pip compile`), and vendors wheels into `dist/wheels/` per arch.
180
+ - cpp: CMake build in Docker → `dist/<arch>/<entry>` plus `dist/<arch>/lib/librb_sdk.so`.
181
+ - ros2: colcon build in Docker → `dist/ros2_ws/install-<arch>/`.
182
+ 5. Packs `dist/` into `<project dir>-<version>.zip` in the project root.
134
183
 
135
- | Where you run it | What it updates |
184
+ | Option | Meaning |
136
185
  |---|---|
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) |
186
+ | `--arch` | target arch; asked in a TTY, `both` otherwise |
187
+
188
+ ### 3.5 `update`
140
189
 
141
190
  ```bash
142
- muscat-pk update # this project only
191
+ muscat-pk update # this project only (when it has a local install)
143
192
  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
193
+ muscat-pk update dev # follow the dev channel
146
194
  ```
147
195
 
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
196
+ | Where you run it | What it updates |
197
+ |---|---|
198
+ | Inside a project with a local install | that project's `muscat-plugin-kit` + `plugin-ui` |
199
+ | Same, with `--global` | the above + the global CLI |
200
+ | Anywhere else | the global CLI (and local deps if inside a project) |
152
201
 
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.
202
+ After updating it lists UI code that still uses removed APIs. Fix them (or run `check_project` through `muscat-pk mcp`).
157
203
 
158
- Move images to a machine with no internet:
204
+ ### 3.6 `sdk`
159
205
 
160
206
  ```bash
161
- # on a machine with network
162
- docker save muscat-pk-python-dev muscat-pk-runtime | gzip > muscat-pk-images.tar.gz
163
-
164
- # on the offline machine
165
- gunzip -c muscat-pk-images.tar.gz | docker load
207
+ muscat-pk sdk list # published rainbow-rb-sdk versions
208
+ muscat-pk sdk update [version] # latest when omitted
166
209
  ```
167
210
 
168
- Force a rebuild with `MUSCAT_PK_REBUILD_IMAGE=1`.
211
+ - python: rewrites every `rainbow-rb-*==` pin in `requirements.txt` together (they pin each other exactly).
212
+ - C++ (`cpp`, `ros2-cpp`): rewrites `find_package(rb_sdk <ver> EXACT REQUIRED)` in `CMakeLists.txt`.
213
+
214
+ ### 3.7 `storybook`, `mcp`, `completion`, `version`
169
215
 
170
- ### Shell completion
216
+ ```bash
217
+ muscat-pk storybook [--port 6007] [--rebuild]
218
+ muscat-pk mcp [--install codex]
219
+ muscat-pk completion [bash|zsh|fish|powershell]
220
+ muscat-pk version
221
+ ```
222
+
223
+ `completion` prints a Tab-completion script for your shell — once registered, `muscat-pk d<Tab>` offers `dev`, `dev:ui`, `dev:back`. It only prints the script; append it to your shell's rc file as below. `[shell]` is `bash`, `zsh`, `fish` or `powershell`, defaulting to `$SHELL`. Windows `cmd.exe` has no completion hook — use PowerShell, Git Bash or WSL. Optional convenience; nothing depends on it.
171
224
 
172
- | OS / shell | Command |
225
+ | OS / shell | Completion setup |
173
226
  |---|---|
174
227
  | macOS (zsh) | `muscat-pk completion zsh >> ~/.zshrc && source ~/.zshrc` |
175
228
  | Linux (bash) | `muscat-pk completion bash >> ~/.bashrc && source ~/.bashrc` |
176
229
  | fish | `muscat-pk completion fish > ~/.config/fish/completions/muscat-pk.fish` |
177
- | Windows (PowerShell) | `muscat-pk completion powershell >> $PROFILE; . $PROFILE` |
230
+ | PowerShell | `muscat-pk completion powershell >> $PROFILE; . $PROFILE` |
231
+
232
+ ---
233
+
234
+ ## 4. Project layout
235
+
236
+ ```
237
+ my-plugin/
238
+ ├─ ui/
239
+ │ ├─ content.tsx # UI entry (one file per contributes[].ui), `export default App`
240
+ │ └─ style.css # auto-loaded for every entry
241
+ ├─ py/main.py # python backend entry (runtime.entry)
242
+ ├─ cpp/ # cpp backend (CMake) — cpp type
243
+ ├─ ros2_ws/ # colcon workspace — ros2 / ros2-cpp type
244
+ ├─ assets/icon.svg
245
+ ├─ manifest.json
246
+ ├─ requirements.txt # python deps (pinned on build)
247
+ ├─ package.json # UI deps (not shipped)
248
+ ├─ vite.config.ts # must use the muscat() preset
249
+ └─ README.md # copied into dist/
250
+ ```
178
251
 
179
- Windows `cmd.exe` has no completion mechanism to hook into — use PowerShell, Git Bash, or WSL.
252
+ `vite.config.ts`:
180
253
 
181
- ## Backend port policy
254
+ ```ts
255
+ import { defineConfig } from "vite";
256
+ import tailwindcss from "@tailwindcss/vite";
257
+ import { muscat } from "@rainbow-robotics/muscat-plugin-kit/vite";
182
258
 
183
- A plugin backend that opens a TCP listener must declare every such port in
184
- `manifest.json`:
259
+ export default defineConfig({ plugins: [tailwindcss(), muscat()] });
260
+ ```
261
+
262
+ The ZIP contains only the plugin's own code and assets. `@rainbow-robotics/plugin-ui`, `react` and `react-dom` come from the host's import map.
263
+
264
+ ---
265
+
266
+ ## 5. manifest.json
267
+
268
+ Validated by `muscat-pk dev`, `dev:back` and `build`.
269
+
270
+ ### 5.1 Full example
271
+
272
+ ```json
273
+ {
274
+ "id": "com.example.my-plugin",
275
+ "name": "My Plugin",
276
+ "version": "1.0.0",
277
+ "minMuscatVersion": "1.0.0",
278
+ "author": "Kim",
279
+ "email": "kim@example.com",
280
+ "company": "Example Inc.",
281
+ "description": { "en": "Gripper helper", "ko": "그리퍼 도우미" },
282
+ "icons": "assets/icon.svg",
283
+ "targetModels": "all",
284
+ "targetCategorys": ["manipulate"],
285
+ "contributes": [
286
+ { "point": "toolbar", "icon": "assets/icon.svg", "label": { "en": "My Plugin" }, "ui": "content", "opens": "popover" }
287
+ ],
288
+ "variables": [
289
+ { "name": "RBP_grip_force", "type": "number", "init": 0.5, "description": { "en": "Gripper force (0–1)" } }
290
+ ],
291
+ "ports": [28600],
292
+ "runtime": { "type": "python", "entry": "main.py" }
293
+ }
294
+ ```
295
+
296
+ ### 5.2 Top-level fields
297
+
298
+ | Field | Required | Rule |
299
+ |---|---|---|
300
+ | `id` | yes | reverse-domain: `.`-separated parts of lowercase letters/digits joined by single `-`, each starting with a letter (`com.example.my-plugin`). No `_` |
301
+ | `name`, `version`, `minMuscatVersion` | yes | non-empty strings |
302
+ | `author`, `email` | yes | non-empty strings |
303
+ | `company` | no | string |
304
+ | `description` | yes | string or i18n object |
305
+ | `icons` | yes | SVG path string, or `{ "16", "48", "128" }` for PNG/JPG (all three) |
306
+ | `targetModels` / `targetCategorys` | at least one | `"all"` or a non-empty array. Categories: `manipulate`, `amr`, `rby1`, `vision` |
307
+ | `contributes` | yes | see 5.3 |
308
+ | `variables` | no | see 5.5 |
309
+ | `ports` | no | see 5.6 |
310
+ | `runtime` | yes | see 5.7 |
311
+
312
+ i18n object: `en` is required; allowed keys `ko`, `en`, `ja`, `zh`, `vi`, `th`, `es`.
313
+
314
+ ### 5.3 `contributes`
315
+
316
+ | Field | Rule |
317
+ |---|---|
318
+ | `point` | `toolbar`, `lnb`, `move.panel`, `program.panel`, `program.monitor` |
319
+ | `ui` | UI entry name under `ui/` without extension (default `content`); unique across entries |
320
+ | `label` | required, string or i18n object |
321
+ | `icon` | icon path |
322
+ | `opens` | toolbar only: `popover`, `panel`, `page` |
323
+ | `commandInfo` | required for `program.panel` (5.4) |
324
+
325
+ At least one `toolbar` entry is required.
326
+
327
+ | Point | Where it shows |
328
+ |---|---|
329
+ | `toolbar` | header toolbar button; opens as popover / panel / page |
330
+ | `lnb` | left navigation |
331
+ | `move.panel` | Move page panel |
332
+ | `program.panel` | a custom command in the Program page's step tree; its `ui` is the step's edit form |
333
+ | `program.monitor` | a tab in the Program page's monitor panel (next to General / Digital I/O / Monitor / Log), rendered per task |
334
+
335
+ ### 5.4 `commandInfo` (`program.panel`)
336
+
337
+ ```json
338
+ {
339
+ "point": "program.panel",
340
+ "ui": "program",
341
+ "label": { "en": "My Step" },
342
+ "commandInfo": {
343
+ "name": "My Step",
344
+ "icon": "assets/icon.svg",
345
+ "funcName": "rb_program_sdk.plugin_execute",
346
+ "args": { "message": "hello" }
347
+ }
348
+ }
349
+ ```
350
+
351
+ | Field | Type | Meaning |
352
+ |---|---|---|
353
+ | `name` | string, required | command name in the tree |
354
+ | `icon` | string, required | icon path |
355
+ | `iconClassName` | string | extra icon class |
356
+ | `funcName` | string | function the executor calls (`rb_program_sdk.plugin_execute` for Python backends) |
357
+ | `args` | object | default step args, passed with `plugin_id` to the backend |
358
+ | `disabled`, `syncGroupId`, `disableCopy`, `disableDelete`, `requireChildren`, `requireParent` | boolean | tree editing rules |
359
+ | `allowChildren`, `allowParents`, `notAllowChildren` | string[] | allowed / forbidden neighbour commands |
360
+ | `childrenCommand` | string | command created as a child |
361
+
362
+ ### 5.5 `variables` (PyFM)
363
+
364
+ Variables the plugin exposes to PyFM programs. The Program page's variable picker lists only declared ones.
365
+
366
+ ```json
367
+ { "name": "RBP_target_pose", "type": "array", "init": [0, 0, 0], "description": { "en": "Target pose" } }
368
+ ```
369
+
370
+ | Field | Required | Rule |
371
+ |---|---|---|
372
+ | `name` | yes | `RBP_` + letters/digits/underscore; unique per plugin |
373
+ | `type` | yes | `number`, `string`, `boolean`, `array` |
374
+ | `init` | yes | initial value matching `type` |
375
+ | `description` | no | string or i18n object — write one, the UI shows nothing otherwise |
376
+
377
+ ### 5.6 `ports`
378
+
379
+ A backend that opens a TCP listener must declare every port:
185
380
 
186
381
  ```json
187
382
  { "ports": [28600, 28601] }
@@ -189,80 +384,635 @@ A plugin backend that opens a TCP listener must declare every such port in
189
384
 
190
385
  | Rule | Enforced by |
191
386
  |---|---|
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) |
387
+ | Only `28600-28999`, `number[]` | manifest validation |
388
+ | Reserved ports rejected: `502`, `2222`, `4840`, `5170-5179`, `8000-8999`, `10000`, `30004`, `34964`, `44818`, `50000-50005`, `50051` | manifest validation |
389
+ | Every listening port must be declared | `dev` / `dev:back` runtime monitor stops the backend on the first undeclared port |
390
+ | No two enabled plugins share a port | the robot, at install (HTTP 409) |
196
391
 
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.
392
+ The kit's own dev ports (gateway `10000`, the Zenoh dev peer port) are excluded. Under `muscat-pk dev` declared ports are published on `127.0.0.1`; a busy host port is skipped with a warning.
200
393
 
201
- `muscat-pk`'s own dev plumbing (the plugin gateway on `10000`, the Zenoh dev
202
- peer port) is excluded from the check — it isn't the plugin's traffic.
394
+ ### 5.7 `runtime`
203
395
 
204
- Under `muscat-pk dev` declared ports are published to `127.0.0.1` from the local
205
- Muscat container (the backend shares its network), so you can reach the backend
206
- from the host.
396
+ | runtime | Source | Required fields |
397
+ |---|---|---|
398
+ | `{ "type": "none" }` | — | UI-only plugin |
399
+ | `{ "type": "python", "entry": "main.py" }` | `py/` | `entry` |
400
+ | `{ "type": "cpp", "entry": "<executable>" }` | `cpp/` | `entry` = built executable name |
401
+ | `{ "type": "ros2", "rosClient": "rclpy", "package": "<pkg>", "launch": "<pkg>_node" }` | `ros2_ws/` | `rosClient`, `package`, `launch` |
402
+ | `{ "type": "ros2", "rosClient": "rclcpp", "package": "<pkg>", "launch": "<pkg>_node" }` | `ros2_ws/` | same |
207
403
 
208
- ## Variables (pyfm)
404
+ ---
209
405
 
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:
406
+ ## 6. Plugin UI SDK (`useRb`)
213
407
 
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
- ]
408
+ Package `@rainbow-robotics/plugin-ui`:
409
+
410
+ | Import | Contents |
411
+ |---|---|
412
+ | `@rainbow-robotics/plugin-ui/react` | Muscat components (`RB*`), `useRb`, `useRbFormData`, `useRbSave`, `useRbStorage`, `createRbStore` |
413
+ | `@rainbow-robotics/plugin-ui/sdk` | the same hooks without the components, `RbSdk` / `RbStore` / `RbSetState` types |
414
+
415
+ ### 6.1 Entry component
416
+
417
+ Each top-level `ui/*.tsx` default-exports a React component. The host mounts it and provides `rb` through context.
418
+
419
+ ```tsx
420
+ import { useRb } from "@rainbow-robotics/plugin-ui/sdk";
421
+ import { RBLabelButton } from "@rainbow-robotics/plugin-ui/react";
422
+
423
+ export default function App() {
424
+ const rb = useRb();
425
+ return <RBLabelButton onClick={() => rb.action("grip", { width: 20 })}>Grip</RBLabelButton>;
220
426
  }
221
427
  ```
222
428
 
223
- | Field | Required | Rule |
429
+ Use plugin-ui components instead of native `<button>`, `<input>`, `<select>`, `<textarea>`, `<table>`, `<dialog>`. Common mappings: button → `RBLabelButton` / `RBIconButton`, text input → `RBTextInput` / `RBTextField`, number → `RBSideNumberInput`, select → `RBSelectBox` + `Option`, checkbox → `RBCheckBox`, switch → `RBSwitch`, slider → `RBSlider`, table → `RBTable`, modal → `RBModal`, tabs → `RBTabs`. `muscat-pk storybook` and `muscat-pk mcp` show props and examples.
430
+
431
+ ### 6.2 Messaging with the backend
432
+
433
+ | UI | Backend (Python) | Pattern |
224
434
  |---|---|---|
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. |
435
+ | `rb.pub(topic, payload)` | `@plugin.subscribe(topic)` | fire-and-forget UI → backend |
436
+ | `rb.sub(topic, handler)` | `plugin.publish(topic, payload)` | backend → UI stream |
437
+ | `rb.action(topic, payload?)` | `@plugin.serve(topic)` | UI request, backend reply |
438
+ | `rb.serve(topic, handler)` | `plugin.call(topic, payload=...)` | backend request, UI reply |
229
439
 
230
- Validated by `muscat-pk build` / `dev` alongside the rest of the manifest.
440
+ ```tsx
441
+ const rb = useRb();
231
442
 
232
- ## IDE support (Python)
443
+ await rb.pub("event", { clicked: true });
233
444
 
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).
445
+ const sub = await rb.sub<{ ready: boolean }>("state", (s) => setReady(s.ready));
446
+ // cleanup: sub.undeclare()
235
447
 
236
- If it can't be created (no local Python 3 or `uv` found), scaffolding prints a warning and continues — Docker-based dev still works without it:
448
+ const res = await rb.action<{ ok: boolean }, { width: number }>("grip", { width: 20 });
237
449
 
450
+ await rb.serve("tool/ping", (payload) => ({ pong: payload }));
238
451
  ```
239
- Could not create .venv (no local Python/uv?) — skipping, Docker dev still works.
452
+
453
+ - Topics are scoped to the plugin id: `"state"` → `{pluginId}/state`. A leading `/` makes it absolute (`"/broadcast/x"`).
454
+ - All four take generics for payload / result types.
455
+ - Unsubscribe in a `useEffect` cleanup:
456
+
457
+ ```tsx
458
+ useEffect(() => {
459
+ let sub: { undeclare(): Promise<void> } | undefined;
460
+ rb.sub("state", setState).then((s) => (sub = s));
461
+ return () => void sub?.undeclare();
462
+ }, [rb]);
240
463
  ```
241
464
 
242
- Install `uv` (recommended) or a system Python 3.12, then re-run `muscat-pk new` (or `muscat-pk type change`) to create it.
465
+ ### 6.3 Host dialogs and toast
243
466
 
244
- ## AI coding agent setup (`muscat-pk mcp`)
467
+ | API | Resolves |
468
+ |---|---|
469
+ | `rb.toast({ title?, message, type?, actionLabel?, handleActionClick?, closeOnActionClick?, duration?, onClose? })` | — (`message`: string or ReactElement; `type`: `info`, `danger`, `warning`, `caution`, `success`) |
470
+ | `rb.alert({ title?, message, buttonText? })` | when closed |
471
+ | `rb.confirm({ title?, message, buttonText?: { yes, no } })` | `true` / `false` |
472
+ | `rb.prompt({ title?, message, value?, placeholder?, buttonText?: { yes, no } })` | entered string, or `undefined` when cancelled |
473
+
474
+ ```tsx
475
+ if (await rb.confirm({ title: "Run", message: "Start the backend action?" })) {
476
+ await rb.action("run");
477
+ rb.toast({ message: "Started", type: "success" });
478
+ }
479
+ ```
245
480
 
246
- `muscat-pk mcp` runs an MCP server (stdio) that gives an AI coding agent grounded info about `rb-components`, design tokens, and the Python `rainbow-rb-sdk` — actual prop types, real Storybook examples, and live-introspected SDK signatures/docstrings, instead of the agent guessing from training data. Use it while working inside a plugin project scaffolded by `muscat-pk new`.
481
+ ### 6.4 Program page (`rb.program`)
247
482
 
248
- ### Claude Code
483
+ | API | Meaning |
484
+ |---|---|
485
+ | `rb.program.getSteps(scope?)` | main step tree of the program open on the Program page |
486
+ | `rb.program.setSteps(steps, scope?)` | replace it — an unsaved edit the user then saves |
487
+ | `rb.program.getCatalog(scope?)` | the loaded robot's command catalog (names, default args, nesting rules) |
488
+
489
+ ```ts
490
+ type PluginProgramStep = {
491
+ command: string; // "MoveJ", "Point", "Wait", "Repeat", "Condition", ...
492
+ args?: Record<string, unknown>;
493
+ summary?: string;
494
+ fields?: Record<string, unknown>; // step-level fields, e.g. SubTask's subTaskType
495
+ children?: PluginProgramStep[];
496
+ };
497
+ type PluginProgramScope = { taskId?: string; robotModel?: string };
498
+ ```
249
499
 
250
- Claude Code reads a project-scoped `.mcp.json` at the plugin's root:
500
+ Without `scope` the host uses the first main task. In a `program.monitor` tab `rb.data` is `{ point: "program.monitor", taskId, robotModel }` — pass it as `scope` so a multi-component robot hits the right task.
251
501
 
252
- ```json
253
- {
254
- "mcpServers": {
255
- "muscat": {
256
- "command": "muscat-pk",
257
- "args": ["mcp"]
258
- }
259
- }
502
+ ### 6.5 Step form save/restore (`program.panel`)
503
+
504
+ | Hook | Meaning |
505
+ |---|---|
506
+ | `useRbFormData<T>()` | values saved in this step, `undefined` for a new step |
507
+ | `useRbSave(fn)` | registers the getter the host calls on Save; always sees current state. Return value must be JSON |
508
+
509
+ ```tsx
510
+ import { useState } from "react";
511
+ import { RBSlider, useRbFormData, useRbSave } from "@rainbow-robotics/plugin-ui/react";
512
+
513
+ type Args = { width: number };
514
+
515
+ export default function App() {
516
+ const saved = useRbFormData<Args>();
517
+ const [args, setArgs] = useState<Args>({ width: 20, ...saved });
518
+ useRbSave(() => ({ args, summary: `${args.width}mm` }));
519
+ return (
520
+ <RBSlider
521
+ value={[args.width]}
522
+ min={0}
523
+ max={100}
524
+ onValueChange={(values: number[]) => setArgs({ width: values[0] ?? 0 })}
525
+ />
526
+ );
260
527
  }
261
528
  ```
262
529
 
263
- ### Codex
530
+ The saved `args` reach the backend's `on_execute(args)` (see [7.3](#73-pluginprogramcontext--program-command)).
531
+
532
+ ### 6.6 Browser storage (`rb.storage`, `useRbStorage`, `createRbStore`)
533
+
534
+ Keep browser-side plugin data only here. Everything is namespaced `muscat-plugin:<pluginId>`, and the host deletes it when the plugin is uninstalled — data stored any other way (raw `localStorage`, your own IndexedDB) is never cleaned up.
535
+
536
+ | Backend | Use |
537
+ |---|---|
538
+ | `rb.storage` (= `rb.storage.indexedDB`) | default; structured values (objects, arrays, `Date`, `Blob`, …) |
539
+ | `rb.storage.local` | `localStorage`, JSON values, survives reloads |
540
+ | `rb.storage.session` | `sessionStorage`, JSON values, gone when the tab closes |
541
+ | `rb.storage.cookie` | ~4 KB per value and sent with every request to the host — tiny values only |
542
+
543
+ Every backend has the same API:
544
+
545
+ | Method | Meaning |
546
+ |---|---|
547
+ | `get<T>(key)` | value, or `undefined` |
548
+ | `set(key, value)` | save |
549
+ | `delete(key)` | remove |
550
+ | `keys()` | this plugin's keys |
551
+ | `subscribe(listener, key?)` | `({ kind, key, value }) => void` on every change made through this API — this screen, other screens of the plugin (toolbar panel, Program tab, …) and other browser tabs. `value` is `undefined` after a delete. Returns the unsubscribe function |
552
+ | `getItem` / `setItem` / `removeItem` | zustand `StateStorage` (raw strings) |
553
+
554
+ ```tsx
555
+ const rb = useRb();
556
+ await rb.storage.set("lastRun", { at: new Date(), ok: true });
557
+ const last = await rb.storage.get<{ at: Date; ok: boolean }>("lastRun");
558
+ await rb.storage.local.set("theme", "dark");
559
+ ```
560
+
561
+ #### `useRbStorage` — one stored value as React state
562
+
563
+ ```tsx
564
+ import { useRbStorage } from "@rainbow-robotics/plugin-ui/react";
565
+
566
+ type Draft = { name: string };
567
+
568
+ function Editor() {
569
+ const [draft, setDraft] = useRbStorage<Draft>("draft", { kind: "local", initial: { name: "" } });
570
+ // setDraft(next) saves; setDraft(undefined) deletes
571
+ return <RBTextInput value={draft?.name ?? ""} handleChange={(name: string) => void setDraft({ name })} />;
572
+ }
573
+ ```
574
+
575
+ - `useRbStorage<T>(key, { kind?, initial? })` → `[value, setValue]`. `kind` defaults to `"indexeddb"`.
576
+ - Re-renders when the value changes anywhere (other screens, other tabs).
577
+ - `value` is `initial` until the stored value has loaded.
578
+
579
+ #### `createRbStore` — persisted zustand-style store
580
+
581
+ Define once at module level, use from any screen of the plugin:
582
+
583
+ ```tsx
584
+ import { createRbStore } from "@rainbow-robotics/plugin-ui/react";
585
+
586
+ type Settings = {
587
+ speed: number;
588
+ points: string[];
589
+ setSpeed: (v: number) => void;
590
+ addPoint: (p: string) => void;
591
+ };
592
+
593
+ export const useSettings = createRbStore<Settings>(
594
+ (set, get) => ({
595
+ speed: 50,
596
+ points: [],
597
+ setSpeed: (speed) => set({ speed }),
598
+ addPoint: (p) => set((s) => ({ points: [...s.points, p] })),
599
+ }),
600
+ { name: "settings", storage: "local" },
601
+ );
602
+
603
+ function SpeedPanel() {
604
+ const speed = useSettings((s) => s.speed); // re-renders only when speed changes
605
+ const setSpeed = useSettings((s) => s.setSpeed);
606
+ return <RBSlider value={[speed]} min={0} max={100} onValueChange={(v: number[]) => setSpeed(v[0] ?? 0)} />;
607
+ }
608
+ ```
609
+
610
+ | Member | Meaning |
611
+ |---|---|
612
+ | `createRbStore<T>(init, { name, storage? })` | `init(set, get)` returns initial state + actions. `name` is the storage key; `storage` is a backend kind (default `"indexeddb"`) |
613
+ | `useStore()` / `useStore(selector)` | whole state / a slice (re-renders only when the slice changes) |
614
+ | `useStore.getState()` | current state, outside React |
615
+ | `useStore.setState(partial \| (s) => partial)` | merge a change, outside React |
616
+ | `useStore.subscribe((next, prev) => …)` | listen outside React; returns unsubscribe |
617
+
618
+ - State changes only through `set` / `setState`. Every change is saved and re-renders every screen and tab using the store.
619
+ - Functions (actions) are not saved; only data fields are.
620
+ - The saved data loads when the first component using the store mounts (the plugin id is known only then). Before that `getState()` returns the initial values.
621
+ - Changes are merged shallowly: set a new array/object (`[...s.points, p]`) instead of mutating it.
622
+
623
+ #### Using zustand `persist` directly
624
+
625
+ Every backend is a zustand `StateStorage`:
626
+
627
+ ```ts
628
+ import { persist, createJSONStorage } from "zustand/middleware";
629
+ persist(fn, { name: "settings", storage: createJSONStorage(() => rb.storage.local) });
630
+ ```
631
+
632
+ `zustand` is then a dependency of your plugin; `createRbStore` needs nothing extra.
633
+
634
+ ### 6.7 Other
635
+
636
+ | API | Meaning |
637
+ |---|---|
638
+ | `rb.data` | host-provided mount data (`program.monitor` scope, saved form values) |
639
+ | `rb.rosPeers.get()` | PCs allowed to reach this plugin's ROS 2 nodes |
640
+ | `rb.rosPeers.set(ips)` | save the list and restart the backend; rejects an invalid IP |
641
+
642
+ ---
643
+
644
+ ## 7. Python plugin SDK
645
+
646
+ Python backends (`runtime.type: "python"`, entry `py/main.py`) use `rainbow-rb-sdk`, pinned in `requirements.txt` and changed with `muscat-pk sdk update`. The robot's plugin container provides the SDK at runtime.
647
+
648
+ ```python
649
+ from rainbow.rb_sdk import RBPluginCall, PluginProgramContext, RBManipulateSDK, RBAmrSDK
650
+ from rainbow.rb_sdk.plugin import PluginRouteError
651
+ from rainbow.rb_sdk.rb_sdk_cpp import ConnectOptions
652
+ ```
653
+
654
+ Which API depends on the contribution point:
655
+
656
+ | Contribution point | Backend |
657
+ |---|---|
658
+ | `toolbar` / `lnb` / `move.panel` / `program.monitor` | `RBPluginCall` + robot SDKs (`RBManipulateSDK`, `RBAmrSDK`) called directly. No stop/pause/variable context |
659
+ | `program.panel` | `PluginProgramContext` — runs as a program step, tied to stop/pause and program variables |
660
+
661
+ ### 7.1 `RBPluginCall` — UI ↔ backend
662
+
663
+ Internal only (UI ↔ backend, plugin ↔ plugin); not reachable from outside Muscat. The plugin id comes from env `RB_PLUGIN_ID`.
664
+
665
+ ```python
666
+ plugin = RBPluginCall()
667
+
668
+ @plugin.serve("grip") # UI: await rb.action("grip", { width })
669
+ def grip(payload=None):
670
+ width = (payload or {}).get("width", 20)
671
+ plugin.publish("state", {"last_grip_width": width})
672
+ return {"ok": True, "width": width}
673
+
674
+ @plugin.subscribe("event") # UI: rb.pub("event", ...)
675
+ def on_event(payload=None):
676
+ print(payload)
677
+
678
+ plugin.publish("state", {"ready": True}) # UI: rb.sub("state", ...)
679
+ res = plugin.call("tool/ping", payload={"hi": 1}, timeout=3.0) # UI: rb.serve("tool/ping", ...)
680
+ ```
681
+
682
+ | Member | Meaning |
683
+ |---|---|
684
+ | `serve(keyexpr, handler=None)` | request/response handler; decorator or direct call. Return value = reply |
685
+ | `subscribe(topic, callback=None)` | receive publishes |
686
+ | `publish(topic, payload=None)` | push to subscribers |
687
+ | `call(keyexpr, *, payload, timeout=3.0)` | request the other side and wait |
688
+ | `unserve(keyexpr=None)` | remove one / all serve handlers |
689
+ | `unsubscribe(topic=None, callback=None)` | remove subscriptions |
690
+ | `plugin_id` | the plugin id in use (dev id under `muscat-pk dev`) |
691
+ | `connected` | transport connected |
692
+ | `data_dir` | persistent data directory, created on access. Survives updates and restarts; removed with the plugin. The install directory (cwd) is replaced on every update — keep state, caches and settings here |
693
+ | `router` | external endpoints (7.2) |
694
+
695
+ Payloads must be JSON-serialisable. Handlers may be sync or async.
696
+
697
+ ### 7.2 `plugin.router` — external endpoints
698
+
699
+ Exposes `common/plugin/{plugin_id}/{path}` over REST, Socket.IO, TCP, MQTT and WebRTC. The running Muscat (`common`) picks it up without a restart; it disappears when the plugin stops. Requires `rainbow-rb-sdk` ≥ 0.0.9.dev5 on the robot (older: `AttributeError`).
700
+
701
+ ```python
702
+ @plugin.router.on("/hello") # every transport
703
+ def hello(payload, meta):
704
+ return {"msg": f"hi {payload.get('name')}", "via": meta["transport"]}
705
+
706
+ @plugin.router.on("/admin", explicit_routes=["api"]) # REST only
707
+ def admin(payload, meta):
708
+ if not (meta.get("principal") or {}).get("is_admin"):
709
+ raise PluginRouteError(403, "admin only")
710
+ return {"ok": True}
711
+
712
+ @plugin.router.subscribe("/cmd", explicit_events=["tcp"])
713
+ def on_cmd(payload):
714
+ ...
715
+
716
+ plugin.router.emit("/state", {"busy": True})
717
+ ```
718
+
719
+ | API | Meaning | External side |
720
+ |---|---|---|
721
+ | `router.on(path, explicit_routes=None)` | request/response | calls `common/plugin/{id}/{path}` (REST: `POST`) |
722
+ | `router.subscribe(topic, explicit_events=None)` | receive external publishes | publishes to `common/plugin/{id}/{topic}` |
723
+ | `router.emit(topic, payload=None)` | push to external subscribers | subscribes to `common/plugin/{id}/{topic}` |
724
+
725
+ Handler contract:
726
+
727
+ - Signature `(payload, meta)`, `(payload)` or `()` — only declared arguments are injected. Sync or async.
728
+ - `payload`: what the caller sent (REST: JSON body merged with query params).
729
+ - `meta`: `{"transport": "api"|"socket"|"tcp"|"mqtt"|"webrtc", "principal": dict | None}`. With the login policy on, `principal` is the token subject. Role checks are the plugin's job.
730
+ - Return value = response body (JSON). `None` → empty.
731
+ - `raise PluginRouteError(code, message)` sets the status; any other exception → 500 with `str(exc)`.
732
+ - `subscribe` handlers take `(payload)` and return nothing.
733
+
734
+ Rules:
735
+
736
+ - Write only the path; the SDK adds `plugin_id`. Multi-segment paths work (`/tool/echo`). Declaring a path twice → `ValueError`.
737
+ - `explicit_routes` / `explicit_events`: `None` = every transport. Names `api | socket | tcp | mqtt | webrtc`; `subscribe` accepts only `tcp | mqtt | webrtc`. Unknown → `ValueError`. A disallowed transport gets 404.
738
+ - `on` and `subscribe` may share a name.
739
+
740
+ Calling from outside:
741
+
742
+ | Transport | How |
743
+ |---|---|
744
+ | REST | `POST /common/plugin/{id}/{path}`, JSON body |
745
+ | Socket.IO | emit `common/plugin/{id}/{path}`; reply in the ack |
746
+ | TCP | topic `common/plugin/{id}/{path}` request / publish |
747
+ | MQTT | request `common/plugin/{id}/{path}/__req__`; publish `common/plugin/{id}/{topic}` |
748
+ | WebRTC | data channel topic `common/plugin/{id}/{path}` |
749
+
750
+ ```bash
751
+ curl -s -X POST http://<robot>/common/plugin/com.example.my-plugin/hello \
752
+ -H 'Content-Type: application/json' -d '{"name":"kim"}'
753
+ # under muscat-pk dev (the dev id is printed at start)
754
+ curl -s -X POST http://localhost:3000/common/plugin/<dev-id>/hello -H 'Content-Type: application/json' -d '{}'
755
+ ```
756
+
757
+ | Situation | Status |
758
+ |---|---|
759
+ | No such path / plugin down / transport not allowed | 404 |
760
+ | No answer within 10 s | 504 |
761
+ | `common` ↔ gateway not connected | 503 |
762
+ | Handler raised | 500 (`PluginRouteError` → its code) |
763
+
764
+ Auth: with the login policy on a token is required (any role); REST uses the same Bearer token as other Muscat APIs. Off: no token.
765
+
766
+ Known limits:
767
+
768
+ - A path named `install` can't be called over REST (taken by the registry install route).
769
+ - MQTT echoes to the sender: `emit` + `subscribe` on the same topic receives its own push over `mqtt`. Drop `mqtt` from `explicit_events` or use separate topics.
770
+ - After restarting the plugin, external calls may return 504 for up to a minute, then 404, until the gateway drops the old socket.
771
+
772
+ ### 7.3 `PluginProgramContext` — program command
773
+
774
+ Used only for `program.panel`. When the program reaches the step, the executor calls `commandInfo.funcName` with `plugin_id` + the step args, and the SDK calls your `on_execute`.
775
+
776
+ ```python
777
+ import asyncio
778
+ from rainbow.rb_sdk import PluginProgramContext
779
+
780
+ ctx = PluginProgramContext(plugin_id="com.example.my-plugin")
781
+
782
+ @ctx.on_execute
783
+ async def handle(args: dict) -> None:
784
+ count = await ctx.get_variable("counter")
785
+ await ctx.set_variable("counter", (count or 0) + 1)
786
+ await ctx.set_variable("RBP_my_plugin_result", 42) # new variable → RBP_ required
787
+
788
+ for _ in range(100):
789
+ await ctx.check_stop() # stop → CancelledError, pause → waits until resumed
790
+ await asyncio.sleep(0.5)
791
+
792
+ await ctx.done() # always; the step never finishes otherwise
793
+
794
+ @ctx.on_executor_state_change
795
+ async def on_change(robot_model: str, state: str) -> None:
796
+ if state == "paused":
797
+ print(f"{robot_model} paused")
798
+
799
+ async def background() -> None:
800
+ while True:
801
+ await asyncio.sleep(1.0)
802
+ await ctx.set_variable("RBP_my_plugin_heartbeat", 1) # ignored when no program runs
803
+
804
+ async def main() -> None:
805
+ asyncio.create_task(background())
806
+ await ctx.start() # stays resident
807
+
808
+ asyncio.run(main())
809
+ ```
810
+
811
+ | Member | Meaning |
812
+ |---|---|
813
+ | `on_execute(fn)` | decorator; called once per execution of this step with the step args |
814
+ | `done()` | step finished — always call |
815
+ | `check_stop(robot_model=None)` | stop → `CancelledError`, pause → waits. Call every loop iteration |
816
+ | `get_variable(name, robot_model=None, plugin_id=None)` | read; `None` when no program runs |
817
+ | `set_variable(name, value)` | write to every running main task; ignored when no program runs |
818
+ | `executor_state(robot_model=None)` | state string for a model, or `{model: state}` for all |
819
+ | `is_paused(robot_model=None)` / `is_stopped(robot_model=None)` | state checks |
820
+ | `on_executor_state_change(fn)` | decorator; `(robot_model, state)` once per change |
821
+ | `start()` / `stop()` / `pause()` | lifecycle |
822
+ | `in_execute`, `exec_robot_model`, `plugin_id`, `started`, `control_topic` | properties |
823
+
824
+ #### Variables
825
+
826
+ - Existing variable: written by name as-is — exchanging values with the program is the point.
827
+ - New variable: must start with `RBP_` (`RuntimeError` otherwise), inside or outside a step. Use `RBP_{plugin}_{name}` to avoid collisions; `RBP_` alone doesn't prevent two plugins overwriting each other.
828
+ - Declare in `manifest.json` → `variables` what the Program page should offer (5.5).
829
+
830
+ `set_variable` targets every running main task:
831
+
832
+ | When | Own program | Other robots' main tasks |
833
+ |---|---|---|
834
+ | During own step | immediate | at their next step boundary |
835
+ | Outside a step | next step boundary | same |
836
+
837
+ `get_variable` options:
838
+
839
+ ```python
840
+ await ctx.get_variable("counter") # first value across main tasks
841
+ await ctx.get_variable("counter", robot_model="C500920") # one robot
842
+ await ctx.get_variable("RBP_TEST", plugin_id="A.plugin") # what that plugin last wrote
843
+ ```
844
+
845
+ Each variable keeps a per-plugin record (`meta.writers`), so a colliding name is still recoverable: `get_variable(..., plugin_id=...)` from the SDK, `get_var("A.plugin", "RBP_TEST")` in a program expression. A write can rarely be lost when it races the worker's flush — re-check important values.
846
+
847
+ #### Executor state
848
+
849
+ States: `idle | running | paused | waiting | stopped | post_start | completed | error`. A model with no running task is `None` / absent.
850
+
851
+ - Published every 0.8 s on `muscat/executor/state` — up to ~1 s latency.
852
+ - With `IS_DEV=true` there is no publish; always `None`.
853
+ - `on_executor_state_change` fires once per per-model change; handler exceptions are only logged.
264
854
 
265
- Codex has no project-scoped config file — it reads a single, per-user `~/.codex/config.toml`. Add:
855
+ Scope of `check_stop` / `is_paused` / `is_stopped`:
856
+
857
+ 1. `robot_model` given → that model
858
+ 2. omitted inside `on_execute` → the model executing this step
859
+ 3. omitted elsewhere → all models
860
+
861
+ ### 7.4 Robot SDKs from the backend
862
+
863
+ ```python
864
+ manipulate = RBManipulateSDK()
865
+ manipulate.move.call_move_j(
866
+ robot_model="C500920",
867
+ target={"tar_values": [0, 0, 0, 0, 0, 0, 0], "tar_frame": -1, "tar_unit": 0},
868
+ )
869
+ ```
870
+
871
+ Add your own safety checks before moving the robot. Exact signatures: `muscat-pk mcp` (`search_rb_sdk`, `get_rb_sdk_api`).
872
+
873
+ ### 7.5 Remote dev (laptop → robot)
874
+
875
+ ```bash
876
+ export MUSCAT_ROBOT_HOST=192.168.1.100
877
+ export MUSCAT_ROBOT_TOKEN=<service token> # only when the robot's login policy is on
878
+ muscat-pk dev:back
879
+ ```
880
+
881
+ ```python
882
+ async def main():
883
+ host = os.getenv("MUSCAT_ROBOT_HOST")
884
+ if host:
885
+ opts = ConnectOptions()
886
+ opts.host = host
887
+ opts.token = os.getenv("MUSCAT_ROBOT_TOKEN", "")
888
+ manipulate.connect(opts)
889
+ register_handlers() # after connect() — connect() reopens the session and drops earlier handlers
890
+ ...
891
+ if host:
892
+ await manipulate.disconnect()
893
+ ```
894
+
895
+ The scaffolded `py/main.py` already follows this.
896
+
897
+ ---
898
+
899
+ ## 8. Other backend runtimes (C++ / ROS 2)
900
+
901
+ All non-`none` runtimes run in Docker during `dev:back`, `dev` and `build`.
902
+
903
+ ### 8.1 C++ (`cpp`)
904
+
905
+ - Source in `cpp/`, CMake build in Docker against the C++ SDK (apt `rainbow-rb-sdk-dev`).
906
+ - CMake: `find_package(rb_sdk <ver> EXACT REQUIRED)` + `target_link_libraries(<target> rb_sdk::rb_sdk)` (C++20).
907
+ - Headers: `<rainbow/rb_sdk/manipulate/rb_manipulate_sdk.hpp>`, `<rainbow/rb_sdk/plugin/rb_plugin.hpp>` (`rb_plugin::RBPluginCall`), `<rainbow/rb_sdk/program/plugin_program_context.hpp>` (`rb_plugin::PluginProgramContext`).
908
+ - Dynamic link; `build` copies `librb_sdk.so` into `dist/<arch>/lib/`.
909
+
910
+ Building outside the container (Ubuntu 24.04):
911
+
912
+ ```bash
913
+ curl -fsSL https://rainbowrobotics.github.io/rb-sdk/rb-sdk-apt-repo.gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/rb-sdk-apt.gpg
914
+ echo 'deb [signed-by=/usr/share/keyrings/rb-sdk-apt.gpg] https://rainbowrobotics.github.io/rb-sdk stable main' | sudo tee /etc/apt/sources.list.d/rb-sdk.list
915
+ sudo apt update && sudo apt install rainbow-rb-sdk-dev
916
+ ```
917
+
918
+ ### 8.2 ROS 2 (`ros2` + `rclpy` / `rclcpp`)
919
+
920
+ - Source in `ros2_ws/src/<package>/`; `runtime.package` = colcon package, `runtime.launch` = node executable.
921
+ - Relative topic names live under the plugin's namespace: id with `.` → `/`, `-` → `_` (`com.example.my-plugin` → `/com/example/my_plugin`). Use absolute names (`/cmd_vel`) for shared topics.
922
+ - `RBPluginCall` works in an rclpy node too.
923
+ - ROS discovery stays on the robot (`ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST`). Allow external PCs from the UI with `rb.rosPeers.set([...])`; Muscat restarts the backend with `ROS_STATIC_PEERS`. In dev the list is in `.muscat-dev/ros-peers.json`.
924
+
925
+ ---
926
+
927
+ ## 9. Development environment
928
+
929
+ ### 9.1 Local Muscat runtime
930
+
931
+ Without a robot IP, `dev` / `dev:ui` need a Muscat on `localhost:3000`:
932
+
933
+ - reuses any container already publishing `:10000` (`muscat`, `muscat-dev`, …);
934
+ - otherwise starts `muscat-pk-muscat` — the S3 runtime image running only `common`, cached in `~/.muscat-pk/muscat/`. It keeps running between runs.
935
+
936
+ ```bash
937
+ docker rm -f muscat-pk-muscat # stop it
938
+ MUSCAT_PK_NO_LOCAL_MUSCAT=1 muscat-pk dev # don't start it
939
+ ```
940
+
941
+ ### 9.2 Developing on the robot PC
942
+
943
+ On the robot PC Muscat already serves the gateway on `10000`; the CLI uses it and starts no local Muscat.
944
+
945
+ ### 9.3 Several developers on one robot
946
+
947
+ Against a remote robot, `dev` / `dev:back` run the plugin as `<manifest id>.dev-<user>-<random>` (random part kept in `~/.muscat-pk/dev-id`), so two machines don't answer each other's UI. UI, backend, ROS namespace and external routes all use the dev id. `--keep-id` uses the manifest id (for program `execute` or external clients) — then only one machine may connect.
948
+
949
+ ### 9.4 Local vs global install
950
+
951
+ If the project has its own `muscat-plugin-kit` in `node_modules`, any `muscat-pk` hands off to it, so the CLI, `vite` preset and `preview-host` are always the project's pinned version. `muscat-pk version` prints the copy that actually ran.
952
+
953
+ ### 9.5 Offline / air-gapped
954
+
955
+ Docker images are built only when missing (a rebuild contacts the registry even when cached).
956
+
957
+ ```bash
958
+ docker save muscat-pk-python-dev muscat-pk-runtime | gzip > muscat-pk-images.tar.gz # online machine
959
+ gunzip -c muscat-pk-images.tar.gz | docker load # offline machine
960
+ ```
961
+
962
+ Force a rebuild with `MUSCAT_PK_REBUILD_IMAGE=1`.
963
+
964
+ ### 9.6 IDE support (Python)
965
+
966
+ `muscat-pk new` creates `.venv` with `requirements.txt` installed — for editor linting only; the backend runs in Docker. Without `uv` or Python 3 it prints `Could not create .venv (no local Python/uv?) — skipping, Docker dev still works.`; install one and re-run `new` or `type change`.
967
+
968
+ ### 9.7 JavaScript dependencies
969
+
970
+ `build` runs the project's own Vite build, so packages must be installed. Don't `npm install --omit=dev` before building — `vite`, `typescript`, `tailwindcss`, `sass`, `less` are devDependencies. The ZIP ships no `node_modules`.
971
+
972
+ ---
973
+
974
+ ## 10. Styles
975
+
976
+ - `muscat()` auto-loads `ui/style.{css,scss,sass,less}` / `ui/styles.*` for every entry and `ui/{entry}.*` for a matching entry, and injects the CSS into the JS bundle.
977
+ - Built selectors are scoped under `[data-muscat-plugin-id="..."]`, so plugin CSS can't touch the host or other plugins.
978
+ - Use design-token classes (`bg-bg-*`, `text-fg-*`, `typo-*`) rather than hex colors.
979
+
980
+ Tailwind (default, no preflight):
981
+
982
+ ```css
983
+ @layer theme, utilities;
984
+ @import "tailwindcss/theme.css" layer(theme);
985
+ @import "tailwindcss/utilities.css" layer(utilities);
986
+ @source "./**/*.{ts,tsx}";
987
+ ```
988
+
989
+ SCSS / Less: `npm i -D sass` (or `less`) and create `ui/style.scss` (`ui/style.less`).
990
+
991
+ Emotion `styled` works as is. For the `css` prop:
992
+
993
+ ```bash
994
+ npm i @emotion/react @emotion/styled && npm i -D @emotion/babel-plugin
995
+ ```
996
+
997
+ ```ts
998
+ muscat({ react: { jsxImportSource: "@emotion/react", babel: { plugins: ["@emotion/babel-plugin"] } } })
999
+ ```
1000
+
1001
+ and `"jsxImportSource": "@emotion/react"` in `tsconfig.json`. Emotion `Global` is not scoped — avoid it.
1002
+
1003
+ ---
1004
+
1005
+ ## 11. AI coding agent setup (`muscat-pk mcp`)
1006
+
1007
+ An MCP server (stdio) with grounded info on rb-components (props, Storybook examples), design tokens, and the Python `rainbow-rb-sdk` (introspected signatures/docstrings), plus `check_project` for upgrading old projects.
1008
+
1009
+ Claude Code — `.mcp.json` at the plugin root:
1010
+
1011
+ ```json
1012
+ { "mcpServers": { "muscat": { "command": "muscat-pk", "args": ["mcp"] } } }
1013
+ ```
1014
+
1015
+ Codex — `muscat-pk mcp --install codex`, or `~/.codex/config.toml`:
266
1016
 
267
1017
  ```toml
268
1018
  [mcp_servers.muscat]
@@ -270,13 +1020,40 @@ command = "muscat-pk"
270
1020
  args = ["mcp"]
271
1021
  ```
272
1022
 
273
- You don't run `muscat-pk mcp` yourself either way — the agent spawns and manages it automatically once it's registered.
1023
+ The agent starts the server itself.
1024
+
1025
+ ---
274
1026
 
275
- ## Package roles
1027
+ ## 12. Environment variables
276
1028
 
277
- | Package | Role |
1029
+ | Variable | Effect |
278
1030
  |---|---|
279
- | `@rainbow-robotics/plugin-ui` | UI runtime contract: React direct mount, `useRb()` context, host services |
280
- | `@rainbow-robotics/muscat-plugin-kit` | CLI, templates, build/pack, preview host |
1031
+ | `MUSCAT_ROBOT_HOST` | robot IP for `dev` / `dev:ui` / `dev:back` (skips the prompt) |
1032
+ | `MUSCAT_ROBOT_TOKEN` | service token when the robot's login policy is on |
1033
+ | `MUSCAT_PK_NO_LOCAL_MUSCAT=1` | don't start the local Muscat runtime |
1034
+ | `MUSCAT_PK_REBUILD_IMAGE=1` | rebuild Docker images |
1035
+ | `MUSCAT_PK_RB_SDK_VERSION` | C++ SDK version baked into the C++ images (with `MUSCAT_PK_REBUILD_IMAGE=1`) |
1036
+ | `MUSCAT_PK_NO_UPDATE_CHECK=1` | skip the update check on `dev` / `dev:ui` |
1037
+ | `RB_REGISTRY` | npm registry for the update check (default `https://registry.npmjs.org`) |
1038
+ | `MUSCAT_PK_TAG` | install script channel / version |
1039
+ | `NO_COLOR` / `FORCE_COLOR` | output colors |
1040
+
1041
+ Inside the backend container: `RB_PLUGIN_ID` (the plugin / dev id).
281
1042
 
282
- A plugin ZIP only contains the plugin's own code and assets. `@rainbow-robotics/plugin-ui`, `react`, and `react-dom` are provided by the host's (Muscat/robot) import map.
1043
+ ---
1044
+
1045
+ ## 13. Troubleshooting
1046
+
1047
+ | Symptom | Fix |
1048
+ |---|---|
1049
+ | `manifest.json errors: …` | fix each listed field (section 5) |
1050
+ | backend stops with an undeclared port | add the port (28600-28999) to `ports` |
1051
+ | UI preview says "no default export" | entry must `export default App`; `vite.config.ts` must use `muscat()`; run `check_project` via `muscat-pk mcp` |
1052
+ | `vite not found — UI will not load` | run `npm install` in the project |
1053
+ | `AttributeError: router` | robot SDK older than 0.0.9.dev5 |
1054
+ | step never finishes | `on_execute` must `await ctx.done()` |
1055
+ | Stop doesn't stop the plugin | call `await ctx.check_stop()` in every loop |
1056
+ | `RuntimeError` on `set_variable` | new variable names need `RBP_` |
1057
+ | `get_variable` returns `None` | no program running (or `IS_DEV=true` for executor state) |
1058
+ | external call 504 after restart | wait up to a minute for the gateway to drop the old socket |
1059
+ | a newer/older local install answers | expected — the project's local kit wins; `muscat-pk update` |