@jacebenson/jsn 4.3.7 → 4.3.8

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.
package/src/cli.js CHANGED
@@ -10,7 +10,8 @@ import { resolveSession, applySession } from './session.js';
10
10
  import { renderHelp } from './help.js';
11
11
  import { isMutationCommand, refreshMutationCommands } from './mutations.js';
12
12
  import { noInstanceCommands, dailyCheckSkipCommands } from './capabilities.js';
13
- import { guardExit, exitWithError } from './errors.js';
13
+ import { guardExit, exitWithError, AppError, CodeUsage } from './errors.js';
14
+ import { formatCommandList } from './helpers.js';
14
15
 
15
16
  // Command modules
16
17
  import { setupCmd } from './commands/setup.js';
@@ -69,6 +70,41 @@ import { b4rulesCmd } from './commands/b4rules.js';
69
70
  import { atfCmd } from './commands/atf.js';
70
71
  import { approvalsCmd } from './commands/approvals.js';
71
72
  import { perfCmd } from './commands/perf.js';
73
+ import { midserversCmd } from './commands/midservers.js';
74
+ import { diffCmd } from './commands/diff.js';
75
+ import { graphqlCmd } from './commands/graphql.js';
76
+
77
+ /**
78
+ * The output format the caller asked for, resolved from PARSED argv.
79
+ *
80
+ * One policy, two callers: the format middleware applies it once a command
81
+ * parses, and the parse-time failure handler applies it when parsing fails.
82
+ * They used to disagree — the failure path re-scanned the raw tokens (any
83
+ * `--json` anywhere, first `--format` match, no option termination, no values
84
+ * consumed by string options, no `--no-` negation), so the stream and the shape
85
+ * of a failure depended on which layer rejected the command instead of on the
86
+ * command line the caller typed.
87
+ *
88
+ * Precedence is the middleware's, unchanged: the shortcut flags beat `--format`,
89
+ * and among the shortcuts the one applied last wins
90
+ * (csv > markdown > styled > quiet > json).
91
+ */
92
+ export function resolveRequestedFormat(argv) {
93
+ if (!argv) return '';
94
+ if (argv.csv) return 'csv';
95
+ if (argv.markdown) return 'markdown';
96
+ if (argv.styled) return 'styled';
97
+ if (argv.quiet) return 'quiet';
98
+ if (argv.json) return 'json';
99
+ return typeof argv.format === 'string' ? argv.format : '';
100
+ }
101
+
102
+ /**
103
+ * Parent commands owned by another change. The adapter's discovery fallback
104
+ * (which gives a handler-less parent its child list instead of a silent exit 0)
105
+ * is skipped for these families so their own fix owns the behavior.
106
+ */
107
+ const DISCOVERY_OWNED_ELSEWHERE = new Set(['auth', 'perf', 'skill', 'groupmembers', 'grouproles']);
72
108
 
73
109
  function wrap(handler) {
74
110
  return async (argv) => {
@@ -148,15 +184,14 @@ export function buildCLI() {
148
184
  },
149
185
  })
