@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.45 → 0.1.2-dev.47

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.
@@ -10,15 +10,7 @@
10
10
  import { spawn, spawnSync } from "node:child_process";
11
11
  import { existsSync, watch } from "node:fs";
12
12
  import { resolve } from "node:path";
13
- import {
14
- detectPythonCmd,
15
- ensureDocker,
16
- findPluginRoot,
17
- hasBin,
18
- log,
19
- promptRobotConnection,
20
- venvPython,
21
- } from "./util.mjs";
13
+ import { ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection } from "./util.mjs";
22
14
  import { assertManifestPorts, manifestRuntime, readManifest, validateManifest } from "./manifest.mjs";
23
15
  import {
24
16
  formatPortRanges,
@@ -43,33 +35,16 @@ import {
43
35
  } from "./cpp.mjs";
44
36
 
45
37
  // ── IDE tooling venv (best-effort, non-blocking) ────────────────────────────
46
- // Docker is the source of truth for actually RUNNING the backend — this just
47
- // gives editors/linters (pylint, pyright, mypy, ...) a real interpreter with
48
- // the plugin's deps installed, since they inspect the host filesystem, not the
49
- // container. Runs in the background so it never delays `dev:back` starting,
50
- // and any failure here (no local Python toolchain, offline, ...) is silent —
51
- // Docker works fine with zero local Python.
38
+ // `muscat-pk new` already creates this up front (see new.mjs); this just
39
+ // refreshes it on every `dev`/`dev:back` in case requirements.txt changed.
40
+ // Docker remains the actual execution environment — this never affects it.
41
+ // Runs in the background so it never delays `dev:back` starting, and any
42
+ // failure here (no local Python toolchain, offline, ...) is silent.
52
43
  function syncIdeVenv(root) {
53
- const venvDir = resolve(root, ".venv");
54
- const py = venvPython(venvDir);
55
-
56
44
  setImmediate(() => {
57
45
  try {
58
- if (!existsSync(py)) {
59
- let created;
60
- if (hasBin("uv")) {
61
- created = spawnSync("uv", ["venv", venvDir, "--python", "3.12", "--seed"], { cwd: root, stdio: "ignore" });
62
- } else {
63
- const { cmd } = detectPythonCmd();
64
- created = spawnSync(cmd[0], [...cmd.slice(1), "-m", "venv", venvDir], { cwd: root, stdio: "ignore" });
65
- }
66
- if (created.status !== 0 || !existsSync(py)) return;
67
- }
68
- const reqs = resolve(root, "requirements.txt");
69
- const args = ["-m", "pip", "install", "-q"];
70
- if (existsSync(reqs)) args.push("-r", reqs);
71
- if (spawnSync(py, args, { cwd: root, stdio: "ignore" }).status === 0) {
72
- log.info(`IDE venv ready → .venv (point your editor's Python interpreter there for pylint/pyright)`);
46
+ if (ensureIdeVenv(root)) {
47
+ log.info("IDE venv ready → .venv (point your editor's Python interpreter there for pylint/pyright)");
73
48
  }
74
49
  } catch {
75
50
  /* best-effort only */
@@ -82,9 +57,11 @@ function syncIdeVenv(root) {
82
57
  // Under `muscat-pk dev`, it shares the bridge container's netns, so the SDK
83
58
  // reaches the Socket.IO bridge at the default localhost:10000.
84
59
  async function runPython(root, portRanges) {
60
+ // Independent of Docker readiness — must still happen if Docker isn't
61
+ // installed/running, since that's exactly when local IDE tooling matters most.
62
+ syncIdeVenv(root);
85
63
  pyAssertDockerReady();
86
64
  await pyEnsureImage();
87
- syncIdeVenv(root);
88
65
 
89
66
  const manifest = readManifest(root);
90
67
  const pluginId = manifest.id ?? "";
@@ -154,16 +131,19 @@ async function runPython(root, portRanges) {
154
131
 
155
132
  // ── ros2 ───────────────────────────────────────────────────────────────────────
156
133
  async function runRos2(root, portRanges) {
134
+ const manifest = readManifest(root);
135
+ const isRclcpp = manifest.runtime?.rosClient === "rclcpp";
136
+ // Independent of Docker readiness — must still happen if Docker isn't
137
+ // installed/running, since that's exactly when local IDE tooling matters most.
138
+ if (!isRclcpp) syncIdeVenv(root); // rclpy is plain Python — rclcpp is C++, no venv needed
139
+
157
140
  assertDockerReady();
158
141
  const pkg = ros2PackageName(root);
159
142
  if (!pkg) throw new Error("no ROS2 package found under ros2_ws/src.");
160
143
 
161
144
  await ensureImage();
162
145
 
163
- const manifest = readManifest(root);
164
146
  const pluginId = manifest.id ?? "";
165
- const isRclcpp = manifest.runtime?.rosClient === "rclcpp";
166
- if (!isRclcpp) syncIdeVenv(root); // rclpy is plain Python — rclcpp is C++, no venv needed
167
147
  const container = `${ROS2_DEV_IMAGE}-run`;
168
148
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
169
149
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
package/cli/src/mcp.mjs CHANGED
@@ -1,7 +1,6 @@
1
- // muscat-pk mcp — an MCP (Model Context Protocol) server that gives an AI
2
- // coding agent (Claude Code, Codex, ...) grounded knowledge of the Muscat
3
- // plugin UI surface, instead of it guessing prop names or grepping
4
- // node_modules for @rainbow-robotics/plugin-ui and @repo/rb-components:
1
+ // muscat-pk mcp — an MCP (Model Context Protocol) server exposing the Muscat
2
+ // plugin UI/SDK surface to an AI coding agent (Claude Code, Codex, ...) so it
3
+ // looks things up instead of guessing. Tools:
5
4
  //
6
5
  // Frontend UI (@rainbow-robotics/plugin-ui/react, via dist/react.d.ts):
7
6
  // - list_components every exported component/hook/type.
@@ -42,11 +41,9 @@ const DESIGN_TOKENS_CSS = existsSync(SHIPPED_DESIGN_TOKENS)
42
41
 
43
42
  let typesCache = null;
44
43
 
45
- // Resolves @rainbow-robotics/plugin-ui/react's bundled, self-contained
46
- // dist/react.d.ts (see plugin-ui/build-dist.mjs — rb-components' own types are
47
- // inlined into it, so this single file is enough; no need to resolve
48
- // @repo/rb-components separately, which wouldn't exist in an external install
49
- // anyway).
44
+ // Parse plugin-ui/react's dist/react.d.ts. It's self-contained (rb-components
45
+ // types are inlined at build — see plugin-ui/build-dist.mjs), so this one file
46
+ // is enough even in an external install.
50
47
  async function loadReactTypes() {
51
48
  if (typesCache) return typesCache;
52
49
 
@@ -113,9 +110,7 @@ function findStorySource(name) {
113
110
  /* README search */
114
111
  /* -------------------------------------------------------------------------- */
115
112
 
116
- // Splits the plugin-author README on headings and keyword-scores each
117
- // section — good enough for short-query lookups ("save form data", "toast",
118
- // "manifest ui") without pulling in a real search dependency.
113
+ // Split the README on headings and keyword-score each section.
119
114
  function searchReadme(query) {
120
115
  if (!existsSync(TEMPLATE_README)) return [];
121
116
  const text = readFileSync(TEMPLATE_README, "utf8");
@@ -200,9 +195,8 @@ function pythonCmd() {
200
195
  return null;
201
196
  }
202
197
 
203
- // Runs a short introspection script against the INSTALLED rainbow.rb_sdk. It's
204
- // the plugin's own backend dependency (requirements.txt / `muscat-pk sdk`), so
205
- // in a real plugin env it's importable; otherwise we return a clear hint.
198
+ // Run an introspection script against the installed rainbow.rb_sdk (a plugin's
199
+ // backend dependency). Returns a clear hint if it isn't importable.
206
200
  function introspectRbSdk(script) {
207
201
  const py = pythonCmd();
208
202
  if (!py) {
@@ -305,16 +299,27 @@ export async function runMcp() {
305
299
  "Get the real Storybook story source for a component (e.g. RBSlider) — actual working " +
306
300
  "props, imports, and layout, not a guessed shape. Prefer this over get_component_props when " +
307
301
  "you just need to know how to use a component, not its full type.",
308
- inputSchema: { name: z.string().describe('Component name, e.g. "RBSlider"') },
302
+ // `component` accepted as an alias — agents often guess it over `name`.
303
+ inputSchema: {
304
+ name: z.string().optional().describe('Component name, e.g. "RBSlider"'),
305
+ component: z.string().optional().describe("Alias for `name`."),
306
+ },
309
307
  },
310
- async ({ name }) => {
311
- const source = findStorySource(name);
308
+ async ({ name, component }) => {
309
+ const target = name ?? component;
310
+ if (!target) {
311
+ return {
312
+ isError: true,
313
+ content: [{ type: "text", text: 'Missing required argument: "name" (component name, e.g. "RBSlider").' }],
314
+ };
315
+ }
316
+ const source = findStorySource(target);
312
317
  if (!source) {
313
318
  return {
314
319
  content: [
315
320
  {
316
321
  type: "text",
317
- text: `No Storybook example found for "${name}". Run "muscat-pk storybook" to browse all components live, or use get_component_props for its type.`,
322
+ text: `No Storybook example found for "${target}". Run "muscat-pk storybook" to browse all components live, or use get_component_props for its type.`,
318
323
  },
319
324
  ],
320
325
  };
@@ -467,20 +472,66 @@ print(json.dumps(out))
467
472
  description:
468
473
  "Get the signature(s) and docstring for a rainbow.rb_sdk class or function (e.g. " +
469
474
  "'PluginProgramContext', 'RBManipulateSDK'). For a class, lists its public methods with " +
470
- "signatures. Requires rainbow-rb-sdk to be pip-installed.",
471
- inputSchema: { name: z.string().describe('e.g. "PluginProgramContext"') },
475
+ "signatures. To drill into ONE method of a class (e.g. RBManipulateSDK.call_move_j), pass " +
476
+ "`method` alongside `name` — do not put both in `name`. Requires rainbow-rb-sdk to be " +
477
+ "pip-installed.",
478
+ inputSchema: {
479
+ name: z.string().describe('Class or function name, e.g. "RBManipulateSDK"'),
480
+ method: z.string().optional().describe('Optional: a specific method on that class, e.g. "call_move_j"'),
481
+ },
472
482
  },
473
- async ({ name }) => {
483
+ async ({ name, method }) => {
484
+ // Tolerate a combined "ClassName method_name" in `name` (a common agent
485
+ // mistake) by splitting it ourselves instead of failing validation.
486
+ let className = name;
487
+ let methodName = method;
488
+ if (!methodName && /\s/.test(name.trim())) {
489
+ const parts = name.trim().split(/\s+/);
490
+ if (parts.length === 2) [className, methodName] = parts;
491
+ }
492
+
474
493
  const { data, error } = introspectRbSdk(`
475
494
  import inspect, json
476
495
  mod = importlib.import_module("rainbow.rb_sdk")
477
- name = ${JSON.stringify(name)}
496
+ name = ${JSON.stringify(className)}
497
+ method = ${JSON.stringify(methodName ?? null)}
478
498
  obj = getattr(mod, name, None)
479
499
  if obj is None:
480
500
  print(json.dumps({"__missing__": True})); raise SystemExit(0)
481
501
  def sig(o):
482
502
  try: return str(inspect.signature(o))
483
503
  except Exception: return "(...)"
504
+
505
+ if method:
506
+ # Direct method on the class itself.
507
+ fn = getattr(obj, method, None) if inspect.isclass(obj) else None
508
+ # rb_sdk classes often group calls under a nested namespace set in
509
+ # __init__ (e.g. RBManipulateSDK().move.call_move_j) — that only exists on
510
+ # an INSTANCE, which we won't construct (side effects, needs a robot
511
+ # connection). Best-effort: search the class's own __init__ source text
512
+ # for self.<attr> = <NestedClass>(...) and check each nested class too.
513
+ owner = name
514
+ if fn is None and inspect.isclass(obj):
515
+ try:
516
+ src = inspect.getsource(obj.__init__)
517
+ except Exception:
518
+ src = ""
519
+ import re
520
+ for attr, nested_name in re.findall(r"self\\.(\\w+)\\s*=\\s*(\\w+)\\(", src):
521
+ nested = mod.__dict__.get(nested_name) or globals().get(nested_name)
522
+ cand = getattr(nested, method, None) if nested else None
523
+ if cand:
524
+ fn = cand
525
+ owner = f"{name}.{attr} ({nested_name})"
526
+ break
527
+ if fn is None:
528
+ print(json.dumps({"__missing__": True, "__method__": True})); raise SystemExit(0)
529
+ print(json.dumps({
530
+ "name": name, "kind": "method", "owner": owner,
531
+ "signature": method + sig(fn), "doc": inspect.getdoc(fn) or "",
532
+ }))
533
+ raise SystemExit(0)
534
+
484
535
  res = {"name": name, "doc": inspect.getdoc(obj) or ""}
485
536
  if inspect.isclass(obj):
486
537
  res["kind"] = "class"
@@ -497,12 +548,17 @@ print(json.dumps(res))
497
548
  `);
498
549
  if (error) return { isError: true, content: [{ type: "text", text: error }] };
499
550
  if (data.__missing__) {
500
- return {
501
- isError: true,
502
- content: [
503
- { type: "text", text: `No "${name}" in rainbow.rb_sdk. Call list_rb_sdk for valid names.` },
504
- ],
505
- };
551
+ const text = data.__method__
552
+ ? `No method "${methodName}" found directly on "${className}" (nor in an obvious nested ` +
553
+ `namespace set in __init__). Call get_rb_sdk_api with just \`name: "${className}"\` to see ` +
554
+ `its full method list and constructor first.`
555
+ : `No "${className}" in rainbow.rb_sdk. Call list_rb_sdk for valid names.`;
556
+ return { isError: true, content: [{ type: "text", text }] };
557
+ }
558
+ if (data.kind === "method") {
559
+ const lines = [`# ${data.owner}.${data.signature.split("(")[0]}`, data.signature];
560
+ if (data.doc) lines.push("", data.doc);
561
+ return { content: [{ type: "text", text: lines.join("\n") }] };
506
562
  }
507
563
  const lines = [`# ${data.name} (${data.kind})`];
508
564
  if (data.doc) lines.push(data.doc, "");
package/cli/src/new.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  writeFileSync,
13
13
  } from "node:fs";
14
14
  import { basename, dirname, resolve } from "node:path";
15
- import { hasBin, kitVersion, parseArgs, prompt, templatesDir, log } from "./util.mjs";
15
+ import { ensureIdeVenv, hasBin, kitVersion, parseArgs, prompt, templatesDir, log } from "./util.mjs";
16
16
 
17
17
  // Like cpSync(src, dst, {recursive:true}) but reads from the embedded snapshot
18
18
  // when the templates dir doesn't exist on disk (compiled binary mode).
@@ -227,6 +227,21 @@ export async function runNew(args) {
227
227
  } catch {
228
228
  log.warn(`'${pm} install' failed; run it manually in ${targetName}`);
229
229
  }
230
+
231
+ // Local .venv is for your editor/linter (pylint, pyright, ...) only — the
232
+ // backend actually RUNS in Docker (see README), this never affects that.
233
+ // rclcpp is C++, so no venv applies there.
234
+ const needsIdeVenv =
235
+ runtimeChoice.type === "python" ||
236
+ (runtimeChoice.type === "ros2" && runtimeChoice.rosClient === "rclpy");
237
+ if (needsIdeVenv) {
238
+ log.step("creating .venv for your editor (pylint/pyright)…");
239
+ if (ensureIdeVenv(target)) {
240
+ log.ok(".venv ready — point your editor's Python interpreter there.");
241
+ } else {
242
+ log.warn("Could not create .venv (no local Python/uv?) — skipping, Docker dev still works.");
243
+ }
244
+ }
230
245
  }
231
246
 
232
247
  // The back end (python / cpp / ros2) runs in a Docker container that matches
@@ -46,12 +46,11 @@ import { setHostServices } from "@rainbow-robotics/plugin-ui/host-ui";
46
46
  import { RbWs, createLazyRbSdk } from "@rainbow-robotics/plugin-ui/ws";
47
47
  import App from ${entry};
48
48
 
49
- // HMR only hands the new module to the accept callback — it does NOT rebind the
50
- // static \`App\` import above. So we track the latest component at MODULE scope.
51
- // Without this, navigating away and back (a fresh mount() call on this cached
52
- // bootstrap module) would render the STALE component captured at first import;
53
- // only a hard refresh fixed it. \`activeRender\` points at the currently-mounted
54
- // root so live edits re-render it in place.
49
+ // HMR passes the new module to the accept callback but doesn't rebind the
50
+ // static \`App\` import, so we track the latest at module scope. Without this,
51
+ // navigating away and back (a fresh mount() on this cached module) renders the
52
+ // stale App from first import. \`activeRender\` points at the mounted root so
53
+ // live edits re-render in place.
55
54
  let latestApp = App;
56
55
  let activeRender = null;
57
56
  if (import.meta.hot) {
package/cli/src/util.mjs CHANGED
@@ -39,6 +39,32 @@ export function venvPython(venvDir) {
39
39
  : resolve(venvDir, "bin", "python");
40
40
  }
41
41
 
42
+ /**
43
+ * Create/refresh a local `.venv` with requirements.txt installed, for the
44
+ * editor/linter only (the backend actually runs in Docker). Uses `uv` if
45
+ * present, else the system Python. Returns true if the venv is ready.
46
+ */
47
+ export function ensureIdeVenv(root) {
48
+ const venvDir = resolve(root, ".venv");
49
+ const py = venvPython(venvDir);
50
+
51
+ if (!existsSync(py)) {
52
+ let created;
53
+ if (hasBin("uv")) {
54
+ created = spawnSync("uv", ["venv", venvDir, "--python", "3.12", "--seed"], { cwd: root, stdio: "ignore" });
55
+ } else {
56
+ const { cmd } = detectPythonCmd();
57
+ created = spawnSync(cmd[0], [...cmd.slice(1), "-m", "venv", venvDir], { cwd: root, stdio: "ignore" });
58
+ }
59
+ if (created.status !== 0 || !existsSync(py)) return false;
60
+ }
61
+
62
+ const reqs = resolve(root, "requirements.txt");
63
+ const args = ["-m", "pip", "install", "-q"];
64
+ if (existsSync(reqs)) args.push("-r", reqs);
65
+ return spawnSync(py, args, { cwd: root, stdio: "ignore" }).status === 0;
66
+ }
67
+
42
68
  /** Published install/CLI package name. Author imports use @rainbow-robotics/plugin-ui separately. */
43
69
  export const KIT_PUBLISHED_NAME = "@rainbow-robotics/muscat-plugin-kit";
44
70
 
@@ -1 +1 @@
1
- {"generatedAt":1783605738795,"userSince":1750932757926,"hasCustomBabel":false,"hasCustomWebpack":false,"hasStaticDirs":false,"hasStorybookEslint":true,"refCount":0,"testPackages":{"@vitest/browser":"3.2.4","@vitest/coverage-v8":"3.2.4","playwright":"1.59.1","vitest":"3.2.4"},"hasRouterPackage":false,"monorepo":"Turborepo","packageManager":{"type":"yarn","version":"berry","agent":"yarn@berry"},"preview":{"usesGlobals":false},"framework":{"name":"@storybook/react-vite","options":{}},"builder":"@storybook/builder-vite","renderer":"@storybook/react","portableStoriesFileCount":0,"applicationFileCount":0,"storybookVersion":"8.6.18","storybookVersionSpecifier":"^8.6.4","language":"typescript","storybookPackages":{"@chromatic-com/storybook":{"version":"3.2.7"},"@storybook/addon-essentials":{"version":"8.6.18"},"@storybook/addon-links":{"version":"8.6.18"},"@storybook/addon-onboarding":{"version":"8.6.18"},"@storybook/addon-themes":{"version":"8.6.18"},"@storybook/blocks":{"version":"8.6.18"},"@storybook/experimental-addon-test":{"version":"8.6.18"},"@storybook/react":{"version":"8.6.18"},"@storybook/react-vite":{"version":"8.6.18"},"@storybook/test":{"version":"8.6.18"},"@storybook/theming":{"version":"8.6.18"},"eslint-plugin-storybook":{"version":"0.11.6"},"storybook":{"version":"8.6.18"},"storybook-addon-react-docgen":{"version":"1.2.44"}},"addons":{"$SNIP/node_modules/@storybook/addon-links":{"version":null},"$SNIP/node_modules/@storybook/addon-essentials":{"version":null},"$SNIP/node_modules/@chromatic-com/storybook":{"version":null},"$SNIP/node_modules/@storybook/addon-themes":{"version":null},"$SNIP/node_modules/@storybook/experimental-addon-test":{"version":null}}}
1
+ {"generatedAt":1783624567446,"userSince":1750932757926,"hasCustomBabel":false,"hasCustomWebpack":false,"hasStaticDirs":false,"hasStorybookEslint":true,"refCount":0,"testPackages":{"@vitest/browser":"3.2.4","@vitest/coverage-v8":"3.2.4","playwright":"1.59.1","vitest":"3.2.4"},"hasRouterPackage":false,"monorepo":"Turborepo","packageManager":{"type":"yarn","version":"berry","agent":"yarn@berry"},"preview":{"usesGlobals":false},"framework":{"name":"@storybook/react-vite","options":{}},"builder":"@storybook/builder-vite","renderer":"@storybook/react","portableStoriesFileCount":0,"applicationFileCount":0,"storybookVersion":"8.6.18","storybookVersionSpecifier":"^8.6.4","language":"typescript","storybookPackages":{"@chromatic-com/storybook":{"version":"3.2.7"},"@storybook/addon-essentials":{"version":"8.6.18"},"@storybook/addon-links":{"version":"8.6.18"},"@storybook/addon-onboarding":{"version":"8.6.18"},"@storybook/addon-themes":{"version":"8.6.18"},"@storybook/blocks":{"version":"8.6.18"},"@storybook/experimental-addon-test":{"version":"8.6.18"},"@storybook/react":{"version":"8.6.18"},"@storybook/react-vite":{"version":"8.6.18"},"@storybook/test":{"version":"8.6.18"},"@storybook/theming":{"version":"8.6.18"},"eslint-plugin-storybook":{"version":"0.11.6"},"storybook":{"version":"8.6.18"},"storybook-addon-react-docgen":{"version":"1.2.44"}},"addons":{"$SNIP/node_modules/@storybook/addon-links":{"version":null},"$SNIP/node_modules/@storybook/addon-essentials":{"version":null},"$SNIP/node_modules/@chromatic-com/storybook":{"version":null},"$SNIP/node_modules/@storybook/addon-themes":{"version":null},"$SNIP/node_modules/@storybook/experimental-addon-test":{"version":null}}}
@@ -10,6 +10,8 @@
10
10
  "@tailwindcss/vite": "^4.1.0",
11
11
  "@rainbow-robotics/muscat-plugin-kit": "__RB_VERSION__",
12
12
  "@rainbow-robotics/preview-host": "__RB_VERSION__",
13
+ "@types/react": "^19.0.10",
14
+ "@types/react-dom": "^19.0.4",
13
15
  "@vitejs/plugin-react": "^4.0.0",
14
16
  "tailwindcss": "^4.1.0",
15
17
  "typescript": "^5.8.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-robotics/muscat-plugin-kit",
3
- "version": "0.1.2-dev.45",
3
+ "version": "0.1.2-dev.47",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -53,8 +53,8 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@modelcontextprotocol/sdk": "^1.29.0",
56
- "@rainbow-robotics/plugin-ui": "0.1.2-dev.45",
57
- "@rainbow-robotics/preview-host": "0.1.2-dev.45",
56
+ "@rainbow-robotics/plugin-ui": "0.1.2-dev.47",
57
+ "@rainbow-robotics/preview-host": "0.1.2-dev.47",
58
58
  "postcss": "^8.5.8",
59
59
  "socket.io": "^4.8.1",
60
60
  "typescript": "^5.8.0",