@jacebenson/jsn 4.0.4 → 4.2.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 (47) hide show
  1. package/README.md +18 -2
  2. package/package.json +4 -3
  3. package/src/app.js +48 -64
  4. package/src/auth.js +46 -11
  5. package/src/capabilities.js +128 -0
  6. package/src/cli.js +129 -65
  7. package/src/commands/_ticket.js +37 -93
  8. package/src/commands/approvals.js +350 -0
  9. package/src/commands/atf.js +635 -0
  10. package/src/commands/auth.js +93 -13
  11. package/src/commands/catalog/enrich.js +139 -0
  12. package/src/commands/catalog.js +25 -81
  13. package/src/commands/cmdb.js +613 -0
  14. package/src/commands/codesearch.js +103 -0
  15. package/src/commands/dev/_generic.js +63 -111
  16. package/src/commands/dev/_simple.js +16 -12
  17. package/src/commands/dev/b4rules.js +1 -1
  18. package/src/commands/dev/domains.js +4 -0
  19. package/src/commands/dev/eval.js +65 -7
  20. package/src/commands/dev/flows.js +3 -0
  21. package/src/commands/dev/logs.js +97 -0
  22. package/src/commands/dev/rest.js +6 -0
  23. package/src/commands/dev/restmessage.js +1 -0
  24. package/src/commands/dev/scopes.js +49 -25
  25. package/src/commands/dev/scrapi.js +5 -0
  26. package/src/commands/dev/soapmessages.js +1 -0
  27. package/src/commands/dev/updatesets.js +145 -15
  28. package/src/commands/dev.js +3 -2
  29. package/src/commands/docs/db.js +14 -0
  30. package/src/commands/docs/docs.js +9 -21
  31. package/src/commands/docs/search.js +33 -10
  32. package/src/commands/groups.js +3 -0
  33. package/src/commands/records.js +274 -12
  34. package/src/commands/setup.js +5 -0
  35. package/src/commands/skill.js +3 -0
  36. package/src/commands/snippets.js +155 -0
  37. package/src/commands/tickets.js +9 -29
  38. package/src/commands/users.js +9 -35
  39. package/src/commands/version.js +3 -0
  40. package/src/errors.js +107 -1
  41. package/src/help.js +1 -0
  42. package/src/helpers.js +131 -15
  43. package/src/mutations.js +36 -91
  44. package/src/output.js +195 -64
  45. package/src/resolve-record.js +71 -0
  46. package/src/sdk.js +108 -8
  47. package/src/session.js +214 -0
