@thingd/cli 0.53.2 → 0.55.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.
@@ -1 +1 @@
1
- {"version":3,"file":"interactive.d.ts","sourceRoot":"","sources":["../src/interactive.ts"],"names":[],"mappings":"AAylEA,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CA0FvD"}
1
+ {"version":3,"file":"interactive.d.ts","sourceRoot":"","sources":["../src/interactive.ts"],"names":[],"mappings":"AA+/EA,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CA0FvD"}
@@ -80,6 +80,8 @@ let totalActiveJobsCount = 0;
80
80
  let totalDeadJobsCount = 0;
81
81
  let totalLinksCount = 0;
82
82
  let cloudError = null;
83
+ const eventsByStream = new Map();
84
+ const jobsByQueue = new Map();
83
85
  let objectsHistory = [];
84
86
  let eventsHistory = [];
85
87
  let activeJobsHistory = [];
@@ -89,6 +91,7 @@ let objectWriteRateHistory = [];
89
91
  let eventAppendRateHistory = [];
90
92
  let viewerLines = ["Select an item to view details."];
91
93
  let viewerScroll = 0;
94
+ let lastNeighborsRef = "";
92
95
  let loadedItemId = "";
93
96
  let loadTimer = null;
94
97
  let pollTimer = null;
@@ -293,6 +296,29 @@ async function fetchResources() {
293
296
  objectsByCollection.set(col, []);
294
297
  }
295
298
  }
299
+ // Populate events per stream
300
+ eventsByStream.clear();
301
+ await Promise.all(streams.map(async (s) => {
302
+ try {
303
+ const evts = await db.events.list(s, { limit: 20 });
304
+ eventsByStream.set(s, evts);
305
+ }
306
+ catch {
307
+ eventsByStream.set(s, []);
308
+ }
309
+ }));
310
+ // Populate jobs per queue
311
+ jobsByQueue.clear();
312
+ await Promise.all(queues.map(async (q) => {
313
+ try {
314
+ const active = await db.queue(q).list();
315
+ const dead = await db.queue(q).dead();
316
+ jobsByQueue.set(q, { active, dead });
317
+ }
318
+ catch {
319
+ jobsByQueue.set(q, { active: [], dead: [] });
320
+ }
321
+ }));
296
322
  // Calculate Deltas for Operations Throughput Rates
297
323
  const prevObjects = objectsHistory.length > 0
298
324
  ? (objectsHistory[objectsHistory.length - 1] ?? totalObjects)
@@ -509,15 +535,40 @@ function buildTree() {
509
535
  });
510
536
  }
511
537
  for (const stream of streams) {
538
+ const sOpen = expandedSet.has(`stream:${stream}`);
512
539
  nodes.push({
513
540
  id: `stream:${stream}`,
514
541
  parentId: "cat:streams",
515
542
  type: "stream",
516
- label: `${pc.green("")} ${pc.green(stream)}`,
543
+ label: `${sOpen ? pc.cyan("") : pc.dim("▸")} ${pc.green(stream)}`,
517
544
  depth: 1,
518
- expandable: false,
545
+ expandable: true,
519
546
  ref: { name: stream },
520
547
  });
548
+ if (sOpen) {
549
+ const evts = eventsByStream.get(stream) ?? [];
550
+ if (evts.length === 0) {
551
+ nodes.push({
552
+ id: `empty:evt:${stream}`,
553
+ parentId: `stream:${stream}`,
554
+ type: "status",
555
+ label: pc.dim("(no events)"),
556
+ depth: 2,
557
+ expandable: false,
558
+ });
559
+ }
560
+ for (const evt of evts) {
561
+ nodes.push({
562
+ id: `evt:${stream}:${evt.id}`,
563
+ parentId: `stream:${stream}`,
564
+ type: "event",
565
+ label: `${pc.dim("·")} ${pc.dim(evt.type || "unknown")}`,
566
+ depth: 2,
567
+ expandable: false,
568
+ ref: { stream: stream, eventId: evt.id, eventData: evt },
569
+ });
570
+ }
571
+ }
521
572
  }
