agenticros 0.1.17 → 0.1.19

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 (100) hide show
  1. package/dist/__tests__/robot-config.test.d.ts +2 -0
  2. package/dist/__tests__/robot-config.test.d.ts.map +1 -0
  3. package/dist/__tests__/robot-config.test.js +374 -0
  4. package/dist/__tests__/robot-config.test.js.map +1 -0
  5. package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
  6. package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
  7. package/dist/__tests__/transport-shorthand.test.js +88 -0
  8. package/dist/__tests__/transport-shorthand.test.js.map +1 -0
  9. package/dist/commands/down.d.ts.map +1 -1
  10. package/dist/commands/down.js +55 -3
  11. package/dist/commands/down.js.map +1 -1
  12. package/dist/commands/robots.d.ts +57 -0
  13. package/dist/commands/robots.d.ts.map +1 -0
  14. package/dist/commands/robots.js +678 -0
  15. package/dist/commands/robots.js.map +1 -0
  16. package/dist/index.js +28 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/util/mcp-discovery.d.ts +57 -0
  19. package/dist/util/mcp-discovery.d.ts.map +1 -0
  20. package/dist/util/mcp-discovery.js +153 -0
  21. package/dist/util/mcp-discovery.js.map +1 -0
  22. package/dist/util/robot-config.d.ts +167 -0
  23. package/dist/util/robot-config.d.ts.map +1 -0
  24. package/dist/util/robot-config.js +375 -0
  25. package/dist/util/robot-config.js.map +1 -0
  26. package/dist/util/transport-shorthand.d.ts +66 -0
  27. package/dist/util/transport-shorthand.d.ts.map +1 -0
  28. package/dist/util/transport-shorthand.js +102 -0
  29. package/dist/util/transport-shorthand.js.map +1 -0
  30. package/package.json +2 -1
  31. package/runtime/BUNDLE.json +1 -1
  32. package/runtime/README.md +4 -0
  33. package/runtime/package.json +6 -1
  34. package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
  35. package/runtime/packages/agenticros/package.json +2 -1
  36. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
  37. package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
  38. package/runtime/packages/agenticros/src/service.ts +120 -2
  39. package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
  40. package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
  41. package/runtime/packages/agenticros/src/tools/index.ts +49 -9
  42. package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
  43. package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
  44. package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
  45. package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
  46. package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
  47. package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
  48. package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
  49. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
  50. package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
  51. package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
  52. package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
  53. package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
  54. package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
  55. package/runtime/packages/agenticros-claude-code/package.json +2 -1
  56. package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
  57. package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
  58. package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
  59. package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
  60. package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
  61. package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
  62. package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
  63. package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
  64. package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
  65. package/runtime/packages/agenticros-gemini/package.json +1 -0
  66. package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
  67. package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
  68. package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
  69. package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
  70. package/runtime/packages/core/package.json +1 -1
  71. package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
  72. package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
  73. package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
  74. package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
  75. package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
  76. package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
  77. package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
  78. package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
  79. package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
  80. package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
  81. package/runtime/packages/core/src/capabilities.ts +340 -0
  82. package/runtime/packages/core/src/config.ts +220 -38
  83. package/runtime/packages/core/src/discovery.ts +193 -0
  84. package/runtime/packages/core/src/find-robots-for.ts +164 -0
  85. package/runtime/packages/core/src/index.ts +67 -1
  86. package/runtime/packages/core/src/mission-registry.ts +142 -0
  87. package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
  88. package/runtime/packages/core/src/mission.ts +574 -0
  89. package/runtime/packages/core/src/planner/index.ts +564 -0
  90. package/runtime/packages/core/src/robots.ts +282 -0
  91. package/runtime/packages/core/src/topic-utils.ts +47 -9
  92. package/runtime/packages/core/src/transport-pool.ts +171 -0
  93. package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
  94. package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
  95. package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
  96. package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
  97. package/runtime/scripts/refresh-skill-deps.mjs +381 -0
  98. package/runtime/scripts/setup_gateway_plugin.sh +32 -5
  99. package/runtime/scripts/start_demo.sh +26 -3
  100. package/runtime/scripts/sync-skill-tools.mjs +67 -10
