agenticros 0.3.4 → 0.4.0

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 (79) hide show
  1. package/README.md +18 -3
  2. package/dist/commands/doctor.d.ts.map +1 -1
  3. package/dist/commands/doctor.js +34 -1
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/skills.d.ts +3 -5
  6. package/dist/commands/skills.d.ts.map +1 -1
  7. package/dist/commands/skills.js +21 -7
  8. package/dist/commands/skills.js.map +1 -1
  9. package/dist/util/skill-manifest.d.ts.map +1 -1
  10. package/dist/util/skill-manifest.js +22 -1
  11. package/dist/util/skill-manifest.js.map +1 -1
  12. package/dist/util/skills.d.ts +6 -0
  13. package/dist/util/skills.d.ts.map +1 -1
  14. package/dist/util/skills.js +69 -1
  15. package/dist/util/skills.js.map +1 -1
  16. package/package.json +2 -1
  17. package/runtime/BUNDLE.json +1 -1
  18. package/runtime/README.md +31 -6
  19. package/runtime/docs/cli.md +1 -1
  20. package/runtime/docs/robot-setup.md +1 -1
  21. package/runtime/packages/agenticros/openclaw.plugin.json +147 -1
  22. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +2 -0
  23. package/runtime/packages/agenticros/src/index.ts +8 -1
  24. package/runtime/packages/agenticros/src/routes.ts +4 -3
  25. package/runtime/packages/agenticros/src/tools/index.ts +6 -5
  26. package/runtime/packages/agenticros/src/tools/mission-pause.ts +49 -0
  27. package/runtime/packages/agenticros/src/tools/mission-resume.ts +41 -0
  28. package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +9 -12
  29. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +33 -84
  30. package/runtime/packages/agenticros-claude-code/README.md +2 -0
  31. package/runtime/packages/agenticros-claude-code/dist/config.d.ts +5 -3
  32. package/runtime/packages/agenticros-claude-code/dist/config.d.ts.map +1 -1
  33. package/runtime/packages/agenticros-claude-code/dist/config.js +32 -10
  34. package/runtime/packages/agenticros-claude-code/dist/config.js.map +1 -1
  35. package/runtime/packages/agenticros-claude-code/dist/index.js +3 -3
  36. package/runtime/packages/agenticros-claude-code/dist/index.js.map +1 -1
  37. package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
  38. package/runtime/packages/agenticros-claude-code/dist/tools.js +137 -118
  39. package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
  40. package/runtime/packages/agenticros-claude-code/src/config.ts +33 -10
  41. package/runtime/packages/agenticros-claude-code/src/index.ts +3 -3
  42. package/runtime/packages/agenticros-claude-code/src/tools.ts +151 -107
  43. package/runtime/packages/agenticros-gemini/package.json +2 -0
  44. package/runtime/packages/agenticros-gemini/src/config.ts +27 -18
  45. package/runtime/packages/agenticros-gemini/src/find-object/coco-classes.ts +38 -0
  46. package/runtime/packages/agenticros-gemini/src/find-object/find-object.ts +191 -0
  47. package/runtime/packages/agenticros-gemini/src/follow-me/controller.ts +109 -0
  48. package/runtime/packages/agenticros-gemini/src/follow-me/depth-loop.ts +452 -0
  49. package/runtime/packages/agenticros-gemini/src/follow-me/detector.ts +303 -0
  50. package/runtime/packages/agenticros-gemini/src/follow-me/loop.ts +359 -0
  51. package/runtime/packages/agenticros-gemini/src/index.ts +2 -2
  52. package/runtime/packages/agenticros-gemini/src/tools.ts +356 -92
  53. package/runtime/packages/core/README.md +1 -1
  54. package/runtime/packages/core/package.json +1 -1
  55. package/runtime/packages/core/src/__tests__/config-persistence.test.ts +26 -0
  56. package/runtime/packages/core/src/__tests__/external-capability.test.ts +72 -0
  57. package/runtime/packages/core/src/__tests__/heartbeat-fleet.test.ts +152 -0
  58. package/runtime/packages/core/src/__tests__/mission-bindings.test.ts +101 -0
  59. package/runtime/packages/core/src/__tests__/mission-pause.test.ts +104 -0
  60. package/runtime/packages/core/src/__tests__/skill-refs.test.ts +77 -0
  61. package/runtime/packages/core/src/capabilities.ts +38 -3
  62. package/runtime/packages/core/src/capability-schema.ts +58 -0
  63. package/runtime/packages/core/src/config.ts +28 -0
  64. package/runtime/packages/core/src/discoverable-capabilities.ts +178 -0
  65. package/runtime/packages/core/src/discovery.ts +21 -3
  66. package/runtime/packages/core/src/external-capability.ts +232 -0
  67. package/runtime/packages/core/src/fleet-config.ts +91 -0
  68. package/runtime/packages/core/src/heartbeat.ts +167 -0
  69. package/runtime/packages/core/src/index.ts +85 -0
  70. package/runtime/packages/core/src/mission-bindings.ts +189 -0
  71. package/runtime/packages/core/src/mission-registry.ts +31 -3
  72. package/runtime/packages/core/src/mission.ts +64 -7
  73. package/runtime/packages/core/src/robots.ts +6 -2
  74. package/runtime/packages/core/src/skill-refs.ts +330 -0
  75. package/runtime/packages/core/src/transport/rosbridge/client.ts +7 -0
  76. package/runtime/pnpm-lock.yaml +9 -0
  77. package/templates/skills/camera/package.json +1 -1
  78. package/templates/skills/depth/package.json +1 -1
  79. package/templates/skills/robot/package.json +1 -1
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Heartbeat + fleet-config unit tests.
3
+ */
4
+
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ detectRobotsFromTopics,
12
+ discoverRobots,
13
+ } from "../discovery.js";
14
+ import {
15
+ isHeartbeatFresh,
16
+ mergeRobotHeartbeats,
17
+ onlineIdsFromHeartbeats,
18
+ parseRobotInfoMessage,
19
+ DEFAULT_HEARTBEAT_STALENESS_MS,
20
+ } from "../heartbeat.js";
21
+ import { applyFleetOverride, loadFleetFile } from "../fleet-config.js";
22
+ import { listRobots } from "../robots.js";
23
+ import { parseConfig } from "../config.js";
24
+
25
+ test("parseRobotInfoMessage extracts fields", () => {
26
+ const hb = parseRobotInfoMessage("/bot1/agenticros/robot_info", {
27
+ id: "kitchen",
28
+ name: "Kitchen Bot",
29
+ kind: "amr",
30
+ robot_namespace: "bot1",
31
+ capability_ids: ["drive_base", "follow_person"],
32
+ has_realsense: true,
33
+ has_lidar: false,
34
+ has_arm: false,
35
+ stamp: { sec: 1_700_000_000, nanosec: 0 },
36
+ });
37
+ assert.equal(hb.id, "kitchen");
38
+ assert.equal(hb.sensors.has_realsense, true);
39
+ assert.equal(hb.stamp_ms, 1_700_000_000_000);
40
+ assert.deepEqual(hb.capability_ids, ["drive_base", "follow_person"]);
41
+ });
42
+
43
+ test("isHeartbeatFresh respects 5s window", () => {
44
+ const now = 1_000_000;
45
+ const fresh = parseRobotInfoMessage("/n/agenticros/robot_info", {
46
+ id: "a",
47
+ robot_namespace: "n",
48
+ stamp: { sec: Math.floor((now - 2000) / 1000), nanosec: 0 },
49
+ });
50
+ // Fix stamp_ms manually for precise control
51
+ fresh.stamp_ms = now - 2000;
52
+ assert.equal(isHeartbeatFresh(fresh, { nowMs: now }), true);
53
+
54
+ const stale = { ...fresh, stamp_ms: now - DEFAULT_HEARTBEAT_STALENESS_MS - 1 };
55
+ assert.equal(isHeartbeatFresh(stale, { nowMs: now }), false);
56
+ });
57
+
58
+ test("onlineIdsFromHeartbeats maps to configured ids", () => {
59
+ const now = Date.now();
60
+ const hb = parseRobotInfoMessage("/robotabc/agenticros/robot_info", {
61
+ id: "wire-id",
62
+ robot_namespace: "robotabc",
63
+ stamp: { sec: Math.floor(now / 1000), nanosec: 0 },
64
+ });
65
+ hb.stamp_ms = now;
66
+ const map = new Map([["robotabc", "configured-kitchen"]]);
67
+ const online = onlineIdsFromHeartbeats([hb], {
68
+ nowMs: now,
69
+ configuredIdByNamespace: map,
70
+ });
71
+ assert.ok(online.has("configured-kitchen"));
72
+ });
73
+
74
+ test("mergeRobotHeartbeats keeps freshest per namespace", () => {
75
+ const now = Date.now();
76
+ const older = parseRobotInfoMessage("/robotn/agenticros/robot_info", {
77
+ id: "a",
78
+ robot_namespace: "robotn",
79
+ name: "old",
80
+ });
81
+ older.stamp_ms = now - 1000;
82
+ const newer = parseRobotInfoMessage("/robotn/agenticros/robot_info", {
83
+ id: "a",
84
+ robot_namespace: "robotn",
85
+ name: "new",
86
+ });
87
+ newer.stamp_ms = now;
88
+ const merged = mergeRobotHeartbeats([older, newer], { nowMs: now });
89
+ assert.equal(merged.get("robotn")?.name, "new");
90
+ });
91
+
92
+ test("detectRobotsFromTopics includes robot_info-only namespaces", () => {
93
+ const detected = detectRobotsFromTopics([
94
+ { name: "/arm1/agenticros/robot_info", type: "agenticros_msgs/msg/RobotInfo" },
95
+ ]);
96
+ assert.equal(detected.length, 1);
97
+ assert.equal(detected[0]!.id, "arm1");
98
+ });
99
+
100
+ test("discoverRobots treats robot_info topic as online signal", () => {
101
+ // Isolate from a real ~/.agenticros/fleet.json on the developer machine.
102
+ const prev = process.env.AGENTICROS_FLEET_PATH;
103
+ process.env.AGENTICROS_FLEET_PATH = join(tmpdir(), "no-fleet-for-discover-test.json");
104
+ try {
105
+ // Namespace must already be the on-wire form (or UUID) so
106
+ // effectiveCmdVelNamespace matches the topic segment.
107
+ const cfg = parseConfig({
108
+ robots: [{ id: "arm1", namespace: "robotarm1", kind: "arm" }],
109
+ });
110
+ const result = discoverRobots(
111
+ [{ name: "/robotarm1/agenticros/robot_info", type: "agenticros_msgs/msg/RobotInfo" }],
112
+ cfg,
113
+ );
114
+ assert.equal(result.configured_online.length, 1);
115
+ assert.equal(result.configured_online[0]!.id, "arm1");
116
+ } finally {
117
+ if (prev === undefined) delete process.env.AGENTICROS_FLEET_PATH;
118
+ else process.env.AGENTICROS_FLEET_PATH = prev;
119
+ }
120
+ });
121
+
122
+ test("loadFleetFile + applyFleetOverride wins over config.robots", () => {
123
+ const dir = mkdtempSync(join(tmpdir(), "agenticros-fleet-"));
124
+ const path = join(dir, "fleet.json");
125
+ try {
126
+ writeFileSync(
127
+ path,
128
+ JSON.stringify([{ id: "fleet-bot", namespace: "fleetns", kind: "amr", name: "From Fleet" }]),
129
+ );
130
+ const loaded = loadFleetFile(path);
131
+ assert.equal(loaded.used, true);
132
+ assert.equal(loaded.robots[0]!.id, "fleet-bot");
133
+
134
+ const cfg = parseConfig({
135
+ robots: [{ id: "config-bot", namespace: "configns" }],
136
+ });
137
+ const overridden = applyFleetOverride(cfg, path);
138
+ const robots = listRobots(overridden);
139
+ // listRobots also applies fleet from default path — pass overridden
140
+ // config that already has fleet robots embedded:
141
+ assert.equal(overridden.robots?.[0]?.id, "fleet-bot");
142
+ assert.equal(robots[0]!.id, "fleet-bot");
143
+ } finally {
144
+ rmSync(dir, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ test("loadFleetFile missing file is unused", () => {
149
+ const result = loadFleetFile(join(tmpdir(), "no-such-agenticros-fleet-xyz.json"));
150
+ assert.equal(result.used, false);
151
+ assert.equal(result.robots.length, 0);
152
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Unit tests for buildMissionBindings — Phase 1 dynamic bindings.
3
+ */
4
+
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import {
8
+ BUILTIN_MISSION_BINDINGS,
9
+ buildMissionBindings,
10
+ defaultToolForCapability,
11
+ externalToolName,
12
+ isExternalToolName,
13
+ } from "../mission-bindings.js";
14
+ import type { Capability } from "../capabilities.js";
15
+
16
+ test("buildMissionBindings includes all builtins", () => {
17
+ const bindings = buildMissionBindings([]);
18
+ for (const id of Object.keys(BUILTIN_MISSION_BINDINGS)) {
19
+ assert.ok(bindings[id], `missing builtin binding ${id}`);
20
+ }
21
+ });
22
+
23
+ test("buildMissionBindings derives ros2_<id> for skill capabilities", () => {
24
+ const caps: Capability[] = [
25
+ {
26
+ id: "wave_hand",
27
+ verb: "wave",
28
+ description: "Wave",
29
+ source: { kind: "skill", skillId: "wave", package: "agenticros-skill-wave" },
30
+ },
31
+ ];
32
+ const bindings = buildMissionBindings(caps);
33
+ assert.equal(bindings.wave_hand.tool, "ros2_wave_hand");
34
+ assert.deepEqual(bindings.wave_hand.buildArgs({ a: 1 }), { a: 1 });
35
+ });
36
+
37
+ test("buildMissionBindings uses external: prefix for external_ros_node", () => {
38
+ const caps: Capability[] = [
39
+ {
40
+ id: "navigate_to",
41
+ verb: "navigate",
42
+ description: "Nav2",
43
+ implementation: {
44
+ kind: "external_ros_node",
45
+ action: "navigate_to_pose",
46
+ msg_type: "nav2_msgs/action/NavigateToPose",
47
+ },
48
+ source: { kind: "skill", skillId: "navigate", package: "navigate-to" },
49
+ },
50
+ ];
51
+ const bindings = buildMissionBindings(caps);
52
+ assert.equal(bindings.navigate_to.tool, externalToolName("navigate_to"));
53
+ assert.ok(isExternalToolName(bindings.navigate_to.tool));
54
+ });
55
+
56
+ test("buildMissionBindings respects toolNameResolver", () => {
57
+ const caps: Capability[] = [
58
+ {
59
+ id: "custom_cap",
60
+ verb: "custom",
61
+ description: "Custom",
62
+ source: { kind: "skill", skillId: "c", package: "c" },
63
+ },
64
+ ];
65
+ const bindings = buildMissionBindings(caps, {
66
+ toolNameResolver: (cap) => (cap.id === "custom_cap" ? "my_custom_tool" : undefined),
67
+ });
68
+ assert.equal(bindings.custom_cap.tool, "my_custom_tool");
69
+ });
70
+
71
+ test("defaultToolForCapability prefers explicit tool field", () => {
72
+ assert.equal(
73
+ defaultToolForCapability({
74
+ id: "x",
75
+ verb: "x",
76
+ description: "x",
77
+ tool: "ros2_special",
78
+ }),
79
+ "ros2_special",
80
+ );
81
+ });
82
+
83
+ test("extra bindings win over derived", () => {
84
+ const caps: Capability[] = [
85
+ {
86
+ id: "wave_hand",
87
+ verb: "wave",
88
+ description: "Wave",
89
+ source: { kind: "skill", skillId: "wave", package: "w" },
90
+ },
91
+ ];
92
+ const bindings = buildMissionBindings(caps, {
93
+ extra: {
94
+ wave_hand: {
95
+ tool: "override_tool",
96
+ buildArgs: () => ({ overridden: true }),
97
+ },
98
+ },
99
+ });
100
+ assert.equal(bindings.wave_hand.tool, "override_tool");
101
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pause / resume tests for runMission + MissionRegistry.
3
+ */
4
+
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { runMission, type CapabilityToolBindings, type Mission } from "../mission.js";
8
+ import { MissionRegistry } from "../mission-registry.js";
9
+ import type { Capability } from "../capabilities.js";
10
+
11
+ const CAPS: Capability[] = [
12
+ { id: "drive_base", verb: "drive", description: "Drive", source: { kind: "builtin" } },
13
+ { id: "list_topics", verb: "list", description: "List", source: { kind: "builtin" } },
14
+ ];
15
+
16
+ const BINDINGS: CapabilityToolBindings = {
17
+ drive_base: {
18
+ tool: "ros2_publish",
19
+ buildArgs: (inputs) => ({ topic: "cmd_vel", msg: inputs }),
20
+ },
21
+ list_topics: {
22
+ tool: "ros2_list_topics",
23
+ buildArgs: () => ({}),
24
+ },
25
+ };
26
+
27
+ test("pause blocks next step until resume", async () => {
28
+ const token = { cancelled: false, paused: true, reason: "human in aisle" };
29
+ const calls: string[] = [];
30
+ const mission: Mission = {
31
+ name: "pause-test",
32
+ steps: [
33
+ { id: "a", capability: "list_topics" },
34
+ { id: "b", capability: "drive_base", inputs: { linear_x: 0.1 } },
35
+ ],
36
+ };
37
+
38
+ const runPromise = runMission(mission, CAPS, BINDINGS, async (tool) => {
39
+ calls.push(tool);
40
+ return { text: JSON.stringify({ ok: true }) };
41
+ }, { cancellation: token, mission_id: "mn_pause" });
42
+
43
+ // First step should not run while paused — wait a bit then resume.
44
+ await new Promise((r) => setTimeout(r, 150));
45
+ assert.equal(calls.length, 0, "no tools while paused before first step");
46
+ token.paused = false;
47
+
48
+ const result = await runPromise;
49
+ assert.equal(result.status, "ok");
50
+ assert.equal(calls.length, 2);
51
+ assert.deepEqual(calls, ["ros2_list_topics", "ros2_publish"]);
52
+ });
53
+
54
+ test("cancel while paused aborts remaining steps", async () => {
55
+ const token = { cancelled: false, paused: true };
56
+ const mission: Mission = {
57
+ steps: [
58
+ { id: "a", capability: "list_topics" },
59
+ { id: "b", capability: "drive_base" },
60
+ ],
61
+ };
62
+
63
+ const runPromise = runMission(mission, CAPS, BINDINGS, async () => ({ text: "{}" }), {
64
+ cancellation: token,
65
+ });
66
+
67
+ await new Promise((r) => setTimeout(r, 80));
68
+ token.cancelled = true;
69
+ token.paused = false;
70
+
71
+ const result = await runPromise;
72
+ assert.equal(result.status, "cancelled");
73
+ assert.ok(result.steps.every((s) => s.status === "cancelled"));
74
+ });
75
+
76
+ test("MissionRegistry pause/resume/cancel", () => {
77
+ const reg = new MissionRegistry();
78
+ const { entry, dispose } = reg.register("mn_1", { name: "t" });
79
+ assert.equal(entry.cancellation.paused, false);
80
+
81
+ const p1 = reg.pause("mn_1", "wait");
82
+ assert.equal(p1.found, true);
83
+ assert.equal(p1.alreadyPaused, false);
84
+ assert.equal(entry.cancellation.paused, true);
85
+ assert.equal(entry.cancellation.reason, "wait");
86
+
87
+ const p2 = reg.pause("mn_1");
88
+ assert.equal(p2.alreadyPaused, true);
89
+
90
+ const r1 = reg.resume("mn_1");
91
+ assert.equal(r1.found, true);
92
+ assert.equal(r1.wasPaused, true);
93
+ assert.equal(entry.cancellation.paused, false);
94
+
95
+ assert.equal(reg.pause("missing").found, false);
96
+ assert.equal(reg.resume("missing").found, false);
97
+
98
+ reg.pause("mn_1");
99
+ reg.cancel("mn_1", "abort");
100
+ assert.equal(entry.cancellation.cancelled, true);
101
+ assert.equal(entry.cancellation.paused, false);
102
+
103
+ dispose();
104
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Unit tests for skillRefs parsing and discoverable capability merge.
3
+ */
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import {
7
+ parseSkillRef,
8
+ parseConfig,
9
+ listCapabilitiesWithDiscoverable,
10
+ listAllCapabilities,
11
+ } from "../index.js";
12
+
13
+ test("parseSkillRef: owner/skill and @pin", () => {
14
+ assert.deepEqual(parseSkillRef("agenticros/navigate-to"), {
15
+ marketplaceRef: "agenticros/navigate-to",
16
+ owner: "agenticros",
17
+ skill: "navigate-to",
18
+ gitRef: "main",
19
+ });
20
+ assert.equal(parseSkillRef("agenticros/start-slam@v0.1.0")?.gitRef, "v0.1.0");
21
+ assert.equal(parseSkillRef("nope"), null);
22
+ assert.equal(parseSkillRef(""), null);
23
+ });
24
+
25
+ test("parseConfig: skillRefs defaults to []", () => {
26
+ const cfg = parseConfig({});
27
+ assert.deepEqual(cfg.skillRefs, []);
28
+ const cfg2 = parseConfig({ skillRefs: ["agenticros/detect-humans"] });
29
+ assert.deepEqual(cfg2.skillRefs, ["agenticros/detect-humans"]);
30
+ });
31
+
32
+ test("listCapabilitiesWithDiscoverable: soft-fail offline keeps installed", async () => {
33
+ const cfg = parseConfig({});
34
+ const caps = await listCapabilitiesWithDiscoverable(cfg, {
35
+ apiBase: "http://127.0.0.1:9", // nothing listening
36
+ softFail: true,
37
+ });
38
+ const baseline = listAllCapabilities(cfg);
39
+ assert.equal(caps.length, baseline.length);
40
+ assert.ok(caps.every((c) => c.installed !== false || c.discoverable !== true));
41
+ });
42
+
43
+ test("listCapabilitiesWithDiscoverable: merges marketplace caps when fetch works", async () => {
44
+ const originalFetch = globalThis.fetch;
45
+ globalThis.fetch = (async () =>
46
+ new Response(
47
+ JSON.stringify({
48
+ skills: [
49
+ {
50
+ marketplaceRef: "agenticros/detect-humans",
51
+ visibility: "public",
52
+ capabilities: [
53
+ {
54
+ id: "detect_humans",
55
+ verb: "detect",
56
+ description: "Read detections",
57
+ },
58
+ ],
59
+ },
60
+ ],
61
+ }),
62
+ { status: 200, headers: { "Content-Type": "application/json" } },
63
+ )) as typeof fetch;
64
+
65
+ try {
66
+ const cfg = parseConfig({});
67
+ const caps = await listCapabilitiesWithDiscoverable(cfg, { softFail: false });
68
+ const det = caps.find((c) => c.id === "detect_humans");
69
+ assert.ok(det);
70
+ assert.equal(det!.discoverable, true);
71
+ assert.equal(det!.installed, false);
72
+ assert.equal(det!.install_ref, "agenticros/detect-humans");
73
+ assert.ok(caps.some((c) => c.id === "drive_base" && c.installed === true));
74
+ } finally {
75
+ globalThis.fetch = originalFetch;
76
+ }
77
+ });
@@ -193,9 +193,30 @@ function normalizeCapability(
193
193
  };
194
194
  }
195
195
 
196
+ function readCapabilitiesJson(
197
+ packageDir: string,
198
+ ): Array<Partial<Capability> & { id: string }> {
199
+ const capsPath = join(packageDir, "capabilities.json");
200
+ if (!existsSync(capsPath)) return [];
201
+ try {
202
+ const raw = JSON.parse(readFileSync(capsPath, "utf-8")) as unknown;
203
+ const list = Array.isArray(raw)
204
+ ? raw
205
+ : raw && typeof raw === "object" && Array.isArray((raw as { capabilities?: unknown }).capabilities)
206
+ ? (raw as { capabilities: unknown[] }).capabilities
207
+ : [];
208
+ const out: Array<Partial<Capability> & { id: string }> = [];
209
+ for (const c of list) if (isCapabilityLike(c)) out.push(c);
210
+ return out;
211
+ } catch {
212
+ return [];
213
+ }
214
+ }
215
+
196
216
  /**
197
- * Read the `agenticros.capabilities[]` array from a skill's package.json.
198
- * Returns `null` if the package doesn't declare a valid `agenticros` block.
217
+ * Read the `agenticros.capabilities[]` array from a skill's package.json,
218
+ * merging any sibling `capabilities.json`. Returns `null` if the package
219
+ * doesn't declare a valid `agenticros` block.
199
220
  *
200
221
  * The legacy `agenticrosSkill: true | { capabilities }` form is NOT supported
201
222
  * — every skill must use the single `agenticros` block as its source of truth.
@@ -220,10 +241,24 @@ function readSkillManifestFromDir(
220
241
  const packageName = pkg.name ?? "unknown";
221
242
  const skillId = pkg.agenticros.id;
222
243
  const rawCaps: Array<Partial<Capability> & { id: string }> = [];
244
+ const seenIds = new Set<string>();
223
245
 
224
246
  const caps = pkg.agenticros.capabilities;
225
247
  if (Array.isArray(caps)) {
226
- for (const c of caps) if (isCapabilityLike(c)) rawCaps.push(c);
248
+ for (const c of caps) {
249
+ if (isCapabilityLike(c) && !seenIds.has(c.id)) {
250
+ seenIds.add(c.id);
251
+ rawCaps.push(c);
252
+ }
253
+ }
254
+ }
255
+
256
+ // Sibling capabilities.json merges in (package.json wins on id conflict).
257
+ for (const c of readCapabilitiesJson(packageDir)) {
258
+ if (!seenIds.has(c.id)) {
259
+ seenIds.add(c.id);
260
+ rawCaps.push(c);
261
+ }
227
262
  }
228
263
 
229
264
  return { packageName, skillId, rawCaps };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Zod schemas for capability manifests (skill load / CLI publish).
3
+ */
4
+
5
+ import { z } from "zod";
6
+
7
+ export const CapabilityFieldSchema = z.object({
8
+ type: z.string(),
9
+ description: z.string().optional(),
10
+ optional: z.boolean().optional(),
11
+ default: z.unknown().optional(),
12
+ });
13
+
14
+ export const CapabilityImplementationSchema = z.union([
15
+ z.object({ kind: z.literal("in_process") }),
16
+ z.object({
17
+ kind: z.literal("external_ros_node"),
18
+ package: z.string().optional(),
19
+ launch: z.string().optional(),
20
+ action: z.string().optional(),
21
+ service: z.string().optional(),
22
+ topic: z.string().optional(),
23
+ msg_type: z.string().optional(),
24
+ }),
25
+ ]);
26
+
27
+ export const CapabilitySchema = z.object({
28
+ id: z.string().min(1),
29
+ verb: z.string().min(1),
30
+ description: z.string().default(""),
31
+ inputs: z.record(CapabilityFieldSchema).optional(),
32
+ outputs: z.record(CapabilityFieldSchema).optional(),
33
+ preconditions: z.array(z.string()).optional(),
34
+ interruptible: z.boolean().optional(),
35
+ blocks_base: z.boolean().optional(),
36
+ implementation: CapabilityImplementationSchema.optional(),
37
+ tool: z.string().optional(),
38
+ });
39
+
40
+ export type ParsedCapability = z.infer<typeof CapabilitySchema>;
41
+
42
+ /**
43
+ * Validate a raw capability object. Throws ZodError on failure.
44
+ */
45
+ export function parseCapability(raw: unknown): ParsedCapability {
46
+ return CapabilitySchema.parse(raw);
47
+ }
48
+
49
+ /**
50
+ * Soft-validate: returns { ok, capability?, error? }.
51
+ */
52
+ export function safeParseCapability(
53
+ raw: unknown,
54
+ ): { ok: true; capability: ParsedCapability } | { ok: false; error: string } {
55
+ const result = CapabilitySchema.safeParse(raw);
56
+ if (result.success) return { ok: true, capability: result.data };
57
+ return { ok: false, error: result.error.message };
58
+ }
@@ -418,6 +418,14 @@ export const AgenticROSConfigSchema = z.object({
418
418
 
419
419
  /** Npm (or local) package names to load as skills. Resolved via require.resolve from plugin context. */
420
420
  skillPackages: z.array(z.string()).default([]),
421
+
422
+ /**
423
+ * Marketplace skill refs to auto-fetch into `~/.agenticros/skills-cache/`
424
+ * (e.g. `agenticros/navigate-to` or `owner/skill@main`). Resolved at
425
+ * adapter startup into effective skillPaths. Never auto-upgrades pins.
426
+ * Distinct from `skills` (per-skill runtime config object).
427
+ */
428
+ skillRefs: z.array(z.string()).default([]),
421
429
  });
422
430
 
423
431
  export type AgenticROSConfig = z.infer<typeof AgenticROSConfigSchema>;
@@ -441,6 +449,26 @@ export function parseConfig(raw: Record<string, unknown>): AgenticROSConfig {
441
449
  return AgenticROSConfigSchema.parse(normalized);
442
450
  }
443
451
 
452
+ /**
453
+ * Strip Zod defaults that were never present in the raw config before parse.
454
+ * The config page validates via parseConfig() but should not inject keys the
455
+ * user never set — e.g. `robots: []` from the schema default breaks OpenClaw's
456
+ * strict plugin configSchema when that field isn't declared yet.
457
+ */
458
+ export function prepareConfigForPersistence(
459
+ parsed: AgenticROSConfig,
460
+ raw: Record<string, unknown>,
461
+ ): Record<string, unknown> {
462
+ const out = JSON.parse(JSON.stringify(parsed)) as Record<string, unknown>;
463
+ if (!Object.prototype.hasOwnProperty.call(raw, "robots")) {
464
+ const robots = out.robots;
465
+ if (Array.isArray(robots) && robots.length === 0) {
466
+ delete out.robots;
467
+ }
468
+ }
469
+ return out;
470
+ }
471
+
444
472
  /**
445
473
  * Build TransportConfig from full config for createTransport().
446
474
  */