522
573
  }
523
574
  // Queues
@@ -540,17 +591,97 @@ function buildTree() {
540
591
  });
541
592
  }
542
593
  for (const q of queues) {
594
+ const qOpen = expandedSet.has(`queue:${q}`);
543
595
  nodes.push({
544
596
  id: `queue:${q}`,
545
597
  parentId: "cat:queues",
546
598
  type: "queue",
547
- label: `${pc.magenta("")} ${pc.magenta(q)}`,
599
+ label: `${qOpen ? pc.cyan("") : pc.dim("▸")} ${pc.magenta(q)}`,
548
600
  depth: 1,
549
- expandable: false,
601
+ expandable: true,
550
602
  ref: { name: q },
551
603
  });
604
+ if (qOpen) {
605
+ const jobData = jobsByQueue.get(q);
606
+ const activeJobs = jobData?.active ?? [];
607
+ const deadJobs = jobData?.dead ?? [];
608
+ // Active jobs subcategory
609
+ const activeOpen = expandedSet.has(`queue:${q}:active`);
610
+ nodes.push({
611
+ id: `queue:${q}:active`,
612
+ parentId: `queue:${q}`,
613
+ type: "category",
614
+ label: `${activeOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.bold("Active")} ${pc.dim(`(${activeJobs.length})`)}`,
615
+ depth: 2,
616
+ expandable: true,
617
+ });
618
+ if (activeOpen) {
619
+ if (activeJobs.length === 0) {
620
+ nodes.push({
621
+ id: `empty:active:${q}`,
622
+ parentId: `queue:${q}:active`,
623
+ type: "status",
624
+ label: pc.dim("(no active jobs)"),
625
+ depth: 3,
626
+ expandable: false,
627
+ });
628
+ }
629
+ for (const job of activeJobs) {
630
+ nodes.push({
631
+ id: `job:${q}:active:${job.id}`,
632
+ parentId: `queue:${q}:active`,
633
+ type: "job",
634
+ label: `${pc.cyan("●")} ${pc.dim(job.id.slice(0, 12))}`,
635
+ depth: 3,
636
+ expandable: false,
637
+ ref: { queue: q, jobId: job.id, status: "active", jobData: job },
638
+ });
639
+ }
640
+ }
641
+ // Dead jobs subcategory
642
+ const deadOpen = expandedSet.has(`queue:${q}:dead`);
643
+ nodes.push({
644
+ id: `queue:${q}:dead`,
645
+ parentId: `queue:${q}`,
646
+ type: "category",
647
+ label: `${deadOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.bold("Dead")} ${pc.dim(`(${deadJobs.length})`)}`,
648
+ depth: 2,
649
+ expandable: true,
650
+ });
651
+ if (deadOpen) {
652
+ if (deadJobs.length === 0) {
653
+ nodes.push({
654
+ id: `empty:dead:${q}`,
655
+ parentId: `queue:${q}:dead`,
656
+ type: "status",
657
+ label: pc.dim("(no dead jobs)"),
658
+ depth: 3,
659
+ expandable: false,
660
+ });
661
+ }
662
+ for (const job of deadJobs) {
663
+ nodes.push({
664
+ id: `job:${q}:dead:${job.id}`,
665
+ parentId: `queue:${q}:dead`,
666
+ type: "job",
667
+ label: `${pc.red("○")} ${pc.dim(job.id.slice(0, 12))}`,
668
+ depth: 3,
669
+ expandable: false,
670
+ ref: { queue: q, jobId: job.id, status: "dead", jobData: job },
671
+ });
672
+ }
673
+ }
674
+ }
552
675
  }
553
676
  }
677
+ // Links
678
+ nodes.push({
679
+ id: "node:links",
680
+ type: "link",
681
+ label: `${pc.blue("◈")} ${pc.dim("Links")} ${pc.dim(`(${totalLinksCount})`)}`,
682
+ depth: 0,
683
+ expandable: false,
684
+ });
554
685
  // Metrics
