@rainbow-robotics/muscat-plugin-kit 1.10.2-dev.5 → 1.10.2-dev.7

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 (36) hide show
  1. package/cli/bin/muscat-pk.mjs +3 -0
  2. package/cli/src/build.mjs +4 -9
  3. package/cli/src/cppIde.mjs +2 -1
  4. package/cli/src/dev.mjs +4 -1
  5. package/cli/src/devBack.mjs +10 -7
  6. package/cli/src/devId.mjs +54 -0
  7. package/cli/src/devUi.mjs +24 -14
  8. package/cli/src/mcpProxy.mjs +110 -0
  9. package/cli/src/muscatLocal.mjs +15 -2
  10. package/cli/src/storybook.mjs +2 -20
  11. package/cli/src/templates-embedded.mjs +2 -2
  12. package/cli/src/util.mjs +29 -1
  13. package/cli/src/zip.mjs +121 -0
  14. package/cli/storybook-dist/assets/{Color-YHDXOIA2-d4UialIW.js → Color-YHDXOIA2-BoqBLKNL.js} +1 -1
  15. package/cli/storybook-dist/assets/{Docs-DpuckkF2.js → Docs-BhJTZ0C_.js} +1 -1
  16. package/cli/storybook-dist/assets/{Docs-tWjVyRAS.js → Docs-BlIu-JrJ.js} +1 -1
  17. package/cli/storybook-dist/assets/{Docs-vU43epz3.js → Docs-C4ktkQce.js} +1 -1
  18. package/cli/storybook-dist/assets/{Docs-DOzwT3cw.js → Docs-Cg3A7xDz.js} +1 -1
  19. package/cli/storybook-dist/assets/{Docs-CppxziOu.js → Docs-VKVuhalv.js} +1 -1
  20. package/cli/storybook-dist/assets/{Docs-BySWRVQD.js → Docs-_i1XHVXU.js} +1 -1
  21. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-Boj7BBcx.js → DocsRenderer-CFRXHY34-BGHjhFiI.js} +1 -1
  22. package/cli/storybook-dist/assets/{entry-preview-BfxbSNyQ.js → entry-preview-C2IBQfq7.js} +1 -1
  23. package/cli/storybook-dist/assets/{iframe-D3ykNOcq.js → iframe-D-mTyH7q.js} +2 -2
  24. package/cli/storybook-dist/assets/{index-NJyPL3Uy.js → index-DKrg3vQJ.js} +3 -3
  25. package/cli/storybook-dist/assets/preview-CpePTsLG.js +5 -0
  26. package/cli/storybook-dist/assets/{preview-BoJV1ynr.js → preview-i5rYb1IR.js} +2 -2
  27. package/cli/storybook-dist/iframe.html +1 -1
  28. package/cli/storybook-dist/project.json +1 -1
  29. package/cli/templates/common/README.md +14 -1
  30. package/cli/templates/common/py/main.py +2 -2
  31. package/cli/templates/cpp/cpp/main.cpp +1 -1
  32. package/cli/templates/readme-cpp/README.md +16 -3
  33. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +12 -1
  34. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +18 -0
  35. package/package.json +3 -3
  36. package/cli/storybook-dist/assets/preview-3_yzT7Wt.js +0 -3
