@rainbow-robotics/muscat-plugin-kit 0.10.1-dev.0 → 0.10.1-dev.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.ko.md +2 -0
- package/README.md +2 -0
- package/cli/prepack.mjs +2 -0
- package/cli/program-commands.json +2079 -0
- package/cli/src/ai-rules.mjs +2 -1
- package/cli/src/devUi.mjs +15 -11
- package/cli/src/mcp.mjs +79 -0
- package/cli/src/programCommands.mjs +327 -0
- package/cli/storybook-dist/project.json +1 -1
- package/cli/templates/common/requirements.txt +3 -3
- package/cli/templates/cpp/cpp/CMakeLists.txt +1 -1
- package/cli/templates/readme-cpp/README.md +1 -1
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +1 -1
- package/package.json +3 -3
package/cli/src/ai-rules.mjs
CHANGED
|
@@ -69,7 +69,8 @@ ${nativeTable()}
|
|
|
69
69
|
Upgrading a project made with an older kit:
|
|
70
70
|
${MIGRATION_RULES.map((r) => `- ${r}`).join("\n")}
|
|
71
71
|
|
|
72
|
-
Backend, manifest and SDK questions: search_rb_sdk, list_rb_sdk, get_rb_sdk_api
|
|
72
|
+
Backend, manifest and SDK questions: search_rb_sdk, list_rb_sdk, get_rb_sdk_api.
|
|
73
|
+
Program commands and building/running programs (rb.program, /common/program REST): list_program_commands, get_program_api.`;
|
|
73
74
|
|
|
74
75
|
// AGENTS.md (Codex, Cursor, Gemini CLI, Copilot …). CLAUDE.md imports it with `@AGENTS.md`.
|
|
75
76
|
// The backend type is not named: `muscat-pk type change` would leave it stale.
|
package/cli/src/devUi.mjs
CHANGED
|
@@ -196,6 +196,17 @@ export async function runDevUi(args = []) {
|
|
|
196
196
|
const readyFile = resolve(outDir, "plugin-dev-port.json");
|
|
197
197
|
rmSync(readyFile, { force: true });
|
|
198
198
|
const sharedRuntimeBase = `http://localhost:${port}/plugin-shared`;
|
|
199
|
+
// Registered before the first spawn: a Ctrl+C that arrived during `dev`'s synchronous backend build
|
|
200
|
+
// is handled only now, and a process started after it never got the signal itself.
|
|
201
|
+
const procs = [];
|
|
202
|
+
const stopProcs = () => {
|
|
203
|
+
for (const p of procs) if (p.exitCode === null) p.kill();
|
|
204
|
+
};
|
|
205
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
206
|
+
process.once(signal, () => {
|
|
207
|
+
stopProcs();
|
|
208
|
+
process.exit(130);
|
|
209
|
+
});
|
|
199
210
|
const viteProc = spawnPluginDevServer(
|
|
200
211
|
pluginRoot,
|
|
201
212
|
node,
|
|
@@ -203,6 +214,7 @@ export async function runDevUi(args = []) {
|
|
|
203
214
|
readyFile,
|
|
204
215
|
sharedRuntimeBase,
|
|
205
216
|
);
|
|
217
|
+
if (viteProc) procs.push(viteProc);
|
|
206
218
|
let devPort = DEV_PLUGIN_PORT;
|
|
207
219
|
|
|
208
220
|
if (!viteProc) {
|
|
@@ -227,6 +239,7 @@ export async function runDevUi(args = []) {
|
|
|
227
239
|
stdio: "inherit",
|
|
228
240
|
env: { ...process.env, VITE_PLUGIN_PREVIEW: "true" },
|
|
229
241
|
});
|
|
242
|
+
procs.push(p);
|
|
230
243
|
p.on("exit", (c) => (c === 0 ? res() : rej(new Error("preview-host build failed"))));
|
|
231
244
|
});
|
|
232
245
|
}
|
|
@@ -247,6 +260,7 @@ export async function runDevUi(args = []) {
|
|
|
247
260
|
// preview-host only prints its address and paths; the "UI →" line below says what matters.
|
|
248
261
|
{ stdio: ["inherit", "ignore", "inherit"] },
|
|
249
262
|
);
|
|
263
|
+
procs.push(child);
|
|
250
264
|
|
|
251
265
|
for (let i = 0; i < 50 && !(await fetch(`http://localhost:${port}/`).then(() => true, () => false)); i++)
|
|
252
266
|
await new Promise((r) => setTimeout(r, 200));
|
|
@@ -254,18 +268,8 @@ export async function runDevUi(args = []) {
|
|
|
254
268
|
// Only for a person at a terminal — not CI, scripts or `--no-open`.
|
|
255
269
|
if (process.stdout.isTTY && !process.env.CI && !parsed["no-open"]) openBrowser(`http://localhost:${port}`);
|
|
256
270
|
|
|
257
|
-
let cleaned = false;
|
|
258
|
-
const cleanup = () => {
|
|
259
|
-
if (cleaned) return;
|
|
260
|
-
cleaned = true;
|
|
261
|
-
if (viteProc && !viteProc.killed && viteProc.exitCode === null) viteProc.kill();
|
|
262
|
-
if (!child.killed && child.exitCode === null) child.kill();
|
|
263
|
-
};
|
|
264
|
-
process.once("SIGINT", cleanup);
|
|
265
|
-
process.once("SIGTERM", cleanup);
|
|
266
|
-
process.once("SIGHUP", cleanup);
|
|
267
271
|
child.on("exit", (code) => {
|
|
268
|
-
|
|
272
|
+
stopProcs();
|
|
269
273
|
process.exit(code ?? 0);
|
|
270
274
|
});
|
|
271
275
|
}
|
package/cli/src/mcp.mjs
CHANGED
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
// rainbow-rb-sdk-dev apt package out of the cpp dev image).
|
|
19
19
|
// - get_rb_sdk_api signature + docstring for an rb_sdk class/function.
|
|
20
20
|
//
|
|
21
|
+
// Program page (nexus program.c.tsx + common OpenAPI):
|
|
22
|
+
// - list_program_commands built-in commands, default args, nesting rules.
|
|
23
|
+
// - get_program_api rb.program / REST guide + live endpoint schemas.
|
|
24
|
+
//
|
|
21
25
|
// Add to an MCP client config, e.g. Claude Code's .mcp.json:
|
|
22
26
|
// { "mcpServers": { "muscat": { "command": "muscat-pk", "args": ["mcp"] } } }
|
|
23
27
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -45,6 +49,7 @@ import { SHIPPED_STORIES_SRC, SHIPPED_DESIGN_TOKENS } from "./storybook.mjs";
|
|
|
45
49
|
import { CPP_DEV_IMAGE } from "./cpp.mjs";
|
|
46
50
|
import { NATIVE_TO_RB, SERVER_INSTRUCTIONS } from "./ai-rules.mjs";
|
|
47
51
|
import { checkProject, formatProjectCheck } from "./projectCheck.mjs";
|
|
52
|
+
import { describeProgramApi, loadProgramCommands, PROGRAM_API_GUIDE } from "./programCommands.mjs";
|
|
48
53
|
|
|
49
54
|
const TEMPLATE_READMES = { python: "common/README.md", cpp: "readme-cpp/README.md" };
|
|
50
55
|
// Design tokens: prefer the shipped copy, fall back to the monorepo source.
|
|
@@ -791,6 +796,80 @@ export async function runMcp() {
|
|
|
791
796
|
},
|
|
792
797
|
);
|
|
793
798
|
|
|
799
|
+
/* --- program commands / program API --- */
|
|
800
|
+
|
|
801
|
+
server.registerTool(
|
|
802
|
+
"list_program_commands",
|
|
803
|
+
{
|
|
804
|
+
title: "List built-in Muscat program commands",
|
|
805
|
+
description:
|
|
806
|
+
"List the built-in commands of the Muscat Program page (from nexus program.c.tsx): Wait, Repeat, " +
|
|
807
|
+
"Condition, Switch/Case, Thread, Script, MoveJ/MoveL + Point, Gripper, AMR Goal, ... with their " +
|
|
808
|
+
"category (common | manipulate | amr | rby1), funcName, default args and nesting rules " +
|
|
809
|
+
"(allowChildren/allowParents/notAllowChildren/requireChildren/requireParent). Pass `command` for one " +
|
|
810
|
+
"command's full defaults. Use it to build steps for rb.program.setSteps or POST /common/program/steps/upsert " +
|
|
811
|
+
"(see get_program_api), or to fill a program.panel commandInfo's allowParents/allowChildren with real names. " +
|
|
812
|
+
"Values in <angle brackets> are computed when the step is added (live robot state, ids).",
|
|
813
|
+
inputSchema: {
|
|
814
|
+
command: z.string().optional().describe('Command name, e.g. "Point" or "Repeat"'),
|
|
815
|
+
category: z.enum(["common", "manipulate", "amr", "rby1"]).optional().describe("Filter by robot category"),
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
async ({ command, category }) => {
|
|
819
|
+
const all = loadProgramCommands();
|
|
820
|
+
if (command) {
|
|
821
|
+
const found = all.find((c) => c.command.toLowerCase() === command.toLowerCase());
|
|
822
|
+
if (!found) {
|
|
823
|
+
const hints = suggestExports(command, all.map((c) => c.command));
|
|
824
|
+
const text = `No program command "${command}".${hints.length ? ` Did you mean: ${hints.join(", ")}?` : ""} Call list_program_commands without command for the list.`;
|
|
825
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
826
|
+
}
|
|
827
|
+
return { content: [{ type: "text", text: JSON.stringify(found, null, 2) }] };
|
|
828
|
+
}
|
|
829
|
+
const lines = all
|
|
830
|
+
.filter((c) => !category || c.category === category)
|
|
831
|
+
.map((c) => {
|
|
832
|
+
const r = c.rules;
|
|
833
|
+
const notes = [
|
|
834
|
+
c.funcName && `func ${c.funcName}${c.postFuncName ? ` → ${c.postFuncName}` : ""}`,
|
|
835
|
+
r.allowChildren && `children: ${r.allowChildren.join(",")}`,
|
|
836
|
+
r.allowParents && `parents: ${r.allowParents.join(",")}`,
|
|
837
|
+
r.requireChildren && "needs children",
|
|
838
|
+
r.requireParent && "needs parent",
|
|
839
|
+
r.notAllowChildren && `no children: ${r.notAllowChildren.join(",")}`,
|
|
840
|
+
r.disabled === true && "disabled",
|
|
841
|
+
c.computedDefaults && "computed defaults",
|
|
842
|
+
c.asksOnInsert && "asks on insert",
|
|
843
|
+
c.defaultArgs && `args: ${Object.keys(c.defaultArgs).join(",")}`,
|
|
844
|
+
].filter(Boolean);
|
|
845
|
+
return `${c.command} [${c.category}] ${c.label}${notes.length ? ` — ${notes.join("; ")}` : ""}`;
|
|
846
|
+
});
|
|
847
|
+
return { content: [{ type: "text", text: `${lines.join("\n")}\n\nCall again with command for full default args.` }] };
|
|
848
|
+
},
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
server.registerTool(
|
|
852
|
+
"get_program_api",
|
|
853
|
+
{
|
|
854
|
+
title: "How to build and run a Muscat program from a plugin",
|
|
855
|
+
description:
|
|
856
|
+
"How a plugin creates, edits, saves and runs Muscat programs: rb.program.getCatalog/getSteps/setSteps/" +
|
|
857
|
+
"getCommandDefaults from plugin UI, and the common REST API (/common/program/create, /program/steps/upsert " +
|
|
858
|
+
"step fields and ordering, load/start/stop, export/import). Without `path` returns the guide plus the live " +
|
|
859
|
+
"/program endpoint list; with `path` (e.g. \"/program/steps/upsert\") returns that endpoint's request/response " +
|
|
860
|
+
"schema from the running Muscat's OpenAPI (default http://localhost:3000; pass `url` for a robot).",
|
|
861
|
+
inputSchema: {
|
|
862
|
+
path: z.string().optional().describe('Endpoint path, e.g. "/program/steps/upsert"'),
|
|
863
|
+
url: z.string().optional().describe('Muscat base URL, default "http://localhost:3000"'),
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
async ({ path, url }) => {
|
|
867
|
+
const live = await describeProgramApi(path, url);
|
|
868
|
+
const text = path ? live : `${PROGRAM_API_GUIDE}\n## Live /program endpoints\n\n${live}`;
|
|
869
|
+
return { content: [{ type: "text", text }] };
|
|
870
|
+
},
|
|
871
|
+
);
|
|
872
|
+
|
|
794
873
|
/* --- design tokens --- */
|
|
795
874
|
|
|
796
875
|
server.registerTool(
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// Built-in Program page commands for the `mcp` command's list_program_commands
|
|
2
|
+
// tool. The source of truth is nexus's program.c.tsx; it imports React/host
|
|
3
|
+
// code, so instead of running it we read its object literals with the
|
|
4
|
+
// TypeScript AST. prepack stages the result as JSON so an external install
|
|
5
|
+
// (no monorepo) gets the same list.
|
|
6
|
+
import ts from "typescript";
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { kitRoot } from "./util.mjs";
|
|
10
|
+
|
|
11
|
+
const REPO_PROGRAM_SRC = resolve(kitRoot, "../..", "apps/web/nexus/src/constants/program.c.tsx");
|
|
12
|
+
export const SHIPPED_PROGRAM_COMMANDS = resolve(kitRoot, "cli/program-commands.json");
|
|
13
|
+
|
|
14
|
+
// Exported list name → robot category the Program page shows it for.
|
|
15
|
+
const LISTS = {
|
|
16
|
+
COMMON_PROGRAM_COMMAND_LIST: "common",
|
|
17
|
+
MANIPULATE_PROGRAM_COMMAND_LIST: "manipulate",
|
|
18
|
+
AMR_PROGRAM_COMMAND_LIST: "amr",
|
|
19
|
+
RBY1_PROGRAM_COMMAND_LIST: "rby1",
|
|
20
|
+
};
|
|
21
|
+
// Reached through the Thread modal, not the palette list (nexus CALL_EVENT_PLUGIN_COMMAND).
|
|
22
|
+
const EXTRA = { CALL_EVENT_THREAD_PROGRAM_COMMAND: "common" };
|
|
23
|
+
const RULE_KEYS = [
|
|
24
|
+
"allowChildren", "allowParents", "notAllowChildren", "childrenCommand", "requireChildren",
|
|
25
|
+
"requireParent", "fixChildren", "syncGroupId", "groupFirstItem", "groupLastItem", "isStrict",
|
|
26
|
+
"disableCopy", "disableDelete", "disabled",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const UNRESOLVED = Symbol("unresolved");
|
|
30
|
+
|
|
31
|
+
function propName(name, sf) {
|
|
32
|
+
return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText(sf);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Best-effort literal evaluation. Anything that needs the live robot or host
|
|
36
|
+
// (fetch*, uniqueId(), imported option tables) comes back as its source text
|
|
37
|
+
// in angle brackets, so the agent sees what the value depends on.
|
|
38
|
+
function makeEvaluator(sf, fileScope) {
|
|
39
|
+
const raw = (node) => `<${node.getText(sf).replace(/\s+/g, " ")}>`;
|
|
40
|
+
|
|
41
|
+
function evalFn(fn, scope) {
|
|
42
|
+
if (!ts.isBlock(fn.body)) return evalNode(fn.body, scope);
|
|
43
|
+
const local = new Map(scope);
|
|
44
|
+
let result = UNRESOLVED;
|
|
45
|
+
for (const st of fn.body.statements) {
|
|
46
|
+
if (ts.isVariableStatement(st)) {
|
|
47
|
+
for (const d of st.declarationList.declarations) {
|
|
48
|
+
if (ts.isIdentifier(d.name) && d.initializer) local.set(d.name.text, evalNode(d.initializer, local));
|
|
49
|
+
}
|
|
50
|
+
} else if (ts.isReturnStatement(st) && st.expression) {
|
|
51
|
+
result = evalNode(st.expression, local);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result === UNRESOLVED ? raw(fn) : result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function evalNode(node, scope) {
|
|
58
|
+
if (!node) return null;
|
|
59
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isAwaitExpression(node)) {
|
|
60
|
+
return evalNode(node.expression, scope);
|
|
61
|
+
}
|
|
62
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
63
|
+
if (ts.isNumericLiteral(node)) return Number(node.text);
|
|
64
|
+
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(node.operand)) {
|
|
65
|
+
return -Number(node.operand.text);
|
|
66
|
+
}
|
|
67
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
68
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
69
|
+
if (node.kind === ts.SyntaxKind.NullKeyword) return null;
|
|
70
|
+
if (ts.isIdentifier(node)) {
|
|
71
|
+
if (node.text === "undefined") return null;
|
|
72
|
+
if (scope.has(node.text)) return scope.get(node.text);
|
|
73
|
+
const decl = fileScope.get(node.text);
|
|
74
|
+
return decl ? evalNode(decl, scope) : raw(node);
|
|
75
|
+
}
|
|
76
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
77
|
+
return node.elements.flatMap((el) => {
|
|
78
|
+
if (!ts.isSpreadElement(el)) return [evalNode(el, scope)];
|
|
79
|
+
const v = evalNode(el.expression, scope);
|
|
80
|
+
return Array.isArray(v) ? v : [raw(el)];
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
84
|
+
const out = {};
|
|
85
|
+
for (const p of node.properties) {
|
|
86
|
+
if (ts.isSpreadAssignment(p)) {
|
|
87
|
+
const v = evalNode(p.expression, scope);
|
|
88
|
+
if (v && typeof v === "object" && !Array.isArray(v)) Object.assign(out, v);
|
|
89
|
+
} else if (ts.isPropertyAssignment(p)) {
|
|
90
|
+
out[propName(p.name, sf)] = evalNode(p.initializer, scope);
|
|
91
|
+
} else if (ts.isShorthandPropertyAssignment(p)) {
|
|
92
|
+
out[p.name.text] = evalNode(p.name, scope);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
98
|
+
const obj = evalNode(node.expression, scope);
|
|
99
|
+
return obj && typeof obj === "object" && Object.hasOwn(obj, node.name.text) ? obj[node.name.text] : raw(node);
|
|
100
|
+
}
|
|
101
|
+
if (ts.isTemplateExpression(node)) {
|
|
102
|
+
let s = node.head.text;
|
|
103
|
+
for (const span of node.templateSpans) {
|
|
104
|
+
const v = evalNode(span.expression, scope);
|
|
105
|
+
if (typeof v === "string" && v.startsWith("<")) return raw(node);
|
|
106
|
+
s += String(v) + span.literal.text;
|
|
107
|
+
}
|
|
108
|
+
return s;
|
|
109
|
+
}
|
|
110
|
+
// (() => ({...}))() — an IIFE building the default data.
|
|
111
|
+
if (ts.isCallExpression(node) && node.arguments.length === 0) {
|
|
112
|
+
const callee = ts.isParenthesizedExpression(node.expression) ? node.expression.expression : node.expression;
|
|
113
|
+
if (ts.isArrowFunction(callee) || ts.isFunctionExpression(callee)) return evalFn(callee, scope);
|
|
114
|
+
}
|
|
115
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return evalFn(node, scope);
|
|
116
|
+
return raw(node);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return evalNode;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function extractProgramCommands(srcPath = REPO_PROGRAM_SRC) {
|
|
123
|
+
const sf = ts.createSourceFile(srcPath, readFileSync(srcPath, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
124
|
+
const fileScope = new Map();
|
|
125
|
+
for (const st of sf.statements) {
|
|
126
|
+
if (!ts.isVariableStatement(st)) continue;
|
|
127
|
+
for (const d of st.declarationList.declarations) {
|
|
128
|
+
if (ts.isIdentifier(d.name) && d.initializer) fileScope.set(d.name.text, d.initializer);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const evalNode = makeEvaluator(sf, fileScope);
|
|
132
|
+
|
|
133
|
+
const toCommand = (node, category) => {
|
|
134
|
+
// A list element is an object literal, a const name, or { ...CONST, data: {...} }.
|
|
135
|
+
const props = new Map();
|
|
136
|
+
const collect = (n) => {
|
|
137
|
+
if (ts.isIdentifier(n) && fileScope.has(n.text)) return collect(fileScope.get(n.text));
|
|
138
|
+
if (!ts.isObjectLiteralExpression(n)) return;
|
|
139
|
+
for (const p of n.properties) {
|
|
140
|
+
if (ts.isSpreadAssignment(p)) collect(p.expression);
|
|
141
|
+
else if (ts.isPropertyAssignment(p)) props.set(propName(p.name, sf), p.initializer);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
collect(node);
|
|
145
|
+
if (!props.has("command")) return null;
|
|
146
|
+
|
|
147
|
+
const value = (key) => (props.has(key) ? evalNode(props.get(key), new Map()) : undefined);
|
|
148
|
+
const factory = props.get("factoryDataFn");
|
|
149
|
+
const data = value("data") ?? (factory ? evalNode(factory, new Map()) : undefined);
|
|
150
|
+
const rules = Object.fromEntries(RULE_KEYS.map((k) => [k, value(k)]).filter(([, v]) => v !== undefined));
|
|
151
|
+
return {
|
|
152
|
+
command: value("command"),
|
|
153
|
+
category,
|
|
154
|
+
label: value("label"),
|
|
155
|
+
method: data?.method ?? value("command"),
|
|
156
|
+
funcName: data?.funcName ?? null,
|
|
157
|
+
postFuncName: data?.postFuncName ?? null,
|
|
158
|
+
defaultArgs: data?.args ?? null,
|
|
159
|
+
defaultData: data ?? null,
|
|
160
|
+
rules,
|
|
161
|
+
// factoryDataFn: defaults are computed when the step is added (group/task ids, live robot pose, tool list, ...).
|
|
162
|
+
computedDefaults: !!factory,
|
|
163
|
+
// confirmModal: the Program page asks the user something (type, count, program) before adding it.
|
|
164
|
+
asksOnInsert: props.has("confirmModal"),
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const out = [];
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
for (const [name, category] of Object.entries({ ...LISTS, ...EXTRA })) {
|
|
171
|
+
const init = fileScope.get(name);
|
|
172
|
+
if (!init) continue;
|
|
173
|
+
const nodes = ts.isArrayLiteralExpression(init) ? init.elements : [init];
|
|
174
|
+
for (const n of nodes) {
|
|
175
|
+
const cmd = toCommand(n, category);
|
|
176
|
+
if (cmd && !seen.has(cmd.command)) {
|
|
177
|
+
seen.add(cmd.command);
|
|
178
|
+
out.push(cmd);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function loadProgramCommands() {
|
|
186
|
+
if (existsSync(REPO_PROGRAM_SRC)) return extractProgramCommands();
|
|
187
|
+
if (existsSync(SHIPPED_PROGRAM_COMMANDS)) return JSON.parse(readFileSync(SHIPPED_PROGRAM_COMMANDS, "utf8"));
|
|
188
|
+
throw new Error(`Program command list not found (${SHIPPED_PROGRAM_COMMANDS}) — reinstall muscat-plugin-kit.`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// prepack: stage the list so it ships with the kit. Outside the monorepo the
|
|
192
|
+
// already-staged file is kept.
|
|
193
|
+
export function stageProgramCommands() {
|
|
194
|
+
if (!existsSync(REPO_PROGRAM_SRC)) return;
|
|
195
|
+
writeFileSync(SHIPPED_PROGRAM_COMMANDS, JSON.stringify(extractProgramCommands(), null, 2) + "\n");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const DEFAULT_COMMON_URL = "http://localhost:3000";
|
|
199
|
+
|
|
200
|
+
export const PROGRAM_API_GUIDE = `# Building a Muscat program from a plugin
|
|
201
|
+
|
|
202
|
+
Commands: list_program_commands gives every built-in command (name, default args, funcName,
|
|
203
|
+
nesting rules). A step is one command instance; nesting goes through children / parentStepId.
|
|
204
|
+
|
|
205
|
+
## A. From plugin UI: rb.program (edits the program open on the Program page)
|
|
206
|
+
|
|
207
|
+
\`\`\`ts
|
|
208
|
+
const rb = useRb();
|
|
209
|
+
const catalog = await rb.program.getCatalog(scope); // commands of the loaded robot, incl. other plugins' commands
|
|
210
|
+
const steps = await rb.program.getSteps(scope); // current main step tree
|
|
211
|
+
await rb.program.setSteps([ // replaces the main tree (unsaved — the user presses Save)
|
|
212
|
+
{ command: "MoveJ", children: [{ command: "Point", args: { tar_values: [0, 0, 90, 0, 90, 0, 0] } }] },
|
|
213
|
+
{ command: "Wait", args: { second: 1 } },
|
|
214
|
+
{ command: "Repeat", args: { count: 3 }, children: [{ command: "Wait", args: { second: 0.5 } }] },
|
|
215
|
+
{ command: "SubTask", fields: { subTaskType: "INSERT", subTaskScriptPath: "..." } },
|
|
216
|
+
], scope);
|
|
217
|
+
const pointDefaults = await rb.program.getCommandDefaults?.("Point", scope); // computedDefaults commands (ids, live robot state)
|
|
218
|
+
\`\`\`
|
|
219
|
+
|
|
220
|
+
- Step shape: { command, args?, summary?, fields?, children? }. The host fills the rest from the command
|
|
221
|
+
defaults: args = default args merged with yours, robot_model filled with the loaded robot, name = the
|
|
222
|
+
command label, funcName/postFuncName, nesting rules, fresh stepIds.
|
|
223
|
+
- fields only accepts subTaskType, subTaskScriptPath, name, memo, funcName, postFuncName.
|
|
224
|
+
- scope = { taskId, robotModel }: a program.monitor plugin gets it as rb.data; without it the first main task is used.
|
|
225
|
+
- Unknown command names throw ("unknown command: X").
|
|
226
|
+
|
|
227
|
+
## B. From a backend or external tool: common REST API
|
|
228
|
+
|
|
229
|
+
Base: http://<robot>:3000/common (robot backend: http://127.0.0.1:3000, muscat-pk dev backend container on
|
|
230
|
+
macOS/Windows: http://host.docker.internal:3000). The same API is reachable over TCP/MQTT/Socket.IO/WebRTC
|
|
231
|
+
with topic common/<path> (e.g. common/program/steps/upsert) and the request body as data.
|
|
232
|
+
|
|
233
|
+
1. Create: POST /program/create { name, repeatCnt } → { program, mainTasks[] } — take program.programId and the
|
|
234
|
+
main task's taskId (one main task per robot component, see mainTasks[].robotModel).
|
|
235
|
+
2. Steps: POST /program/steps/upsert { steps: [...] } with a FLAT list. Each step needs
|
|
236
|
+
stepId (unique string), taskId, programId, name, method, order. Also send what the command defines:
|
|
237
|
+
funcName / postFuncName / args, and its rules (allowChildren, requireChildren, groupId, ...).
|
|
238
|
+
- name is required (422 without it). The Program page stores the command label, e.g. "program.button.wait"
|
|
239
|
+
(label in list_program_commands), not the display text.
|
|
240
|
+
- method = the command name ("MoveJ", "Point", "Wait", ...).
|
|
241
|
+
- Nesting: a child's parentStepId = parent's stepId. order = pre-order (DFS) index over the whole tree,
|
|
242
|
+
starting at 0 — the parent before its children.
|
|
243
|
+
- Args like "$parent.move_type" are resolved from the parent step at run time; keep them as given.
|
|
244
|
+
- If/ElseIf/Else: sibling Condition steps sharing one groupId, args.condition_type "If" | "ElseIf" | "Else",
|
|
245
|
+
names "program.button.condition" | "program.button.elif" | "program.button.else". Switch and its Case
|
|
246
|
+
children share one groupId too.
|
|
247
|
+
- robot_model "" in default args → set it to the task's robotModel.
|
|
248
|
+
3. Read back: GET /program/main-tasks/{programId}, GET /program/steps/{taskId}, GET /program/step/{stepId}.
|
|
249
|
+
4. Delete steps: DELETE /program/steps/delete. Edit / clone / delete program: PUT /program/edit,
|
|
250
|
+
POST /program/clone, DELETE /program/delete/{programId}. List: GET /programs.
|
|
251
|
+
5. Run (moves the robot): POST /program/load { programId } then POST /program/start { programId };
|
|
252
|
+
/program/pause | resume | stop with the same body. State: GET /program/current/state,
|
|
253
|
+
GET /program/task/{taskId}/step-state, GET /program/executor/state, variables: GET /program/{robot_model}/variables.
|
|
254
|
+
6. Export / import as a script: GET /program/export/{programId or name} (.zip, or .py with taskId),
|
|
255
|
+
POST /program/import/preview, POST /program/import.
|
|
256
|
+
|
|
257
|
+
Exact request/response fields come from the live OpenAPI: call get_program_api with path, e.g. "/program/steps/upsert".
|
|
258
|
+
|
|
259
|
+
## Manipulator Point notes
|
|
260
|
+
|
|
261
|
+
- tar_values always has 7 values. input_method 0 = absolute target. Joint targets (tar_frame -1) go under MoveJ/MoveJB,
|
|
262
|
+
Cartesian (tar_frame 0) under MoveL/MoveLB. PinPoint (Cartesian pin) under MoveL, PinJoint under MoveJ.
|
|
263
|
+
- Relative move: input_method 1 + a reference point (pin name or RB_POINT_REF) + 7 offsets.
|
|
264
|
+
`;
|
|
265
|
+
|
|
266
|
+
function compactSchema(schema, spec, depth = 0) {
|
|
267
|
+
if (!schema) return "any";
|
|
268
|
+
if (schema.$ref) {
|
|
269
|
+
const name = schema.$ref.split("/").pop();
|
|
270
|
+
if (depth > 3) return name;
|
|
271
|
+
return compactSchema(spec.components?.schemas?.[name], spec, depth + 1);
|
|
272
|
+
}
|
|
273
|
+
const alt = schema.anyOf ?? schema.oneOf;
|
|
274
|
+
if (alt) return alt.map((s) => compactSchema(s, spec, depth)).map((s) => (typeof s === "string" ? s : JSON.stringify(s))).join(" | ");
|
|
275
|
+
if (schema.enum) return schema.enum.map((v) => JSON.stringify(v)).join(" | ");
|
|
276
|
+
if (schema.type === "array") return [compactSchema(schema.items, spec, depth)];
|
|
277
|
+
if (schema.type === "object" || schema.properties) {
|
|
278
|
+
if (!schema.properties) return "object";
|
|
279
|
+
const req = new Set(schema.required ?? []);
|
|
280
|
+
return Object.fromEntries(
|
|
281
|
+
Object.entries(schema.properties).map(([k, v]) => [req.has(k) ? k : `${k}?`, compactSchema(v, spec, depth)]),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return schema.type ?? "any";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Live endpoint list / schema from the common service's OpenAPI.
|
|
288
|
+
export async function describeProgramApi(path, baseUrl = DEFAULT_COMMON_URL) {
|
|
289
|
+
const url = `${baseUrl.replace(/\/$/, "")}/common/openapi.json`;
|
|
290
|
+
let spec;
|
|
291
|
+
try {
|
|
292
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
293
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
294
|
+
spec = await res.json();
|
|
295
|
+
} catch (e) {
|
|
296
|
+
return `Could not read ${url} (${e.message}). Start Muscat (muscat-pk dev) or pass url with the robot's address, e.g. "http://192.168.0.10:3000".`;
|
|
297
|
+
}
|
|
298
|
+
const paths = spec.paths ?? {};
|
|
299
|
+
if (!path) {
|
|
300
|
+
return Object.entries(paths)
|
|
301
|
+
.filter(([p]) => p.startsWith("/program"))
|
|
302
|
+
.flatMap(([p, ops]) => Object.entries(ops).map(([m, op]) => `${m.toUpperCase()} /common${p} — ${(op.summary ?? "").trim()}`))
|
|
303
|
+
.join("\n");
|
|
304
|
+
}
|
|
305
|
+
const key = path.replace(/^\/common/, "");
|
|
306
|
+
const ops = paths[key];
|
|
307
|
+
if (!ops) {
|
|
308
|
+
const near = Object.keys(paths).filter((p) => p.includes(key.split("/").filter(Boolean).pop() ?? ""));
|
|
309
|
+
return `No endpoint "${path}".${near.length ? ` Similar: ${near.slice(0, 10).join(", ")}` : ""}`;
|
|
310
|
+
}
|
|
311
|
+
return Object.entries(ops)
|
|
312
|
+
.map(([m, op]) => {
|
|
313
|
+
const body = op.requestBody?.content?.["application/json"]?.schema;
|
|
314
|
+
const params = (op.parameters ?? []).map((p) => `${p.name} (${p.in}${p.required ? ", required" : ""}): ${JSON.stringify(compactSchema(p.schema, spec))}`);
|
|
315
|
+
const ok = op.responses?.["200"]?.content?.["application/json"]?.schema;
|
|
316
|
+
return [
|
|
317
|
+
`${m.toUpperCase()} /common${key} — ${(op.summary ?? "").trim()}`,
|
|
318
|
+
op.description ? op.description.trim() : null,
|
|
319
|
+
params.length ? `Parameters:\n ${params.join("\n ")}` : null,
|
|
320
|
+
body ? `Request body (? = optional):\n${JSON.stringify(compactSchema(body, spec), null, 2)}` : null,
|
|
321
|
+
ok ? `Response 200:\n${JSON.stringify(compactSchema(ok, spec), null, 2)}` : null,
|
|
322
|
+
]
|
|
323
|
+
.filter(Boolean)
|
|
324
|
+
.join("\n\n");
|
|
325
|
+
})
|
|
326
|
+
.join("\n\n---\n\n");
|
|
327
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"generatedAt":
|
|
1
|
+
{"generatedAt":1793262550568,"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,4 +1,4 @@
|
|
|
1
|
-
rainbow-rb-sdk==0.10.
|
|
2
|
-
rainbow-rb-flat-buffers==0.10.
|
|
3
|
-
rainbow-rb-zenoh==0.10.
|
|
1
|
+
rainbow-rb-sdk==0.10.4
|
|
2
|
+
rainbow-rb-flat-buffers==0.10.4
|
|
3
|
+
rainbow-rb-zenoh==0.10.4
|
|
4
4
|
requests==2.34.2
|
|
@@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
|
7
7
|
# Rainbow Robotics C++ SDK — provided by the apt package rainbow-rb-sdk-dev
|
|
8
8
|
# (pre-installed in the muscat-pk dev container). The version here is the one the
|
|
9
9
|
# dev container installs; change it with `muscat-pk sdk update <version>`.
|
|
10
|
-
find_package(rb_sdk 0.10.
|
|
10
|
+
find_package(rb_sdk 0.10.4 EXACT REQUIRED)
|
|
11
11
|
|
|
12
12
|
# The executable name must match runtime.entry in manifest.json.
|
|
13
13
|
add_executable(__PLUGIN_SLUG__ main.cpp)
|
|
@@ -244,7 +244,7 @@ don't install anything locally.
|
|
|
244
244
|
|
|
245
245
|
```cmake
|
|
246
246
|
# cpp/CMakeLists.txt — the version is the SDK the dev container installs (muscat-pk sdk update <version>)
|
|
247
|
-
find_package(rb_sdk 0.10.
|
|
247
|
+
find_package(rb_sdk 0.10.4 EXACT REQUIRED)
|
|
248
248
|
add_executable(__PLUGIN_SLUG__ main.cpp) # name must equal runtime.entry
|
|
249
249
|
target_link_libraries(__PLUGIN_SLUG__ PRIVATE rb_sdk::rb_sdk)
|
|
250
250
|
```
|
|
@@ -10,7 +10,7 @@ find_package(rclcpp REQUIRED)
|
|
|
10
10
|
# Rainbow Robotics C++ SDK (apt: rainbow-rb-sdk-dev, pre-installed in the dev
|
|
11
11
|
# container). The version here is the one the dev container installs; change it
|
|
12
12
|
# with `muscat-pk sdk update <version>`.
|
|
13
|
-
find_package(rb_sdk 0.10.
|
|
13
|
+
find_package(rb_sdk 0.10.4 EXACT REQUIRED)
|
|
14
14
|
|
|
15
15
|
add_executable(__PLUGIN_SLUG___node src/node.cpp)
|
|
16
16
|
ament_target_dependencies(__PLUGIN_SLUG___node rclcpp)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rainbow-robotics/muscat-plugin-kit",
|
|
3
|
-
"version": "0.10.1-dev.
|
|
3
|
+
"version": "0.10.1-dev.1",
|
|
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.10.1-dev.
|
|
57
|
-
"@rainbow-robotics/preview-host": "0.10.1-dev.
|
|
56
|
+
"@rainbow-robotics/plugin-ui": "0.10.1-dev.1",
|
|
57
|
+
"@rainbow-robotics/preview-host": "0.10.1-dev.1",
|
|
58
58
|
"@types/react": "^19.0.10",
|
|
59
59
|
"postcss": "^8.5.8",
|
|
60
60
|
"typescript": "^5.8.0",
|