@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.24 → 0.1.2-dev.26

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/cli/src/new.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // muscat-pk new — scaffold a new plugin project from templates.
2
- import { execFileSync, execSync } from "node:child_process";
2
+ import { execSync } from "node:child_process";
3
3
  import {
4
4
  cpSync,
5
5
  existsSync,
@@ -12,11 +12,35 @@ import {
12
12
  } from "node:fs";
13
13
  import { createInterface } from "node:readline/promises";
14
14
  import { basename, resolve } from "node:path";
15
- import { detectPythonCmd, hasBin, kitVersion, parseArgs, templatesDir, log, venvPython } from "./util.mjs";
16
-
17
- const TYPES = ["python", "ros2"];
15
+ import { rmSync } from "node:fs";
16
+ import { hasBin, kitVersion, parseArgs, templatesDir, log } from "./util.mjs";
17
+
18
+ // `--type` values map to a runtime descriptor. ros2 has a python (rclpy) and a
19
+ // C++ (rclcpp) flavor; we expose them as distinct type choices for clarity.
20
+ export const TYPE_CHOICES = {
21
+ python: { type: "python" },
22
+ cpp: { type: "cpp" },
23
+ ros2: { type: "ros2", rosClient: "rclpy" },
24
+ "ros2-cpp":{ type: "ros2", rosClient: "rclcpp" },
25
+ };
26
+ const TYPES = Object.keys(TYPE_CHOICES);
18
27
  const PMS = ["npm", "yarn", "pnpm", "bun"];
19
28
 
29
+ // Build the manifest runtime object for a given type choice + plugin slug.
30
+ export function runtimeForChoice(choice, slug) {
31
+ switch (choice.type) {
32
+ case "cpp":
33
+ // entry = built executable name (matches add_executable in cpp/CMakeLists.txt)
34
+ return { type: "cpp", entry: slug };
35
+ case "ros2":
36
+ // package = colcon package name; launch = node executable run on the robot
37
+ return { type: "ros2", rosClient: choice.rosClient, package: slug, launch: `${slug}_node` };
38
+ case "python":
39
+ default:
40
+ return { type: "python", entry: "main.py" };
41
+ }
42
+ }
43
+
20
44
  async function prompt(question, fallback) {
21
45
  if (!process.stdin.isTTY) return fallback;
22
46
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -74,16 +98,25 @@ function renameSlugPaths(dir, slug) {
74
98
  // Copy the ros2_ws/ overlay into a plugin and substitute the package slug into
75
99
  // every path segment. The dev Dockerfile is NOT scaffolded here — it lives in
76
100
  // the kit (cli/docker/) so authors can't edit it. Caller runs applyTokens after.
77
- export function overlayRos2(target, slug) {
78
- cpSync(resolve(templatesDir, "ros2"), target, { recursive: true });
101
+ // rclpy → templates/ros2 (ament_python)
102
+ // rclcpp → templates/ros2-cpp (ament_cmake)
103
+ export function overlayRos2(target, slug, rosClient = "rclpy") {
104
+ const tmpl = rosClient === "rclcpp" ? "ros2-cpp" : "ros2";
105
+ cpSync(resolve(templatesDir, tmpl), target, { recursive: true });
79
106
  renameSlugPaths(resolve(target, "ros2_ws"), slug);
80
107
  }
81
108
 
109
+ // Copy the cpp/ overlay (CMake project) into a plugin. Caller runs applyTokens after.
110
+ export function overlayCpp(target) {
111
+ cpSync(resolve(templatesDir, "cpp"), target, { recursive: true });
112
+ }
113
+
82
114
  export async function runNew(args) {
83
115
  const opts = parseArgs(args, { booleans: ["yes", "no-install", "help"] });
84
116
  if (opts.help) {
85
117
  console.log(
86
- "muscat-pk new [dir] [--type python|ros2] [--pm npm|yarn|pnpm|bun]\n" +
118
+ "muscat-pk new [dir] [--type python|cpp|ros2|ros2-cpp] [--pm npm|yarn|pnpm|bun]\n" +
119
+ " [--author <name>] [--email <email>] [--description <text>]\n" +
87
120
  " [--yes] [--no-install]",
88
121
  );
89
122
  return;
@@ -97,15 +130,28 @@ export async function runNew(args) {
97
130
  throw new Error(`directory not empty: ${target}`);
98
131
  }
99
132
 
133
+ let authorArg = opts.author;
134
+ let emailArg = opts.email;
135
+ let descriptionArg = opts.description;
136
+
137
+ if (!opts.yes) {
138
+ if (!authorArg) authorArg = await prompt("Author name: ", "");
139
+ if (!emailArg) emailArg = await prompt("Email (required): ", "");
140
+ if (!descriptionArg) descriptionArg = await prompt("Description (English): ", "");
141
+ }
142
+
100
143
  let type = opts.type;
101
144
  if (!type && !opts.yes) {
102
145
  console.log("\n플러그인 타입을 선택하세요:");
103
146
  console.log(" 1) Python");
104
- console.log(" 2) ROS2(rclpy)");
105
- const choice = await prompt("\n선택 [1/2, default: 1]: ", "1");
106
- type = choice === "2" ? "ros2" : "python";
147
+ console.log(" 2) C++");
148
+ console.log(" 3) ROS2 (rclpy / Python)");
149
+ console.log(" 4) ROS2 (rclcpp / C++)");
150
+ const choice = await prompt("\n선택 [1/2/3/4, default: 1]: ", "1");
151
+ type = { "2": "cpp", "3": "ros2", "4": "ros2-cpp" }[choice] ?? "python";
107
152
  }
108
153
  type = TYPES.includes(type) ? type : "python";
154
+ const runtimeChoice = TYPE_CHOICES[type];
109
155
 
110
156
  // Auto-detect yarn workspace: if .yarnrc.yml exists anywhere up the tree, default to yarn.
111
157
  function isInsideYarnWorkspace(dir) {
@@ -121,7 +167,7 @@ export async function runNew(args) {
121
167
  let pm = opts.pm;
122
168
  if (!pm && !opts.yes) {
123
169
  const defaultPm = isInsideYarnWorkspace(process.cwd()) ? "yarn" : "npm";
124
- pm = await prompt(`Package manager (${PMS.join("/")}) [${defaultPm}]: `, defaultPm);
170
+ pm = await prompt(`Front-end package manager (${PMS.join("/")}) [${defaultPm}]: `, defaultPm);
125
171
  }
126
172
  pm = PMS.includes(pm) ? pm : "npm";
127
173
 
@@ -129,11 +175,18 @@ export async function runNew(args) {
129
175
 
130
176
  mkdirSync(target, { recursive: true });
131
177
 
132
- // common(UI + main.py + requirements.txt + manifest/config)은 항상 포함.
133
- // ros2면 ros2_ws/ 오버레이만 추가로 얹는다. python은 ros2_ws/가 없을 뿐 구조는 동일.
178
+ // common(UI + manifest/config)은 항상 포함. 백엔드 오버레이는 타입별로 얹는다.
179
+ // python → common의 py/ 사용
180
+ // cpp → cpp/ 오버레이, py/·requirements.txt 제거
181
+ // ros2(*) → ros2_ws/ 오버레이 (rclpy=ament_python, rclcpp=ament_cmake)
134
182
  log.step(`scaffolding ${type} plugin → ${target}`);
135
183
  cpSync(resolve(templatesDir, "common"), target, { recursive: true });
136
- if (type === "ros2") overlayRos2(target, slug);
184
+ if (runtimeChoice.type === "ros2") overlayRos2(target, slug, runtimeChoice.rosClient);
185
+ if (runtimeChoice.type === "cpp") {
186
+ overlayCpp(target);
187
+ rmSync(resolve(target, "py"), { recursive: true, force: true });
188
+ rmSync(resolve(target, "requirements.txt"), { force: true });
189
+ }
137
190
 
138
191
  applyTokens(target, {
139
192
  __PLUGIN_ID__: pluginId,
@@ -141,15 +194,24 @@ export async function runNew(args) {
141
194
  __PLUGIN_DIR__: dir,
142
195
  __PLUGIN_SLUG__: slug,
143
196
  __PLUGIN_CLASS__: pluginClass,
197
+ __PLUGIN_AUTHOR__: authorArg || "",
198
+ __PLUGIN_EMAIL__: emailArg || "",
144
199
  __PM__: pm,
145
- __RUNTIME_TYPE__: type,
200
+ __RUNTIME_TYPE__: runtimeChoice.type,
146
201
  __RB_VERSION__: kitVersion(),
147
202
  });
148
203
 
204
+ // Write the correct runtime block into manifest.json (per-type fields).
205
+ const manifestPath = resolve(target, "manifest.json");
206
+ const manifestJson = JSON.parse(readFileSync(manifestPath, "utf8"));
207
+ manifestJson.runtime = runtimeForChoice(runtimeChoice, slug);
208
+ if (descriptionArg) manifestJson.description = { en: descriptionArg };
209
+ writeFileSync(manifestPath, JSON.stringify(manifestJson, null, 2) + "\n");
210
+
149
211
  // gitignore (templates can't ship a literal .gitignore via npm reliably)
150
212
  writeFileSync(
151
213
  resolve(target, ".gitignore"),
152
- ["node_modules", "dist", ".muscat-dev", ".venv", "*.zip", "ros2_ws/install", ""].join("\n"),
214
+ ["node_modules", "dist", ".muscat-dev", ".venv", "*.zip", "ros2_ws/install", "cpp/build", ""].join("\n"),
153
215
  );
154
216
 
155
217
  if (!opts["no-install"]) {
@@ -161,38 +223,10 @@ export async function runNew(args) {
161
223
  }
162
224
  }
163
225
 
164
- let noPython = false;
165
- let venvFailed = false;
166
-
167
- if (!opts["no-install"]) {
168
- // Python venv (robot targets 3.12) — cross-platform.
169
- // uv is preferred: handles uv-managed Pythons that break standard ensurepip.
170
- try {
171
- const venv = resolve(target, ".venv");
172
- log.step("creating .venv + installing requirements…");
173
- if (hasBin("uv")) {
174
- execFileSync("uv", ["venv", venv, "--python", "3.12", "--seed"], { cwd: target, stdio: "inherit" });
175
- } else if (hasBin("conda")) {
176
- execFileSync("conda", ["create", "-p", venv, "python=3.12", "-y", "--quiet"], { cwd: target, stdio: "inherit" });
177
- } else if (hasBin("python3.12") || hasBin("python3") || hasBin("python")) {
178
- const { cmd, is312 } = detectPythonCmd();
179
- if (!is312) log.warn("python3.12 not found; venv uses fallback python (robot targets 3.12).");
180
- execFileSync(cmd[0], [...cmd.slice(1), "-m", "venv", venv], { cwd: target, stdio: "inherit" });
181
- } else {
182
- noPython = true;
183
- }
184
- if (!noPython) {
185
- const py = venvPython(venv);
186
- if (existsSync(resolve(target, "requirements.txt"))) {
187
- execFileSync(py, ["-m", "pip", "install", "-q", "-r", "requirements.txt"], { cwd: target, stdio: "inherit" });
188
- }
189
- }
190
- } catch {
191
- venvFailed = true;
192
- }
193
- } else {
194
- noPython = !hasBin("uv") && !hasBin("conda") && !hasBin("python3.12") && !hasBin("python3") && !hasBin("python");
195
- }
226
+ // The back end (python / cpp / ros2) runs in a Docker container that matches
227
+ // the robot — no local Python/toolchain setup needed. Only `none` skips it.
228
+ const needsDocker = runtimeChoice.type !== "none";
229
+ const noDocker = needsDocker && !hasBin("docker");
196
230
 
197
231
  log.ok(`created ${targetName} (type: ${type}, pm: ${pm})`);
198
232
  log.info(`\n cd ${targetName}`);
@@ -201,19 +235,15 @@ export async function runNew(args) {
201
235
 
202
236
  // ── post-scaffold environment warnings ───────────────────────────────────
203
237
  const noNode = !hasBin("node");
204
- if (noNode || noPython || venvFailed) {
238
+ if (noNode || noDocker) {
205
239
  log.info("");
206
240
  if (noNode) {
207
241
  log.warn("Node.js not found. Install Node.js 20+ to run JS deps and dev:ui.");
208
242
  log.warn(" https://nodejs.org");
209
243
  }
210
- if (noPython) {
211
- log.warn("Python not found. Install Python 3.12 to run the backend.");
212
- log.warn(" https://www.python.org/downloads/ (or: brew install python@3.12 / uv / conda)");
213
- log.warn(` Then: cd ${targetName} && python3.12 -m venv .venv && pip install -r requirements.txt`);
214
- } else if (venvFailed) {
215
- log.warn("venv setup failed. Create it manually:");
216
- log.warn(` cd ${targetName} && python3.12 -m venv .venv && pip install -r requirements.txt`);
244
+ if (noDocker) {
245
+ log.warn("Docker not found. The plugin back end runs in a container (matches the robot env).");
246
+ log.warn(" https://docs.docker.com/get-docker/");
217
247
  }
218
248
  log.info("");
219
249
  }
@@ -0,0 +1,31 @@
1
+ // Shared Python Docker helpers (Ubuntu 24.04 + Python 3.12 — matches the robot).
2
+ // Used by dev:back so the dev backend runs in the same environment as the robot.
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
6
+ import { hasBin, kitRoot, log } from "./util.mjs";
7
+
8
+ export const PYTHON_DEV_IMAGE = "muscat-pk-python-dev";
9
+
10
+ // Dockerfile lives inside the kit — plugin authors never see or edit it.
11
+ const DOCKERFILE = resolve(kitRoot, "cli/docker/Dockerfile.python");
12
+
13
+ export function assertDockerReady() {
14
+ if (!hasBin("docker")) {
15
+ throw new Error("Docker not found. The Python backend runs in a container (Ubuntu 24.04 + Python 3.12).");
16
+ }
17
+ if (!existsSync(DOCKERFILE)) {
18
+ throw new Error(`kit Dockerfile missing at ${DOCKERFILE} (reinstall muscat-plugin-kit).`);
19
+ }
20
+ }
21
+
22
+ /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
23
+ export function ensureImage(image = PYTHON_DEV_IMAGE) {
24
+ log.step(`docker build -t ${image} (first run is slow, then cached)…`);
25
+ const r = spawnSync(
26
+ "docker",
27
+ ["build", "-t", image, "-f", DOCKERFILE, dirname(DOCKERFILE)],
28
+ { stdio: "inherit" },
29
+ );
30
+ if (r.status !== 0) throw new Error("docker build failed.");
31
+ }
@@ -1,7 +1,14 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { Server } from "socket.io";
4
- import { log } from "./util.mjs";
5
+
6
+ // Self-contained logger — this module also runs standalone inside the bridge
7
+ // Docker container, where ./util.mjs is not available.
8
+ const log = {
9
+ step: (...m) => console.log("›", ...m),
10
+ warn: (...m) => console.warn("⚠", ...m),
11
+ };
5
12
 
6
13
  const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
7
14
 
@@ -114,3 +121,9 @@ export function startSocketBridge({ port = 10000, host = "0.0.0.0" } = {}) {
114
121
  });
115
122
  });
116
123
  }
124
+
125
+ // When executed directly (inside the bridge Docker container), start the server.
126
+ if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
127
+ const port = Number(process.env.PORT) || 10000;
128
+ startSocketBridge({ port });
129
+ }
package/cli/src/type.mjs CHANGED
@@ -1,13 +1,21 @@
1
1
  // muscat-pk type change — switch an existing plugin's backend type.
2
- // python ↔ ros2. python→ros2 adds the ros2_ws/ overlay; ros2→python flips
3
- // the manifest type and leaves ros2_ws/ in place (never deletes user code).
2
+ // python | cpp | ros2 (rclpy) | ros2-cpp (rclcpp).
3
+ // Adds the matching backend overlay; never deletes existing user code
4
+ // (e.g. ros2_ws/ or cpp/ are left in place when switching away).
4
5
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
6
  import { createInterface } from "node:readline/promises";
6
7
  import { basename, resolve } from "node:path";
7
8
  import { findPluginRoot, log } from "./util.mjs";
8
- import { applyTokens, deriveNames, overlayRos2 } from "./new.mjs";
9
+ import {
10
+ TYPE_CHOICES,
11
+ applyTokens,
12
+ deriveNames,
13
+ overlayCpp,
14
+ overlayRos2,
15
+ runtimeForChoice,
16
+ } from "./new.mjs";
9
17
 
10
- const TYPES = ["python", "ros2"];
18
+ const TYPES = Object.keys(TYPE_CHOICES);
11
19
 
12
20
  async function prompt(question, fallback) {
13
21
  if (!process.stdin.isTTY) return fallback;
@@ -23,44 +31,54 @@ function readManifest(root) {
23
31
  return { path, json: JSON.parse(readFileSync(path, "utf8")) };
24
32
  }
25
33
 
34
+ // Derive the current `--type`-style key from a manifest runtime block.
35
+ function currentTypeKey(runtime) {
36
+ if (runtime?.type === "ros2") return runtime.rosClient === "rclcpp" ? "ros2-cpp" : "ros2";
37
+ return runtime?.type ?? "python";
38
+ }
39
+
26
40
  export async function runType(args) {
27
- // accept `type change`, `type`, `type --to ros2`
41
+ // accept `type change`, `type`, `type --to ros2-cpp`
28
42
  const sub = args[0];
29
43
  const toFlagIdx = args.indexOf("--to");
30
44
  let to = toFlagIdx >= 0 ? args[toFlagIdx + 1] : undefined;
31
45
 
32
46
  const root = findPluginRoot();
33
47
  const { path, json } = readManifest(root);
34
- const current = json.runtime?.type ?? "python";
48
+ const current = currentTypeKey(json.runtime);
35
49
 
36
50
  if (sub && sub !== "change") {
37
- log.warn(`unknown subcommand '${sub}'. Usage: muscat-pk type change [--to python|ros2]`);
51
+ log.warn(`unknown subcommand '${sub}'. Usage: muscat-pk type change [--to python|cpp|ros2|ros2-cpp]`);
38
52
  }
39
53
 
40
54
  if (!to) {
41
55
  console.log(`\n현재 타입: ${current}`);
42
56
  console.log("변경할 타입을 선택하세요:");
43
57
  console.log(" 1) Python");
44
- console.log(" 2) ROS2(rclpy)");
45
- const choice = await prompt("\n선택 [1/2]: ", "");
46
- to = choice === "1" ? "python" : choice === "2" ? "ros2" : undefined;
58
+ console.log(" 2) C++");
59
+ console.log(" 3) ROS2 (rclpy / Python)");
60
+ console.log(" 4) ROS2 (rclcpp / C++)");
61
+ const choice = await prompt("\n선택 [1/2/3/4]: ", "");
62
+ to = { "1": "python", "2": "cpp", "3": "ros2", "4": "ros2-cpp" }[choice];
47
63
  }
48
64
 
49
65
  if (!TYPES.includes(to)) {
50
- throw new Error(`invalid type. choose python or ros2.`);
66
+ throw new Error(`invalid type. choose python | cpp | ros2 | ros2-cpp.`);
51
67
  }
52
68
  if (to === current) {
53
69
  log.info(`already '${current}' — nothing to change.`);
54
70
  return;
55
71
  }
56
72
 
57
- if (to === "ros2") {
58
- const { slug, pluginName, pluginClass, pluginId } = deriveNames(root);
73
+ const runtimeChoice = TYPE_CHOICES[to];
74
+ const { slug, pluginName, pluginClass, pluginId } = deriveNames(root);
75
+
76
+ if (runtimeChoice.type === "ros2") {
59
77
  if (existsSync(resolve(root, "ros2_ws"))) {
60
78
  log.warn("ros2_ws/ already exists — keeping it, only flipping manifest type.");
61
79
  } else {
62
- log.step("adding ros2_ws/ overlay");
63
- overlayRos2(root, slug);
80
+ log.step(`adding ros2_ws/ overlay (${runtimeChoice.rosClient})`);
81
+ overlayRos2(root, slug, runtimeChoice.rosClient);
64
82
  applyTokens(root, {
65
83
  __PLUGIN_ID__: pluginId,
66
84
  __PLUGIN_NAME__: pluginName,
@@ -69,14 +87,28 @@ export async function runType(args) {
69
87
  __RUNTIME_TYPE__: "ros2",
70
88
  });
71
89
  }
90
+ } else if (runtimeChoice.type === "cpp") {
91
+ if (existsSync(resolve(root, "cpp"))) {
92
+ log.warn("cpp/ already exists — keeping it, only flipping manifest type.");
93
+ } else {
94
+ log.step("adding cpp/ overlay");
95
+ overlayCpp(root);
96
+ applyTokens(root, {
97
+ __PLUGIN_ID__: pluginId,
98
+ __PLUGIN_NAME__: pluginName,
99
+ __PLUGIN_SLUG__: slug,
100
+ __PLUGIN_CLASS__: pluginClass,
101
+ __RUNTIME_TYPE__: "cpp",
102
+ });
103
+ }
72
104
  } else {
73
- // ros2 → python
74
- if (existsSync(resolve(root, "ros2_ws"))) {
75
- log.warn("ros2_ws/ left in place (not deleted). Remove it manually if unused.");
105
+ // → python. Leave any ros2_ws/ or cpp/ in place (never delete user code).
106
+ if (existsSync(resolve(root, "ros2_ws")) || existsSync(resolve(root, "cpp"))) {
107
+ log.warn("existing backend dir left in place (not deleted). Remove it manually if unused.");
76
108
  }
77
109
  }
78
110
 
79
- json.runtime = { ...json.runtime, type: to };
111
+ json.runtime = runtimeForChoice(runtimeChoice, slug);
80
112
  writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
81
113
  log.ok(`${basename(root)} type: ${current} → ${to}`);
82
114
  }
package/cli/src/util.mjs CHANGED
@@ -91,6 +91,16 @@ export const log = {
91
91
  error: (...m) => console.error(`${RED}✖${RESET}`, ...m.map(s => `${RED}${s}${RESET}`)),
92
92
  };
93
93
 
94
+ // dev / dev:back / build run the plugin back end, the dev bridge, and cpp/ros2
95
+ // compilation in Docker. Show a clear install hint when Docker is missing.
96
+ export function ensureDocker() {
97
+ if (hasBin("docker")) return;
98
+ log.warn("Docker not found.");
99
+ log.warn(" muscat-pk runs the plugin back end, the dev bridge, and cpp/ros2 builds in Docker.");
100
+ log.warn(" Install Docker, then retry: https://docs.docker.com/get-docker/");
101
+ throw new Error("Docker is required for dev / dev:back / build.");
102
+ }
103
+
94
104
  export function humanSize(bytes) {
95
105
  if (bytes < 1024) return `${bytes} B`;
96
106
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -102,8 +102,9 @@ connect(App);
102
102
  ## Backend (`py/main.py`)
103
103
 
104
104
  The backend communicates with the frontend using `rainbow.rb_sdk.RBPluginCall`.
105
- `muscat-pk dev` starts the local Socket.IO bridge; `py/main.py` should contain plugin
106
- logic only and must not start its own server.
105
+ `muscat-pk dev` starts the Socket.IO bridge in Docker (published on port 10000) and
106
+ runs the backend container sharing its network, so the SDK connects at localhost:10000.
107
+ `py/main.py` should contain plugin logic only and must not start its own server.
107
108
 
108
109
  ### Python plugin
109
110
 
@@ -230,11 +231,26 @@ class __PLUGIN_CLASS__Node(Node):
230
231
  }
231
232
  ],
232
233
 
233
- // "none" | "python" | "ros2"
234
+ // runtime — one of:
235
+ // { "type": "none" }
236
+ // { "type": "python", "entry": "main.py" }
237
+ // { "type": "cpp", "entry": "<executable name>" }
238
+ // { "type": "ros2", "rosClient": "rclpy" | "rclcpp", "package": "...", "launch": "..._node" }
234
239
  "runtime": { "type": "python", "entry": "main.py" }
235
240
  }
236
241
  ```
237
242
 
243
+ **Backend types:**
244
+
245
+ | type | source dir | build / run | entry |
246
+ |---|---|---|---|
247
+ | `python` | `py/` | pip + run in Docker | `main.py` |
248
+ | `cpp` | `cpp/` | CMake in Docker | built executable name |
249
+ | `ros2` (rclpy) | `ros2_ws/` | colcon (ament_python) in Docker | — |
250
+ | `ros2` (rclcpp) | `ros2_ws/` | colcon (ament_cmake) in Docker | — |
251
+
252
+ > Every back end runs in a Docker container matching the robot (Ubuntu 24.04 + Python 3.12), so dev behaves like production and compiled binaries are ABI-compatible. **Docker is required** for all backend types (only a frontend-only `none` plugin skips it). No local Python/toolchain setup is needed.
253
+
238
254
  **Contribution points:**
239
255
 
240
256
  | point | Where it appears | `commandInfo` required |
@@ -306,24 +322,23 @@ numpy==1.26.4
306
322
  opencv-python
307
323
  ```
308
324
 
309
- Run `muscat-pk dev` or `muscat-pk dev:back` — dependencies are installed automatically on every startup.
310
-
311
- - **Python plugin**: installed into `.venv` via `pip install -r requirements.txt`
312
- - **ROS2 plugin**: installed inside the Docker container via `pip install -r requirements.txt`
325
+ Run `muscat-pk dev` or `muscat-pk dev:back` — dependencies are installed automatically inside the dev container (`pip install -r requirements.txt`) on every startup. No local Python or venv needed.
313
326
 
314
- > `muscat-pk build` only **pins** versions in `requirements.txt` — it does not run `pip install`.
327
+ > `muscat-pk build` only **pins** versions in `requirements.txt` — it does not run `pip install` (the robot installs them at load time).
315
328
 
316
329
  ---
317
330
 
318
331
  ## Switching backend type
319
332
 
320
333
  ```bash
321
- muscat-pk type change # interactive
322
- muscat-pk type change --to ros2 # python → ros2
323
- muscat-pk type change --to python # ros2 → python
334
+ muscat-pk type change # interactive
335
+ muscat-pk type change --to cpp # → C++
336
+ muscat-pk type change --to ros2 # → ROS2 (rclpy / Python)
337
+ muscat-pk type change --to ros2-cpp # → ROS2 (rclcpp / C++)
338
+ muscat-pk type change --to python # → Python
324
339
  ```
325
340
 
326
- > `ros2_ws/` is not deleted when switching back to python. Remove it manually if no longer needed.
341
+ > Existing backend dirs (`ros2_ws/`, `cpp/`) are not deleted when switching away. Remove them manually if no longer needed.
327
342
 
328
343
  ---
329
344
 
@@ -3,7 +3,8 @@
3
3
  "name": "__PLUGIN_NAME__",
4
4
  "version": "1.0.0",
5
5
  "minMuscatVersion": "1.0.0",
6
- "author": "",
6
+ "author": "__PLUGIN_AUTHOR__",
7
+ "email": "__PLUGIN_EMAIL__",
7
8
  "description": {
8
9
  "ko": "",
9
10
  "en": ""
@@ -12,30 +13,12 @@
12
13
  "targetModels": "all",
13
14
  "targetCategorys": "all",
14
15
  "contributes": [
15
- {
16
- "point": "program.command",
17
- "icon": "assets/icon.svg",
18
- "label": { "ko": "__PLUGIN_NAME__", "en": "__PLUGIN_NAME__" },
19
- "ui": "program/content",
20
- "commandInfo": {
21
- "name": "__PLUGIN_NAME__",
22
- "icon": "assets/icon.svg",
23
- "funcName": "plugin.run",
24
- "args": {}
25
- }
26
- },
27
16
  {
28
17
  "point": "toolbar",
29
18
  "icon": "assets/icon.svg",
30
19
  "label": { "ko": "__PLUGIN_NAME__", "en": "__PLUGIN_NAME__" },
31
20
  "ui": "content",
32
21
  "opens": "popover"
33
- },
34
- {
35
- "point": "move.rnb",
36
- "icon": "assets/icon.svg",
37
- "label": { "ko": "__PLUGIN_NAME__", "en": "__PLUGIN_NAME__" },
38
- "ui": "move/content"
39
22
  }
40
23
  ],
41
24
  "runtime": { "type": "__RUNTIME_TYPE__", "entry": "main.py" }
@@ -0,0 +1,14 @@
1
+ cmake_minimum_required(VERSION 3.16)
2
+ project(__PLUGIN_SLUG__ CXX)
3
+
4
+ set(CMAKE_CXX_STANDARD 17)
5
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
6
+
7
+ # The executable name must match runtime.entry in manifest.json.
8
+ add_executable(__PLUGIN_SLUG__ main.cpp)
9
+
10
+ # TODO(cpp-sdk): link the rainbow cpp SDK dynamically (the robot provides the
11
+ # .so at runtime — do NOT bundle/static-link it). Once the SDK ships an apt
12
+ # package with CMake config, enable:
13
+ # find_package(rainbow_cpp_sdk REQUIRED)
14
+ # target_link_libraries(__PLUGIN_SLUG__ PRIVATE rainbow::cpp_sdk)
@@ -0,0 +1,25 @@
1
+ // __PLUGIN_NAME__ — C++ plugin back end.
2
+ //
3
+ // The C++ plugin SDK is not available yet, so this is a build/run skeleton.
4
+ // Once the SDK lands, connect to the plugin gateway here (the host exposes it
5
+ // at the RB_PLUGIN_GATEWAY / RB_PLUGIN_ID environment variables) and wire up
6
+ // pub / sub / serve, mirroring the Python rainbow.rb_sdk.RBPluginCall API.
7
+
8
+ #include <chrono>
9
+ #include <cstdlib>
10
+ #include <iostream>
11
+ #include <thread>
12
+
13
+ int main() {
14
+ const char* plugin_id = std::getenv("RB_PLUGIN_ID");
15
+ std::cout << "[__PLUGIN_NAME__] backend ready"
16
+ << (plugin_id ? std::string(" (id=") + plugin_id + ")" : "")
17
+ << std::endl;
18
+
19
+ // TODO: connect to the plugin gateway and register handlers once the
20
+ // C++ SDK is available. For now, just keep the process alive.
21
+ while (true) {
22
+ std::this_thread::sleep_for(std::chrono::seconds(1));
23
+ }
24
+ return 0;
25
+ }
@@ -0,0 +1,17 @@
1
+ cmake_minimum_required(VERSION 3.16)
2
+ project(__PLUGIN_SLUG__)
3
+
4
+ if(NOT CMAKE_CXX_STANDARD)
5
+ set(CMAKE_CXX_STANDARD 17)
6
+ endif()
7
+
8
+ find_package(ament_cmake REQUIRED)
9
+ find_package(rclcpp REQUIRED)
10
+
11
+ add_executable(__PLUGIN_SLUG___node src/node.cpp)
12
+ ament_target_dependencies(__PLUGIN_SLUG___node rclcpp)
13
+
14
+ install(TARGETS __PLUGIN_SLUG___node
15
+ DESTINATION lib/${PROJECT_NAME})
16
+
17
+ ament_package()
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0"?>
2
+ <package format="3">
3
+ <name>__PLUGIN_SLUG__</name>
4
+ <version>0.0.1</version>
5
+ <description>__PLUGIN_NAME__ ROS2 node (rclcpp)</description>
6
+ <maintainer email="todo@example.com">maintainer</maintainer>
7
+ <license>Proprietary</license>
8
+ <buildtool_depend>ament_cmake</buildtool_depend>
9
+ <depend>rclcpp</depend>
10
+ <export><build_type>ament_cmake</build_type></export>
11
+ </package>
@@ -0,0 +1,22 @@
1
+ // __PLUGIN_NAME__ — ROS2 node (rclcpp).
2
+ //
3
+ // The node is launched with `--ros-args -r __ns:=/{slug}` automatically by
4
+ // dev:back. ROS2 topics are bridged to the plugin communication layer, so a
5
+ // plain rclcpp node is all you need — relative topic names get the namespace
6
+ // prefix applied automatically.
7
+ #include "rclcpp/rclcpp.hpp"
8
+
9
+ class __PLUGIN_CLASS__Node : public rclcpp::Node {
10
+ public:
11
+ __PLUGIN_CLASS__Node() : Node("__PLUGIN_SLUG___node") {
12
+ RCLCPP_INFO(this->get_logger(), "__PLUGIN_NAME__ node started");
13
+ // create publishers / subscribers / services here
14
+ }
15
+ };
16
+
17
+ int main(int argc, char** argv) {
18
+ rclcpp::init(argc, argv);
19
+ rclcpp::spin(std::make_shared<__PLUGIN_CLASS__Node>());
20
+ rclcpp::shutdown();
21
+ return 0;
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-robotics/muscat-plugin-kit",
3
- "version": "0.1.2-dev.24",
3
+ "version": "0.1.2-dev.26",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -31,7 +31,7 @@
31
31
  "@types/react-dom": "^19.0.4"
32
32
  },
33
33
  "dependencies": {
34
- "@rainbow-robotics/preview-host": "0.1.2-dev.24",
34
+ "@rainbow-robotics/preview-host": "0.1.2-dev.26",
35
35
  "socket.io": "^4.8.1"
36
36
  },
37
37
  "optionalDependencies": {