@@ -0,0 +1,678 @@
1
+ /**
2
+ * `agenticros robots` — manage the multi-robot fleet config.
3
+ *
4
+ * Subactions:
5
+ * list / show / ls Print configured robots + their online status (default).
6
+ * discover / scan Live-discover robots on the wire, offer to add unknown ones.
7
+ * add [id] Add a robot to config.robots[] (interactive when id omitted).
8
+ * Flags: --name, --namespace, --camera, --default,
9
+ * --transport=<shorthand>, --transport-json=<json>.
10
+ * remove / rm <id> Remove a robot from config.robots[].
11
+ * set-default <id> Mark a robot as the active default.
12
+ * set-transport <id> [shorthand] Apply a per-robot transport override (or use
13
+ * --transport / --transport-json instead of [shorthand]).
14
+ * clear-transport <id> Drop the per-robot transport override so the robot
15
+ * inherits the global transport config.
16
+ *
17
+ * All mutations target `~/.agenticros/config.json`. The legacy single-robot
18
+ * `config.robot` is automatically promoted into `config.robots[]` on the first
19
+ * multi-robot write — see `util/robot-config.ts` for the exact rules.
20
+ *
21
+ * Live discovery is delegated to the `ros2_discover_robots` MCP tool via a
22
+ * subprocess to the agenticros-claude-code server. That keeps this CLI small
23
+ * (no `@agenticros/core` import) AND ensures the user sees the same robots
24
+ * the AI agent sees.
25
+ */
26
+ import { checkbox, confirm, input, select } from "@inquirer/prompts";
27
+ import { addRobot, clearTransportForRobot, getActiveRobotId, readConfigObject, readRobots, removeRobot, robotConfigPath, setDefaultRobot, setTransportForRobot, writeConfigObject, } from "../util/robot-config.js";
28
+ import { discoverViaMcp, } from "../util/mcp-discovery.js";
29
+ import { colors, dim, err, header, info, isTty, ok, warn } from "../util/logger.js";
30
+ import { parseTransportJson, parseTransportShorthand, } from "../util/transport-shorthand.js";
31
+ /** Known sensor flags. We reject unknown keys with an actionable error. */
32
+ const KNOWN_SENSOR_KEYS = new Set([
33
+ "has_realsense",
34
+ "has_lidar",
35
+ "has_arm",
36
+ ]);
37
+ /**
38
+ * Parse a `--sensors=<csv>` value into a RobotSensors partial.
39
+ *
40
+ * Grammar: `key[,...]` where each entry is `keyname` (sets true) or
41
+ * `!keyname` (sets false). Unknown keys throw with the accepted set so
42
+ * the user can self-correct.
43
+ *
44
+ * Examples:
45
+ * "has_realsense" → { has_realsense: true }
46
+ * "has_realsense,has_lidar" → { has_realsense: true, has_lidar: true }
47
+ * "has_realsense,!has_arm" → { has_realsense: true, has_arm: false }
48
+ * "" → {}
49
+ *
50
+ * Returns `undefined` when the input is undefined (so callers can
51
+ * distinguish "user didn't pass the flag" from "user passed empty").
52
+ */
53
+ function parseSensorsCsv(raw) {
54
+ if (raw === undefined)
55
+ return undefined;
56
+ const trimmed = raw.trim();
57
+ if (!trimmed)
58
+ return {};
59
+ const out = {};
60
+ for (const part of trimmed.split(",")) {
61
+ const entry = part.trim();
62
+ if (!entry)
63
+ continue;
64
+ let value = true;
65
+ let key = entry;
66
+ if (entry.startsWith("!")) {
67
+ value = false;
68
+ key = entry.slice(1).trim();
69
+ }
70
+ if (!KNOWN_SENSOR_KEYS.has(key)) {
71
+ throw new Error(`Unknown sensor tag "${key}". Accepted: ${[...KNOWN_SENSOR_KEYS].join(", ")}. ` +
72
+ 'Prefix with "!" to set false (e.g. --sensors=has_realsense,!has_arm).');
73
+ }
74
+ out[key] = value;
75
+ }
76
+ return out;
77
+ }
78
+ /**
79
+ * Parse a `--capabilities=<csv>` value. Empty string returns an empty
80
+ * array (which the caller interprets as "clear the field"); undefined
81
+ * returns undefined (meaning the flag wasn't supplied).
82
+ */
83
+ function parseCapabilitiesCsv(raw) {
84
+ if (raw === undefined)
85
+ return undefined;
86
+ const trimmed = raw.trim();
87
+ if (!trimmed)
88
+ return [];
89
+ return trimmed
90
+ .split(",")
91
+ .map((s) => s.trim())
92
+ .filter((s) => s.length > 0);
93
+ }
94
+ export async function robotsCommand(opts) {
95
+ const action = (opts.action ?? "list").toLowerCase();
96
+ switch (action) {
97
+ case "list":
98
+ case "ls":
99
+ case "show":
100
+ return listAction();
101
+ case "discover":
102
+ case "scan":
103
+ return discoverAction();
104
+ case "add":
105
+ return addAction(opts);
106
+ case "remove":
107
+ case "rm":
108
+ case "delete":
109
+ return removeAction(opts.arg);
110
+ case "set-default":
111
+ case "default":
112
+ return setDefaultAction(opts.arg);
113
+ case "set-transport":
114
+ case "transport":
115
+ return setTransportAction(opts);
116
+ case "clear-transport":
117
+ return clearTransportAction(opts.arg);
118
+ default:
119
+ err(`Unknown robots action '${opts.action}'.`);
120
+ err("Use: list | discover | add [id] | remove <id> | set-default <id> | set-transport <id> [shorthand] | clear-transport <id>");
121
+ process.exit(2);
122
+ }
123
+ }
124
+ /**
125
+ * Parse `--transport` / `--transport-json` (mutually exclusive) into the
126
+ * JSON-shape stored under `config.robots[i].transport`. Returns
127
+ * `undefined` when neither flag is set. Exits the process on parse
128
+ * errors with an actionable message — we deliberately don't propagate
129
+ * the throw because option-parse errors should be terminal.
130
+ */
131
+ function parseOverrideFromOptions(opts) {
132
+ if (opts.transport && opts.transportJson) {
133
+ err("Pass either --transport=<shorthand> OR --transport-json=<json>, not both.");
134
+ process.exit(2);
135
+ }
136
+ try {
137
+ if (opts.transport) {
138
+ return parseTransportShorthand(opts.transport);
139
+ }
140
+ if (opts.transportJson) {
141
+ return parseTransportJson(opts.transportJson);
142
+ }
143
+ }
144
+ catch (e) {
145
+ err(e instanceof Error ? e.message : String(e));
146
+ process.exit(2);
147
+ }
148
+ return undefined;
149
+ }
150
+ // ─────────────────────────────────────────────────────────────────────────────
151
+ // list
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+ async function listAction() {
154
+ header("AgenticROS robots");
155
+ info(`Config: ${robotConfigPath()}`);
156
+ const obj = readConfigObject();
157
+ const { robots, from } = readRobots(obj);
158
+ const activeId = getActiveRobotId(obj);
159
+ if (robots.length === 0) {
160
+ dim("No robots configured yet. Run `agenticros robots discover` to find robots on the wire.");
161
+ return;
162
+ }
163
+ // Best-effort discovery so we can mark each entry online/offline.
164
+ // It's fine if this fails — the list still renders without the badge.
165
+ let discovery;
166
+ try {
167
+ discovery = await discoverViaMcp();
168
+ }
169
+ catch {
170
+ /* render list-only — badge falls back to "?" */
171
+ }
172
+ process.stdout.write(`\n${colors.bold("Configured robots:")} ${dimSource(from)}\n`);
173
+ for (const r of robots) {
174
+ const isActive = r.id === activeId;
175
+ const badge = renderOnlineBadge(r, discovery);
176
+ const star = isActive ? colors.bold(colors.green(" (active)")) : "";
177
+ process.stdout.write(` ${badge} ${colors.bold(r.id)}${star} ${colors.dim(r.name ?? "Robot")}\n`);
178
+ process.stdout.write(` namespace: ${colors.dim(r.namespace || "(empty)")} ` +
179
+ `camera: ${colors.dim(r.cameraTopic || "(default)")}\n`);
180
+ // Phase 1.e: show kind + sensors so a user can confirm at a glance
181
+ // that the fleet metadata behind ros2_find_robots_for is correct.
182
+ // Suppress the row when kind is "amr" AND sensors are unset — that's
183
+ // the implicit default and doesn't need to add visual noise.
184
+ const showKind = r.kind && r.kind !== "amr";
185
+ const sensorsSummary = r.sensors ? describeSensors(r.sensors) : "";
186
+ if (showKind || sensorsSummary) {
187
+ const kindPart = showKind ? `kind: ${colors.cyan(r.kind)}` : "";
188
+ const sensorsPart = sensorsSummary
189
+ ? `sensors: ${colors.cyan(sensorsSummary)}`
190
+ : "";
191
+ process.stdout.write(` ${[kindPart, sensorsPart].filter((s) => s).join(" ")}\n`);
192
+ }
193
+ if (r.capabilities && r.capabilities.length > 0) {
194
+ process.stdout.write(` capabilities: ${colors.cyan(r.capabilities.join(", "))}\n`);
195
+ }
196
+ if (r.transport) {
197
+ process.stdout.write(` transport: ${colors.cyan(describeOverride(r.transport))}\n`);
198
+ }
199
+ }
200
+ if (discovery && discovery.unknown_detected.length > 0) {
201
+ process.stdout.write(`\n${colors.bold(colors.yellow("Detected on the wire but NOT in config:"))}\n`);
202
+ for (const d of discovery.unknown_detected) {
203
+ process.stdout.write(` ${colors.yellow("○")} ${colors.bold(d.id)} ${colors.dim(d.cmdVelTopic)} ${colors.dim(`(${d.topicCount} topic(s))`)}\n`);
204
+ }
205
+ dim(`Promote any of them with 'agenticros robots add <id>' or 'agenticros robots discover'.`);
206
+ }
207
+ process.stdout.write("\n");
208
+ }
209
+ function dimSource(from) {
210
+ if (from === "explicit")
211
+ return colors.dim("(explicit robots[] in config)");
212
+ if (from === "legacy")
213
+ return colors.dim("(synthesised from legacy config.robot — promoted on next add)");
214
+ return colors.dim("(none)");
215
+ }
216
+ function renderOnlineBadge(r, discovery) {
217
+ if (!discovery)
218
+ return colors.dim("●");
219
+ const onlineIds = new Set(discovery.configured_online.map((x) => x.id));
220
+ if (onlineIds.has(r.id))
221
+ return colors.green("●");
222
+ return colors.dim("○");
223
+ }
224
+ // ─────────────────────────────────────────────────────────────────────────────
225
+ // discover
226
+ // ─────────────────────────────────────────────────────────────────────────────
227
+ async function discoverAction() {
228
+ header("Discover AgenticROS robots");
229
+ info("Scanning the live ROS2 topic graph via the MCP server …");
230
+ let discovery;
231
+ try {
232
+ discovery = await discoverViaMcp();
233
+ }
234
+ catch (e) {
235
+ err(`Discovery failed: ${e instanceof Error ? e.message : String(e)}`);
236
+ err("Check that the ROS transport is reachable (zenohd / rosbridge / etc) and that " +
237
+ "`packages/agenticros-claude-code/dist/index.js` is built. See `agenticros doctor`.");
238
+ process.exit(1);
239
+ }
240
+ info(`Scanned ${discovery.total_topics} topic(s).`);
241
+ process.stdout.write(`\n${colors.bold("On the wire right now:")}\n`);
242
+ if (discovery.detected.length === 0) {
243
+ dim(" No /<ns>/cmd_vel topics detected. Is the robot's bridge/router up?");
244
+ }
245
+ else {
246
+ for (const d of discovery.detected) {
247
+ const tag = d.configuredRobotId !== null
248
+ ? colors.green(`● configured as ${d.configuredRobotId}`)
249
+ : colors.yellow("○ unknown (not in config)");
250
+ process.stdout.write(` ${tag} ${colors.bold(d.id)} ${colors.dim(d.cmdVelTopic)} ${colors.dim(`(${d.topicCount} topic(s))`)}\n`);
251
+ }
252
+ }
253
+ if (discovery.configured_offline.length > 0) {
254
+ process.stdout.write(`\n${colors.bold(colors.yellow("Configured but silent:"))}\n`);
255
+ for (const r of discovery.configured_offline) {
256
+ process.stdout.write(` ${colors.yellow("○")} ${colors.bold(r.id)}\n`);
257
+ }
258
+ }
259
+ if (discovery.unknown_detected.length === 0) {
260
+ process.stdout.write("\n");
261
+ ok("No new robots to add — every detected namespace is already in config.");
262
+ return;
263
+ }
264
+ if (!isTty) {
265
+ process.stdout.write("\n");
266
+ info(`Found ${discovery.unknown_detected.length} unknown robot(s).`);
267
+ info("Run interactively to register them, or use `agenticros robots add <id>`.");
268
+ return;
269
+ }
270
+ process.stdout.write("\n");
271
+ const picks = await checkbox({
272
+ message: `Add ${discovery.unknown_detected.length} new robot(s) to config?`,
273
+ choices: discovery.unknown_detected.map((d) => ({
274
+ name: `${d.id} (${d.topicCount} topic(s), cmd_vel: ${d.cmdVelTopic})`,
275
+ value: d.id,
276
+ checked: true,
277
+ })),
278
+ });
279
+ if (picks.length === 0) {
280
+ info("Nothing selected. Run `agenticros robots add <id>` later to add one by id.");
281
+ return;
282
+ }
283
+ const obj = readConfigObject();
284
+ let writes = 0;
285
+ for (const id of picks) {
286
+ const detected = discovery.unknown_detected.find((d) => d.id === id);
287
+ const result = addRobot({
288
+ id: detected.id,
289
+ name: detected.id,
290
+ namespace: detected.id,
291
+ cameraTopic: "",
292
+ }, { obj });
293
+ if (result.added)
294
+ writes += 1;
295
+ if (result.promotedLegacy && writes === 1) {
296
+ info(`Promoted legacy config.robot into config.robots[0] (kept as default). The new entries are appended.`);
297
+ }
298
+ }
299
+ if (writes > 0) {
300
+ writeConfigObject(obj);
301
+ ok(`Added ${writes} robot(s) to ${robotConfigPath()}.`);
302
+ warn("Restart any running MCP servers (Claude Code, Claude desktop, OpenClaw) so they re-read the config.");
303
+ }
304
+ else {
305
+ info("Nothing new to write (all picks were already in config).");
306
+ }
307
+ }
308
+ // ─────────────────────────────────────────────────────────────────────────────
309
+ // add
310
+ // ─────────────────────────────────────────────────────────────────────────────
311
+ async function addAction(opts) {
312
+ let id = opts.arg?.trim();
313
+ // Resolve --transport / --transport-json up-front so we fail fast on
314
+ // a malformed value before we touch the live MCP server or prompt
315
+ // the user. parseOverrideFromOptions exits the process on parse
316
+ // failures, so undefined here means "no override flag was passed".
317
+ const overrideFromFlag = parseOverrideFromOptions(opts);
318
+ // Phase 1.e — parse --sensors and --capabilities up-front (same
319
+ // fail-fast story as transport). Both are best-effort: we accept
320
+ // unknown sensor keys with a friendly error rather than crashing the
321
+ // process via Zod, since the user is more likely to typo here than
322
+ // in the transport shorthand.
323
+ let sensorsFromFlag;
324
+ try {
325
+ sensorsFromFlag = parseSensorsCsv(opts.sensors);
326
+ }
327
+ catch (e) {
328
+ err(e instanceof Error ? e.message : String(e));
329
+ process.exit(2);
330
+ }
331
+ const capabilitiesFromFlag = parseCapabilitiesCsv(opts.capabilities);
332
+ // True when the user gave us any --flag, meaning "do not prompt".
333
+ // We treat non-interactive mode as the *implicit* default once any
334
+ // flag is set — running `robots add alpha --transport=zenoh` should
335
+ // not interrupt the user with extra prompts for name/namespace.
336
+ const usingFlags = overrideFromFlag !== undefined ||
337
+ opts.name !== undefined ||
338
+ opts.namespace !== undefined ||
339
+ opts.camera !== undefined ||
340
+ opts.default === true ||
341
+ opts.kind !== undefined ||
342
+ sensorsFromFlag !== undefined ||
343
+ capabilitiesFromFlag !== undefined;
344
+ // Try to fetch live discovery once so the prompts can pre-fill defaults
345
+ // and so an interactive `add` (no id) lets the user pick from the wire.
346
+ // Skipped in flag-driven runs (we have everything we need from argv).
347
+ let discovery;
348
+ if (!usingFlags) {
349
+ try {
350
+ discovery = await discoverViaMcp();
351
+ }
352
+ catch {
353
+ /* offline-add is supported — just no auto-fill */
354
+ }
355
+ }
356
+ if (!id) {
357
+ if (!isTty) {
358
+ err("Usage: agenticros robots add <id> [--name=...] [--namespace=...] [--transport=<shorthand>]");
359
+ process.exit(2);
360
+ }
361
+ id = await pickIdInteractively(discovery);
362
+ if (!id) {
363
+ info("Cancelled.");
364
+ return;
365
+ }
366
+ }
367
+ // Look up any existing entry so flag-driven updates can merge with
368
+ // prior values instead of clobbering them. (addRobot itself doesn't
369
+ // merge name/namespace/cameraTopic — see util/robot-config.ts.)
370
+ const obj = readConfigObject();
371
+ const existing = readRobots(obj).robots.find((r) => r.id === id);
372
+ // Default fallbacks. Precedence: explicit --flag > prior persisted value > discovery hint > id.
373
+ const detected = discovery?.unknown_detected.find((d) => d.id === id);
374
+ let name = opts.name ?? existing?.name ?? id;
375
+ let namespace = opts.namespace ?? existing?.namespace ?? detected?.id ?? id;
376
+ let cameraTopic = opts.camera ?? existing?.cameraTopic ?? "";
377
+ // Interactive prompts only when the user didn't pass any flags AND
378
+ // we have a TTY. This preserves the original wizard UX while still
379
+ // allowing one-shot CLI usage.
380
+ if (isTty && !usingFlags) {
381
+ name = (await input({
382
+ message: "Display name:",
383
+ default: name,
384
+ validate: (v) => v.trim().length > 0 || "Required",
385
+ })).trim();
386
+ namespace = (await input({ message: "ROS2 namespace:", default: namespace })).trim();
387
+ cameraTopic = (await input({
388
+ message: "Default camera topic (optional, blank ⇒ /<namespace>/camera/...):",
389
+ default: cameraTopic,
390
+ })).trim();
391
+ }
392
+ // Mark default? --default flag forces it; otherwise prompt in TTY-and-no-flags mode.
393
+ let setDefault = opts.default === true;
394
+ if (!setDefault && isTty && !usingFlags) {
395
+ const { robots } = readRobots(obj);
396
+ if (robots.length >= 1 && robots[0].id !== id) {
397
+ setDefault = await confirm({
398
+ message: `Make "${id}" the default robot?`,
399
+ default: false,
400
+ });
401
+ }
402
+ }
403
+ // Phase 1.e merges: when --sensors=key1,key2 was passed, merge the
404
+ // delta over the existing sensors block so the user can flip ONE
405
+ // flag without re-declaring the rest. (Vs --transport which is a
406
+ // whole-object replacement — the contracts are different because
407
+ // sensors are a small named set.)
408
+ let mergedSensors;
409
+ if (sensorsFromFlag !== undefined) {
410
+ mergedSensors = { ...(existing?.sensors ?? {}), ...sensorsFromFlag };
411
+ }
412
+ // Capability allowlist: treat the flag as a full replacement (empty
413
+ // string already became []). Same semantics as --transport.
414
+ const capabilitiesToApply = capabilitiesFromFlag;
415
+ const result = addRobot({
416
+ id,
417
+ name,
418
+ namespace,
419
+ cameraTopic,
420
+ transport: overrideFromFlag,
421
+ kind: opts.kind,
422
+ sensors: mergedSensors,
423
+ capabilities: capabilitiesToApply,
424
+ }, { obj, setDefault });
425
+ writeConfigObject(obj);
426
+ if (result.promotedLegacy) {
427
+ info("Promoted legacy config.robot into config.robots[0] (kept as default).");
428
+ }
429
+ if (result.added) {
430
+ ok(`Added robot "${id}" to ${robotConfigPath()}.`);
431
+ }
432
+ else {
433
+ ok(`Updated existing robot "${id}" in ${robotConfigPath()}.`);
434
+ }
435
+ if (overrideFromFlag) {
436
+ ok(`Per-robot transport override applied: ${describeOverride(overrideFromFlag)}.`);
437
+ }
438
+ if (opts.kind)
439
+ ok(`Robot kind set to "${opts.kind}".`);
440
+ if (mergedSensors) {
441
+ const formatted = describeSensors(mergedSensors);
442
+ if (formatted)
443
+ ok(`Sensors: ${formatted}.`);
444
+ }
445
+ if (capabilitiesToApply !== undefined) {
446
+ if (capabilitiesToApply.length === 0) {
447
+ ok("Per-robot capability allowlist cleared (robot now inherits the gateway-wide registry).");
448
+ }
449
+ else {
450
+ ok(`Per-robot capability allowlist: ${capabilitiesToApply.join(", ")}.`);
451
+ }
452
+ }
453
+ if (setDefault)
454
+ ok(`"${id}" is now the default robot.`);
455
+ warn("Restart any running MCP servers so they re-read the config.");
456
+ }
457
+ /**
458
+ * Render a sensors object as a stable, comma-separated human summary
459
+ * for log messages. Omits keys that are unset (undefined) so we don't
460
+ * lie about defaults.
461
+ */
462
+ function describeSensors(s) {
463
+ const parts = [];
464
+ for (const k of ["has_realsense", "has_lidar", "has_arm"]) {
465
+ const v = s[k];
466
+ if (v === undefined)
467
+ continue;
468
+ parts.push(v ? k : `!${k}`);
469
+ }
470
+ return parts.join(", ");
471
+ }
472
+ /**
473
+ * Render a one-line human summary of a transport override for log
474
+ * messages. Deliberately tolerant — the JSON has already passed our
475
+ * shorthand parser (or made it through `--transport-json`), and the
476
+ * core Zod schema will field-validate at load time. We only need this
477
+ * good enough to confirm to the user "yes, we wrote that thing you
478
+ * just typed".
479
+ */
480
+ function describeOverride(override) {
481
+ const mode = String(override["mode"] ?? "?");
482
+ switch (mode) {
483
+ case "zenoh": {
484
+ const ep = override["zenoh"] && typeof override["zenoh"] === "object"
485
+ ? override["zenoh"]["routerEndpoint"]
486
+ : undefined;
487
+ return ep ? `zenoh @ ${String(ep)}` : "zenoh (inherits global router endpoint)";
488
+ }
489
+ case "rosbridge": {
490
+ const url = override["rosbridge"] && typeof override["rosbridge"] === "object"
491
+ ? override["rosbridge"]["url"]
492
+ : undefined;
493
+ return url ? `rosbridge @ ${String(url)}` : "rosbridge (inherits global url)";
494
+ }
495
+ case "local": {
496
+ const dom = override["local"] && typeof override["local"] === "object"
497
+ ? override["local"]["domainId"]
498
+ : undefined;
499
+ return dom !== undefined
500
+ ? `local DDS (domainId=${String(dom)})`
501
+ : "local DDS (inherits global domainId)";
502
+ }
503
+ case "webrtc": {
504
+ const sig = override["webrtc"] && typeof override["webrtc"] === "object"
505
+ ? override["webrtc"]["signalingUrl"]
506
+ : undefined;
507
+ return sig ? `webrtc @ ${String(sig)}` : "webrtc (inherits global signaling url)";
508
+ }
509
+ default:
510
+ return `mode=${mode}`;
511
+ }
512
+ }
513
+ async function pickIdInteractively(discovery) {
514
+ const unknown = discovery?.unknown_detected ?? [];
515
+ if (unknown.length === 0) {
516
+ return (await input({
517
+ message: "Robot id (free-form, e.g. robot-alpha):",
518
+ validate: (v) => v.trim().length > 0 || "Required",
519
+ })).trim();
520
+ }
521
+ const choice = await select({
522
+ message: "Pick a robot to add:",
523
+ choices: [
524
+ ...unknown.map((d) => ({
525
+ name: `${d.id} (cmd_vel: ${d.cmdVelTopic}, ${d.topicCount} topic(s))`,
526
+ value: d.id,
527
+ })),
528
+ { name: "Enter id manually…", value: "__manual__" },
529
+ { name: "Cancel", value: "__cancel__" },
530
+ ],
531
+ });
532
+ if (choice === "__cancel__")
533
+ return undefined;
534
+ if (choice === "__manual__") {
535
+ return (await input({
536
+ message: "Robot id:",
537
+ validate: (v) => v.trim().length > 0 || "Required",
538
+ })).trim();
539
+ }
540
+ return choice;
541
+ }
542
+ // ─────────────────────────────────────────────────────────────────────────────
543
+ // remove
544
+ // ─────────────────────────────────────────────────────────────────────────────
545
+ async function removeAction(arg) {
546
+ const id = arg?.trim();
547
+ if (!id) {
548
+ err("Usage: agenticros robots remove <id>");
549
+ process.exit(2);
550
+ }
551
+ const obj = readConfigObject();
552
+ const result = removeRobot(id, obj);
553
+ if (!result.removed) {
554
+ warn(`No robot with id "${id}" found in ${robotConfigPath()}.`);
555
+ return;
556
+ }
557
+ writeConfigObject(obj);
558
+ ok(`Removed robot "${id}".`);
559
+ if (result.robots.length === 0) {
560
+ info("robots[] is now empty — the gateway will fall back to legacy config.robot (if set). " +
561
+ "Run `agenticros robots discover` to re-populate.");
562
+ }
563
+ warn("Restart any running MCP servers so they re-read the config.");
564
+ }
565
+ // ─────────────────────────────────────────────────────────────────────────────
566
+ // set-default
567
+ // ─────────────────────────────────────────────────────────────────────────────
568
+ async function setDefaultAction(arg) {
569
+ const id = arg?.trim();
570
+ if (!id) {
571
+ err("Usage: agenticros robots set-default <id>");
572
+ process.exit(2);
573
+ }
574
+ const obj = readConfigObject();
575
+ try {
576
+ const { promotedLegacy } = setDefaultRobot(id, obj);
577
+ writeConfigObject(obj);
578
+ if (promotedLegacy) {
579
+ info("Promoted legacy config.robot into config.robots[0] first.");
580
+ }
581
+ ok(`"${id}" is now the default robot.`);
582
+ warn("Restart any running MCP servers so they re-read the config.");
583
+ }
584
+ catch (e) {
585
+ err(e instanceof Error ? e.message : String(e));
586
+ process.exit(1);
587
+ }
588
+ }
589
+ // ─────────────────────────────────────────────────────────────────────────────
590
+ // set-transport / clear-transport
591
+ // ─────────────────────────────────────────────────────────────────────────────
592
+ /**
593
+ * `agenticros robots set-transport <id> [shorthand]`
594
+ *
595
+ * Two ways to supply the override:
596
+ * - Positional `[shorthand]` (parsed via parseTransportShorthand).
597
+ * - `--transport=<shorthand>` / `--transport-json=<json>` flags.
598
+ * They're mutually exclusive — picking one is enough.
599
+ *
600
+ * Why a dedicated subcommand when `add` already takes the same flags?
601
+ * Two reasons. First, it surfaces the transport-override knob in
602
+ * --help so users discover the feature without reading code. Second,
603
+ * it makes the intent unambiguous in scripts: `add` can't tell whether
604
+ * a new entry is wanted or an update; `set-transport` is always-update
605
+ * and throws if the id is unknown (no silent typo-promotion).
606
+ */
607
+ async function setTransportAction(opts) {
608
+ const id = opts.arg?.trim();
609
+ if (!id) {
610
+ err("Usage: agenticros robots set-transport <id> <shorthand>\n" +
611
+ " or: agenticros robots set-transport <id> --transport=<shorthand>\n" +
612
+ " or: agenticros robots set-transport <id> --transport-json=<json>");
613
+ process.exit(2);
614
+ }
615
+ // Resolve the override. Positional argument is a convenience for the
616
+ // common case `set-transport alpha zenoh:ws://farm:10000`; flags are
617
+ // preferred when scripting or when the user has TTY tab completion.
618
+ // We don't accept "positional + flag" simultaneously — too ambiguous.
619
+ // We *do* re-use parseOverrideFromOptions so flag-driven runs share
620
+ // the same error path as `add`.
621
+ let override;
622
+ if (opts.transport || opts.transportJson) {
623
+ override = parseOverrideFromOptions(opts);
624
+ }
625
+ else {
626
+ err("Missing override. Supply either:\n" +
627
+ " agenticros robots set-transport <id> --transport=<shorthand>\n" +
628
+ " agenticros robots set-transport <id> --transport-json=<json>");
629
+ process.exit(2);
630
+ }
631
+ if (!override)
632
+ return;
633
+ const obj = readConfigObject();
634
+ try {
635
+ const { promotedLegacy } = setTransportForRobot(id, override, obj);
636
+ writeConfigObject(obj);
637
+ if (promotedLegacy)
638
+ info("Promoted legacy config.robot into config.robots[0] first.");
639
+ ok(`Applied per-robot transport override to "${id}": ${describeOverride(override)}.`);
640
+ warn("Restart any running MCP servers so they re-read the config.");
641
+ }
642
+ catch (e) {
643
+ err(e instanceof Error ? e.message : String(e));
644
+ process.exit(1);
645
+ }
646
+ }
647
+ /**
648
+ * `agenticros robots clear-transport <id>` — drop the per-robot
649
+ * transport override, letting the robot inherit the global transport
650
+ * settings again. Idempotent: prints a friendly "(nothing to clear)"
651
+ * message when the robot didn't have an override to begin with.
652
+ */
653
+ async function clearTransportAction(arg) {
654
+ const id = arg?.trim();
655
+ if (!id) {
656
+ err("Usage: agenticros robots clear-transport <id>");
657
+ process.exit(2);
658
+ }
659
+ const obj = readConfigObject();
660
+ try {
661
+ const { cleared, promotedLegacy } = clearTransportForRobot(id, obj);
662
+ writeConfigObject(obj);
663
+ if (promotedLegacy)
664
+ info("Promoted legacy config.robot into config.robots[0] first.");
665
+ if (cleared) {
666
+ ok(`Cleared per-robot transport override on "${id}".`);
667
+ warn("Restart any running MCP servers so they re-read the config.");
668
+ }
669
+ else {
670
+ info(`"${id}" had no transport override (nothing to clear).`);
671
+ }
672
+ }
673
+ catch (e) {
674
+ err(e instanceof Error ? e.message : String(e));
675
+ process.exit(1);
676
+ }
677
+ }
678
+ //# sourceMappingURL=robots.js.map