150
186
  .middleware([
151
- // Apply format option overrides
187
+ // Apply format option overrides. The resolution lives in
188
+ // resolveRequestedFormat so the parse-time failure path applies the
189
+ // identical policy instead of approximating it from raw tokens.
152
190
  (argv) => {
153
- if (argv.json) argv.format = 'json';
154
- if (argv.quiet) argv.format = 'quiet';
155
- if (argv.styled) argv.format = 'styled';
156
- if (argv.markdown) argv.format = 'markdown';
157
- if (argv.csv) argv.format = 'csv';
158
- if (argv.format) {
159
- app.output.setFormat(argv.format);
191
+ const requested = resolveRequestedFormat(argv);
192
+ if (requested) {
193
+ argv.format = requested;
194
+ app.output.setFormat(requested);
160
195
  }
161
196
  if (argv.get) {
162
197
  app.output.setJqFilter(argv.get);
@@ -371,6 +406,9 @@ export function buildCLI() {
371
406
  .command(atfCmd(wrap))
372
407
  .command(approvalsCmd(wrap))
373
408
  .command(perfCmd(wrap))
409
+ .command(midserversCmd(wrap))
410
+ .command(diffCmd(wrap))
411
+ .command(graphqlCmd(wrap))
374
412
  .command(evalCmd(wrap))
375
413
  .command(restCmd(wrap))
376
414
  .command(skillCmd(wrap))
@@ -429,14 +467,48 @@ export function buildCLI() {
429
467
  cliInstance.showCompletionScript();
430
468
  },
431
469
  })
470
+ .onParentDiscovery((argv, { path, subcommands }) => {
471
+ // Reached only for a parent command that declares no default handler of
472
+ // its own (records attachments, docs community, and any future parent) —
473
+ // the adapter used to fall through and exit 0 having printed nothing.
474
+ // Children come from the registry, description included, so this list
475
+ // cannot drift from the registrations the way a hand-written one did.
476
+ if (DISCOVERY_OWNED_ELSEWHERE.has(path[0])) return;
477
+ const canonical = path.join(' ');
478
+ const summary = `jsn ${canonical} — available subcommands.`;
479
+ // Same error boundary as every registered handler (AGENTS.md: handlers go
480
+ // through wrap()). Discovery runs outside any command handler, and the
481
+ // shared extractor throws on a `--get` path that does not resolve — an
482
+ // unwrapped callback rejected parse() into an entry point with no
483
+ // rejection handler, so a missing path printed a Node stack instead of the
484
+ // extraction_not_found envelope.
485
+ return wrap(async () => {
486
+ (argv.app || app).ok({
487
+ command: canonical,
488
+ subcommands,
489
+ _formatted: formatCommandList({
490
+ title: `jsn ${canonical}`,
491
+ commands: subcommands,
492
+ footer: `Run "jsn ${canonical} <command> --help" for details.`,
493
+ }),
494
+ }, { summary });
495
+ })(argv);
496
+ })
432
497
  .help('help', 'Show help')
433
498
  .version(false)
434
499
  .strictCommands()
435
500
  .strictOptions(false)
436
501
  .fail((msg, err) => {
437
502
  if (err) throw err;
503
+ const raw = process.argv.slice(2);
504
+ // A parse-time failure never reaches the format middleware, so resolve the
505
+ // requested format with the adapter's OWN tokenizer (same option specs,
506
+ // option termination, consumed values, repeated options, `--no-`
507
+ // negation) instead of re-scanning raw argv — one policy, one answer for
508
+ // this command line no matter which layer rejects it.
509
+ const formatArgv = cliInstance.parseGlobalOptions(raw);
510
+ const requestedFormat = resolveRequestedFormat(formatArgv);
438
511
  if (msg === 'You must specify a command') {
439
- const raw = process.argv.slice(2);
440
512
  const hasLoneProfile = raw.some((a, i, arr) => {
441
513
  if (a !== '--profile' && a !== '-p') return false;
442
514
  const next = arr[i + 1];
@@ -452,11 +524,47 @@ export function buildCLI() {
452
524
  }
453
525
  process.exit(0);
454
526
  }
455
- process.stdout.write(renderHelp(loadConfig()) + '\n');
527
+ // No command is a discovery request, not a failure: the human view
528
+ // stays exactly as it was (exit 0 + grouped help), and an explicit
529
+ // --json gets the same surface as machine-readable data instead of
530
+ // prose that a parser cannot read.
531
+ if (requestedFormat === 'json') {
532
+ app.output.setFormat('json');
533
+ // `--get` is jsn's jq substitute for every JSON shape (AGENTS.md), so
534
+ // the root discovery envelope honours it too: extract when the path
535
+ // resolves, and fail with the structured envelope when it does not,
536
+ // instead of always dumping the whole envelope.
537
+ if (typeof formatArgv?.get === 'string' && formatArgv.get) {
538
+ app.output.setJqFilter(formatArgv.get);
539
+ }
540
+ const { commands } = cliInstance.commandRegistry();
541
+ try {
542
+ app.ok({ commands }, {
543
+ summary: `${commands.length} registered commands — run "jsn <command> --json" for a command's subcommands.`,
544
+ });
545
+ } catch (discoveryError) {
546
+ exitWithError(discoveryError, { app });
547
+ }
548
+ process.exit(0);
549
+ }
550
+ process.stdout.write(renderHelp(loadConfig(), cliInstance.commandRegistry()) + '\n');
456
551
  process.exit(0);
457
552
  }
458
- process.stderr.write(msg + '\n');
459
- process.exit(1);
553
+ // Every other failure here (unknown command, missing required argument,
554
+ // invalid choice/number) is a usage failure. Route it through the same
555
+ // renderer as handler and guard failures: exit 2, and the documented
556
+ // error envelope on stdout when the caller is in JSON mode. Previously
557
+ // this wrote raw prose to stderr and exited 1, so the exit code and the
558
+ // output shape depended on which layer rejected the command.
559
+ if (requestedFormat) app.output.setFormat(requestedFormat);
560
+ let hint;
561
+ try {
562
+ const path = cliInstance._findPath(raw).path.map((n) => n.name).join(' ');
563
+ hint = `Run "jsn ${path ? `${path} ` : ''}--help" for usage.`;
564
+ } catch {
565
+ hint = 'Run "jsn --help" for the command list.';
566
+ }
567
+ exitWithError(new AppError(CodeUsage, msg, hint), { app });
460
568
  });
461
569
 
462
570
  // Snapshot the root command names (plus aliases) for the completion
@@ -181,11 +181,12 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
181
181
  }),
182
182
  })
183
183
  .command({
184
- command: 'show <identifier>',
184
+ command: opts.resolveIdentifier ? 'show [identifier]' : 'show <identifier>',
185
185
  aliases: ['get'],
186
186
  describe: `Show ${vowelArticle(singular)} ${singular} by name or sys_id`,
187
187
  handler: wrap(async (argv, app) => {
188
- const id = argv.identifier;
188
+ const id = opts.resolveIdentifier ? await opts.resolveIdentifier(argv, app) : argv.identifier;
189
+ if (id === undefined) return;
189
190
  // Only restrict sysparm_fields if showFields is explicitly set.
190
191
  // Go version fetches all fields for show unless explicitly restricted.
191
192
  const fields = (showFields && showFields.length > 0)
@@ -45,7 +45,7 @@ async function fetchExtensionChain(sdk, record) {
45
45
  return chain;
46
46
  }
47
47
 
48
- export const actionsCmd = (wrap) => buildDevCmd('actions', 'sys_hub_action_type_definition', ['action'], ['name', 'active', 'sys_scope', 'sys_updated_on'], wrap, { singular: 'action', scopeValidation: true });
48
+ export { actionsCmd } from './actions.js';
49
49
 
50
50
  export const includesCmd = (wrap) => buildDevCmd('includes', 'sys_script_include', ['include', 'si'], ['name', 'api_name', 'active', 'sys_scope'], wrap, { singular: 'script include', scopeValidation: true });
51
51
 
@@ -0,0 +1,156 @@
1
+ import { buildDevCmd } from './_generic.js';
2
+ import { formatCommandList, parseDataArg } from '../helpers.js';
3
+ import { resolveRecord, unwrapSysId } from '../resolve-record.js';
4
+ import { declareCapabilities } from '../capabilities.js';
5
+ import { ACTION_TABLE, actionScope } from '../processflow-actions.js';
6
+ import { errUsage } from '../errors.js';
7
+ import { cancelled, pickDesignerRecord, designerScope, guidedActionCreate, guidedActionEdit, guidedActionTestInputs, interactiveDesigner, hasDefinitionData, confirmDesignerMutation } from '../flow-designer-interactive.js';
8
+
9
+ const ACTION_COMMANDS = [
10
+ { name: 'list', description: 'List actions' },
11
+ { name: 'show [identifier]', description: 'Show an action by name or sys_id' },
12
+ { name: 'definition [identifier]', description: 'Read the full JSON definition including executable step_instances' },
13
+ { name: 'step-types', description: 'Read step schema templates for a target scope' },
14
+ { name: 'create', description: 'Create an action: guided in a terminal, full JSON definition in scripts' },
15
+ { name: 'update [identifier]', description: 'Save a full definition through Process Flow (alias: edit)' },
16
+ { name: 'test [identifier]', description: 'Execute an action and optionally wait for actual outputs' },
17
+ { name: 'delete [identifier]', description: 'Delete an action parent explicitly' },
18
+ ];
19
+
20
+ const scopeOption = y => y.option('scope', { type: 'string', describe: 'Exact transaction scope sys_id; inferred from existing action when omitted' });
21
+ const dataOptions = y => y
22
+ .option('data-file', { type: 'string', describe: 'Full action definition JSON file, not table fields' })
23
+ .option('data', { type: 'string', describe: 'Full action definition as JSON' });
24
+ async function actionID(argv, app) {
25
+ const identifier = await pickDesignerRecord(argv, app, ACTION_TABLE, 'action');
26
+ if (identifier === undefined) return undefined;
27
+ argv = { ...argv, identifier };
28
+ return unwrapSysId(await resolveRecord(app.sdk, { table: ACTION_TABLE, identifier: argv.identifier, matchField: 'name', resource: 'action' }));
29
+ }
30
+
31
+ export const actionsCmd = wrap => {
32
+ // Reuse the existing list/show picker and output contract only.
33
+ const base = buildDevCmd('actions', ACTION_TABLE, ['action'], ['name', 'active', 'sys_scope', 'sys_updated_on'], wrap, {
34
+ singular: 'action', readOnly: true,
35
+ resolveIdentifier: (argv, app) => cancelled(() => pickDesignerRecord(argv, app, ACTION_TABLE, 'action')),
36
+ showBreadcrumbs: (_record, id) => [
37
+ { action: 'definition', cmd: `jsn actions definition ${id}`, description: 'Read complete executable definition' },
38
+ { action: 'list', cmd: 'jsn actions list', description: 'Back to actions' },
39
+ ],
40
+ });
41
+ declareCapabilities('actions', { mutationSubcommands: ['create', 'update', 'edit', 'delete', 'test'] });
42
+ // Retain the common wrap and output controller; domain failures get a
43
+ // structured error envelope and nonzero exit, never a success-shaped dispatch.
44
+ const run = handler => wrap(async (argv, app) => {
45
+ try { await cancelled(() => handler(argv, app)); }
46
+ catch (error) {
47
+ if (!error.code?.startsWith('action_')) throw error;
48
+ if (error.details) error.hint = JSON.stringify(error.details);
49
+ app.err(error);
50
+ process.exitCode = 1;
51
+ }
52
+ });
53
+ return {
54
+ ...base,
55
+ describe: 'Flow Designer actions: create, edit full definitions, and test with outputs',
56
+ builder: y => base.builder(y)
57
+ .command({
58
+ command: 'create', describe: 'Create parent and executable Process Flow action; verify persistence',
59
+ builder: yy => dataOptions(yy).option('scope', { type: 'string', describe: 'Exact target scope sys_id, or global' }),
60
+ handler: run(async (argv, app) => {
61
+ const guided = !hasDefinitionData(argv) ? await guidedActionCreate(argv, app) : undefined;
62
+ const definition = guided?.definition ?? parseDataArg(argv);
63
+ const scope = guided?.scope ?? await designerScope(argv, app);
64
+ const result = await app.sdk.createProcessFlowAction(scope, definition);
65
+ app.ok(result, { summary: `Created and verified action ${result.sys_id}` });
66
+ }),
67
+ })
68
+ .command({
69
+ command: 'definition [identifier]', describe: 'Read full JSON definition including executable step_instances',
70
+ builder: scopeOption,
71
+ handler: run(async (argv, app) => {
72
+ const id = await actionID(argv, app);
73
+ if (id === undefined) return;
74
+ const scope = await actionScope(app.sdk, id, argv.scope);
75
+ app.ok(await app.sdk.getProcessFlowAction(id, scope), { summary: `Read action definition ${id}` });
76
+ }),
77
+ })
78
+ .command({
79
+ command: 'update [identifier]', aliases: ['edit'], describe: 'Save a full definition through Process Flow PUT and verify readback',
80
+ builder: yy => dataOptions(scopeOption(yy)),
81
+ handler: run(async (argv, app) => {
82
+ if (!hasDefinitionData(argv) && !interactiveDesigner(argv, app)) throw errUsage('Provide --data-file or --data for the full action definition in noninteractive mode');
83
+ const id = await actionID(argv, app);
84
+ if (id === undefined) return;
85
+ const definition = hasDefinitionData(argv) ? parseDataArg(argv)
86
+ : await guidedActionEdit(argv, app, await app.sdk.getProcessFlowAction(id, await actionScope(app.sdk, id, argv.scope)));
87
+ const result = await app.sdk.updateProcessFlowAction(id, argv.scope, definition);
88
+ app.ok(result, { summary: `Saved and verified action ${id}` });
89
+ }),
90
+ })
91
+ .command({
92
+ command: 'test [identifier]', describe: 'Execute saved or supplied definition; optionally wait for actual outputs',
93
+ builder: yy => dataOptions(scopeOption(yy))
94
+ .option('force', { type: 'boolean', default: false, describe: 'Confirm execution of action scripts, which may have side effects' })
95
+ .option('output-map', { type: 'string', describe: 'JSON action input names to values; sent as outputMap' })
96
+ .option('run-on-thread', { type: 'boolean', default: true, describe: 'Run test on a worker thread' })
97
+ .option('tracing-enabled', { type: 'boolean', default: false, describe: 'Enable Flow Designer tracing' })
98
+ .option('wait', { type: 'boolean', default: false, describe: 'Wait for COMPLETE context and actual runtime outputs' })
99
+ .option('timeout', { type: 'number', default: 60, describe: 'Maximum wait seconds after dispatch, greater than 0, at most 3600' }),
100
+ handler: run(async (argv, app) => {
101
+ let outputMap;
102
+ try { outputMap = JSON.parse(argv['output-map'] ?? '{}'); }
103
+ catch { throw errUsage('--output-map must be valid JSON'); }
104
+ let definition = hasDefinitionData(argv) ? parseDataArg(argv) : undefined;
105
+ const id = await actionID(argv, app);
106
+ if (id === undefined) return;
107
+ if (argv['output-map'] === undefined && interactiveDesigner(argv, app)) {
108
+ definition = await app.sdk.getProcessFlowAction(id, await actionScope(app.sdk, id, argv.scope));
109
+ outputMap = await guidedActionTestInputs(argv, app, definition);
110
+ }
111
+ await confirmDesignerMutation(argv, app, `Execute action '${id}' scripts, which may have side effects`);
112
+ const result = await app.sdk.testProcessFlowAction(id, argv.scope, definition, outputMap, {
113
+ wait: argv.wait, timeout: argv.timeout, runOnThread: argv['run-on-thread'], tracingEnabled: argv['tracing-enabled'],
114
+ });
115
+ app.ok(result, { summary: result.status === 'complete' ? `Test completed ${result.context}` : `Test dispatched ${result.context}; outputs not yet verified` });
116
+ }),
117
+ })
118
+ .command({
119
+ command: 'step-types', describe: 'Read step schema templates for a target scope',
120
+ builder: yy => yy.option('scope', { type: 'string', describe: 'Exact transaction scope sys_id, or global' }),
121
+ handler: run(async (argv, app) => {
122
+ const scope = await designerScope(argv, app);
123
+ app.ok(await app.sdk.getProcessFlowStepTypes(scope), { summary: 'Process Flow step schemas' });
124
+ }),
125
+ })
126
+ .command({
127
+ command: 'delete [identifier]', describe: 'Delete action parent explicitly',
128
+ builder: yy => scopeOption(yy).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
129
+ handler: run(async (argv, app) => {
130
+ const id = await actionID(argv, app);
131
+ if (id === undefined) return;
132
+ await actionScope(app.sdk, id, argv.scope);
133
+ await confirmDesignerMutation(argv, app, `Delete action '${id}'`);
134
+ await app.sdk.delete(ACTION_TABLE, id);
135
+ // Verify the exact target is absent. Do not interpret a 403 as deletion.
136
+ const query = new URLSearchParams({ sysparm_query: `sys_id=${id}`, sysparm_limit: '1', sysparm_fields: 'sys_id', sysparm_display_value: 'false' });
137
+ const response = await app.sdk.request(`${app.sdk.baseURL}/api/now/table/${ACTION_TABLE}?${query}`, { method: 'GET' });
138
+ if (!response || response.error || !Array.isArray(response.result) || response.result.length) {
139
+ throw new Error(`Delete is unverified; action ${id} absence was not explicitly confirmed`);
140
+ }
141
+ app.ok({ name: argv.identifier, sys_id: id, deleted: true }, { summary: `Deleted action ${id}` });
142
+ }),
143
+ }),
144
+ handler: wrap(async (_argv, app) => {
145
+ const summary = 'Manage Flow Designer actions from the sys_hub_action_type_definition table. Create and update guide terminal users; automation passes full JSON definitions, not table fields.';
146
+ app.ok({
147
+ commands: ACTION_COMMANDS.map(command => command.name),
148
+ _formatted: formatCommandList({
149
+ title: summary,
150
+ commands: ACTION_COMMANDS,
151
+ footer: 'Run "jsn actions <command> --help" for details. In a terminal you can omit the target and pick one.',
152
+ }),
153
+ }, { summary });
154
+ }),
155
+ };
156
+ };
@@ -1,5 +1,6 @@
1
1
  import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile, setProfile } from '../config.js';
2
- import { errUsage } from '../errors.js';
2
+ import { errUsage, errUsageHint } from '../errors.js';
3
+ import { canUsePicker } from '../helpers.js';
3
4
  import { declareCapabilities } from '../capabilities.js';
4
5
  import process from 'node:process';
5
6
 
@@ -128,14 +129,16 @@ export function validateLoginSelectors(argv = {}, targetProfile = null) {
128
129
  }
129
130
 
130
131
  /**
131
- * Pick a profile by name — interactive search picker when ambiguous,
132
- * auto-selects when there's exactly one, throws when none configured.
132
+ * Pick a profile by name — interactive search picker when ambiguous and a
133
+ * human is available, auto-selects when there's exactly one, throws when
134
+ * none configured. A non-interactive caller (agent, pipe, --json/--get)
135
+ * never opens a picker; it falls back to the single/default profile.
133
136
  */
134
137
  export async function pickProfile(app, message) {
135
138
  const { search } = await import('@inquirer/prompts');
136
139
  const profileNames = Object.keys(app.config.profiles || {});
137
140
  if (profileNames.length === 0) throw errUsage('No profiles configured. Run "jsn setup" first.');
138
- if (profileNames.length === 1 || !app.isInteractive()) return profileNames[0];
141
+ if (profileNames.length === 1 || !canUsePicker(app, {})) return profileNames[0];
139
142
  const choices = profileNames.map(name => ({
140
143
  name: `${name} — ${app.config.profiles[name].instance_url}`,
141
144
  value: name,
@@ -281,10 +284,9 @@ export async function loginWizard(app, argv = {}) {
281
284
  app.config.activeProfile = profileName;
282
285
  app.config.defaultProfile = profileName;
283
286
 
284
- // Present all connection-level options during setup. Gated on interactivity
285
- // so a piped/scripted call never blocks on prompts.
286
- const { canPrompt } = await import('../helpers.js');
287
- if (canPrompt()) {
287
+ // Present all connection-level options during setup. Gated on
288
+ // interactivity so a piped/scripted call never blocks on prompts.
289
+ if (canUsePicker(app, argv)) {
288
290
  // @inquirer/prompts owns stdin while these confirmations run. Close the
289
291
  // older readline interface before handing stdin to Inquirer. This matters
290
292
  // on Windows Git Bash: leaving both readers attached can make the later
@@ -604,7 +606,7 @@ export function authCmd(wrap) {
604
606
  instanceURL = normalizeInstanceURL(namedProfile.instance_url);
605
607
  } else if (argv.instance) {
606
608
  instanceURL = resolveInstanceArg(argv.instance, app.config);
607
- } else if (app.isInteractive()) {
609
+ } else if (canUsePicker(app, argv)) {
608
610
  // gh-faithful: interactive terminals always get a picker —
609
611
  // existing profiles plus "+ Add a new instance". Zero profiles
610
612
  // goes straight to the setup wizard.
@@ -987,7 +989,9 @@ Examples:
987
989
  if (profileNames.length === 0) {
988
990
  throw errUsage('No profiles configured. Run "jsn auth login" first.');
989
991
  }
990
- if (profileNames.length > 1 && app.isInteractive()) {
992
+ if (profileNames.length === 1) {
993
+ name = profileNames[0];
994
+ } else if (canUsePicker(app, argv)) {
991
995
  const { search } = await import('@inquirer/prompts');
992
996
  const choices = profileNames.map(profileName => ({
993
997
  name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
@@ -1002,7 +1006,13 @@ Examples:
1002
1006
  });
1003
1007
  name = selected;
1004
1008
  } else {
1005
- name = profileNames[0];
1009
+ // Never silently switch the active profile for a
1010
+ // non-interactive caller: picking one for them is a
1011
+ // config write they did not ask for.
1012
+ throw errUsageHint(
1013
+ `Which profile? ${profileNames.length} profiles are configured: ${profileNames.join(', ')}`,
1014
+ 'Pass the profile name: jsn auth switch <name>'
1015
+ );
1006
1016
  }
1007
1017
  }
1008
1018
  await setActiveProfile(app.config, name);
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  assertSafeExactMatch,
10
10
  buildQuerySuffix,
11
+ formatCommandList,
11
12
  formatRecordForDisplay,
12
13
  getStringField,
13
14
  interactiveList,
@@ -351,6 +352,23 @@ export function cmdbCmd(wrap) {
351
352
  return {
352
353
  command: 'cmdb [subcommand]',
353
354
  describe: 'Query the Configuration Management Database (CMDB) — CIs and relationship traversal (read-only)',
355
+ // Parent discovery. Without a handler `jsn cmdb` exited 0 having printed
356
+ // nothing: a script reads that as success and a human as a hang. The child
357
+ // list comes from the registry (argv.subcommands), so it cannot drift from
358
+ // what is registered; the styled view is a side channel that JSON ignores.
359
+ handler: wrap(async (argv, app) => {
360
+ const subcommands = argv.subcommands || [];
361
+ const summary = 'Query the Configuration Management Database (CMDB): CI lookup and relationship traversal. Read-only.';
362
+ app.ok({
363
+ command: 'cmdb',
364
+ subcommands,
365
+ _formatted: formatCommandList({
366
+ title: summary,
367
+ commands: subcommands,
368
+ footer: 'Run "jsn cmdb <command> --help" for details.',
369
+ }),
370
+ }, { summary });
371
+ }),
354
372
  builder: (yargs) => {
355
373
  return yargs
356
374
  .command({