555
686
  nodes.push({
556
687
  id: "node:status",
@@ -638,43 +769,59 @@ async function loadContent(node) {
638
769
  }
639
770
  else if (node.type === "stream" && node.ref) {
640
771
  const ref = node.ref;
641
- const events = await db.events.list(ref.name);
642
- let res = `${pc.bold(ref.name)} ${pc.dim(`(${events.length} events)`)}\n\n`;
643
- if (events.length === 0) {
644
- res += pc.dim("No events in this stream.");
772
+ const evts = eventsByStream.get(ref.name) ?? [];
773
+ let res = `${pc.bold(ref.name)} ${pc.dim(`(${evts.length} events shown)`)}\n\n`;
774
+ res += pc.dim("Expand to browse individual events, or press [c] to append.\n");
775
+ content = res;
776
+ }
777
+ else if (node.type === "event" && node.ref) {
778
+ const ref = node.ref;
779
+ const evt = ref.eventData;
780
+ let res = `${pc.bold(evt.type || "unknown")} ${pc.dim(evt.id)}\n`;
781
+ res += ` ${pc.dim("Stream:")} ${pc.green(ref.stream)}\n`;
782
+ res += ` ${pc.dim("Created:")} ${pc.dim(evt.createdAt || "—")}\n\n`;
783
+ const display = { ...evt };
784
+ for (const k of ["id", "stream", "sequence", "createdAt", "idempotencyKey"]) {
785
+ delete display[k];
786
+ }
787
+ if (Object.keys(display).length > 0) {
788
+ res += highlightJson(display);
645
789
  }
646
790
  else {
647
- const lines = events.map((e) => {
648
- const ts = e.createdAt ? pc.dim(String(e.createdAt)) : "";
649
- const type = pc.magenta(e.type || "unknown");
650
- return ` ${ts} ${type}`;
651
- });
652
- res += lines.join("\n");
791
+ res += pc.dim("No payload.");
653
792
  }
654
793
  content = res;
655
794
  }
656
795
  else if (node.type === "queue" && node.ref) {
657
796
  const ref = node.ref;
658
- const queue = db.queue(ref.name);
659
- const [active, dead] = await Promise.all([queue.list(), queue.dead()]);
797
+ const jobData = jobsByQueue.get(ref.name);
798
+ const active = jobData?.active ?? [];
799
+ const dead = jobData?.dead ?? [];
660
800
  let res = `${pc.bold(ref.name)}\n\n`;
661
801
  res += `${pc.cyan("Active")} ${pc.dim(`(${active.length})`)}\n`;
662
- if (active.length === 0) {
663
- res += pc.dim(" No jobs\n");
802
+ res += `${pc.red("Dead")} ${pc.dim(`(${dead.length})`)}\n\n`;
803
+ res += pc.dim("Expand Active or Dead to browse jobs, [c] to push new job.");
804
+ content = res;
805
+ }
806
+ else if (node.type === "job" && node.ref) {
807
+ const ref = node.ref;
808
+ const job = ref.jobData;
809
+ let res = `${pc.bold(job.id)} ${pc.yellow(job.status || ref.status)}\n`;
810
+ res += ` ${pc.dim("Queue:")} ${pc.magenta(ref.queue)}\n`;
811
+ res += ` ${pc.dim("Attempts:")} ${job.attempts}/${job.maxAttempts}\n`;
812
+ res += ` ${pc.dim("Created:")} ${pc.dim(job.createdAt || "—")}\n`;
813
+ if (job.lastError) {
814
+ res += ` ${pc.dim("Error:")} ${pc.red(job.lastError)}\n`;
815
+ }
816
+ res += "\n";
817
+ if (job.payload && Object.keys(job.payload).length > 0) {
818
+ res += highlightJson(job.payload);
664
819
  }
665
820
  else {
666
- for (const j of active) {
667
- res += ` ${pc.cyan("●")} ${j.id} ${pc.yellow(j.status)} ${pc.dim(`${j.attempts}/${j.maxAttempts}`)}\n`;
668
- }
669
- }
670
- res += `${pc.red("Dead")} ${pc.dim(`(${dead.length})`)}\n`;
671
- if (dead.length === 0) {
672
- res += pc.dim(" No dead jobs\n");
821
+ res += pc.dim("No payload.");
673
822
  }
674
- else {
675
- for (const j of dead) {
676
- res += ` ${pc.red("○")} ${j.id} ${pc.dim(`${j.attempts}/${j.maxAttempts}`)}\n`;
677
- }
823
+ if (ref.status === "dead") {
824
+ res += `\n\n${pc.dim("[e] Retry (ack) [d] Nack (remove from dead letter)")}`;
678
825
  }
679
826
  content = res;
680
827
  }
@@ -742,6 +889,19 @@ async function loadContent(node) {
742
889
  content += `\n ${pc.yellow("⚠")} ${pc.dim(cloudError)}\n`;
743
890
  }
744
891
  }
892
+ else if (node.type === "link") {
893
+ content = [
894
+ ` ${pc.bold("Links")} ${pc.dim(`(${totalLinksCount} total`)}${lastNeighborsRef ? pc.dim(`, last browsed: ${lastNeighborsRef}`) : ""})`,
895
+ "",
896
+ totalLinksCount === 0
897
+ ? ` ${pc.dim("No links yet.")}`
898
+ : ` ${pc.dim("Select an object and press")} ${pc.bold("n")} ${pc.dim("to browse its neighbors.")}`,
899
+ "",
900
+ ` ${pc.bold("Operations")}`,
901
+ ` ${pc.bold("[c]")} Create a new link`,
902
+ ` ${pc.bold("[d]")} Delete a link by ID`,
903
+ ].join("\n");
904
+ }
745
905
  else if (node.type === "category") {
746
906
  content = pc.dim("Expand to browse items.");
747
907
  }
@@ -881,7 +1041,7 @@ function draw() {
881
1041
  help = ` ${pc.dim("↑↓")} nav ${pc.dim("enter")} connect ${pc.dim("q")} quit `;
882
1042
  }
883
1043
  else {
884
- help = ` ${pc.dim("↑↓")} nav ${pc.dim("←→")} toggle ${pc.dim("c")} create ${pc.dim("e")} edit ${pc.dim("d")} delete ${pc.dim("/")} search ${pc.dim("i")} info ${pc.dim("r")} refresh ${pc.dim("s")} switch ${pc.dim("l")} logout ${pc.dim("q")} quit `;
1044
+ help = ` ${pc.dim("↑↓")} nav ${pc.dim("←→")} toggle ${pc.dim("c")} create ${pc.dim("e")} edit ${pc.dim("d")} delete ${pc.dim("/")} search ${pc.dim("n")} neighbors ${pc.dim("i")} info ${pc.dim("r")} refresh ${pc.dim("s")} switch ${pc.dim("l")} logout ${pc.dim("q")} quit `;
885
1045
  }
886
1046
  buf += `${pc.dim("─".repeat(W))}\n`;
887
1047
  buf += padToWidth(help, W);
@@ -1058,23 +1218,33 @@ async function handleCreate(selected) {
1058
1218
  openForm("Create Resource", [
1059
1219
  {
1060
1220
  id: "kind",
1061
- label: "Kind (object, event, queue)",
1062
- value: defaultStream ? "event" : defaultQueue ? "queue" : "object",
1063
- options: ["object", "event", "queue"],
1221
+ label: "Kind (object, event, queue, link)",
1222
+ value: defaultStream
1223
+ ? "event"
1224
+ : defaultQueue
1225
+ ? "queue"
1226
+ : selected?.type === "link"
1227
+ ? "link"
1228
+ : "object",
1229
+ options: ["object", "event", "queue", "link"],
1064
1230
  },
1065
1231
  {
1066
1232
  id: "target",
1067
- label: "Target (Collection, Stream, or Queue Name)",
1233
+ label: "Target (Collection, Stream, Queue, or From Reference)",
1068
1234
  value: defaultCol || defaultStream || defaultQueue,
1069
1235
  options: Array.from(new Set([...collections, ...streams, ...queues])).sort(),
1070
1236
  allowCustom: true,
1071
1237
  },
1072
1238
  {
1073
1239
  id: "objId",
1074
- label: "Object ID (only for objects, auto if blank)",
1240
+ label: "Object / To Reference ID (auto if blank for objects)",
1075
1241
  placeholder: "Leave blank to auto-generate",
1076
1242
  },
1077
- { id: "payload", label: "Data (JSON or key=value)", placeholder: 'e.g. name="John" age=30' },
1243
+ {
1244
+ id: "payload",
1245
+ label: "Data, Link Type, or JSON Fields",
1246
+ placeholder: 'e.g. name="John" age=30 or {"linkType":"follows","weight":1}',
1247
+ },
1078
1248
  ], async (vals) => {
1079
1249
  const kind = (vals.kind || "").toLowerCase();
1080
1250
  const target = (vals.target || "").trim();
@@ -1111,8 +1281,34 @@ async function handleCreate(selected) {
1111
1281
  await db.queue(target).push(data);
1112
1282
  expandedSet.add("cat:queues");
1113
1283
  }
1284
+ else if (kind === "link") {
1285
+ const toRef = (vals.objId || "").trim();
1286
+ if (!toRef) {
1287
+ throw new Error("To Reference is required (use Object ID field).");
1288
+ }
1289
+ let linkType = "related";
1290
+ let weight;
1291
+ let metadataJson;
1292
+ try {
1293
+ const parsed = JSON.parse(vals.payload || "{}");
1294
+ linkType = parsed.linkType || parsed.link_type || "related";
1295
+ if (parsed.weight !== undefined) {
1296
+ weight = Number(parsed.weight);
1297
+ }
1298
+ if (parsed.metadata || parsed.metadataJson) {
1299
+ metadataJson =
1300
+ typeof parsed.metadata === "string"
1301
+ ? parsed.metadata
1302
+ : JSON.stringify(parsed.metadata);
1303
+ }
1304
+ }
1305
+ catch {
1306
+ linkType = (vals.payload || "").trim() || "related";
1307
+ }
1308
+ await db.links.create(target, linkType, toRef, weight, metadataJson);
1309
+ }
1114
1310
  else {
1115
- throw new Error("Kind must be 'object', 'event', or 'queue'.");
1311
+ throw new Error("Kind must be 'object', 'event', 'queue', or 'link'.");
1116
1312
  }
1117
1313
  });
1118
1314
  }
@@ -1162,6 +1358,41 @@ async function handleEdit(selected) {
1162
1358
  }
1163
1359
  });
1164
1360
  }
1361
+ else if (selected.type === "job" && selected.ref) {
1362
+ const ref = selected.ref;
1363
+ if (ref.status === "dead") {
1364
+ openForm(`Retry Dead Job: ${ref.jobId.slice(0, 12)}`, [
1365
+ {
1366
+ id: "action",
1367
+ label: "Action",
1368
+ value: "ack",
1369
+ options: ["ack", "nack"],
1370
+ },
1371
+ {
1372
+ id: "error",
1373
+ label: "Error message (for nack)",
1374
+ placeholder: "Optional error",
1375
+ },
1376
+ ], async (vals) => {
1377
+ const action = vals.action || "";
1378
+ if (action === "ack") {
1379
+ await db.queue(ref.queue).ack(ref.jobId);
1380
+ }
1381
+ else if (action === "nack") {
1382
+ await db.queue(ref.queue).nack(ref.jobId, { error: vals.error || "Rejected" });
1383
+ }
1384
+ else {
1385
+ throw new Error("Action must be 'ack' or 'nack'.");
1386
+ }
1387
+ });
1388
+ }
1389
+ else {
1390
+ // Active job — nack to fail it back to ready
1391
+ openForm(`Nack Job: ${ref.jobId.slice(0, 12)}`, [{ id: "error", label: "Error message", placeholder: "Optional" }], async (vals) => {
1392
+ await db.queue(ref.queue).nack(ref.jobId, { error: vals.error || "Nacked" });
1393
+ });
1394
+ }
1395
+ }
1165
1396
  else {
1166
1397
  openForm("Edit Not Supported", [{ id: "msg", label: "Error", value: "Editing is only available for Objects and Queues." }], async () => { });
1167
1398
  }
@@ -1204,8 +1435,63 @@ async function handleDelete(selected) {
1204
1435
  }
1205
1436
  });
