@jacebenson/jsn 4.1.0 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +18 -2
  2. package/package.json +4 -1
  3. package/src/app.js +49 -65
  4. package/src/auth.js +46 -11
  5. package/src/capabilities.js +128 -0
  6. package/src/cli.js +129 -79
  7. package/src/commands/_ticket.js +36 -92
  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 +15 -133
  13. package/src/commands/cmdb.js +613 -0
  14. package/src/commands/codesearch.js +103 -0
  15. package/src/commands/dev/_generic.js +62 -110
  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 +40 -4
  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 +254 -9
  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 +92 -1
  41. package/src/help.js +1 -0
  42. package/src/helpers.js +22 -11
  43. package/src/mutations.js +36 -91
  44. package/src/output.js +187 -61
  45. package/src/resolve-record.js +71 -0
  46. package/src/sdk.js +108 -8
  47. package/src/session.js +214 -0
package/src/errors.js CHANGED
@@ -142,15 +142,106 @@ export function isErrorCode(err, code) {
142
142
  return err instanceof AppError && err.code === code;
143
143
  }
144
144
 
145
+ /**
146
+ * ── Unified error renderer ─────────────────────────────────────────────
147
+ * ONE place owns AppError → (stream, text, exit code). wrap()'s catch
148
+ * block, the middleware guard (guardError/guardExit), and any future
149
+ * entry point all route through renderAppError so the rendering can't
150
+ * diverge again (it had: wrap checked app.output.effectiveFormat() while
151
+ * guardError checked argv.format; 'usage' vs 'usage_error' disagreed).
152
+ *
153
+ * Exit-code contract (observable behavior, pinned by tests):
154
+ * not_found → 1 stderr, "Error (not_found): …" + identifier hint
155
+ * usage / usage_error → 2 stderr, "Error (usage): …"
156
+ * system_error → 3 stderr, "Error (system): …"
157
+ * confirmation_required → 1 JSON envelope on stdout in JSON mode
158
+ * (machine consumers), else stderr + hint
159
+ * anything else → 1 stderr, "Error: …"
160
+ *
161
+ * jsonMode resolution: prefer app.output.effectiveFormat() (respects
162
+ * --json/--quiet/etc. shortcuts AND piped-stdout auto-detection); fall
163
+ * back to argv.format for callers without an App (unit tests).
164
+ */
165
+ function resolveJsonMode(app, argv) {
166
+ if (app?.output) return app.output.effectiveFormat() === 'json';
167
+ return argv?.format === 'json';
168
+ }
169
+
170
+ /**
171
+ * Render an error for the terminal/pipe.
172
+ *
173
+ * @param {object} err — Error with optional .code and .hint
174
+ * @param {object} [opts]
175
+ * @param {object} [opts.app] — App instance (format resolution, JSON envelope)
176
+ * @param {object} [opts.argv] — yargs argv (fallback format resolution)
177
+ * @param {string} [opts.identifier] — for not_found: the id that missed
178
+ * @returns {{ stream: 'stdout'|'stderr', text: string|null, exitCode: number, appErr?: boolean }}
179
+ * text is null when the error was rendered through app.err (envelope).
180
+ */
181
+ export function renderAppError(err, { app = null, argv = null, identifier = '' } = {}) {
182
+ const code = err.code || 'unknown';
183
+
184
+ if (code === CodeConfirmationRequired) {
185
+ // Structured error for AI agents / piped consumers: the JSON envelope
186
+ // goes to stdout (where tooling reads) so the agent can surface the
187
+ // decision to its user or re-run with --force.
188
+ if (resolveJsonMode(app, argv) && app) {
189
+ return { stream: 'stdout', text: null, exitCode: 1, appErr: true };
190
+ }
191
+ let text = `Error: ${err.message}\n`;
192
+ if (err.hint) text += `\n${err.hint}\n`;
193
+ return { stream: 'stderr', text, exitCode: 1 };
194
+ }
195
+
196
+ if (code === CodeNotFound) {
197
+ let text = `Error (${code}): ${err.message}\n`;
198
+ if (identifier) {
199
+ text += `\nHint: The identifier "${identifier}" was not found. Check the name or sys_id.\n`;
200
+ }
201
+ return { stream: 'stderr', text, exitCode: 1 };
202
+ }
203
+
204
+ // 'usage' is the legacy literal; CodeUsage ('usage_error') is the
205
+ // exported constant. Both render as the usage class.
206
+ if (code === CodeUsage || code === 'usage') {
207
+ return { stream: 'stderr', text: `Error (usage): ${err.message}\n`, exitCode: 2 };
208
+ }
209
+
210
+ if (code === 'system_error') {
211
+ return { stream: 'stderr', text: `Error (system): ${err.message}\n`, exitCode: 3 };
212
+ }
213
+
214
+ return { stream: 'stderr', text: `Error: ${err.message}\n`, exitCode: 1 };
215
+ }
216
+
217
+ /**
218
+ * Write + exit for a rendered error. Shared by wrap() and guardExit().
219
+ */
220
+ export function exitWithError(err, opts = {}) {
221
+ const { app = null } = opts;
222
+ const r = renderAppError(err, opts);
223
+ if (r.appErr && app) {
224
+ app.err(err);
225
+ } else if (r.stream === 'stdout') {
226
+ process.stdout.write(r.text);
227
+ } else {
228
+ process.stderr.write(r.text);
229
+ }
230
+ process.exit(r.exitCode);
231
+ }
232
+
145
233
  /**
146
234
  * Format a safety-guard failure (mutation guard middleware in cli.js).
147
235
  * In JSON mode writes the documented error envelope to stdout so piped
148
236
  * consumers (`jsn ... --json | jq`) get a structured error instead of a
149
237
  * raw stderr message. Otherwise writes human text to stderr.
150
238
  * Always exits non-zero via guardExit.
239
+ *
240
+ * Routes through the same JSON-mode resolution as renderAppError
241
+ * (app.output.effectiveFormat() when an App is on argv, else argv.format).
151
242
  */
