@rainbow-robotics/muscat-plugin-kit 0.1.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 ADDED
@@ -0,0 +1,27 @@
1
+ # @rainbow-robotics/muscat-plugin-kit
2
+
3
+ Muscat plugin developer kit: `muscat-pk`, `create-muscat-plugin`, build/pack,
4
+ and preview-host.
5
+
6
+ This package does **not** own the UI contract. Plugin source imports
7
+ `@rainbow-robotics/plugin-ui/*`, and `muscat-pk build` leaves that package external so the
8
+ robot/preview-host can provide its own runtime.
9
+
10
+ ```bash
11
+ npm i -g @rainbow-robotics/muscat-plugin-kit
12
+ muscat-pk new my-plugin --type sdk
13
+ cd my-plugin
14
+ muscat-pk build
15
+ muscat-pk dev:ui
16
+ muscat-pk pack
17
+ ```
18
+
19
+ Published package responsibilities:
20
+
21
+ | Package | Role |
22
+ |---|---|
23
+ | `@rainbow-robotics/plugin-ui` | Contract/runtime: rb-* elements, author React bindings, sandbox connect, host renderer |
24
+ | `@rainbow-robotics/muscat-plugin-kit` | CLI, scaffold templates, build/pack, preview-host |
25
+
26
+ The plugin ZIP contains plugin code and assets only. It relies on the host's
27
+ import map for `@rainbow-robotics/plugin-ui`, `react`, and `react-dom`.
package/cli/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @rainbow-robotics/muscat-plugin-kit (`muscat-pk`)
2
+
3
+ CLI to scaffold, build, preview and pack Muscat plugins.
4
+
5
+ Install/use it the same way an external plugin author will:
6
+
7
+ ```bash
8
+ npm i -g @rainbow-robotics/muscat-plugin-kit
9
+ muscat-pk new my-plugin
10
+ ```
11
+
12
+ ```bash
13
+ muscat-pk new my-plugin [--type sdk|ros2|ui] [--pm npm|yarn|pnpm|bun] [--yes]
14
+ muscat-pk build # src/content.tsx -> dist/content.js (+ manifest, sandbox, assets)
15
+ muscat-pk pack # build (minified) + distributable ZIP with a size report
16
+ muscat-pk dev:ui # preview the plugin in a mini-Muscat (preview-host)
17
+ muscat-pk dev:back # run the back end (.venv + python main.py)
18
+ muscat-pk dev # dev:ui + dev:back
19
+ muscat-pk update # update @rainbow-robotics/muscat-plugin-kit and @rainbow-robotics/plugin-ui
20
+ muscat-pk sdk list | sdk update [version] # manage rainbow-rb-sdk (python)
21
+ ```
22
+
23
+ ## How it fits together
24
+
25
+ - **Install/CLI package** is `@rainbow-robotics/muscat-plugin-kit`.
26
+ - **Author API** comes from `@rainbow-robotics/plugin-ui`. `build` keeps that package
27
+ external so robot/preview-host can provide the runtime.
28
+ - **`build`** bundles plugin code into ESM while externalizing
29
+ `@rainbow-robotics/plugin-ui`, React, and ReactDOM.
30
+ - **`dev:ui`** builds the plugin then serves it inside the bundled preview host
31
+ at `/plugin-dev` — a real mini-Muscat shell with no robot/back end.
32
+
33
+ `create-muscat-plugin <dir>` is the bin alias for `muscat-pk new`.
34
+
35
+ ## Update check
36
+
37
+ On `dev` / `dev:ui` the kit checks the registry for a newer published version and,
38
+ if found, suggests `muscat-pk update`. It is non-blocking (≤2s) and silent when
39
+ offline.
40
+
41
+ - `RB_REGISTRY` — registry base URL (default `https://registry.npmjs.org`).
42
+ - `MUSCAT_PK_NO_UPDATE_CHECK=1` — skip the check.
43
+
44
+ Full spec: [`docs/plugin-system/CREATE-CLI.md`](../../docs/plugin-system/CREATE-CLI.md).
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // Thin alias: `npm create @rainbow/muscat-plugin <dir>` → `muscat-pk new <dir>`.
3
+ import { runNew } from "../src/new.mjs";
4
+
5
+ runNew(process.argv.slice(2)).catch((err) => {
6
+ console.error(`\n✖ ${err?.message || err}`);
7
+ process.exit(1);
8
+ });
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // muscat-pk — Muscat plugin developer CLI.
3
+ // Subcommands: new, build, pack, dev, dev:ui, dev:back, update, sdk.
4
+ import { runNew } from "../src/new.mjs";
5
+ import { runBuild } from "../src/build.mjs";
6
+ import { runPack } from "../src/pack.mjs";
7
+ import { runDevUi } from "../src/devUi.mjs";
8
+ import { runDevBack } from "../src/devBack.mjs";
9
+ import { runDev } from "../src/dev.mjs";
10
+ import { runUpdate } from "../src/update.mjs";
11
+ import { runSdk } from "../src/sdk.mjs";
12
+
13
+ const [, , cmd, ...args] = process.argv;
14
+
15
+ const HELP = `muscat-pk — Muscat plugin kit
16
+
17
+ Usage: muscat-pk <command> [options]
18
+
19
+ Commands:
20
+ new [dir] Scaffold a new plugin project
21
+ build Build src/content.tsx -> dist/content.js (+ manifest, assets)
22
+ pack Build then create a distributable ZIP (with size report)
23
+ dev Run dev:ui and dev:back together
24
+ dev:ui Preview the plugin in a mini-Muscat (preview-host)
25
+ dev:back Run the plugin back end (python main.py)
26
+ update Update @rainbow-robotics/muscat-plugin-kit and @rainbow-robotics/plugin-ui
27
+ sdk <list|update [version]> Manage the python rainbow-rb-sdk version
28
+
29
+ Run "muscat-pk <command> --help" for details.`;
30
+
31
+ async function main() {
32
+ try {
33
+ switch (cmd) {
34
+ case "new":
35
+ return await runNew(args);
36
+ case "build":
37
+ return await runBuild(args);
38
+ case "pack":
39
+ return await runPack(args);
40
+ case "dev":
41
+ return await runDev(args);
42
+ case "dev:ui":
43
+ return await runDevUi(args);
44
+ case "dev:back":
45
+ return await runDevBack(args);
46
+ case "update":
47
+ return await runUpdate(args);
48
+ case "sdk":
49
+ return await runSdk(args);
50
+ case undefined:
51
+ case "-h":
52
+ case "--help":
53
+ case "help":
54
+ console.log(HELP);
55
+ return;
56
+ default:
57
+ console.error(`Unknown command: ${cmd}\n`);
58
+ console.log(HELP);
59
+ process.exitCode = 1;
60
+ }
61
+ } catch (err) {
62
+ console.error(`\n✖ ${err?.message || err}`);
63
+ process.exitCode = 1;
64
+ }
65
+ }
66
+
67
+ main();
package/cli/m5-e2e.mjs ADDED
@@ -0,0 +1,69 @@
1
+ // M5 e2e: full developer loop. Verifies a plugin scaffolded by `muscat-pk new`,
2
+ // built by `muscat-pk build`, and served by `muscat-pk dev:ui` renders inside the
3
+ // preview-host mini-Muscat. Requires `muscat-pk dev:ui` running on :4180 for a
4
+ // plugin whose name is "Mpk Test".
5
+ // Run from repo root: node packages/rb-plugin-kit/cli/m5-e2e.mjs
6
+ import { chromium } from "playwright-core";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ const EXEC = join(
11
+ homedir(),
12
+ "Library/Caches/ms-playwright/chromium-1223/chrome-mac-arm64",
13
+ "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
14
+ );
15
+ const BASE = "http://localhost:4180";
16
+ const PLUGIN_NAME = "Mpk Test";
17
+
18
+ const R = [];
19
+ const ok = (n, c, e = "") => R.push({ n, c: !!c, e });
20
+
21
+ const b = await chromium.launch({ executablePath: EXEC });
22
+ const p = await b.newPage({ viewport: { width: 1280, height: 800 } });
23
+ const errs = [];
24
+ p.on("pageerror", (e) => errs.push(e.message));
25
+ const logs = [];
26
+ p.on("console", (m) => logs.push(m.text()));
27
+ const wait = (t, to = 40000) =>
28
+ p.waitForFunction(
29
+ (x) => document.body && document.body.innerText.includes(x),
30
+ t,
31
+ { timeout: to },
32
+ );
33
+
34
+ try {
35
+ await p.goto(`${BASE}/muscat/plugin`, { waitUntil: "commit" });
36
+ await wait("D.Board");
37
+ ok("mini-Muscat shell renders", true);
38
+ await wait(PLUGIN_NAME, 20000);
39
+ ok("scaffolded plugin loaded (LNB/content)", true);
40
+ await wait(`${PLUGIN_NAME} width`, 30000);
41
+ ok("scaffolded plugin content renders via rb-components", true);
42
+
43
+ // Grip action round-trips to the preview SDK
44
+ logs.length = 0;
45
+ await p.locator("button", { hasText: "Grip" }).first().click();
46
+ await p.waitForTimeout(1500);
47
+ ok(
48
+ "Grip action round-trips to host SDK",
49
+ logs.some((l) => l.includes("action") && l.includes("grip")),
50
+ logs.join(" | "),
51
+ );
52
+
53
+ const body = await p.evaluate(() => document.body.innerText);
54
+ ok("no API-failure toast", !body.includes("API Request Failed"));
55
+ ok("no page errors", errs.length === 0, errs.slice(0, 2).join(" | "));
56
+ await p.screenshot({ path: "/tmp/m5-built.png" });
57
+ } catch (e) {
58
+ ok("e2e completed", false, String(e));
59
+ } finally {
60
+ await b.close();
61
+ }
62
+
63
+ let pass = true;
64
+ for (const r of R) {
65
+ console.log(`${r.c ? "PASS" : "FAIL"} ${r.n}${!r.c && r.e ? " :: " + r.e : ""}`);
66
+ if (!r.c) pass = false;
67
+ }
68
+ console.log(pass ? "\nALL CHECKS PASSED" : "\nSOME CHECKS FAILED");
69
+ process.exit(pass ? 0 : 1);
@@ -0,0 +1,95 @@
1
+ // muscat-pk build — bundle src/content.tsx into dist/content.js and stage
2
+ // manifest.json and assets/ for distribution.
3
+ //
4
+ // sandbox.html is generated by the CLI — plugin authors never manage it.
5
+ // The import map always reflects the current plugin-ui entry set, so users
6
+ // don't have to update it when the runtime adds new entries.
7
+ //
8
+ // The plugin author's `@rainbow-robotics/plugin-ui` and React imports are left external.
9
+ // Muscat/robot and preview-host provide those modules through a sandbox import
10
+ // map, so plugin ZIPs do not carry the UI runtime.
11
+ import { cpSync, copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { buildWithFallback } from "./esbuild.mjs";
14
+ import { findPluginRoot, log } from "./util.mjs";
15
+
16
+ const SANDBOX_HTML = `<!doctype html>
17
+ <html lang="en">
18
+ <head>
19
+ <meta charset="UTF-8" />
20
+ <title>plugin sandbox</title>
21
+ <script type="importmap">
22
+ {
23
+ "imports": {
24
+ "@rainbow-robotics/plugin-ui/connect": "/plugin-runtime/@rainbow-robotics/plugin-ui/connect.js",
25
+ "@rainbow-robotics/plugin-ui/react": "/plugin-runtime/@rainbow-robotics/plugin-ui/react.js",
26
+ "@rainbow-robotics/plugin-ui/sdk": "/plugin-runtime/@rainbow-robotics/plugin-ui/sdk.js",
27
+ "@rainbow-robotics/plugin-ui/elements": "/plugin-runtime/@rainbow-robotics/plugin-ui/elements.js",
28
+ "react": "/plugin-runtime/vendor/react.js",
29
+ "react/jsx-runtime": "/plugin-runtime/vendor/react-jsx-runtime.js",
30
+ "react/jsx-dev-runtime": "/plugin-runtime/vendor/react-jsx-dev-runtime.js",
31
+ "react-dom/client": "/plugin-runtime/vendor/react-dom-client.js"
32
+ }
33
+ }
34
+ </script>
35
+ </head>
36
+ <body>
37
+ <script type="module" src="./content.js"></script>
38
+ </body>
39
+ </html>
40
+ `;
41
+
42
+ const HOST_PROVIDED_EXTERNALS = [
43
+ "@rainbow-robotics/plugin-ui",
44
+ "@rainbow-robotics/plugin-ui/*",
45
+ "react",
46
+ "react/jsx-runtime",
47
+ "react/jsx-dev-runtime",
48
+ "react-dom",
49
+ "react-dom/*",
50
+ ];
51
+
52
+ export async function buildPlugin({ root, minify = false } = {}) {
53
+ const pluginRoot = root ?? findPluginRoot();
54
+ const entry = resolve(pluginRoot, "src/content.tsx");
55
+ if (!existsSync(entry)) {
56
+ throw new Error(`missing entry: ${entry}`);
57
+ }
58
+ const distDir = resolve(pluginRoot, "dist");
59
+ rmSync(distDir, { recursive: true, force: true });
60
+ mkdirSync(distDir, { recursive: true });
61
+
62
+ log.step("bundling src/content.tsx → dist/content.js");
63
+ await buildWithFallback({
64
+ entryPoints: [entry],
65
+ outfile: resolve(distDir, "content.js"),
66
+ bundle: true,
67
+ format: "esm",
68
+ jsx: "automatic",
69
+ minify,
70
+ loader: { ".tsx": "tsx", ".ts": "ts", ".svg": "dataurl" },
71
+ define: { "process.env.NODE_ENV": JSON.stringify(minify ? "production" : "development") },
72
+ external: HOST_PROVIDED_EXTERNALS,
73
+ logLevel: "warning",
74
+ });
75
+
76
+ // Stage distribution files.
77
+ const manifestSrc = resolve(pluginRoot, "manifest.json");
78
+ if (existsSync(manifestSrc)) copyFileSync(manifestSrc, resolve(distDir, "manifest.json"));
79
+
80
+ // Generate sandbox.html — plugin authors don't manage this file.
81
+ writeFileSync(resolve(distDir, "sandbox.html"), SANDBOX_HTML, "utf8");
82
+ log.step("generated dist/sandbox.html");
83
+ const assetsSrc = resolve(pluginRoot, "assets");
84
+ if (existsSync(assetsSrc)) {
85
+ cpSync(assetsSrc, resolve(distDir, "assets"), { recursive: true });
86
+ }
87
+
88
+ log.ok(`built → ${distDir}`);
89
+ return { pluginRoot, distDir };
90
+ }
91
+
92
+ export async function runBuild(args) {
93
+ const minify = args.includes("--minify");
94
+ await buildPlugin({ minify });
95
+ }
@@ -0,0 +1,28 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readdirSync, statSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
7
+ const roots = [resolve(root, "cli"), resolve(root, "preview-host")];
8
+
9
+ function* mjsFiles(dir) {
10
+ for (const entry of readdirSync(dir)) {
11
+ const full = resolve(dir, entry);
12
+ const stat = statSync(full);
13
+ if (stat.isDirectory()) {
14
+ if (entry === "dist" || entry === "runtime" || entry === "templates") {
15
+ continue;
16
+ }
17
+ yield* mjsFiles(full);
18
+ } else if (entry.endsWith(".mjs")) {
19
+ yield full;
20
+ }
21
+ }
22
+ }
23
+
24
+ for (const dir of roots) {
25
+ for (const file of mjsFiles(dir)) {
26
+ execFileSync(process.execPath, ["--check", file], { stdio: "inherit" });
27
+ }
28
+ }
@@ -0,0 +1,11 @@
1
+ // muscat-pk dev — run dev:ui and dev:back together.
2
+ import { runDevUi } from "./devUi.mjs";
3
+ import { runDevBack } from "./devBack.mjs";
4
+ import { log } from "./util.mjs";
5
+
6
+ export async function runDev(args = []) {
7
+ log.step("starting dev:ui + dev:back");
8
+ // dev:back runs to completion / detaches on its own; dev:ui owns the process.
9
+ runDevBack().catch((e) => log.warn(`dev:back: ${e.message}`));
10
+ await runDevUi(args);
11
+ }
@@ -0,0 +1,65 @@
1
+ // muscat-pk dev:back — run the plugin back end.
2
+ // sdk : create/refresh .venv then `python main.py`
3
+ // ros2 : requires Docker (Jazzy container) — guidance only here
4
+ // none : nothing to run
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { findPluginRoot, log } from "./util.mjs";
9
+
10
+ function manifestRuntime(root) {
11
+ try {
12
+ const m = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
13
+ return m.runtime?.type ?? "none";
14
+ } catch {
15
+ return "none";
16
+ }
17
+ }
18
+
19
+ export async function runDevBack() {
20
+ const root = findPluginRoot();
21
+ const type = manifestRuntime(root);
22
+
23
+ if (type === "none") {
24
+ log.info("runtime: none — no back end to run.");
25
+ return;
26
+ }
27
+
28
+ if (type === "ros2") {
29
+ log.warn(
30
+ "ROS2 back end runs in a Jazzy + Python 3.12 Docker container.\n" +
31
+ " Ensure Docker is running, then: muscat-pk dev:back (container support TBD).",
32
+ );
33
+ return;
34
+ }
35
+
36
+ // python / rb_sdk
37
+ const venv = resolve(root, ".venv");
38
+ const py = process.platform === "win32"
39
+ ? resolve(venv, "Scripts", "python.exe")
40
+ : resolve(venv, "bin", "python");
41
+
42
+ if (!existsSync(py)) {
43
+ log.step("creating .venv (python3.12 preferred)…");
44
+ const python = spawnSync("python3.12", ["--version"]).status === 0
45
+ ? "python3.12"
46
+ : "python3";
47
+ if (python !== "python3.12") {
48
+ log.warn("python3.12 not found; using python3 (robot targets 3.12).");
49
+ }
50
+ spawnSync(python, ["-m", "venv", venv], { cwd: root, stdio: "inherit" });
51
+ }
52
+
53
+ const reqs = resolve(root, "requirements.txt");
54
+ if (existsSync(reqs)) {
55
+ log.step("pip install -r requirements.txt…");
56
+ spawnSync(py, ["-m", "pip", "install", "-q", "-r", reqs], {
57
+ cwd: root,
58
+ stdio: "inherit",
59
+ });
60
+ }
61
+
62
+ log.step("python main.py");
63
+ const child = spawn(py, ["main.py"], { cwd: root, stdio: "inherit" });
64
+ child.on("exit", (code) => process.exit(code ?? 0));
65
+ }
@@ -0,0 +1,50 @@
1
+ // muscat-pk dev:ui — preview the plugin inside a mini-Muscat (preview-host).
2
+ import { spawn } from "node:child_process";
3
+ import { createRequire } from "node:module";
4
+ import { existsSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
6
+ import { buildPlugin } from "./build.mjs";
7
+ import { checkKitUpdate, findPluginRoot, kitRoot, log } from "./util.mjs";
8
+
9
+ /** Resolve @rainbow-robotics/preview-host package root.
10
+ * Works in monorepo (yarn workspace hoisting) and when both packages are
11
+ * installed as deps of the plugin project. */
12
+ function findPreviewHostRoot() {
13
+ try {
14
+ const req = createRequire(import.meta.url);
15
+ return dirname(req.resolve("@rainbow-robotics/preview-host/package.json"));
16
+ } catch {
17
+ // fallback: sibling package in muscat-plugin-kit/ workspace
18
+ return resolve(kitRoot, "../preview-host");
19
+ }
20
+ }
21
+
22
+ export async function runDevUi(args = []) {
23
+ const portIdx = args.indexOf("--port");
24
+ const port = portIdx !== -1 ? args[portIdx + 1] : "4180";
25
+
26
+ await checkKitUpdate();
27
+ const { distDir } = await buildPlugin();
28
+
29
+ const previewRoot = findPreviewHostRoot();
30
+ const serveEntry = resolve(previewRoot, "serve.mjs");
31
+ if (!existsSync(resolve(previewRoot, "dist", "index.html"))) {
32
+ log.step("building preview-host bundle (first run)…");
33
+ await new Promise((res, rej) => {
34
+ const p = spawn("node", [resolve(previewRoot, "build.mjs")], {
35
+ stdio: "inherit",
36
+ });
37
+ p.on("exit", (c) => (c === 0 ? res() : rej(new Error("preview-host build failed"))));
38
+ });
39
+ }
40
+
41
+ log.step(`starting preview-host on :${port}`);
42
+ const child = spawn(
43
+ "node",
44
+ [serveEntry, "--plugin", distDir, "--port", String(port)],
45
+ { stdio: "inherit" },
46
+ );
47
+ child.on("exit", (code) => process.exit(code ?? 0));
48
+ }
49
+
50
+ export { findPluginRoot };
@@ -0,0 +1,15 @@
1
+ export async function buildWithFallback(options) {
2
+ const native = await import("esbuild");
3
+ try {
4
+ return await native.build(options);
5
+ } catch (error) {
6
+ const message = String(error?.message || error);
7
+ if (!message.includes("another platform")) {
8
+ throw error;
9
+ }
10
+
11
+ const wasm = await import("esbuild-wasm");
12
+ console.warn("[muscat-pk] native esbuild binary mismatch; falling back to esbuild-wasm");
13
+ return wasm.build(options);
14
+ }
15
+ }
@@ -0,0 +1,125 @@
1
+ // muscat-pk new — scaffold a new plugin project from templates.
2
+ import { execSync } from "node:child_process";
3
+ import {
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { createInterface } from "node:readline/promises";
13
+ import { basename, resolve } from "node:path";
14
+ import { parseArgs, templatesDir, log } from "./util.mjs";
15
+
16
+ const TYPES = ["sdk", "ros2", "ui"];
17
+ const PMS = ["npm", "yarn", "pnpm", "bun"];
18
+ const DEFAULT_SDK_VERSION = "2.4.1";
19
+
20
+ async function prompt(question, fallback) {
21
+ if (!process.stdin.isTTY) return fallback;
22
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
23
+ const answer = (await rl.question(question)).trim();
24
+ rl.close();
25
+ return answer || fallback;
26
+ }
27
+
28
+ function applyTokens(dir, tokens) {
29
+ for (const entry of readdirSync(dir)) {
30
+ const full = resolve(dir, entry);
31
+ if (statSync(full).isDirectory()) {
32
+ applyTokens(full, tokens);
33
+ continue;
34
+ }
35
+ if (/\.(png|jpg|jpeg|gif|woff2?|glb|mp3)$/.test(entry)) continue;
36
+ let content = readFileSync(full, "utf8");
37
+ let changed = false;
38
+ for (const [k, v] of Object.entries(tokens)) {
39
+ if (content.includes(k)) {
40
+ content = content.split(k).join(v);
41
+ changed = true;
42
+ }
43
+ }
44
+ if (changed) writeFileSync(full, content);
45
+ }
46
+ }
47
+
48
+ export async function runNew(args) {
49
+ const opts = parseArgs(args, { booleans: ["yes", "no-install", "help"] });
50
+ if (opts.help) {
51
+ console.log(
52
+ "muscat-pk new [dir] [--type sdk|ros2|ui] [--pm npm|yarn|pnpm|bun]\n" +
53
+ " [--sdk-version <v>] [--yes] [--no-install]",
54
+ );
55
+ return;
56
+ }
57
+
58
+ const dirArg = opts._[0];
59
+ const targetName =
60
+ dirArg || (await prompt("Plugin directory name: ", "my-plugin"));
61
+ const target = resolve(process.cwd(), targetName);
62
+ if (existsSync(target) && readdirSync(target).length > 0) {
63
+ throw new Error(`directory not empty: ${target}`);
64
+ }
65
+
66
+ let type = opts.type;
67
+ if (!type && !opts.yes) {
68
+ type = await prompt(`Plugin type (${TYPES.join("/")}) [sdk]: `, "sdk");
69
+ }
70
+ type = TYPES.includes(type) ? type : "sdk";
71
+
72
+ let pm = opts.pm;
73
+ if (!pm && !opts.yes) {
74
+ pm = await prompt(`Package manager (${PMS.join("/")}) [npm]: `, "npm");
75
+ }
76
+ pm = PMS.includes(pm) ? pm : "npm";
77
+
78
+ const sdkVersion = opts["sdk-version"] || DEFAULT_SDK_VERSION;
79
+ const pluginName = basename(target)
80
+ .replace(/[-_]/g, " ")
81
+ .replace(/\b\w/g, (c) => c.toUpperCase());
82
+ const pluginId = `com.example.${basename(target).toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
83
+
84
+ mkdirSync(target, { recursive: true });
85
+
86
+ log.step(`scaffolding ${type} plugin → ${target}`);
87
+ cpSync(resolve(templatesDir, "common"), target, { recursive: true });
88
+ if (type === "sdk") {
89
+ cpSync(resolve(templatesDir, "sdk"), target, { recursive: true });
90
+ } else if (type === "ros2") {
91
+ if (existsSync(resolve(templatesDir, "ros2-rclpy"))) {
92
+ cpSync(resolve(templatesDir, "ros2-rclpy"), target, { recursive: true });
93
+ } else {
94
+ log.warn("ros2 template not bundled; scaffolding UI-only base");
95
+ }
96
+ }
97
+
98
+ applyTokens(target, {
99
+ __PLUGIN_ID__: pluginId,
100
+ __PLUGIN_NAME__: pluginName,
101
+ __SDK_VERSION__: sdkVersion,
102
+ __PM__: pm,
103
+ __RUNTIME_TYPE__: type === "ui" ? "none" : type === "ros2" ? "ros2" : "python",
104
+ });
105
+
106
+ // gitignore (templates can't ship a literal .gitignore via npm reliably)
107
+ writeFileSync(
108
+ resolve(target, ".gitignore"),
109
+ ["node_modules", "dist", ".venv", "*.zip", "ros2_ws/install", ""].join("\n"),
110
+ );
111
+
112
+ if (!opts["no-install"]) {
113
+ try {
114
+ log.step(`installing JS deps with ${pm}…`);
115
+ execSync(`${pm} install`, { cwd: target, stdio: "inherit" });
116
+ } catch {
117
+ log.warn(`'${pm} install' failed; run it manually in ${targetName}`);
118
+ }
119
+ }
120
+
121
+ log.ok(`created ${targetName} (type: ${type}, pm: ${pm})`);
122
+ log.info(`\n cd ${targetName}`);
123
+ log.info(` ${pm} run dev # preview in mini-Muscat + back end`);
124
+ log.info(` ${pm} run pack # build a distributable ZIP\n`);
125
+ }
@@ -0,0 +1,58 @@
1
+ // muscat-pk pack — build, then create a distributable ZIP with a size report.
2
+ import { execSync } from "node:child_process";
3
+ import { existsSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
4
+ import { basename, resolve } from "node:path";
5
+ import { buildPlugin } from "./build.mjs";
6
+ import { findPluginRoot, humanSize, log } from "./util.mjs";
7
+
8
+ function dirSize(dir) {
9
+ let total = 0;
10
+ for (const entry of readdirSync(dir)) {
11
+ const full = resolve(dir, entry);
12
+ const st = statSync(full);
13
+ total += st.isDirectory() ? dirSize(full) : st.size;
14
+ }
15
+ return total;
16
+ }
17
+
18
+ export async function runPack() {
19
+ const pluginRoot = findPluginRoot();
20
+ await buildPlugin({ root: pluginRoot, minify: true });
21
+
22
+ const manifest = JSON.parse(
23
+ readFileSync(resolve(pluginRoot, "manifest.json"), "utf8"),
24
+ );
25
+ const zipName = `${basename(pluginRoot)}-${manifest.version || "0.0.0"}.zip`;
26
+ const zipPath = resolve(pluginRoot, zipName);
27
+ rmSync(zipPath, { force: true });
28
+
29
+ // Contents: dist/ + back-end files if present.
30
+ const entries = ["dist"];
31
+ for (const f of ["main.py", "requirements.txt"]) {
32
+ if (existsSync(resolve(pluginRoot, f))) entries.push(f);
33
+ }
34
+ if (existsSync(resolve(pluginRoot, "ros2_ws/install"))) {
35
+ entries.push("ros2_ws/install");
36
+ }
37
+
38
+ log.step(`packing ${entries.join(", ")} → ${zipName}`);
39
+ try {
40
+ execSync(`zip -r -q "${zipPath}" ${entries.map((e) => `"${e}"`).join(" ")}`, {
41
+ cwd: pluginRoot,
42
+ stdio: "inherit",
43
+ });
44
+ } catch {
45
+ throw new Error("`zip` not found. Install zip or pack manually.");
46
+ }
47
+
48
+ const zipBytes = statSync(zipPath).size;
49
+ log.ok(`${zipName} ${humanSize(zipBytes)}`);
50
+ for (const e of entries) {
51
+ const full = resolve(pluginRoot, e);
52
+ const size = statSync(full).isDirectory() ? dirSize(full) : statSync(full).size;
53
+ log.info(` ├─ ${e} ${humanSize(size)}`);
54
+ }
55
+ log.warn(
56
+ "robot install checks free space via GET /plugin/capacity (507 if low).",
57
+ );
58
+ }
@@ -0,0 +1,67 @@
1
+ // muscat-pk sdk <list|update [version]> — manage the python rainbow-rb-sdk.
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { findPluginRoot, log } from "./util.mjs";
6
+
7
+ const PKG = "rainbow-rb-sdk";
8
+
9
+ async function fetchVersions() {
10
+ try {
11
+ const res = await fetch(`https://pypi.org/pypi/${PKG}/json`);
12
+ if (!res.ok) return [];
13
+ const data = await res.json();
14
+ return Object.keys(data.releases || {}).sort((a, b) =>
15
+ b.localeCompare(a, undefined, { numeric: true }),
16
+ );
17
+ } catch {
18
+ return [];
19
+ }
20
+ }
21
+
22
+ export async function runSdk(args = []) {
23
+ const sub = args[0];
24
+ const root = findPluginRoot();
25
+ const reqs = resolve(root, "requirements.txt");
26
+
27
+ if (sub === "list") {
28
+ const versions = await fetchVersions();
29
+ if (!versions.length) {
30
+ log.warn("could not fetch versions (offline?).");
31
+ return;
32
+ }
33
+ log.info(`${PKG} versions:\n ${versions.slice(0, 12).join("\n ")}`);
34
+ return;
35
+ }
36
+
37
+ if (sub === "update") {
38
+ let version = args[1];
39
+ if (!version) {
40
+ const versions = await fetchVersions();
41
+ version = versions[0] || "latest";
42
+ }
43
+ let content = existsSync(reqs) ? readFileSync(reqs, "utf8") : "";
44
+ const line = `${PKG}==${version}`;
45
+ if (new RegExp(`^${PKG}==`, "m").test(content)) {
46
+ content = content.replace(new RegExp(`^${PKG}==.*$`, "m"), line);
47
+ } else {
48
+ content = (content ? content.trimEnd() + "\n" : "") + line + "\n";
49
+ }
50
+ writeFileSync(reqs, content);
51
+ log.ok(`requirements.txt → ${line}`);
52
+
53
+ const py = resolve(root, ".venv", "bin", "python");
54
+ if (existsSync(py)) {
55
+ log.step("pip install -r requirements.txt…");
56
+ spawnSync(py, ["-m", "pip", "install", "-q", "-r", reqs], {
57
+ cwd: root,
58
+ stdio: "inherit",
59
+ });
60
+ } else {
61
+ log.warn(".venv not found; run `muscat-pk dev:back` to install.");
62
+ }
63
+ return;
64
+ }
65
+
66
+ log.info("Usage: muscat-pk sdk <list|update [version]>");
67
+ }
@@ -0,0 +1,52 @@
1
+ // muscat-pk update — update the JS plugin kit and host-provided UI contract.
2
+ import { execSync } from "node:child_process";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { findPluginRoot, KIT_PUBLISHED_NAME, log, parseArgs } from "./util.mjs";
6
+
7
+ const PLUGIN_UI_PACKAGE = "@rainbow-robotics/plugin-ui";
8
+
9
+ function detectPm(root) {
10
+ try {
11
+ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
12
+ if (pkg.packageManager) return pkg.packageManager.split("@")[0];
13
+ } catch {
14
+ /* ignore */
15
+ }
16
+ if (existsSync(resolve(root, "pnpm-lock.yaml"))) return "pnpm";
17
+ if (existsSync(resolve(root, "yarn.lock"))) return "yarn";
18
+ if (existsSync(resolve(root, "bun.lockb"))) return "bun";
19
+ return "npm";
20
+ }
21
+
22
+ export async function runUpdate(args = []) {
23
+ const root = findPluginRoot();
24
+ const pm = detectPm(root);
25
+ const opts = parseArgs(args);
26
+
27
+ const range =
28
+ opts._[0] ||
29
+ opts.version ||
30
+ opts["muscat-plugin-kit"] ||
31
+ opts["plugin-ui"] ||
32
+ "latest";
33
+ const list = [
34
+ `${KIT_PUBLISHED_NAME}@${range}`,
35
+ `${PLUGIN_UI_PACKAGE}@${range}`,
36
+ ];
37
+
38
+ const cmd =
39
+ pm === "npm"
40
+ ? `npm install ${list.join(" ")}`
41
+ : pm === "yarn"
42
+ ? `yarn add ${list.join(" ")}`
43
+ : `${pm} add ${list.join(" ")}`;
44
+
45
+ log.step(`${pm}: ${list.join(", ")}`);
46
+ try {
47
+ execSync(cmd, { cwd: root, stdio: "inherit" });
48
+ log.ok("kit packages updated");
49
+ } catch {
50
+ log.warn("update failed (offline?). Run manually:\n " + cmd);
51
+ }
52
+ }
@@ -0,0 +1,120 @@
1
+ // Shared helpers for muscat-pk.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /** Published install/CLI package name. Author imports use @rainbow-robotics/plugin-ui separately. */
7
+ export const KIT_PUBLISHED_NAME = "@rainbow-robotics/muscat-plugin-kit";
8
+
9
+ // This file lives at <pkg>/cli/src/util.mjs → package root is two levels up.
10
+ export const kitRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
11
+ export const templatesDir = resolve(kitRoot, "cli/templates");
12
+
13
+ /** Parse `--flag value` and `--bool` from argv into an object. */
14
+ export function parseArgs(args, { booleans = [] } = {}) {
15
+ const out = { _: [] };
16
+ for (let i = 0; i < args.length; i++) {
17
+ const a = args[i];
18
+ if (a.startsWith("--")) {
19
+ const key = a.slice(2);
20
+ if (booleans.includes(key)) {
21
+ out[key] = true;
22
+ } else if (args[i + 1] && !args[i + 1].startsWith("--")) {
23
+ out[key] = args[++i];
24
+ } else {
25
+ out[key] = true;
26
+ }
27
+ } else {
28
+ out._.push(a);
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /** Walk up from cwd to find the plugin project root (has manifest.json). */
35
+ export function findPluginRoot(start = process.cwd()) {
36
+ let dir = resolve(start);
37
+ for (;;) {
38
+ if (existsSync(resolve(dir, "manifest.json"))) return dir;
39
+ const parent = dirname(dir);
40
+ if (parent === dir) break;
41
+ dir = parent;
42
+ }
43
+ throw new Error(
44
+ "Not a muscat plugin project (no manifest.json found). Run from your plugin directory.",
45
+ );
46
+ }
47
+
48
+ export const log = {
49
+ info: (...m) => console.log(...m),
50
+ step: (...m) => console.log("›", ...m),
51
+ ok: (...m) => console.log("✓", ...m),
52
+ warn: (...m) => console.warn("⚠", ...m),
53
+ };
54
+
55
+ export function humanSize(bytes) {
56
+ if (bytes < 1024) return `${bytes} B`;
57
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
58
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
59
+ }
60
+
61
+ /** This kit's installed version (from its package.json). */
62
+ export function kitVersion() {
63
+ try {
64
+ return JSON.parse(readFileSync(resolve(kitRoot, "package.json"), "utf8"))
65
+ .version;
66
+ } catch {
67
+ return "0.0.0";
68
+ }
69
+ }
70
+
71
+ /** Compare semver-ish "x.y.z[-pre]". Returns true if `a` > `b` (release parts). */
72
+ export function isNewer(a, b) {
73
+ const parse = (v) =>
74
+ String(v)
75
+ .split("-")[0]
76
+ .split(".")
77
+ .map((n) => parseInt(n, 10) || 0);
78
+ const [a1, a2, a3] = parse(a);
79
+ const [b1, b2, b3] = parse(b);
80
+ if (a1 !== b1) return a1 > b1;
81
+ if (a2 !== b2) return a2 > b2;
82
+ return a3 > b3;
83
+ }
84
+
85
+ /**
86
+ * Non-blocking check for a newer published kit. Prints an update suggestion if
87
+ * one exists. Fails silently when offline. Honors `RB_REGISTRY` and is skipped
88
+ * by `MUSCAT_PK_NO_UPDATE_CHECK=1`.
89
+ */
90
+ export async function checkKitUpdate({ timeoutMs = 2000 } = {}) {
91
+ if (process.env.MUSCAT_PK_NO_UPDATE_CHECK === "1") return;
92
+ const current = kitVersion();
93
+ const registry = (process.env.RB_REGISTRY || "https://registry.npmjs.org").replace(/\/$/, "");
94
+ try {
95
+ const ac = new AbortController();
96
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
97
+ const res = await fetch(
98
+ `${registry}/${encodeURIComponent(KIT_PUBLISHED_NAME)}/latest`,
99
+ { signal: ac.signal },
100
+ );
101
+ clearTimeout(timer);
102
+ if (!res.ok) return;
103
+ const { version: latest } = await res.json();
104
+ if (latest && isNewer(latest, current)) {
105
+ const line = "─".repeat(58);
106
+ console.log(
107
+ [
108
+ "",
109
+ line,
110
+ ` muscat-plugin-kit 새 버전이 있습니다: ${current} → ${latest}`,
111
+ ` 업데이트: muscat-pk update`,
112
+ line,
113
+ "",
114
+ ].join("\n"),
115
+ );
116
+ }
117
+ } catch {
118
+ /* offline or registry unreachable — ignore */
119
+ }
120
+ }
@@ -0,0 +1,16 @@
1
+ # __PLUGIN_NAME__
2
+
3
+ A Muscat plugin scaffolded with `muscat-pk`.
4
+
5
+ ```bash
6
+ __PM__ run dev # preview in a mini-Muscat (preview-host) + back end
7
+ __PM__ run dev:ui # UI preview only
8
+ __PM__ run pack # build a distributable ZIP
9
+ ```
10
+
11
+ - `src/content.tsx` — your plugin UI. Import components from `@rainbow-robotics/plugin-ui/react`.
12
+ - `manifest.json` — id, contributions, actions, runtime.
13
+ - `assets/` — static assets (icons referenced from the manifest).
14
+ - `main.py` — (sdk runtime) back-end handler using `rainbow-rb-sdk`.
15
+
16
+ The plugin only emits `rb-*` name tags; Muscat renders the real components.
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="1em" height="1em" fill="none">
2
+ <path fill="currentColor" d="M16 3a13 13 0 1 0 0 26 13 13 0 0 0 0-26Zm0 4a3 3 0 0 1 3 3v2.18a3 3 0 0 1 0 5.64V20a3 3 0 1 1-6 0v-2.18a3 3 0 0 1 0-5.64V10a3 3 0 0 1 3-3Z"/>
3
+ </svg>
@@ -0,0 +1,25 @@
1
+ {
2
+ "id": "__PLUGIN_ID__",
3
+ "name": "__PLUGIN_NAME__",
4
+ "version": "1.0.0",
5
+ "minAppVersion": "2.0.0",
6
+ "icon": "assets/icon.svg",
7
+ "contributes": [
8
+ { "point": "program.panel", "label": "__PLUGIN_NAME__", "icon": "assets/icon.svg" },
9
+ { "point": "lnb", "label": "__PLUGIN_NAME__", "icon": "assets/icon.svg" },
10
+ {
11
+ "point": "header.toolbar",
12
+ "label": "__PLUGIN_NAME__",
13
+ "icon": "assets/icon.svg",
14
+ "opens": "popover"
15
+ }
16
+ ],
17
+ "actions": [
18
+ {
19
+ "id": "grip",
20
+ "topic": "rby2/plugin/__PLUGIN_ID__/grip",
21
+ "schema": { "width": "number" }
22
+ }
23
+ ],
24
+ "runtime": { "type": "__RUNTIME_TYPE__", "entry": "main.py" }
25
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "__PLUGIN_ID__",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "muscat-pk dev",
8
+ "dev:ui": "muscat-pk dev:ui",
9
+ "dev:back": "muscat-pk dev:back",
10
+ "build": "muscat-pk build",
11
+ "pack": "muscat-pk pack"
12
+ },
13
+ "dependencies": {
14
+ "@rainbow-robotics/plugin-ui": "^0.1.0"
15
+ },
16
+ "devDependencies": {
17
+ "@rainbow-robotics/muscat-plugin-kit": "^0.1.0",
18
+ "@rainbow-robotics/preview-host": "^0.1.0",
19
+ "typescript": "^5.8.0",
20
+ "vite": "^6.4.1"
21
+ }
22
+ }
@@ -0,0 +1,32 @@
1
+ import { useState } from "react";
2
+ import { connect } from "@rainbow-robotics/plugin-ui/connect";
3
+ import { Slider, Button, Text, Switch, useRb } from "@rainbow-robotics/plugin-ui/react";
4
+
5
+ function App() {
6
+ const rb = useRb();
7
+ const [width, setWidth] = useState(20);
8
+ const [open, setOpen] = useState(false);
9
+
10
+ return (
11
+ <>
12
+ <Text>__PLUGIN_NAME__ width: {width}</Text>
13
+ <Slider
14
+ value={width}
15
+ min={0}
16
+ max={100}
17
+ onChange={(event: { detail?: number }) => setWidth(event.detail ?? 0)}
18
+ />
19
+ <Button label="Grip" onPress={() => rb.action("grip", { width })} />
20
+ <Switch
21
+ checked={open}
22
+ onChange={(event: { detail?: boolean }) => {
23
+ const next = event.detail ?? false;
24
+ setOpen(next);
25
+ rb.notify(next ? "opened" : "closed", "info");
26
+ }}
27
+ />
28
+ </>
29
+ );
30
+ }
31
+
32
+ connect(App);
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "lib": ["DOM", "DOM.Iterable", "ESNext"]
11
+ },
12
+ "include": ["src"]
13
+ }
@@ -0,0 +1,29 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ // Plugin UI and React are provided by Muscat/preview-host via import map.
5
+ // They must NOT be bundled into the plugin ZIP.
6
+ const HOST_PROVIDED_EXTERNALS = [
7
+ "@rainbow-robotics/plugin-ui",
8
+ /^@rainbow\/plugin-ui\//,
9
+ "react",
10
+ "react/jsx-runtime",
11
+ "react/jsx-dev-runtime",
12
+ "react-dom",
13
+ /^react-dom\//,
14
+ ];
15
+
16
+ export default defineConfig({
17
+ plugins: [react()],
18
+ build: {
19
+ lib: {
20
+ entry: "src/content.tsx",
21
+ formats: ["es"],
22
+ fileName: "content",
23
+ },
24
+ outDir: "dist",
25
+ rollupOptions: {
26
+ external: HOST_PROVIDED_EXTERNALS,
27
+ },
28
+ },
29
+ });
@@ -0,0 +1,21 @@
1
+ import asyncio
2
+
3
+ from rb_sdk import RBManipulateSDK # PyPI: rainbow-rb-sdk
4
+
5
+ sdk = RBManipulateSDK()
6
+
7
+
8
+ async def on_action(payload: dict):
9
+ # Matches manifest.actions[].topic — handle the "grip" action here.
10
+ width = payload.get("width")
11
+ print(f"[__PLUGIN_NAME__] grip width={width}")
12
+ # TODO: drive the robot via the SDK.
13
+
14
+
15
+ async def main():
16
+ await sdk.subscribe("rby2/plugin/__PLUGIN_ID__/grip", on_action)
17
+ await sdk.spin()
18
+
19
+
20
+ if __name__ == "__main__":
21
+ asyncio.run(main())
@@ -0,0 +1 @@
1
+ rainbow-rb-sdk==__SDK_VERSION__
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@rainbow-robotics/muscat-plugin-kit",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=20"
7
+ },
8
+ "description": "Muscat plugin kit: muscat-pk CLI, plugin scaffold, build/pack/dev tools",
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "bin": {
13
+ "create-muscat-plugin": "./cli/bin/create-muscat-plugin.mjs",
14
+ "muscat-pk": "./cli/bin/muscat-pk.mjs"
15
+ },
16
+ "files": [
17
+ "cli",
18
+ "package.json"
19
+ ],
20
+ "scripts": {
21
+ "lint": "node cli/src/check-package.mjs",
22
+ "check": "node cli/src/check-package.mjs",
23
+ "check-types": "node cli/src/check-package.mjs"
24
+ },
25
+ "devDependencies": {
26
+ "@eslint/js": "^9.22.0",
27
+ "@repo/eslint-config": "*",
28
+ "@repo/typescript-config": "*",
29
+ "@types/node": "^22.13.9",
30
+ "@types/react": "^19.0.10",
31
+ "@types/react-dom": "^19.0.4"
32
+ },
33
+ "dependencies": {
34
+ "@rainbow-robotics/preview-host": "*",
35
+ "esbuild": "^0.25.0",
36
+ "esbuild-wasm": "^0.25.0"
37
+ }
38
+ }