1206
1437
  }
1438
+ else if (selected.type === "link" || loadedItemId === "neighbors_result") {
1439
+ openForm("Delete Link", [
1440
+ {
1441
+ id: "linkId",
1442
+ label: "Link ID",
1443
+ placeholder: "Paste the link ID from the neighbors view",
1444
+ },
1445
+ { id: "confirm", label: 'Type "yes" to confirm deletion', placeholder: "yes" },
1446
+ ], async (vals) => {
1447
+ const linkId = (vals.linkId || "").trim();
1448
+ if (!linkId) {
1449
+ throw new Error("Link ID is required.");
1450
+ }
1451
+ if ((vals.confirm || "").toLowerCase() !== "yes") {
1452
+ throw new Error("Canceled");
1453
+ }
1454
+ const ok = await db.links.delete(linkId);
1455
+ if (!ok) {
1456
+ throw new Error(`Link '${linkId}' not found.`);
1457
+ }
1458
+ });
1459
+ }
1460
+ else if (selected.type === "job" && selected.ref) {
1461
+ const ref = selected.ref;
1462
+ if (ref.status === "dead") {
1463
+ openForm(`Remove Dead Job: ${ref.jobId.slice(0, 12)}`, [
1464
+ {
1465
+ id: "action",
1466
+ label: "Action",
1467
+ value: "nack",
1468
+ options: ["ack", "nack"],
1469
+ },
1470
+ { id: "confirm", label: 'Type "yes" to confirm', placeholder: "yes" },
1471
+ ], async (vals) => {
1472
+ if ((vals.confirm || "").toLowerCase() !== "yes") {
1473
+ throw new Error("Canceled");
1474
+ }
1475
+ if (vals.action === "ack") {
1476
+ await db.queue(ref.queue).ack(ref.jobId);
1477
+ }
1478
+ else {
1479
+ await db.queue(ref.queue).nack(ref.jobId, { error: "Removed from dead letter" });
1480
+ }
1481
+ });
1482
+ }
1483
+ else {
1484
+ openForm("Delete Not For Active Jobs", [{ id: "msg", label: "Info", value: "Use [e] to nack an active job back to ready state." }], async () => { });
1485
+ }
1486
+ }
1207
1487
  else {
1208
- openForm("Delete Not Supported", [{ id: "msg", label: "Error", value: "Deletion is only available for Objects and Queues." }], async () => { });
1488
+ openForm("Delete Not Supported", [
1489
+ {
1490
+ id: "msg",
1491
+ label: "Error",
1492
+ value: "Deletion is only available for Objects, Links, and Queues.",
1493
+ },
1494
+ ], async () => { });
1209
1495
  }