@@ -121,6 +121,9 @@ async function main() {
121
121
  case "storybook":
122
122
  return await runStorybook(args);
123
123
  case "mcp": {
124
+ // Relay by default so `muscat-pk update` reaches a running session; --serve is the server.
125
+ if (args[0] !== "--serve" && args[0] !== "--install" && args[0] !== "install")
126
+ return (await import("../src/mcpProxy.mjs")).runMcpProxy();
124
127
  const { runMcp, installMcp } = await import("../src/mcp.mjs");
125
128
  if (args[0] === "--install" || args[0] === "install")
126
129
  return installMcp(args[1]);
package/cli/src/build.mjs CHANGED
@@ -13,9 +13,10 @@ import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, re
13
13
  import { createRequire } from "node:module";
14
14
  import { basename, dirname, resolve } from "node:path";
15
15
  import { createInterface } from "node:readline/promises";
16
- import { execSync, spawnSync } from "node:child_process";
16
+ import { spawnSync } from "node:child_process";
17
17
  import { ensureDocker, findPluginRoot, hasBin, humanSize, log, parseArgs, prompt } from "./util.mjs";
18
18
  import { assertManifest, manifestRuntime } from "./manifest.mjs";
19
+ import { writeZip } from "./zip.mjs";
19
20
 
20
21
  // Find a usable JS runtime for running vite as a subprocess.
21
22
  // In binary (Bun) mode, try `bun` first (it can run vite directly), then node.
@@ -343,14 +344,8 @@ function packZip(pluginRoot) {
343
344
  const entries = ["dist"];
344
345
 
345
346
  log.step(`packing ${entries.join(", ")} → ${zipName}`);
346
- try {
347
- execSync(`zip -r -q "${zipPath}" ${entries.map((e) => `"${e}"`).join(" ")}`, {
348
- cwd: pluginRoot,
349
- stdio: "inherit",
350
- });
351
- } catch {
352
- throw new Error("`zip` not found. Install zip or pack manually.");
353
- }
347
+ // In-process, not the `zip` CLI: Windows has none, and it keeps the robot's executable bits (zip.mjs).
348
+ writeZip(zipPath, entries, pluginRoot);
354
349
 
355
350
  const zipBytes = statSync(zipPath).size;
356
351
  log.ok(`${zipName} ${humanSize(zipBytes)}`);
@@ -5,7 +5,7 @@
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
- import { cppSdkFiles, log } from "./util.mjs";
8
+ import { cppSdkFiles, ensureDevDir, log } from "./util.mjs";
9
9
 
10
10
  const SYSROOT = ".muscat-dev/sysroot";
11
11
  const CONFIG_NAME = "Muscat";
@@ -25,6 +25,7 @@ export function syncCppIde(root, image) {
25
25
  const stamp = resolve(sysroot, ".image-id");
26
26
  if (!existsSync(stamp) || readFileSync(stamp, "utf8") !== id) {
27
27
  rmSync(sysroot, { recursive: true, force: true });
28
+ ensureDevDir(root);
28
29
  const create = spawnSync("docker", ["create", image], { encoding: "utf8" });
29
30
  if (create.status !== 0) return log.warn("could not copy SDK headers for the editor (docker create failed).");
30
31
  const container = create.stdout.trim();
package/cli/src/dev.mjs CHANGED
@@ -7,7 +7,8 @@ import { fileURLToPath } from "node:url";
7
7
  import { runDevUi } from "./devUi.mjs";
8
8
  import { stopDevBackContainers } from "./devBack.mjs";
9
9
  import { ensureLocalMuscat, GATEWAY_PORT, isPortInUse, robotHasMuscat } from "./muscatLocal.mjs";
10
- import { assertManifestPorts, assertManifestVariables } from "./manifest.mjs";
10
+ import { assertManifestPorts, assertManifestVariables, readManifest } from "./manifest.mjs";
11
+ import { devPluginId } from "./devId.mjs";
11
12
  import {
12
13
  ensureDocker,
13
14
  ensureIdeVenv,
@@ -119,6 +120,8 @@ export async function runDev(args = []) {
119
120
  process.env.MUSCAT_ROBOT_TOKEN = robotToken;
120
121
 
121
122
  const remoteRobot = isRemoteRobot(robotIp);
123
+ // Before dev:back is spawned: it inherits the id through the environment.
124
+ devPluginId(readManifest(root).id, { robotIp, args });
122
125
  const hostAgent = await maybeStartHostAgent(root, { robotIp, robotToken });
123
126
  const proxyEndpoint = process.env.MUSCAT_PROXY_ENDPOINT || "";
124
127
  // No proxy (Linux, disabled, ...): fall back to the container reverse-dialing
@@ -8,12 +8,13 @@
8
8
  // ros2 : Jazzy + Python 3.12 container, colcon build, run the node, restart
9
9
  // on source changes (rclcpp rebuilds on change).
10
10
  import { spawn, spawnSync } from "node:child_process";
11
- import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
11
+ import { existsSync, readFileSync, watch, writeFileSync } from "node:fs";
12
12
  import { isIP } from "node:net";
13
- import { dirname, resolve } from "node:path";
14
- import { dockerUserFlags, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
13
+ import { resolve } from "node:path";
14
+ import { dockerUserFlags, ensureDevDir, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
15
15
  import { ensureRemoteZenohEnv, getFreePort, isRemoteRobot, usesHostNetwork } from "./zenohDev.mjs";
16
16
  import { GATEWAY_PORT, localMuscatContainer, robotHasMuscat } from "./muscatLocal.mjs";
17
+ import { devPluginId } from "./devId.mjs";
17
18
  import { PLUGIN_ID_RE, assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, rosNamespace, validateManifest } from "./manifest.mjs";
18
19
  import {
19
20
  portEnvFlags,
@@ -110,8 +111,8 @@ async function serveRosPeers(root, pluginId, restart) {
110
111
  if (!Array.isArray(peers) || !peers.every((p) => typeof p === "string" && isIP(p))) {
111
112
  return { ok: false, error: "peers must be a list of IP addresses" };
112
113
  }
114
+ ensureDevDir(root);
113
115
  const file = resolve(root, ROS_PEERS_FILE);
114
- mkdirSync(dirname(file), { recursive: true });
115
116
  writeFileSync(file, JSON.stringify(peers) + "\n");
116
117
  log.step(`ROS_STATIC_PEERS → ${peers.join(";") || "(none)"} — restarting node`);
117
118
  restart();
@@ -147,7 +148,7 @@ async function runPython(root, portRanges, robotGateway) {
147
148
  await pyEnsureImage();
148
149
 
149
150
  const manifest = readManifest(root);
150
- const pluginId = manifest.id ?? "";
151
+ const pluginId = process.env.MUSCAT_PK_DEV_PLUGIN_ID || (manifest.id ?? "");
151
152
  const container = `${PYTHON_DEV_IMAGE}-run`;
152
153
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
153
154
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
@@ -527,7 +528,7 @@ async function runRos2(root, portRanges, robotGateway) {
527
528
 
528
529
  await ensureImage();
529
530
 
530
- const pluginId = manifest.id ?? "";
531
+ const pluginId = process.env.MUSCAT_PK_DEV_PLUGIN_ID || (manifest.id ?? "");
531
532
  const container = `${ROS2_DEV_IMAGE}-run`;
532
533
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
533
534
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
@@ -622,7 +623,7 @@ async function runCpp(root, portRanges, robotGateway) {
622
623
 
623
624
  await cppEnsureImage();
624
625
 
625
- const pluginId = manifest.id ?? "";
626
+ const pluginId = process.env.MUSCAT_PK_DEV_PLUGIN_ID || (manifest.id ?? "");
626
627
  const container = `${CPP_DEV_IMAGE}-run`;
627
628
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
628
629
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
@@ -705,6 +706,8 @@ export async function runDevBack(args = []) {
705
706
  const { robotIp, robotToken } = await promptRobotConnection(args);
706
707
  if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
707
708
  if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
709
+ // Under `dev` this returns the id `dev` chose (environment); standalone it decides it the same way.
710
+ devPluginId(readManifest(root).id, { robotIp, args });
708
711
  // Standalone `dev:back` against a remote robot needs the Zenoh reverse-path
709
712
  // advertise host/port too (idempotent — `muscat-pk dev` already set these).
710
713
  // Byte-proxy mode (a host-side host_agent set MUSCAT_PROXY_ENDPOINT) makes
@@ -0,0 +1,54 @@
1
+ // The plugin id a dev session runs under.
2
+ //
3
+ // Machines developing the same plugin against one robot register the same topics on its gateway, and
4
+ // requests (rb.action, external routes) then reach whichever backend registered last — with no error,
5
+ // so one developer's button runs the other's code. Against a remote robot each machine therefore runs
6
+ // as <manifest id>.dev-<user>-<random>. The random part is kept in ~/.muscat-pk/dev-id: the id stays the
7
+ // same across runs (stable external URLs) and differs between machines even when host or user names match.
8
+ // --keep-id runs under the manifest id (program execute, external clients that use the real id); then only
9
+ // one machine may be connected to that robot.
10
+ import { randomBytes } from "node:crypto";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { homedir, userInfo } from "node:os";
13
+ import { dirname, resolve } from "node:path";
14
+ import { PLUGIN_ID_RE } from "./manifest.mjs";
15
+ import { log } from "./util.mjs";
16
+ import { isRemoteRobot } from "./zenohDev.mjs";
17
+
18
+ const DEV_ID_FILE = resolve(homedir(), ".muscat-pk", "dev-id");
19
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
20
+ // `dev` decides once; its dev:back child gets no CLI args and reads the id from here.
21
+ const ENV = "MUSCAT_PK_DEV_PLUGIN_ID";
22
+
23
+ function machineToken() {
24
+ const saved = existsSync(DEV_ID_FILE) ? readFileSync(DEV_ID_FILE, "utf8").trim() : "";
25
+ if (/^[a-z0-9]{6}$/.test(saved)) return saved;
26
+ const token = [...randomBytes(6)].map((b) => ALPHABET[b % ALPHABET.length]).join("");
27
+ mkdirSync(dirname(DEV_ID_FILE), { recursive: true });
28
+ writeFileSync(DEV_ID_FILE, `${token}\n`);
29
+ return token;
30
+ }
31
+
32
+ function userName() {
33
+ try {
34
+ return userInfo().username.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 12);
35
+ } catch {
36
+ return "";
37
+ }
38
+ }
39
+
40
+ /** The id for this dev session (UI, backend, ROS namespace and external routes all use it). */
41
+ export function devPluginId(manifestId, { robotIp, args = [] }) {
42
+ if (process.env[ENV]) return process.env[ENV];
43
+ let id = manifestId;
44
+ if (manifestId && isRemoteRobot(robotIp) && !args.includes("--keep-id")) {
45
+ const candidate = `${manifestId}.${["dev", userName(), machineToken()].filter(Boolean).join("-")}`;
46
+ if (PLUGIN_ID_RE.test(candidate)) id = candidate;
47
+ }
48
+ if (id !== manifestId) {
49
+ log.step(`dev plugin id ${id} — this machine's own on the shared robot (--keep-id runs as ${manifestId})`);
50
+ log.step(`external routes: http://${robotIp}:3000/common/plugin/${id}/<path>`);
51
+ }
52
+ if (id) process.env[ENV] = id;
53
+ return id;
54
+ }
package/cli/src/devUi.mjs CHANGED
@@ -2,12 +2,14 @@
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import { createServer as createNetServer } from "node:net";
4
4
  import { createRequire } from "node:module";
5
- import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, watch } from "node:fs";
5
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { checkKitUpdate, findPluginRoot, hasBin, kitRoot, log, parseArgs, prompt } from "./util.mjs";
10
- import { ensureLocalMuscat, GATEWAY_PORT, robotHasMuscat } from "./muscatLocal.mjs";
9
+ import { checkKitUpdate, ensureDevDir, findPluginRoot, hasBin, kitRoot, log, openBrowser, parseArgs, prompt } from "./util.mjs";
10
+ import { devPluginId } from "./devId.mjs";
11
+ import { readManifest } from "./manifest.mjs";
12
+ import { ensureLocalMuscat, GATEWAY_PORT, localFullMuscat, robotHasMuscat } from "./muscatLocal.mjs";
11
13
  import { isRemoteRobot } from "./zenohDev.mjs";
12
14
 
13
15
 
@@ -32,7 +34,6 @@ const CACHE_DIR = resolve(homedir(), ".muscat-pk");
32
34
  const CACHE_ROOT = resolve(CACHE_DIR, "node_modules", "@rainbow-robotics", "preview-host");
33
35
 
34
36
 
35
- const DEV_OUT = ".muscat-dev";
36
37
 
37
38
  function findNodeBin() {
38
39
  if (typeof Bun === "undefined") return process.execPath;
@@ -81,11 +82,15 @@ function syncAssets(pluginRoot, outDir) {
81
82
  }
82
83
 
83
84
 
84
- function stageDevAssets(pluginRoot) {
85
- const outDir = resolve(pluginRoot, DEV_OUT);
86
- mkdirSync(outDir, { recursive: true });
85
+ // preview-host takes the plugin id from this copy, so the UI talks under the session's dev id.
86
+ function stageDevAssets(pluginRoot, pluginId) {
87
+ const outDir = ensureDevDir(pluginRoot);
87
88
  const manifestSrc = resolve(pluginRoot, "manifest.json");
88
- if (existsSync(manifestSrc)) copyFileSync(manifestSrc, resolve(outDir, "manifest.json"));
89
+ if (existsSync(manifestSrc)) {
90
+ const manifest = JSON.parse(readFileSync(manifestSrc, "utf8"));
91
+ if (pluginId) manifest.id = pluginId;
92
+ writeFileSync(resolve(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
93
+ }
89
94
  syncAssets(pluginRoot, outDir);
90
95
  return outDir;
91
96
  }
@@ -162,7 +167,7 @@ export async function runDevUi(args = []) {
162
167
 
163
168
  await checkKitUpdate();
164
169
  const pluginRoot = findPluginRoot();
165
- const outDir = stageDevAssets(pluginRoot);
170
+ const outDir = stageDevAssets(pluginRoot, devPluginId(readManifest(pluginRoot).id, { robotIp, args }));
166
171
  const port = await getFreePort(requestedPort);
167
172
 
168
173
  // No robot: the bundle talks to localhost:3000, so put a Muscat there (`dev` already did).
@@ -194,6 +199,9 @@ export async function runDevUi(args = []) {
194
199
  });
195
200
  }
196
201
 
202
+ const localDevMuscat = await localFullMuscat(robotIp);
203
+ const uiHost = isRemoteRobot(robotIp) ? robotIp : localDevMuscat ? "localhost" : "";
204
+
197
205
  const previewRoot = findPreviewHostRoot();
198
206
  const serveEntry = resolve(previewRoot, "serve.mjs");
199
207
  if (!existsSync(resolve(previewRoot, "dist", "index.html"))) {
@@ -214,11 +222,11 @@ export async function runDevUi(args = []) {
214
222
  "--plugin", outDir,
215
223
  "--port", String(port),
216
224
  "--dev-server-url", `http://localhost:${devPort}`,
217
- // A remote robot turns the bundle into that robot's Muscat UI; localhost stays the mock preview.
218
- ...(isRemoteRobot(robotIp) ? ["--robot-host", robotIp] : []),
219
- // The dev plugin's socket goes to the robot's gateway when the robot runs Muscat (the UI is
220
- // that robot's Muscat then); otherwise to the local runtime.
221
- ...((await robotHasMuscat(robotIp)) ? ["--socket-url", `ws://${robotIp}:${GATEWAY_PORT}`] : []),
225
+ // A remote robot (or a local muscat-dev) turns the bundle into that Muscat's UI; otherwise
226
+ // it stays the mock preview.
227
+ ...(uiHost ? ["--robot-host", uiHost] : []),
228
+ // The dev plugin's socket goes to that Muscat's gateway; otherwise to the local runtime.
229
+ ...((await robotHasMuscat(robotIp)) || localDevMuscat ? ["--socket-url", `ws://${uiHost}:${GATEWAY_PORT}`] : []),
222
230
  ],
223
231
  // preview-host only prints its address and paths; the "UI →" line below says what matters.
224
232
  { stdio: ["inherit", "ignore", "inherit"] },
@@ -227,6 +235,8 @@ export async function runDevUi(args = []) {
227
235
  for (let i = 0; i < 50 && !(await fetch(`http://localhost:${port}/`).then(() => true, () => false)); i++)
228
236
  await new Promise((r) => setTimeout(r, 200));
229
237
  log.ok(`UI → http://localhost:${port}`);
238
+ // Only for a person at a terminal — not CI, scripts or `--no-open`.
239
+ if (process.stdout.isTTY && !process.env.CI && !parsed["no-open"]) openBrowser(`http://localhost:${port}`);
230
240
 
231
241
  let cleaned = false;
232
242
  const cleanup = () => {
@@ -0,0 +1,110 @@
1
+ // `muscat-pk mcp` — AI clients keep one stdio MCP server per session, so a `muscat-pk update`
2
+ // never reached a running one. This relays stdio to the real server (`mcp --serve`) and, when the
3
+ // installed kit changes, swaps that child for the new one: it replays the client's initialize and
4
+ // sends tools/list_changed, so the connection stays up and the next calls use the new tools.
5
+ import { spawn } from "node:child_process";
6
+ import { statSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { createInterface } from "node:readline";
9
+ import { kitRoot, resolveLocalCliBin } from "./util.mjs";
10
+
11
+ const isBinaryMode = typeof Bun !== "undefined";
12
+ const REINIT_ID = "__muscat_pk_reinit__";
13
+ const POLL_MS = 3000;
14
+
15
+ // Re-resolved on every (re)start: an update may install, bump or remove the project's local kit.
16
+ function serverCommand() {
17
+ const local = resolveLocalCliBin(process.cwd());
18
+ if (local) {
19
+ return { cmd: isBinaryMode ? "node" : process.execPath, args: [local, "mcp", "--serve"], watch: resolve(local, "../../../package.json") };
20
+ }
21
+ if (isBinaryMode) return { cmd: process.execPath, args: ["mcp", "--serve"], watch: process.execPath };
22
+ return { cmd: process.execPath, args: [process.argv[1], "mcp", "--serve"], watch: resolve(kitRoot, "package.json") };
23
+ }
24
+
25
+ const stamp = (file) => {
26
+ try {
27
+ return `${file}:${statSync(file).mtimeMs}`;
28
+ } catch {
29
+ return file;
30
+ }
31
+ };
32
+
33
+ export function runMcpProxy() {
34
+ let child = null;
35
+ let initRequest = null;
36
+ const pending = new Set(); // client request ids the current child hasn't answered
37
+ const toClient = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
38
+
39
+ const failPending = () => {
40
+ for (const id of pending) {
41
+ toClient({ jsonrpc: "2.0", id, error: { code: -32603, message: "muscat-pk was updated during this call — retry it." } });
42
+ }
43
+ pending.clear();
44
+ };
45
+
46
+ const start = () => {
47
+ const { cmd, args } = serverCommand();
48
+ const c = spawn(cmd, args, { stdio: ["pipe", "pipe", "inherit"] });
49
+ createInterface({ input: c.stdout }).on("line", (line) => {
50
+ let msg = null;
51
+ try {
52
+ msg = JSON.parse(line);
53
+ } catch {
54
+ /* not JSON — pass through */
55
+ }
56
+ if (msg?.id === REINIT_ID) {
57
+ // Our replayed initialize: the client already has its answer from the first child.
58
+ c.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
59
+ toClient({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
60
+ return;
61
+ }
62
+ if (msg && msg.id !== undefined && !msg.method) pending.delete(msg.id);
63
+ process.stdout.write(line + "\n");
64
+ });
65
+ c.on("exit", () => {
66
+ if (c !== child) return; // replaced on purpose
67
+ child = null;
68
+ failPending();
69
+ });
70
+ child = c;
71
+ if (initRequest) c.stdin.write(JSON.stringify({ ...initRequest, id: REINIT_ID }) + "\n");
72
+ };
73
+
74
+ const restart = () => {
75
+ const old = child;
76
+ child = null;
77
+ old?.kill();
78
+ failPending();
79
+ start();
80
+ };
81
+
82
+ start();
83
+ let last = stamp(serverCommand().watch);
84
+ setInterval(() => {
85
+ const now = stamp(serverCommand().watch);
86
+ if (now !== last) {
87
+ last = now;
88
+ process.stderr.write("[muscat-pk] kit updated — restarting the MCP server\n");
89
+ restart();
90
+ }
91
+ }, POLL_MS).unref();
92
+
93
+ createInterface({ input: process.stdin })
94
+ .on("line", (line) => {
95
+ let msg = null;
96
+ try {
97
+ msg = JSON.parse(line);
98
+ } catch {
99
+ /* forward as-is */
100
+ }
101
+ if (msg?.method === "initialize") initRequest = msg;
102
+ if (!child) start(); // the server crashed — bring it back (it replays initialize)
103
+ if (msg && msg.id !== undefined && msg.method) pending.add(msg.id);
104
+ child.stdin.write(line + "\n");
105
+ })
106
+ .on("close", () => {
107
+ child?.kill();
108
+ process.exit(0);
109
+ });
110
+ }
@@ -128,8 +128,7 @@ async function waitForApi(timeoutMs = 180_000) {
128
128
 
129
129
  // Does the robot at `ip` run Muscat (common answers /is-muscat)? Then the dev UI and backend use its
130
130
  // gateway and no local Muscat is started. Local addresses and unreachable hosts → false.
131
- export async function robotHasMuscat(ip) {
132
- if (!ip || LOCAL_HOSTS.has(ip)) return false;
131
+ async function isMuscat(ip) {
133
132
  try {
134
133
  const res = await fetch(`http://${ip}:${API_PORT}/common/is-muscat`, { signal: AbortSignal.timeout(3000) });
135
134
  return res.ok && (await res.json())?.result === true;
@@ -138,6 +137,20 @@ export async function robotHasMuscat(ip) {
138
137
  }
139
138
  }
140
139
 
140
+ export async function robotHasMuscat(ip) {
141
+ if (!ip || LOCAL_HOSTS.has(ip)) return false;
142
+ return isMuscat(ip);
143
+ }
144
+
145
+ // rby2's full Muscat dev stack on this machine — unlike muscat-pk's own minimal runtime it has the
146
+ // whole UI backend, so `dev` without a robot IP shows its real Muscat UI, like a robot's.
147
+ const LOCAL_FULL_MUSCAT = "muscat-dev";
148
+ export async function localFullMuscat(ip) {
149
+ if (!LOCAL_HOSTS.has(ip ?? "") || !hasBin("docker")) return false;
150
+ const r = spawnSync("docker", ["ps", "--filter", `name=^${LOCAL_FULL_MUSCAT}$`, "--format", "{{.Names}}"], { encoding: "utf8" });
151
+ return !!(r.stdout || "").trim() && isMuscat("127.0.0.1");
152
+ }
153
+
141
154
  // Is a host TCP port already bound?
142
155
  export function isPortInUse(port) {
143
156
  return new Promise((res) => {
@@ -14,10 +14,10 @@ import {
14
14
  rmSync,
15
15
  statSync,
16
16
  } from "node:fs";
17
- import { execSync, spawn } from "node:child_process";
17
+ import { execSync } from "node:child_process";
18
18
  import { copyFileSync } from "node:fs";
19
19
  import { extname, join, resolve } from "node:path";
20
- import { kitRoot, log, parseArgs } from "./util.mjs";
20
+ import { kitRoot, log, openBrowser, parseArgs } from "./util.mjs";
21
21
 
22
22
  // Shipped location (under cli/ → included in the npm "files" list).
23
23
  const SHIPPED_DIST = resolve(kitRoot, "cli/storybook-dist");
@@ -93,24 +93,6 @@ export function buildStorybook() {
93
93
  log.ok(`storybook → ${SHIPPED_DIST}`);
94
94
  }
95
95
 
96
- function openBrowser(url) {
97
- const cmd =
98
- process.platform === "darwin"
99
- ? "open"
100
- : process.platform === "win32"
101
- ? "start"
102
- : "xdg-open";
103
- try {
104
- spawn(cmd, [url], {
105
- stdio: "ignore",
106
- detached: true,
107
- shell: process.platform === "win32",
108
- }).unref();
109
- } catch {
110
- /* headless / no browser — the URL is printed anyway */
111
- }
112
- }
113
-
114
96
  function serve(distDir, port) {
115
97
  const server = createServer((req, res) => {
116
98
  const url = decodeURIComponent((req.url || "/").split("?")[0]);