@@ -0,0 +1,613 @@
1
+ // CMDB commands — read-only CI lookup and relationship traversal.
2
+ //
3
+ // relationship BFS walk modeled on @onlyflows/servicenow-mcp
4
+ // relationships.ts (hand-rolled, zero extra deps — jsn's sdk already does
5
+ // Table API GETs). Direction semantics: parent=current → other is child →
6
+ // downstream; child=current → other is parent → upstream.
7
+
8
+ import {
9
+ assertSafeExactMatch,
10
+ buildQuerySuffix,
11
+ formatRecordForDisplay,
12
+ getStringField,
13
+ interactiveList,
14
+ resolveFieldsParam,
15
+ } from '../helpers.js';
16
+ import { errNotFound, errUsage } from '../errors.js';
17
+
18
+ const SYS_ID_RE = /^[0-9a-f]{32}$/i;
19
+ const MAX_DEPTH = 5;
20
+ const MAX_HOP_LIMIT = 1000;
21
+ const SHOW_GROUP_CAP = 4;
22
+ const DEFAULT_CI_COLUMNS = ['name', 'operational_status', 'ip_address'];
23
+
24
+ // Curated field set for the cmdb show card — the facts that matter when
25
+ // you look at a CI. Empty values are skipped.
26
+ const SHOW_FIELDS = [
27
+ 'operational_status',
28
+ 'install_status',
29
+ 'ip_address',
30
+ 'fqdn',
31
+ 'category',
32
+ 'environment',
33
+ 'life_cycle_stage',
34
+ 'monitored',
35
+ 'serial_number',
36
+ 'asset_tag',
37
+ 'os',
38
+ 'os_version',
39
+ 'sys_updated_on',
40
+ 'sys_updated_by',
41
+ ];
42
+
43
+ /**
44
+ * Extract the raw sys_id from a field that may be a string or a
45
+ * {display_value, link, value} object (sysparm_display_value=all shape).
46
+ */
47
+ function extractValue(field) {
48
+ if (!field) return '';
49
+ if (typeof field === 'string') return field;
50
+ if (typeof field === 'object') {
51
+ if (field.value && typeof field.value === 'string') return field.value;
52
+ if (field.link && typeof field.link === 'string') {
53
+ const parts = field.link.split('/');
54
+ return parts[parts.length - 1];
55
+ }
56
+ }
57
+ return '';
58
+ }
59
+
60
+ /**
61
+ * Extract the display name from a field, ignoring bare 32-hex sys_ids.
62
+ */
63
+ function extractDisplay(field) {
64
+ if (!field) return '';
65
+ if (typeof field === 'string') {
66
+ if (/^[a-f0-9]{32}$/.test(field)) return '';
67
+ return field;
68
+ }
69
+ if (typeof field === 'object' && field.display_value != null) {
70
+ return String(field.display_value);
71
+ }
72
+ return '';
73
+ }
74
+
75
+ /**
76
+ * Resolve a CI identifier (sys_id or name) to {sys_id, name, class}.
77
+ * Throws not_found when nothing matches.
78
+ */
79
+ async function resolveCI(app, identifier) {
80
+ const ciParam = String(identifier || '').trim();
81
+ if (!ciParam) throw errUsage('A CI name or sys_id is required');
82
+
83
+ // Identifier safety: refuse query metacharacters that could turn the
84
+ // name lookup into a compound query matching more than one CI.
85
+ assertSafeExactMatch(ciParam);
86
+
87
+ const bySysId = SYS_ID_RE.test(ciParam);
88
+ const params = new URLSearchParams();
89
+ params.set('sysparm_fields', 'sys_id,name,sys_class_name');
90
+ params.set('sysparm_display_value', 'true');
91
+ params.set('sysparm_query', bySysId ? `sys_id=${ciParam}` : `name=${ciParam}`);
92
+ params.set('sysparm_limit', bySysId ? '1' : '5');
93
+ const rows = await app.sdk.list('cmdb_ci', params);
94
+ if (!rows[0]) throw errNotFound('CMDB CI', ciParam);
95
+
96
+ return {
97
+ sys_id: getStringField(rows[0], 'sys_id'),
98
+ name: getStringField(rows[0], 'name'),
99
+ class: getStringField(rows[0], 'sys_class_name') || 'unknown',
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Breadcrumbs that lead out of a CI record into the traversal commands.
105
+ */
106
+ function relationshipsBreadcrumbs(sysId, ciClass) {
107
+ return [
108
+ {
109
+ action: 'relationships',
110
+ cmd: `jsn cmdb relationships --ci ${sysId}`,
111
+ description: 'Traverse relationships (BFS graph walk)',
112
+ },
113
+ {
114
+ action: 'impact',
115
+ cmd: `jsn cmdb relationships --ci ${sysId} --impact`,
116
+ description: 'Impact analysis: upstream only',
117
+ },
118
+ {
119
+ action: 'list',
120
+ cmd: ciClass ? `jsn cmdb list --query "sys_class_name=${ciClass}"` : 'jsn cmdb list',
121
+ description: 'Other CIs of the same class',
122
+ },
123
+ ];
124
+ }
125
+
126
+ /**
127
+ * Fetch one hop of relationships for a CI and map them to row shape
128
+ * {name, class, type, direction, sys_id} (depth assigned by the caller).
129
+ * Applies direction/type filters at the walk level; the class filter is
130
+ * display-only and stays with the caller so traversal can recurse through
131
+ * filtered nodes.
132
+ */
133
+ async function fetchRelRows(app, currentId, { direction = 'both', type, hopLimit = 100, classCache = new Map() } = {}) {
134
+ let records;
135
+ try {
136
+ const params = new URLSearchParams();
137
+ params.set('sysparm_query', `parent=${currentId}^ORchild=${currentId}`);
138
+ params.set('sysparm_fields', 'parent,child,type');
139
+ params.set('sysparm_display_value', 'all');
140
+ params.set('sysparm_limit', String(hopLimit));
141
+ records = await app.sdk.list('cmdb_rel_ci', params);
142
+ } catch {
143
+ return []; // hop fetch failed — report what we have
144
+ }
145
+
146
+ const getClass = async (id) => {
147
+ if (classCache.has(id)) return classCache.get(id);
148
+ try {
149
+ const params = new URLSearchParams();
150
+ params.set('sysparm_fields', 'sys_class_name');
151
+ params.set('sysparm_display_value', 'true');
152
+ params.set('sysparm_query', `sys_id=${id}`);
153
+ params.set('sysparm_limit', '1');
154
+ const rows = await app.sdk.list('cmdb_ci', params);
155
+ classCache.set(id, rows[0] ? getStringField(rows[0], 'sys_class_name') : 'unknown');
156
+ } catch {
157
+ classCache.set(id, 'unknown');
158
+ }
159
+ return classCache.get(id);
160
+ };
161
+
162
+ const rows = [];
163
+ const seen = new Set();
164
+ for (const rec of records) {
165
+ const parentId = extractValue(rec.parent);
166
+ const childId = extractValue(rec.child);
167
+ const parentName = extractDisplay(rec.parent);
168
+ const childName = extractDisplay(rec.child);
169
+ const typeName = extractDisplay(rec.type) || 'Related to';
170
+
171
+ let otherId;
172
+ let otherName;
173
+ let relDir;
174
+ if (parentId === currentId) {
175
+ otherId = childId;
176
+ otherName = childName;
177
+ relDir = 'downstream';
178
+ } else if (childId === currentId) {
179
+ otherId = parentId;
180
+ otherName = parentName;
181
+ relDir = 'upstream';
182
+ } else {
183
+ continue;
184
+ }
185
+
186
+ if (otherId === currentId) continue;
187
+ if (direction !== 'both' && relDir !== direction) continue;
188
+ if (type && !typeName.toLowerCase().includes(type.toLowerCase())) continue;
189
+
190
+ const pairKey = `${otherId}:${relDir}`;
191
+ if (seen.has(pairKey)) continue;
192
+ seen.add(pairKey);
193
+
194
+ rows.push({
195
+ name: otherName || otherId,
196
+ class: await getClass(otherId),
197
+ type: typeName,
198
+ direction: relDir,
199
+ sys_id: otherId,
200
+ parent: currentId,
201
+ });
202
+ }
203
+ return rows;
204
+ }
205
+
206
+ /**
207
+ * Hourglass layout for a relationship walk (styled "flow-like" UI):
208
+ *
209
+ * ▶ PARENTS ← ancestor chain, growing upward
210
+ * **YOU ARE HERE** ← the CI the walk started from
211
+ * ▶ CHILDREN ← descendant chain, growing downward
212
+ *
213
+ * Built from flat rows carrying `parent` (the CI each row was discovered
214
+ * from). Each CI renders once per section — repeats (cycles) collapse to a
215
+ * ↺ marker instead of re-expanding.
216
+ */
217
+ export function formatCMDBGraph(root, rows) {
218
+ const lines = [];
219
+ lines.push('');
220
+ lines.push(`CMDB: ${root.name} (${root.class})`);
221
+ lines.push(`Sys ID: ${root.sys_id}`);
222
+ lines.push('─'.repeat(56));
223
+
224
+ if (rows.length === 0) {
225
+ lines.push('▶ RELATIONSHIPS (0)');
226
+ lines.push('─'.repeat(56));
227
+ lines.push(' (no relationships found)');
228
+ lines.push('');
229
+ return lines.join('\n') + '\n';
230
+ }
231
+
232
+ const edges = new Map();
233
+ for (const r of rows) {
234
+ if (!edges.has(r.parent)) edges.set(r.parent, []);
235
+ edges.get(r.parent).push(r);
236
+ }
237
+ const ancestorsOf = (id) => (edges.get(id) || []).filter((r) => r.direction === 'upstream');
238
+ const descendantsOf = (id) => (edges.get(id) || []).filter((r) => r.direction === 'downstream');
239
+
240
+ lines.push(`▶ RELATIONSHIPS (${rows.length})`);
241
+ lines.push('─'.repeat(56));
242
+
243
+ // ── Parents: the ancestor chain, nested upward ──
244
+ lines.push('▶ PARENTS');
245
+ const parents = ancestorsOf(root.sys_id);
246
+ if (parents.length === 0) {
247
+ lines.push(' (none)');
248
+ } else {
249
+ const seen = new Set([root.sys_id]);
250
+ const renderAncestors = (id, prefix) => {
251
+ ancestorsOf(id).forEach((p, i) => {
252
+ const last = i === ancestorsOf(id).length - 1;
253
+ const dup = seen.has(p.sys_id);
254
+ lines.push(`${prefix}${last ? '└─ ' : '├─ '}↑ ${p.name} (${p.class}) — ${p.type}${dup ? ' ↺' : ''}`);
255
+ if (!dup) {
256
+ seen.add(p.sys_id);
257
+ renderAncestors(p.sys_id, prefix + (last ? ' ' : '│ '));
258
+ }
259
+ });
260
+ };
261
+ renderAncestors(root.sys_id, '');
262
+ }
263
+
264
+ // ── The CI itself ──
265
+ lines.push('');
266
+ lines.push(`**YOU ARE HERE** ${root.name} (${root.class})`);
267
+ lines.push('');
268
+
269
+ // ── Children: the descendant chain, nested downward ──
270
+ lines.push('▶ CHILDREN');
271
+ const kids = descendantsOf(root.sys_id);
272
+ if (kids.length === 0) {
273
+ lines.push(' (none)');
274
+ } else {
275
+ const seen = new Set([root.sys_id]);
276
+ const renderDescendants = (id, prefix) => {
277
+ descendantsOf(id).forEach((k, i) => {
278
+ const last = i === descendantsOf(id).length - 1;
279
+ const dup = seen.has(k.sys_id);
280
+ lines.push(`${prefix}${last ? '└─ ' : '├─ '}↓ ${k.name} (${k.class}) — ${k.type}${dup ? ' ↺' : ''}`);
281
+ if (!dup) {
282
+ seen.add(k.sys_id);
283
+ renderDescendants(k.sys_id, prefix + (last ? ' ' : '│ '));
284
+ }
285
+ });
286
+ };
287
+ renderDescendants(root.sys_id, '');
288
+ }
289
+
290
+ lines.push('');
291
+ return lines.join('\n') + '\n';
292
+ }
293
+
294
+ /**
295
+ * Curated styled card for `cmdb show`: header + key fields + relationship
296
+ * groups (parents / siblings / children), each capped at SHOW_GROUP_CAP with
297
+ * the true total. Replaces the raw field dump in styled mode.
298
+ */
299
+ export function formatCMDBShow(root, groups, { instanceURL = '', record } = {}) {
300
+ const { parents = [], siblings = [], children = [], counts = {} } = groups;
301
+ const lines = [];
302
+ lines.push('');
303
+ lines.push(`${root.name} (${root.class})`);
304
+ lines.push(`Sys ID: ${root.sys_id}`);
305
+ if (instanceURL && root.sys_id) {
306
+ lines.push(`Link: ${instanceURL}/cmdb_ci.do?sys_id=${root.sys_id}`);
307
+ }
308
+ lines.push('─'.repeat(56));
309
+
310
+ // Key fields — only populated values, aligned label column
311
+ if (record && typeof record === 'object') {
312
+ const fieldLines = SHOW_FIELDS
313
+ .map((f) => [f, getStringField(record, f)])
314
+ .filter(([, v]) => v !== '');
315
+ if (fieldLines.length > 0) {
316
+ const labelWidth = Math.max(...fieldLines.map(([k]) => k.length));
317
+ lines.push('▶ Fields');
318
+ for (const [k, v] of fieldLines) {
319
+ lines.push(` ${k.padEnd(labelWidth)} ${v}`);
320
+ }
321
+ lines.push('');
322
+ }
323
+ }
324
+
325
+ const renderGroup = (label, rows, total, arrow, viaKey) => {
326
+ const totalN = total ?? rows.length;
327
+ lines.push(`▶ ${label} (${totalN})`);
328
+ if (rows.length === 0) {
329
+ lines.push(' (none)');
330
+ } else {
331
+ for (const r of rows) {
332
+ const via = viaKey && r[viaKey] ? ` (via ${r[viaKey]})` : '';
333
+ lines.push(` ${arrow} ${r.name} (${r.class}) — ${r.type}${via}`);
334
+ }
335
+ if (totalN > rows.length) {
336
+ lines.push(` … ${totalN - rows.length} more — run: jsn cmdb relationships --ci ${root.sys_id}`);
337
+ }
338
+ }
339
+ lines.push('');
340
+ };
341
+
342
+ renderGroup('Parents', parents, counts.parents, '↑');
343
+ renderGroup('Siblings', siblings, counts.siblings, '↔', 'via');
344
+ renderGroup('Children', children, counts.children, '↓');
345
+
346
+ if (lines[lines.length - 1] === '') lines.pop();
347
+ return lines.join('\n') + '\n';
348
+ }
349
+
350
+ export function cmdbCmd(wrap) {
351
+ return {
352
+ command: 'cmdb [subcommand]',
353
+ describe: 'Query the Configuration Management Database (CMDB) — CIs and relationship traversal (read-only)',
354
+ builder: (yargs) => {
355
+ return yargs
356
+ .command({
357
+ command: 'list',
358
+ describe: 'List CIs from the CMDB (cmdb_ci)',
359
+ builder: (y) => y
360
+ .option('query', { type: 'string', describe: 'Encoded query (e.g. "sys_class_name=cmdb_ci_server" or "nameLIKEweb")' })
361
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "name,operational_status,ip_address")' })
362
+ .option('limit', { type: 'number', default: 20, describe: 'Max CIs' })
363
+ .option('offset', { type: 'number', default: 0, describe: 'Offset' })
364
+ .option('count', { type: 'boolean', default: true, describe: 'Include the total-matching count (use --no-count to opt out)' }),
365
+ handler: wrap(async (argv, app) => {
366
+ const columns = argv.columns ? argv.columns.split(',') : DEFAULT_CI_COLUMNS;
367
+
368
+ // Interactive picker in TTY with auto format and no explicit query/offset
369
+ if (!argv.query && argv.offset === 0) {
370
+ const picked = await interactiveList({
371
+ app, table: 'cmdb_ci', singular: 'CI', columns, limit: argv.limit, labelField: 'name',
372
+ formatLabel: (r) => {
373
+ const name = getStringField(r, 'name');
374
+ const op = getStringField(r, 'operational_status');
375
+ const ip = getStringField(r, 'ip_address');
376
+ return `${name || '?'}${op ? ` | ${op}` : ''}${ip ? ` | ${ip}` : ''}`;
377
+ },
378
+ });
379
+ if (picked) {
380
+ picked._context = { instance_url: app.getEffectiveInstance(), table: 'cmdb_ci' };
381
+ const sysId = getStringField(picked, 'sys_id');
382
+ const ciClass = getStringField(picked, 'sys_class_name');
383
+ return app.ok(picked, {
384
+ summary: `${getStringField(picked, 'name') || sysId}${ciClass ? ` (${ciClass})` : ''}`,
385
+ breadcrumbs: relationshipsBreadcrumbs(sysId, ciClass),
386
+ });
387
+ }
388
+ }
389
+
390
+ const params = new URLSearchParams();
391
+ params.set('sysparm_limit', String(argv.limit));
392
+ params.set('sysparm_offset', String(argv.offset));
393
+ params.set('sysparm_display_value', 'all');
394
+ const fields = resolveFieldsParam(columns);
395
+ if (fields) params.set('sysparm_fields', fields);
396
+ if (argv.query) params.set('sysparm_query', argv.query);
397
+ const records = await app.sdk.list('cmdb_ci', params);
398
+ const displayRecords = fields ? records.map((r) => formatRecordForDisplay(r, columns)) : records;
399
+
400
+ let total;
401
+ if (argv.count !== false) {
402
+ try { total = await app.sdk.aggregateCount('cmdb_ci', argv.query); } catch { total = undefined; }
403
+ }
404
+
405
+ const breadcrumbs = [
406
+ {
407
+ action: 'show',
408
+ cmd: 'jsn cmdb show <name-or-sys-id>',
409
+ description: 'Get full details for a CI',
410
+ },
411
+ {
412
+ action: 'relationships',
413
+ cmd: 'jsn cmdb relationships --ci <sys-id>',
414
+ description: 'Traverse relationships from a CI',
415
+ },
416
+ ];
417
+ if (records.length === argv.limit) {
418
+ breadcrumbs.push({
419
+ action: 'next',
420
+ cmd: `jsn cmdb list --limit ${argv.limit} --offset ${argv.offset + argv.limit}${buildQuerySuffix(argv.query)}`,
421
+ description: 'Next page',
422
+ });
423
+ }
424
+ if (argv.offset > 0) {
425
+ breadcrumbs.push({
426
+ action: 'prev',
427
+ cmd: `jsn cmdb list --limit ${argv.limit} --offset ${Math.max(0, argv.offset - argv.limit)}${buildQuerySuffix(argv.query)}`,
428
+ description: 'Previous page',
429
+ });
430
+ }
431
+
432
+ app.ok({
433
+ table: 'cmdb_ci',
434
+ count: records.length,
435
+ columns,
436
+ records: displayRecords,
437
+ pagination: { limit: argv.limit, offset: argv.offset, ...(total != null ? { total } : {}) },
438
+ context: { instance_url: app.getEffectiveInstance() },
439
+ }, {
440
+ summary: `${records.length} CI(s) from cmdb_ci${total != null ? ` of ${total}` : ''}`,
441
+ breadcrumbs,
442
+ });
443
+ }),
444
+ })
445
+ .command({
446
+ command: 'show <ci>',
447
+ describe: 'Show a CI by name or sys_id',
448
+ builder: (y) => y
449
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "name,sys_class_name,ip_address") or "*" for all' }),
450
+ handler: wrap(async (argv, app) => {
451
+ const root = await resolveCI(app, argv.ci);
452
+ const params = new URLSearchParams();
453
+ params.set('sysparm_query', `sys_id=${root.sys_id}`);
454
+ params.set('sysparm_limit', '1');
455
+ params.set('sysparm_display_value', 'true');
456
+ if (argv.columns && argv.columns !== '*') params.set('sysparm_fields', `sys_id,${argv.columns}`);
457
+ const records = await app.sdk.list('cmdb_ci', params);
458
+ if (records.length === 0) throw errNotFound('CMDB CI', argv.ci);
459
+ const record = records[0];
460
+ record._context = { instance_url: app.getEffectiveInstance(), table: 'cmdb_ci' };
461
+
462
+ // Viewing a CI should answer "what does this connect to" without a
463
+ // second command: parents (upstream), children (downstream), and
464
+ // siblings (other children of this CI's parents). Each group is
465
+ // capped at 4 — the relationships command shows everything.
466
+ const parents = await fetchRelRows(app, root.sys_id, { direction: 'upstream' });
467
+ const children = await fetchRelRows(app, root.sys_id, { direction: 'downstream' });
468
+ const siblings = [];
469
+ const seenSib = new Set();
470
+ for (const p of parents) {
471
+ if (siblings.length >= SHOW_GROUP_CAP) break;
472
+ const kids = await fetchRelRows(app, p.sys_id, { direction: 'downstream' });
473
+ for (const k of kids) {
474
+ if (k.sys_id === root.sys_id || seenSib.has(k.sys_id)) continue;
475
+ seenSib.add(k.sys_id);
476
+ siblings.push({ ...k, via: p.name });
477
+ if (siblings.length >= SHOW_GROUP_CAP) break;
478
+ }
479
+ }
480
+
481
+ record.relationships = {
482
+ parents: parents.slice(0, SHOW_GROUP_CAP).map((r) => ({ ...r, depth: 1 })),
483
+ siblings: siblings.slice(0, SHOW_GROUP_CAP).map((r) => ({ ...r, depth: 1 })),
484
+ children: children.slice(0, SHOW_GROUP_CAP).map((r) => ({ ...r, depth: 1 })),
485
+ counts: { parents: parents.length, siblings: siblings.length, children: children.length },
486
+ };
487
+ record._formatted = formatCMDBShow(
488
+ { name: getStringField(record, 'name') || root.name, class: getStringField(record, 'sys_class_name') || root.class, sys_id: root.sys_id },
489
+ record.relationships,
490
+ { instanceURL: app.getEffectiveInstance(), record }
491
+ );
492
+
493
+ const breadcrumbs = [
494
+ {
495
+ action: 'relationships',
496
+ cmd: `jsn cmdb relationships --ci ${root.sys_id}`,
497
+ description: 'Show ALL relationships (parents, children, deep traversal)',
498
+ },
499
+ {
500
+ action: 'impact',
501
+ cmd: `jsn cmdb relationships --ci ${root.sys_id} --impact`,
502
+ description: 'Impact analysis: upstream only',
503
+ },
504
+ {
505
+ action: 'list',
506
+ cmd: `jsn cmdb list --query "sys_class_name=${root.class}"`,
507
+ description: 'Other CIs of the same class',
508
+ },
509
+ ];
510
+
511
+ app.ok(record, {
512
+ summary: `${record.name || record.sys_id} (${record.sys_class_name || 'unknown class'}) — `
513
+ + `${parents.length} parent(s), ${siblings.length} sibling(s), ${children.length} child(ren)`,
514
+ breadcrumbs,
515
+ });
516
+ }),
517
+ })
518
+ .command({
519
+ command: 'relationships',
520
+ describe: 'Traverse CMDB CI relationships (BFS graph walk) — impact analysis and dependency mapping',
521
+ builder: (y) => y
522
+ .option('ci', { type: 'string', demandOption: true, describe: 'Root CI: sys_id or name' })
523
+ .option('direction', { type: 'string', choices: ['upstream', 'downstream', 'both'], default: 'both', describe: 'Traversal direction' })
524
+ .option('depth', { type: 'number', default: 3, describe: 'How many levels deep to traverse (1-5)' })
525
+ .option('type', { type: 'string', describe: 'Filter by relationship type name (substring match)' })
526
+ .option('class', { type: 'string', describe: 'Filter CIs by class name (substring match)' })
527
+ .option('impact', { type: 'boolean', default: false, describe: 'Impact analysis mode — walks upstream only' })
528
+ .option('limit', { type: 'number', default: 100, describe: 'Max relationships to fetch per hop' }),
529
+ handler: wrap(async (argv, app) => {
530
+ const ciParam = String(argv.ci || '').trim();
531
+ if (!ciParam) throw errUsage('--ci is required (sys_id or name of the root CI)');
532
+
533
+ const depth = argv.depth == null ? 3 : Math.floor(argv.depth);
534
+ if (depth < 1 || depth > MAX_DEPTH) {
535
+ throw errUsage(`--depth must be between 1 and ${MAX_DEPTH} (got ${argv.depth})`);
536
+ }
537
+ let direction = argv.direction;
538
+ if (argv.impact) direction = 'upstream';
539
+ const hopLimit = Math.max(1, Math.min(Math.floor(argv.limit) || 100, MAX_HOP_LIMIT));
540
+
541
+ // ── Resolve root CI (by sys_id or by name) ──
542
+ const root = await resolveCI(app, ciParam);
543
+ const rootId = root.sys_id;
544
+ const rootName = root.name;
545
+ const rootClass = root.class;
546
+
547
+ // ── Traverse ──
548
+ const visited = new Set([rootId]);
549
+ const classCache = new Map([[rootId, rootClass]]);
550
+ const relationships = [];
551
+
552
+ const traverse = async (currentId, currentDepth) => {
553
+ if (currentDepth > depth) return;
554
+
555
+ const rows = await fetchRelRows(app, currentId, { direction, type: argv.type, hopLimit, classCache });
556
+
557
+ for (const row of rows) {
558
+ // Class filter applies to the displayed rows; traversal still
559
+ // recurses through the CI (matches the reference behavior).
560
+ if (!argv.class || row.class.toLowerCase().includes(argv.class.toLowerCase())) {
561
+ relationships.push({ ...row, depth: currentDepth });
562
+ }
563
+
564
+ if (currentDepth < depth && !visited.has(row.sys_id)) {
565
+ visited.add(row.sys_id);
566
+ await traverse(row.sys_id, currentDepth + 1);
567
+ }
568
+ }
569
+ };
570
+
571
+ await traverse(rootId, 1);
572
+
573
+ const breadcrumbs = [
574
+ {
575
+ action: 'show',
576
+ cmd: `jsn cmdb show ${rootId}`,
577
+ description: 'Full record for the root CI',
578
+ },
579
+ {
580
+ action: 'impact',
581
+ cmd: `jsn cmdb relationships --ci ${rootId} --impact`,
582
+ description: 'Impact analysis: upstream only',
583
+ },
584
+ {
585
+ action: 'deeper',
586
+ cmd: `jsn cmdb relationships --ci ${rootId} --depth ${MAX_DEPTH}`,
587
+ description: `Traverse deeper (max depth ${MAX_DEPTH})`,
588
+ },
589
+ {
590
+ action: 'filter',
591
+ cmd: `jsn cmdb relationships --ci ${rootId} --type "<rel type>" --class "<ci class>"`,
592
+ description: 'Filter by relationship type or CI class',
593
+ },
594
+ ];
595
+
596
+ app.ok({
597
+ root: { name: rootName, class: rootClass, sys_id: rootId },
598
+ records: relationships,
599
+ meta: { depth, direction, total: relationships.length },
600
+ table: 'cmdb_rel_ci',
601
+ count: relationships.length,
602
+ columns: ['name', 'class', 'type', 'direction', 'sys_id', 'depth'],
603
+ context: { instance_url: app.getEffectiveInstance() },
604
+ _formatted: formatCMDBGraph({ name: rootName, class: rootClass, sys_id: rootId }, relationships),
605
+ }, {
606
+ summary: `${relationships.length} relationship(s) for ${rootName} (${rootClass}) — depth ${depth}, ${direction}`,
607
+ breadcrumbs,
608
+ });
609
+ }),
610
+ });
611
+ },
612
+ };
613
+ }