1210
1496
  }
1211
1497
  async function handleSearch() {
@@ -1318,6 +1604,63 @@ async function handleInfo() {
1318
1604
  viewerLines = lines;
1319
1605
  loadedItemId = "info_status";
1320
1606
  }
1607
+ async function handleNeighbors(selected) {
1608
+ if (selected?.type !== "object" || !selected.ref) {
1609
+ viewerLines = [
1610
+ ` ${pc.yellow("Neighbors")}`,
1611
+ "",
1612
+ ` ${pc.dim("Select an object first, then press")} ${pc.bold("n")} ${pc.dim("to browse its links.")}`,
1613
+ ];
1614
+ loadedItemId = "neighbors_info";
1615
+ draw();
1616
+ return;
1617
+ }
1618
+ const ref = selected.ref;
1619
+ const fromRef = `${ref.collection}/${ref.id}`;
1620
+ openForm(`Neighbors of ${fromRef}`, [
1621
+ {
1622
+ id: "direction",
1623
+ label: "Direction",
1624
+ value: "Both",
1625
+ options: ["Both", "Outgoing", "Incoming"],
1626
+ },
1627
+ {
1628
+ id: "linkType",
1629
+ label: "Link Type (optional, leave blank for all)",
1630
+ placeholder: "e.g. follows, owns",
1631
+ },
1632
+ {
1633
+ id: "limit",
1634
+ label: "Max Results (optional)",
1635
+ placeholder: "50",
1636
+ },
1637
+ ], async (vals) => {
1638
+ const direction = (vals.direction || "Both");
1639
+ const linkType = (vals.linkType || "").trim() || undefined;
1640
+ const limitStr = vals.limit || "";
1641
+ const limit = limitStr ? parseInt(limitStr, 10) || undefined : undefined;
1642
+ const links = await db.links.neighbors(fromRef, direction, { linkType, limit });
1643
+ lastNeighborsRef = fromRef;
1644
+ viewerLines = [
1645
+ ` ${pc.bold("Neighbors of")} ${pc.cyan(fromRef)}`,
1646
+ ` ${pc.dim(`(${links.length} link${links.length !== 1 ? "s" : ""}${direction !== "Both" ? `, ${direction.toLowerCase()}` : ""}${linkType ? `, type: ${linkType}` : ""})`)}`,
1647
+ "",
1648
+ ...(links.length === 0 ? [` ${pc.dim("No links found.")}`] : []),
1649
+ ...links.flatMap((link) => [
1650
+ ` ${pc.blue("◈")} ${pc.dim(link.id)}`,
1651
+ ` ${link.fromRef} ${pc.cyan(link.linkType)} ${link.toRef}`,
1652
+ link.weight !== undefined ? ` ${pc.dim(`weight: ${link.weight}`)}` : "",
1653
+ link.metadataJson && link.metadataJson !== "{}"
1654
+ ? ` ${pc.dim(`metadata: ${link.metadataJson}`)}`
1655
+ : "",
1656
+ "",
1657
+ ]),
1658
+ pc.dim("[d] Delete a link by ID [c] Create a new link"),
1659
+ ];
1660
+ loadedItemId = "neighbors_result";
1661
+ draw();
1662
+ });
1663
+ }
1321
1664
  async function handleMaintenance() {
1322
1665
  // Cycle through maintenance operations with each press of 'm'
1323
1666
  const operations = ["health", "checkpoint", "backup"];
@@ -1613,6 +1956,9 @@ function setupKeypress() {
1613
1956
  else if (str === "i" || str === "I") {
1614
1957
  await handleInfo();
1615
1958
  }
1959
+ else if (str === "n" || str === "N") {
1960
+ await handleNeighbors(tree[cursorIndex]);
1961
+ }
1616
1962
  else if (str === "m" || str === "M") {
1617
1963
  await handleMaintenance();
1618
1964
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thingd/cli",
3
- "version": "0.53.2",
3
+ "version": "0.55.0",
4
4
  "description": "CLI, Interactive TUI Dashboard, and MCP server for thingd — a fast object-first data engine for applications and AI agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://engine.thingd.cloud",
@@ -45,7 +45,7 @@
45
45
  "cli-table3": "^0.6.5",
46
46
  "picocolors": "^1.1.1",
47
47
  "zod": "^4.4.3",
48
- "@thingd/sdk": "0.53.2"
48
+ "@thingd/sdk": "0.55.0"
49
49
  },
50
50
  "engines": {
51
51
  "node": ">=24.0.0"