152
243
  export function guardError(argv, { code, message, hint = '' }) {
153
- if (argv?.format === 'json') {
244
+ if (resolveJsonMode(argv?.app, argv)) {
154
245
  return {
155
246
  stream: 'stdout',
156
247
  text: JSON.stringify({ ok: false, error: message, code, hint }) + '\n',
package/src/help.js CHANGED
@@ -115,6 +115,7 @@ const CONFIG_COMMANDS = [
115
115
  ];
116
116
 
117
117
  const DEVELOPER_COMMANDS = [
118
+ { name: 'atf', desc: 'Automated Test Framework (list, suites, run, results)' },
118
119
  { name: 'eval', desc: 'Run server-side JavaScript' },
119
120
  { name: 'rest', desc: 'Raw REST API calls' },
120
121
  { name: 'skill', desc: 'AI agent skill file (show, fetch, install)' },
package/src/helpers.js CHANGED
@@ -251,25 +251,36 @@ export function assertSafeExactMatch(value) {
251
251
  * @param {string} opts.singular — e.g. "script include", "log entry"
252
252
  * @param {string[]} opts.columns — default display columns
253
253
  * @param {number} opts.limit — max records (default 20)
254
- * @param {string} opts.query — optional encoded query
254
+ * @param {string} opts.query — optional encoded query, folded into every
255
+ * request (browse pages, search-as-you-type, and the aggregate count)
255
256
  * @param {Function} opts.formatLabel — (record) => string for the picker display
256
257
  * @param {string} opts.labelField — field used to match selection (default: 'name')
257
- * @returns {Promise<void>|null} null if no selection made or non-interactive
258
+ * @param {string} [opts.message] — picker prompt message
259
+ * (default: "Select a <singular>")
260
+ * @param {Function} [opts.formatValue] — (record) => value for the choice;
261
+ * defaults to returning the full record on selection
262
+ * @param {Function} [opts.promptFn] — test seam: replaces paginatedSearch;
263
+ * injecting it also bypasses the TTY/canPrompt gate (the test drives the
264
+ * interactive path explicitly)
265
+ * @returns {Promise<*|null|undefined>} the selected record (or formatValue
266
+ * result), undefined when the user cancelled, null when non-interactive
258
267
  */
259
- export async function interactiveList({ app, table, singular, columns, limit = 50, _query = '', formatLabel, labelField = 'name' }) {
268
+ export async function interactiveList({ app, table, singular, columns, limit = 50, query = '', formatLabel, labelField = 'name', message, formatValue, promptFn }) {
269
+ const prompt = promptFn || paginatedSearch;
260
270
  app.requireInstance();
261
271
  const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
262
- if (effectiveFormat !== FormatAuto || !canPrompt()) {
272
+ if (!promptFn && (effectiveFormat !== FormatAuto || !canPrompt())) {
263
273
  return null; // not interactive — caller should fall back to text/table
264
274
  }
265
275
 
266
276
  const pickerColumns = ['sys_id', labelField, ...columns.filter(c => c !== labelField && c !== 'sys_id' && c !== '*')];
267
277
  const pickerFields = pickerColumns.join(',');
278
+ const baseQuery = query ? query + '^' : '';
268
279
 
269
280
  // Get total count
270
281
  let totalCount;
271
282
  try {
272
- totalCount = await app.sdk.aggregateCount(table, '');
283
+ totalCount = await app.sdk.aggregateCount(table, query || '');
273
284
  } catch {
274
285
  totalCount = 0;
275
286
  }
@@ -283,9 +294,9 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
283
294
  params.set('sysparm_display_value', 'all');
284
295
  if (pickerFields) params.set('sysparm_fields', pickerFields);
285
296
  if (term) {
286
- params.set('sysparm_query', `${labelField}LIKE${term}^ORDERBYDESCsys_updated_on`);
297
+ params.set('sysparm_query', `${baseQuery}${labelField}LIKE${term}^ORDERBYDESCsys_updated_on`);
287
298
  } else {
288
- params.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
299
+ params.set('sysparm_query', baseQuery + 'ORDERBYDESCsys_updated_on');
289
300
  params.set('sysparm_offset', String(offset));
290
301
  }
291
302
  const records = await app.sdk.list(table, params);
@@ -295,7 +306,7 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
295
306
  }
296
307
  return records.map(r => ({
297
308
  name: formatLabel ? formatLabel(r) : (getStringField(r, labelField) || getStringField(r, 'sys_id')),
298
- value: r,
309
+ value: formatValue ? formatValue(r) : r,
299
310
  }));
300
311
  } catch (err) {
301
312
  console.error('serverSource error:', err.message || err);
@@ -303,14 +314,14 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
303
314
  }
304
315
  }
305
316
 
306
- const selected = await paginatedSearch({
307
- message: `Select ${singular === 'flow' ? 'a flow' : `a ${singular}`}`,
317
+ const selected = await prompt({
318
+ message: message || `Select ${singular === 'flow' ? 'a flow' : `a ${singular}`}`,
308
319
  pageSize: 10,
309
320
  totalCount,
310
321
  source: serverSource,
311
322
  });
312
323
 
313
- return selected?.value; // unwrap {name, value} → raw record, undefined on cancel
324
+ return selected?.value; // unwrap {name, value}, undefined on cancel
314
325
  }
315
326
 
316
327
  /**
package/src/mutations.js CHANGED
@@ -1,105 +1,50 @@
1
- // Centralized mutation command registry
2
- // Used by the read-only profile middleware to block mutation commands.
3
- // Each entry is a command path array matching argv._ tokens.
1
+ // Mutation guard — the public face is isMutationCommand(argv), used by the
2
+ // read-only-profile middleware in cli.js. The command-path data is DERIVED
3
+ // from the capability registry (src/capabilities.js) at call time — command
4
+ // modules declare their mutating subcommands once, at the definition site.
5
+ //
6
+ // The registry used to be a hand-maintained list here, and it rotted: it
7
+ // gated `catalog create-item` while the real command is `catalogitems
8
+ // create` (read-only bypass), and listed `restmethods`, a command that was
9
+ // never registered. Derived data can't drift that way.
4
10
 
5
- // Dev commands that expose create/update/delete via buildDevCmd.
6
- // Each generates the root form (`jsn <name> create`) and the legacy dev form
7
- // (`jsn dev <name> create`), so read-only profiles block both spellings.
8
- const DEV_CRUD_COMMANDS = [
9
- 'actions', 'includes', 'rules', 'clientscripts', 'uiactions', 'uipolicies',
10
- 'tables', 'uipages', 'appmenu', 'acls', 'roles', 'properties', 'relationships',
11
- 'appmodules', 'listcontrols', 'views', 'privileges', 'uxscripts',
12
- 'catalogscripts', 'scriptactions', 'scheduledjobs', 'asyncrules', 'triggers',
13
- 'email', 'restmethods', 'restmessage', 'soapmessages', 'uimacros',
14
- 'cataloguipolicies', 'aliases', 'columns', 'flows', 'scrapi',
15
- ];
11
+ import { mutationPaths } from './capabilities.js';
16
12
 
17
- function devCrudPaths(name) {
18
- return [
19
- [name, 'create'],
20
- [name, 'update'],
21
- [name, 'delete'],
22
- ['dev', name, 'create'],
23
- ['dev', name, 'update'],
24
- ['dev', name, 'delete'],
25
- ];
13
+ /**
14
+ * Back-compat export: the derived mutation path list, refreshed by
15
+ * refreshMutationCommands() — cli.js calls it at the end of buildCLI(),
16
+ * so the data is generated at CLI build time, not hand-maintained.
17
+ * Prefer isMutationCommand().
18
+ */
19
+ export const MUTATION_COMMANDS = [];
20
+
21
+ export function refreshMutationCommands() {
22
+ MUTATION_COMMANDS.length = 0;
23
+ MUTATION_COMMANDS.push(...mutationPaths());
24
+ return MUTATION_COMMANDS;
26
25
  }
27
26
 
28
- export const MUTATION_COMMANDS = [
29
- // Incidents
30
- ['incidents', 'create'],
31
- ['incidents', 'update'],
32
- ['incidents', 'delete'],
33
- // Changes
34
- ['changes', 'create'],
35
- ['changes', 'update'],
36
- ['changes', 'delete'],
37
- // Records
38
- ['records', 'create'],
39
- ['records', 'update'],
40
- ['records', 'delete'],
41
- // Requests
42
- ['requests', 'create'],
43
- ['requests', 'update'],
44
- ['requests', 'delete'],
45
- // Tasks
46
- ['tasks', 'create'],
47
- ['tasks', 'update'],
48
- ['tasks', 'delete'],
49
- // Tickets
50
- ['tickets', 'create'],
51
- ['tickets', 'update'],
52
- ['tickets', 'delete'],
53
- // Users
54
- ['users', 'create'],
55
- ['users', 'update'],
56
- ['users', 'delete'],
57
- // Groups
58
- ['groups', 'create'],
59
- ['groups', 'update'],
60
- ['groups', 'delete'],
61
- // Catalog
62
- ['catalog', 'create-item'],
63
- // Dev CRUD commands (root + `jsn dev` forms)
64
- ...DEV_CRUD_COMMANDS.flatMap(devCrudPaths),
65
- // Dev eval (no sub-action) — both spellings must be gated:
66
- // `jsn eval` (root) and legacy `jsn dev eval`
67
- ['eval'],
68
- ['dev', 'eval'],
69
- // Raw REST passthrough — can hit any endpoint with any method,
70
- // so the whole command is a mutation surface on read-only profiles
71
- ['rest'],
72
- ['dev', 'rest'],
73
- // Scopes: create + set (root + dev forms)
74
- ['scopes', 'create'],
75
- ['scopes', 'set'],
76
- ['dev', 'scopes', 'create'],
77
- ['dev', 'scopes', 'set'],
78
- // Domains: set writes the profile config (root + dev forms)
79
- ['domains', 'set'],
80
- ['dev', 'domains', 'set'],
81
- // Update sets: create, set, complete, ignore, parent (root + dev forms)
82
- ['updatesets', 'create'],
83
- ['updatesets', 'set'],
84
- ['updatesets', 'complete'],
85
- ['updatesets', 'ignore'],
86
- ['updatesets', 'parent'],
87
- ['dev', 'updatesets', 'create'],
88
- ['dev', 'updatesets', 'set'],
89
- ['dev', 'updatesets', 'complete'],
90
- ['dev', 'updatesets', 'ignore'],
91
- ['dev', 'updatesets', 'parent'],
92
- ];
27
+ function currentPaths(paths) {
28
+ return paths || refreshMutationCommands();
29
+ }
93
30
 
94
31
  /**
95
32
  * Check if the parsed argv matches any mutation command pattern.
33
+ * Prefix match: a command whose argv._ path begins with a mutation pattern
34
+ * IS that mutation (trailing positionals like a sys_id or file path don't
35
+ * change the intent). This lets positional mutation subcommands (e.g.
36
+ * `records attachments <id> add <file>`) be gated on read-only profiles.
37
+ *
96
38
  * @param {object} argv — yargs parsed argv with `_` array
39
+ * @param {Array<string[]>} [paths] — mutation path patterns; defaults to
40
+ * the paths derived from the capability registry. (Tests pass explicit
41
+ * paths; production callers should let it derive.)
97
42
  * @returns {boolean}
98
43
  */
99
- export function isMutationCommand(argv) {
44
+ export function isMutationCommand(argv, paths) {
100
45
  const cmd = argv._ || [];
101
- for (const pattern of MUTATION_COMMANDS) {
102
- if (pattern.length !== cmd.length) continue;
46
+ for (const pattern of currentPaths(paths)) {
47
+ if (pattern.length > cmd.length) continue;
103
48
  let match = true;
104
49
  for (let i = 0; i < pattern.length; i++) {
105
50
  if (pattern[i] !== cmd[i]) {
package/src/output.js CHANGED
@@ -7,6 +7,36 @@ export const FormatJSON = 'json';
7
7
  export const FormatMarkdown = 'markdown';
8
8
  export const FormatStyled = 'styled';
9
9
  export const FormatQuiet = 'quiet';
10
+ export const FormatCSV = 'csv';
11
+
12
+ /**
13
+ * Resolve a dotpath (`.data[0].number`, `data.records.0.sys_id`) against an
14
+ * object. Supports dotted keys and `[N]` array indexes. Returns undefined
15
+ * when the path doesn't resolve. Dependency-free — not a jq replacement.
16
+ */
17
+ export function resolvePath(obj, path) {
18
+ const s = String(path || '').trim().replace(/^\./, '');
19
+ if (!s) return undefined;
20
+ // Tokenize on dots, '[' and ']'. Pure-integer segments become array indexes.
21
+ const tokens = [];
22
+ let i = 0;
23
+ while (i < s.length) {
24
+ const ch = s[i];
25
+ if (ch === '.' || ch === '[' || ch === ']') { i += 1; continue; }
26
+ let j = i;
27
+ while (j < s.length && s[j] !== '.' && s[j] !== '[' && s[j] !== ']') j += 1;
28
+ const part = s.slice(i, j);
29
+ const n = Number(part);
30
+ tokens.push(Number.isInteger(n) && String(n) === part ? n : part);
31
+ i = j;
32
+ }
33
+ let cur = obj;
34
+ for (const tok of tokens) {
35
+ if (cur == null) return undefined;
36
+ cur = cur[tok];
37
+ }
38
+ return cur;
39
+ }
10
40
 
11
41
  export function hyperlink(text, url) {
12
42
  if (!url) return text;
@@ -27,6 +57,31 @@ export function isTTY(writer) {
27
57
  return writer.isTTY === true;
28
58
  }
29
59
 
60
+ // Default styled-table column widths for well-known ServiceNow fields.
61
+ // Pure presentation defaults — commands can override per envelope via
62
+ // `data.columnWidths` (see writeRecordsTable).
63
+ const DEFAULT_COLUMN_WIDTHS = {
64
+ number: 14,
65
+ short_description: 48,
66
+ priority: 14,
67
+ state: 10,
68
+ assigned_to: 15,
69
+ risk: 10,
70
+ name: 30,
71
+ user_name: 15,
72
+ email: 30,
73
+ sys_id: 32,
74
+ sys_updated_on: 22,
75
+ sys_created_on: 22,
76
+ opened_at: 22,
77
+ closed_at: 22,
78
+ sys_updated_by: 20,
79
+ sys_created_by: 20,
80
+ opened_by: 20,
81
+ u_category: 20,
82
+ u_subcategory: 20,
83
+ };
84
+
30
85
  // Minimal ANSI colors — replaces chalk (only used for error/hint styling,
31
86
  // which is already TTY-gated by callers).
32
87
  const ansi = (code) => (text) => `\x1b[${code}m${text}\x1b[0m`;
@@ -49,6 +104,16 @@ export class OutputWriter {
49
104
  return this;
50
105
  }
51
106
 
107
+ /**
108
+ * Set a dotpath extractor. When set, successful output is reduced to the
109
+ * value at the path (resolved against the JSON envelope) and printed as-is.
110
+ * Gives pipelines a dependency-free `--get` without needing jq.
111
+ */
112
+ setJqFilter(path) {
113
+ this.jqFilter = path || '';
114
+ return this;
115
+ }
116
+
52
117
  effectiveFormat() {
53
118
  if (this.format === FormatAuto) {
54
119
  return isTTY(this.writer) ? FormatStyled : FormatJSON;
@@ -57,6 +122,16 @@ export class OutputWriter {
57
122
  }
58
123
 
59
124
  ok(data, opts = {}) {
125
+ // --get extractor: resolve against the JSON envelope and print just that
126
+ // value as JSON. Takes precedence over styling/table formats — a path
127
+ // extract is inherently scriptable, not presentational.
128
+ if (this.jqFilter) {
129
+ const envelope = { ok: true, data, summary: opts.summary || '', breadcrumbs: opts.breadcrumbs || [] };
130
+ const value = resolvePath(envelope, this.jqFilter);
131
+ this.writer.write(JSON.stringify(value, null, 2) + '\n');
132
+ return;
133
+ }
134
+
60
135
  const format = this.effectiveFormat();
61
136
  switch (format) {
62
137
  case FormatJSON:
@@ -65,6 +140,8 @@ export class OutputWriter {
65
140
  return this.writeMarkdown(data, opts);
66
141
  case FormatQuiet:
67
142
  return this.writeQuiet(data);
143
+ case FormatCSV:
144
+ return this.writeCSV(data, opts);
68
145
  case FormatStyled:
69
146
  return this.writeStyled(data, opts);
70
147
  default:
@@ -160,7 +237,58 @@ export class OutputWriter {
160
237
  }
161
238
  }
162
239
 
240
+ writeCSV(data, opts) {
241
+ if (opts.summary && isTTY(this.writer)) {
242
+ this.writer.write(opts.summary + '\n\n');
243
+ }
244
+ if (Array.isArray(data)) {
245
+ this.writeCSVTable(data);
246
+ } else if (data && typeof data === 'object' && Array.isArray(data.records)) {
247
+ this.writeCSVTable(data.records);
248
+ } else {
249
+ this.writeCSVTable([data]);
250
+ }
251
+ }
252
+
253
+ writeCSVTable(rows) {
254
+ if (!Array.isArray(rows) || rows.length === 0) {
255
+ this.writer.write('');
256
+ return;
257
+ }
258
+ const columns = Object.keys(rows[0]);
259
+ this.writer.write(columns.map(csvEscape).join(',') + '\n');
260
+ for (const row of rows) {
261
+ const cells = columns.map((col) => {
262
+ const val = row[col];
263
+ if (val == null) return '';
264
+ if (typeof val === 'object') {
265
+ if (val.display_value != null) return csvEscape(val.display_value);
266
+ if (val.value != null) return csvEscape(val.value);
267
+ return csvEscape(JSON.stringify(val));
268
+ }
269
+ return csvEscape(val);
270
+ });
271
+ this.writer.write(cells.join(',') + '\n');
272
+ }
273
+ }
274
+
163
275
  writeStyled(data, opts) {
276
+ // ── The `_formatted` interface ──────────────────────────────────────
277
+ // Commands may ship a pre-rendered styled view as `data._formatted`
278
+ // (a plain string). The writer prints it verbatim and suppresses both
279
+ // opts.summary and the records table / field-dump renderers — the
280
+ // curated visual IS the output. JSON/quiet/markdown/csv formats ignore
281
+ // `_formatted` entirely and serialize the full envelope as usual, so
282
+ // scripting (`--json`, `--get`) is unaffected.
283
+ //
284
+ // This is the documented escape hatch for command-curated visuals
285
+ // (auth status, cmdb show cards/trees, docs search results, …). View
286
+ // logic belongs in the command that owns the domain — output.js stays
287
+ // generic. Other underscore-prefixed side-channel keys commands may
288
+ // attach: `_context` ({instance_url, table} consumed by the record
289
+ // renderer), `_attachments`, `_variables` (record card sections), plus
290
+ // `records`/`columns`/`table`/`relationships` for the generic table and
291
+ // record views below.
164
292
  const hasFormatted = data && typeof data === 'object' && data._formatted;
165
293
 
166
294
  if (opts.summary && !hasFormatted) {
@@ -178,48 +306,18 @@ export class OutputWriter {
178
306
  }
179
307
 
180
308
  const { isRecord, tableName } = detectRecord(data);
181
- if (isRecord) {
309
+ if (isRecord && !data._formatted) {
310
+ // _formatted (e.g. cmdb show card) replaces the raw field dump —
311
+ // the visual is the curated view; JSON/quiet keep full data.
182
312
  this.writeFormattedRecord(data, tableName);
183
313
  }
184
314
 
185
- if (Array.isArray(data.profiles) && data.profiles.length > 0) {
186
- this.writer.write('\n');
187
- for (const p of data.profiles) {
188
- const prefix = p.default ? '* ' : ' ';
189
- const authIcon = p.authenticated ? '✓' : '✗';
190
-
191
- // Show verified status if we got one
192
- let verifiedStr = '';
193
- if (p.verified === true) {
194
- verifiedStr = ' ✅';
195
- } else if (p.verified === false) {
196
- verifiedStr = ' ⚠️';
197
- }
198
-
199
- // Show stale hint if >7 days since last seen
200
- let staleStr = '';
201
- if (p.stale && p.days_since_last_seen) {
202
- staleStr = ` (${p.days_since_last_seen}d ago — may have been released)`;
203
- }
204
-
205
- // Show lock icon for read-only profiles
206
- const lockIcon = p.read_only ? ' 🔒' : '';
207
-
208
- // Show confirmations-off badge for skip_confirmations profiles
209
- const confirmBadge = p.skip_confirmations ? ' ⚡' : '';
210
-
211
- if (p.name) {
212
- this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
213
- } else if (p.username) {
214
- this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
215
- } else {
216
- this.writer.write(`${prefix}${authIcon} ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
217
- }
218
- }
219
- }
220
-
221
315
  if (Array.isArray(data.records) && data.records.length > 0) {
222
- this.writeRecordsTable(data);
316
+ // A _formatted visual (e.g. cmdb relationship tree) replaces the
317
+ // flat table in styled mode — showing both is noise.
318
+ if (!data._formatted) {
319
+ this.writeRecordsTable(data);
320
+ }
223
321
  }
224
322
  } else {
225
323
  if (!opts.summary) {
@@ -241,27 +339,10 @@ export class OutputWriter {
241
339
  const table = data.table || '';
242
340
  const instanceURL = data.context?.instance_url || '';
243
341
 
244
- const colWidths = {
245
- number: 14,
246
- short_description: 48,
247
- priority: 14,
248
- state: 10,
249
- assigned_to: 15,
250
- risk: 10,
251
- name: 30,
252
- user_name: 15,
253
- email: 30,
254
- sys_id: 32,
255
- sys_updated_on: 22,
256
- sys_created_on: 22,
257
- opened_at: 22,
258
- closed_at: 22,
259
- sys_updated_by: 20,
260
- sys_created_by: 20,
261
- opened_by: 20,
262
- u_category: 20,
263
- u_subcategory: 20,
264
- };
342
+ // Default per-column widths for known ServiceNow fields; unknown columns
343
+ // fall back to 20. Commands may override/extend per call by attaching
344
+ // `data.columnWidths = { <col>: <width> }` — hints win over defaults.
345
+ const colWidths = { ...DEFAULT_COLUMN_WIDTHS, ...(data.columnWidths || {}) };
265
346
 
266
347
  this.writer.write('\n');
267
348
 
@@ -285,7 +366,9 @@ export class OutputWriter {
285
366
  const row = records[i];
286
367
  for (let j = 0; j < columns.length; j++) {
287
368
  const col = columns[j];
288
- let val = row[col] || '';
369
+ // Coerce to string — rows can carry ServiceNow reference objects
370
+ // ({display_value, value}) and numeric cells (e.g. cmdb depth)
371
+ let val = getDisplayValue(row[col]);
289
372
  const width = colWidths[col] || 20;
290
373
 
291
374
  if (j === 0 && instanceURL && table && row.sys_id) {
@@ -374,7 +457,7 @@ export class OutputWriter {
374
457
  }
375
458
  }
376
459
 
377
- const otherFields = Object.keys(data).filter(f => !displayed.has(f) && !f.startsWith('_')).sort();
460
+ const otherFields = Object.keys(data).filter(f => !displayed.has(f) && !f.startsWith('_') && f !== 'relationships').sort();
378
461
  if (otherFields.length > 0) {
379
462
  this.writer.write('─ Other ─\n');
380
463
  for (const field of otherFields) {
@@ -391,6 +474,33 @@ export class OutputWriter {
391
474
  this.writer.write('\n');
392
475
  }
393
476
 
477
+ // Inline relationships (e.g. cmdb show). Grouped shape:
478
+ // { parents: [], siblings: [], children: [], counts: {} } renders as
479
+ // sections; a flat array renders as one compact list.
480
+ if (data.relationships && typeof data.relationships === 'object' && !Array.isArray(data.relationships)) {
481
+ const renderGroup = (label, rows, total, arrow, viaKey) => {
482
+ if ((!rows || rows.length === 0) && !total) return;
483
+ const capNote = total && total > (rows?.length || 0) ? ` (showing ${rows.length} of ${total})` : '';
484
+ this.writer.write(`─ ${label}${capNote} ─\n`);
485
+ for (const rel of rows || []) {
486
+ const via = viaKey && rel[viaKey] ? ` (via ${rel[viaKey]})` : '';
487
+ this.writer.write(` ${arrow} ${rel.name} (${rel.class}) — ${rel.type}${via}\n`);
488
+ }
489
+ this.writer.write('\n');
490
+ };
491
+ const counts = data.relationships.counts || {};
492
+ renderGroup('Parents', data.relationships.parents, counts.parents, '↑');
493
+ renderGroup('Siblings', data.relationships.siblings, counts.siblings, '↔', 'via');
494
+ renderGroup('Children', data.relationships.children, counts.children, '↓');
495
+ } else if (Array.isArray(data.relationships) && data.relationships.length > 0) {
496
+ this.writer.write('─ Relationships ─\n');
497
+ for (const rel of data.relationships) {
498
+ const arrow = rel.direction === 'upstream' ? '↑' : '↓';
499
+ this.writer.write(` ${arrow} ${rel.name} (${rel.class}) — ${rel.type}\n`);
500
+ }
501
+ this.writer.write('\n');
502
+ }
503
+
394
504
  // ── Attachments ──
395
505
  if (data._attachments && Array.isArray(data._attachments) && data._attachments.length > 0) {
396
506
  this.writer.write('─ Attachments ─\n');
@@ -458,6 +568,12 @@ function detectRecord(data) {
458
568
  }
459
569
  }
460
570
 
571
+ // Explicit table stamp set by command handlers (e.g. _context.table on
572
+ // cmdb_rel_ci rows, which have neither sys_class_name nor number).
573
+ if (!tableName && data._context && typeof data._context === 'object' && data._context.table) {
574
+ tableName = data._context.table;
575
+ }
576
+
461
577
  return { isRecord: hasSysID && (hasSysClass || tableName), tableName };
462
578
  }
463
579
 
@@ -486,3 +602,13 @@ export function getDisplayValue(val) {
486
602
  }
487
603
  return String(val);
488
604
  }
605
+
606
+ /** Escape a single CSV field: quote when it contains a delimiter, quote, or newline. */
607
+ export function csvEscape(val) {
608
+ if (val == null) return '';
609
+ const s = String(val);
610
+ if (/[",\n\r]/.test(s)) {
611
+ return '"' + s.replace(/"/g, '""') + '"';
612
+ }
613
+ return s;
614
+ }