@runa-cmd/core 0.2.0-beta.2 → 0.2.0-beta.3

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 (56) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/README.md +130 -3
  3. package/dist/cli.d.ts +20 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +254 -69
  6. package/dist/cli.js.map +1 -1
  7. package/dist/command.d.ts +5 -2
  8. package/dist/command.d.ts.map +1 -1
  9. package/dist/command.js +26 -0
  10. package/dist/command.js.map +1 -1
  11. package/dist/config/merge.d.ts +8 -10
  12. package/dist/config/merge.d.ts.map +1 -1
  13. package/dist/config/merge.js +26 -10
  14. package/dist/config/merge.js.map +1 -1
  15. package/dist/config/scope.d.ts +41 -0
  16. package/dist/config/scope.d.ts.map +1 -0
  17. package/dist/config/scope.js +102 -0
  18. package/dist/config/scope.js.map +1 -0
  19. package/dist/errors.d.ts +12 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +24 -0
  22. package/dist/errors.js.map +1 -1
  23. package/dist/help.d.ts.map +1 -1
  24. package/dist/help.js +12 -3
  25. package/dist/help.js.map +1 -1
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/introspect.d.ts.map +1 -1
  31. package/dist/introspect.js +10 -0
  32. package/dist/introspect.js.map +1 -1
  33. package/dist/parse/resolve.d.ts +7 -0
  34. package/dist/parse/resolve.d.ts.map +1 -1
  35. package/dist/parse/resolve.js +39 -9
  36. package/dist/parse/resolve.js.map +1 -1
  37. package/dist/plugin.d.ts +6 -1
  38. package/dist/plugin.d.ts.map +1 -1
  39. package/dist/plugin.js +33 -3
  40. package/dist/plugin.js.map +1 -1
  41. package/dist/stop.d.ts +2 -1
  42. package/dist/stop.d.ts.map +1 -1
  43. package/dist/stop.js +6 -3
  44. package/dist/stop.js.map +1 -1
  45. package/dist/testing.d.ts +28 -0
  46. package/dist/testing.d.ts.map +1 -0
  47. package/dist/testing.js +151 -0
  48. package/dist/testing.js.map +1 -0
  49. package/dist/trace.d.ts +36 -0
  50. package/dist/trace.d.ts.map +1 -0
  51. package/dist/trace.js +24 -0
  52. package/dist/trace.js.map +1 -0
  53. package/dist/types.d.ts +29 -9
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js.map +1 -1
  56. package/package.json +7 -2
package/dist/cli.js CHANGED
@@ -11,8 +11,10 @@
11
11
  * - Error handling
12
12
  */
13
13
  import { parseArgs } from 'node:util';
14
- import { loadAndMergeConfig } from './config/merge.js';
15
- import { CommandNotFoundError, describeFailure, failureReason, isDebugEnabled, isUsageError, RunaError, ValidationError, } from './errors.js';
14
+ import { JSON_OPTION, JSON_SCHEMA } from './command.js';
15
+ import { loadConfigFile } from './config/merge.js';
16
+ import { assertConfigNames, assertValidConfig, commandValues, } from './config/scope.js';
17
+ import { CommandNotFoundError, describeFailure, failureReason, isDebugEnabled, isUsageError, ProcessExit, RunaError, ValidationError, } from './errors.js';
16
18
  import { flagName } from './flag-name.js';
17
19
  import { commandClaims, findHelpTarget, HELP_TOKENS, renderHelp } from './help.js';
18
20
  import { getSchema } from './introspect.js';
@@ -22,20 +24,25 @@ import { buildParseArgsConfig } from './parse/parseargs-bridge.js';
22
24
  import { distributeRest, resolveToolInput, resolveValues } from './parse/resolve.js';
23
25
  import { walkSchema } from './parse/schema-walker.js';
24
26
  import { assertKnownOptions, assertOptionValues } from './parse/strict.js';
25
- import { resolvePlugins, runPluginCleanup, runPluginSetup, STATIC_GLOBAL_OPTION_SOURCE, topologicalSort, validateOptionCollisions, validateOptionNames, } from './plugin.js';
27
+ import { resolvePlugins, runPluginCleanup, runPluginSetup, STATIC_GLOBAL_OPTION_SOURCE, topologicalSort, validateCommandNames, validateOptionCollisions, validateOptionNames, } from './plugin.js';
26
28
  import { watchForStop } from './stop.js';
27
29
  import { findSuggestion } from './suggest.js';
28
30
  import { sendToolReport, takeToolRequest, } from './tool-channel.js';
31
+ import { debugLine, optionEvents } from './trace.js';
29
32
  /** Routing stopped before a command: `path` is how far it got. */
30
33
  class RouteNotFoundError extends CommandNotFoundError {
31
34
  path;
32
35
  isIncomplete;
36
+ flag;
33
37
  constructor(name, suggestion, path,
34
38
  /** Nothing was wrong, only unfinished: argv ended before reaching a command */
35
- isIncomplete) {
39
+ isIncomplete,
40
+ /** What stopped the route, if it was a flag and not a mistyped command */
41
+ flag) {
36
42
  super(name, suggestion);
37
43
  this.path = path;
38
44
  this.isIncomplete = isIncomplete;
45
+ this.flag = flag;
39
46
  }
40
47
  }
41
48
  /**
@@ -77,18 +84,50 @@ function resolveCommand(commands, tokens) {
77
84
  if (next === undefined) {
78
85
  throw new RouteNotFoundError(path.join(' ') || '(no command specified)', undefined, path, true);
79
86
  }
87
+ // A lone `-` means stdin in many CLIs: it isn't a flag, whatever it is here
88
+ const flag = next.length > 1 && next.startsWith('-') ? next : undefined;
80
89
  const suggestion = findSuggestion(next, Object.keys(current));
81
- throw new RouteNotFoundError([...path, next].join(' '), suggestion, path, false);
90
+ throw new RouteNotFoundError([...path, next].join(' '), suggestion, path, false, flag);
82
91
  }
83
92
  // ─── Output Validation ──────────────────────────────────────
84
93
  /** A command returning the wrong shape is an author defect, reported by field. */
85
94
  const INVALID_OUTPUT = 'INVALID_OUTPUT';
86
- function assertValidOutput(command, output, result) {
95
+ /** The key names a command, so that's its name once a run reaches it (ADR-0043). */
96
+ function namedMeta(command, path) {
97
+ return { ...command.meta, name: path.at(-1) };
98
+ }
99
+ function assertValidOutput(name, output, result) {
87
100
  const parsed = output.safeParse(result);
88
101
  if (parsed.success)
89
102
  return;
90
103
  const issues = parsed.error.issues.map((issue) => ` ${issue.path.join('.') || '(root)'}: ${issue.message}`);
91
- throw new RunaError(`Command '${command.meta.name}' returned invalid output:\n${issues.join('\n')}`, { code: INVALID_OUTPUT, exitCode: 1 });
104
+ throw new RunaError(`Command '${name}' returned invalid output:\n${issues.join('\n')}`, {
105
+ code: INVALID_OUTPUT,
106
+ exitCode: 1,
107
+ });
108
+ }
109
+ const asJsonText = (value) => JSON.stringify(value, null, 2);
110
+ /** What a person sees of a result (ADR-0042). */
111
+ function resultText(name, command, result, asJson) {
112
+ if (result === undefined) {
113
+ if (!command.format)
114
+ return undefined;
115
+ throw new RunaError(`Command '${name}' declares format but returned nothing.`, {
116
+ code: 'MISSING_RESULT',
117
+ exitCode: 1,
118
+ });
119
+ }
120
+ if (asJson)
121
+ return asJsonText(result);
122
+ if (command.format)
123
+ return command.format(result);
124
+ return typeof result === 'string' ? result : asJsonText(result);
125
+ }
126
+ function printResult(name, command, result, asJson) {
127
+ const text = resultText(name, command, result, asJson);
128
+ if (text === undefined)
129
+ return;
130
+ process.stdout.write(text.endsWith('\n') ? text : `${text}\n`);
92
131
  }
93
132
  function toolFailureReason(error) {
94
133
  if (isUsageError(error))
@@ -121,9 +160,6 @@ function composeMiddleware(middlewareList, globalOptions, innerFn) {
121
160
  }
122
161
  return chain;
123
162
  }
124
- function loadConfigValues(config) {
125
- return config.config ? loadAndMergeConfig(config.config) : Promise.resolve(undefined);
126
- }
127
163
  function reportDeprecations(input) {
128
164
  for (const warning of deprecationWarnings(input)) {
129
165
  console.error(warning);
@@ -140,6 +176,27 @@ function findBuiltInAnswer(config, route, argv) {
140
176
  return 'help';
141
177
  return route.isIncomplete ? 'usage' : undefined;
142
178
  }
179
+ /** Report a flag before any command: same message as one after it, same exit code. */
180
+ function assertKnownFlag(token, { config, globalOptions, globalMeta }) {
181
+ const rawName = token.split('=')[0];
182
+ const name = rawName.replace(/^-+/, '');
183
+ const longAliases = Object.values(globalMeta.options)
184
+ .flatMap((meta) => meta.alias ?? [])
185
+ .filter((alias) => alias.startsWith('--'))
186
+ .map((alias) => alias.slice(2));
187
+ assertKnownOptions({
188
+ producedKeys: [name],
189
+ knownKeys: new Set(),
190
+ booleanKeys: new Set(),
191
+ suggestionCandidates: [
192
+ 'help',
193
+ ...(config.meta.version === undefined ? [] : ['version']),
194
+ ...Object.keys(globalOptions).map(flagName),
195
+ ...longAliases,
196
+ ],
197
+ tokens: [{ kind: 'option', name, rawName }],
198
+ });
199
+ }
143
200
  /** Select from argv. Undefined means the run ended here, as with --version. */
144
201
  async function selectFromArgv(scope, rest, rootName) {
145
202
  const { config, commands, globalOptions, globalMeta, hookCtx, help } = scope;
@@ -165,11 +222,19 @@ async function selectFromArgv(scope, rest, rootName) {
165
222
  scope.exitWith(USAGE_EXIT_CODE);
166
223
  return undefined;
167
224
  }
225
+ // Globals were taken out already, so a flag here belongs to nobody
226
+ if (route.flag)
227
+ assertKnownFlag(route.flag, scope);
168
228
  // ─── Resolve command ──────────────────────────────
169
- const { command, remainingArgv } = resolveCommandFromTree(commands, commandArgv, rootName);
170
- hookCtx.command = command.meta;
229
+ const { command, path, remainingArgv } = resolveCommandFromTree(commands, commandArgv, rootName);
230
+ hookCtx.command = namedMeta(command, path);
171
231
  // ─── Parse command args + options ──────────────────
172
- const optionMetadata = command.options ? walkSchema(command.options) : [];
232
+ // --json stays out of command.options: it's for the terminal, not for run()
233
+ // or a tool.
234
+ const optionMetadata = [
235
+ ...walkSchema(command.options ?? {}),
236
+ ...(command.format ? walkSchema({ [JSON_OPTION]: JSON_SCHEMA }) : []),
237
+ ];
173
238
  const commandOptionMeta = command.meta.options;
174
239
  const { parseArgsConfig, longAliasMap, knownKeys, booleanKeys } = buildParseArgsConfig(optionMetadata, commandOptionMeta, true);
175
240
  const cmdParseResult = parseArgs({
@@ -216,11 +281,15 @@ async function selectFromArgv(scope, rest, rootName) {
216
281
  longAliasMap,
217
282
  optionMeta: commandOptionMeta,
218
283
  });
219
- const configValues = await loadConfigValues(config);
284
+ const asJson = command.format !== undefined && commandValues[JSON_OPTION] === true;
285
+ if (command.format)
286
+ delete commandValues[JSON_OPTION];
287
+ const { configValues, configSource } = await scope.loadConfig(path);
220
288
  const operands = distributeRest(cmdParseResult.positionals, rest, command.args ?? {});
221
289
  // Nothing reads what's left unless the command asked for it, so it can't stay
222
290
  // silent: as positionals, it gets the same error as any other extra (ADR-0036).
223
- const { parsedArgs, parsedOptions } = resolveValues({
291
+ const { parsedArgs, parsedOptions, optionSources } = resolveValues({
292
+ env: scope.env,
224
293
  parseArgsResult: {
225
294
  values: commandValues,
226
295
  positionals: command.rest
@@ -234,14 +303,16 @@ async function selectFromArgv(scope, rest, rootName) {
234
303
  },
235
304
  meta: { options: commandOptionMeta },
236
305
  configValues,
306
+ configSource,
237
307
  });
238
308
  reportDeprecations({
239
309
  provided: Object.keys(commandValues),
240
310
  longAliasMap,
241
311
  optionMeta: commandOptionMeta,
242
312
  configValues,
313
+ env: scope.env,
243
314
  });
244
- return { command, parsedArgs, parsedOptions, rest: operands.rest };
315
+ return { command, path, parsedArgs, parsedOptions, optionSources, rest: operands.rest, asJson };
245
316
  }
246
317
  function commandAtPath(commands, path) {
247
318
  const notFound = new CommandNotFoundError(path.join(' '));
@@ -257,30 +328,41 @@ function commandAtPath(commands, path) {
257
328
  }
258
329
  /** Select from a tool call: the path names the command, the input carries its values. */
259
330
  async function selectFromInput(scope, call) {
260
- const { config, commands, hookCtx } = scope;
331
+ const { commands, hookCtx } = scope;
261
332
  const command = commandAtPath(commands, call.path);
262
- hookCtx.command = command.meta;
333
+ hookCtx.command = namedMeta(command, call.path);
263
334
  const commandOptionMeta = command.meta.options;
264
- const configValues = await loadConfigValues(config);
265
- const { parsedArgs, parsedOptions } = resolveToolInput({
335
+ const { configValues, configSource } = await scope.loadConfig(call.path);
336
+ const { parsedArgs, parsedOptions, optionSources } = resolveToolInput({
337
+ env: scope.env,
266
338
  input: call.input,
267
339
  schemas: { args: command.args, options: command.options },
268
340
  meta: { options: commandOptionMeta },
269
341
  configValues,
342
+ configSource,
270
343
  });
271
344
  reportDeprecations({
272
345
  provided: Object.keys(call.input),
273
346
  longAliasMap: {},
274
347
  optionMeta: commandOptionMeta,
275
348
  configValues,
349
+ env: scope.env,
276
350
  });
277
- return { command, parsedArgs, parsedOptions, rest: [] };
351
+ return {
352
+ command,
353
+ path: call.path,
354
+ parsedArgs,
355
+ parsedOptions,
356
+ optionSources,
357
+ rest: [],
358
+ asJson: false,
359
+ };
278
360
  }
279
361
  /**
280
362
  * Run the full CLI lifecycle. Only selecting the command and its values depends
281
363
  * on the entry point: everything else is what any run goes through.
282
364
  */
283
- async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}) {
365
+ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harness } = {}) {
284
366
  // ─── State ─────────────────────────────────────────
285
367
  const hookRegistry = createHookRegistry();
286
368
  const mutableCommands = { ...config.commands };
@@ -301,7 +383,15 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
301
383
  ...getSchema({ ...config, commands: mutableCommands }, mutableGlobalOptions, mutableGlobalMeta.options),
302
384
  root: rootName,
303
385
  });
304
- const stop = watchForStop(report !== undefined);
386
+ const stop = watchForStop(report !== undefined, harness === undefined);
387
+ const env = harness?.env ?? process.env;
388
+ const debug = isDebugEnabled(env);
389
+ const trace = (event) => {
390
+ harness?.trace(event);
391
+ const line = debug ? debugLine(event) : undefined;
392
+ if (line)
393
+ console.error(line);
394
+ };
305
395
  // Hook context that grows as we progress through the lifecycle
306
396
  const hookCtx = {
307
397
  cli: config.meta,
@@ -312,6 +402,81 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
312
402
  let lifecycleError;
313
403
  let failureExitCode;
314
404
  let commandResult;
405
+ let leftByExit = false;
406
+ const finishRun = async () => {
407
+ // ─── cleanup (ALWAYS runs) ────────────────────────
408
+ try {
409
+ await emitHook('cleanup');
410
+ }
411
+ catch (hookErr) {
412
+ // ADR-0009: same treatment as onError — log the defect, keep cleaning up.
413
+ console.error('runa: cleanup hook threw:', hookErr);
414
+ }
415
+ // Plugin cleanup in reverse topological order
416
+ if (sortedPlugins.length > 0) {
417
+ const cleanupErrors = await runPluginCleanup(sortedPlugins);
418
+ if (cleanupErrors.length > 0) {
419
+ for (const err of cleanupErrors) {
420
+ console.error(`Plugin cleanup error: ${err.message}`);
421
+ }
422
+ }
423
+ }
424
+ stop.finish();
425
+ // Exit only after cleanup: process.exit inside catch would skip the rest of
426
+ // this block. It can't go after it either: a return in the try never gets there.
427
+ const exitCode = stop.exitCode ?? failureExitCode;
428
+ if (exitCode === undefined)
429
+ return;
430
+ if (harness)
431
+ harness.onExit(exitCode);
432
+ else if (hookCtx.handled) {
433
+ // A plugin that truly recovered keeps control by setting process.exitCode.
434
+ process.exitCode ??= exitCode;
435
+ }
436
+ else {
437
+ process.exit(exitCode);
438
+ }
439
+ };
440
+ const configTarget = {
441
+ commands: mutableCommands,
442
+ globalOptions: mutableGlobalOptions,
443
+ globalMeta: mutableGlobalMeta,
444
+ rootName,
445
+ };
446
+ /** Read when a run needs it: a broken file must not get in the way of --help. */
447
+ const readConfigFile = async () => {
448
+ if (harness) {
449
+ const values = harness.configValues;
450
+ return values && { filePath: 'testCLI config', values };
451
+ }
452
+ return config.config && loadConfigFile(config.config);
453
+ };
454
+ let tracedConfig = false;
455
+ const loadCheckedConfig = async () => {
456
+ const file = await readConfigFile();
457
+ if (!tracedConfig && (config.config || file)) {
458
+ tracedConfig = true;
459
+ trace({ step: 'config', file: file?.filePath });
460
+ }
461
+ if (file)
462
+ assertValidConfig(file, configTarget);
463
+ return file;
464
+ };
465
+ const emitHook = async (name) => {
466
+ if (hookRegistry.getHandlers(name).length === 0)
467
+ return { shortCircuited: false };
468
+ const started = performance.now();
469
+ const result = await hookRegistry.emit(name, hookCtx);
470
+ trace({ step: 'hook', name, ms: Math.round(performance.now() - started) });
471
+ return result;
472
+ };
473
+ const loadConfig = async (path) => {
474
+ const file = await loadCheckedConfig();
475
+ if (!file)
476
+ return {};
477
+ const { values, source } = commandValues(file, path, rootName);
478
+ return { configValues: values, configSource: source };
479
+ };
315
480
  try {
316
481
  // ─── Plugin resolution ────────────────────────────
317
482
  if (config.plugins && config.plugins.length > 0) {
@@ -325,7 +490,7 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
325
490
  middleware: mutableMiddleware,
326
491
  hookRegister: (name, handler) => hookRegistry.register(name, handler),
327
492
  getSchema: currentSchema,
328
- getConfigValues: () => loadConfigValues(config),
493
+ getConfigValues: async (path) => path ? (await loadConfig(path)).configValues : (await loadCheckedConfig())?.values,
329
494
  help,
330
495
  globalOptionSources,
331
496
  };
@@ -335,10 +500,13 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
335
500
  // Post-plugin-setup is the earliest point where the full option
336
501
  // surface (plugin globals + plugin commands) exists. Runs before
337
502
  // any parsing on every invocation; throws RunaError OPTION_COLLISION.
503
+ validateCommandNames(mutableCommands, rootName);
338
504
  validateOptionNames(mutableCommands, mutableGlobalOptions);
339
505
  validateOptionCollisions(mutableCommands, mutableGlobalOptions, mutableGlobalMeta, globalOptionSources);
506
+ if (config.config || harness?.configValues)
507
+ assertConfigNames(configTarget);
340
508
  // ─── beforeParse ──────────────────────────────────
341
- await hookRegistry.emit('beforeParse', hookCtx);
509
+ await emitHook('beforeParse');
342
510
  // Handlers may have mutated hookCtx.rawArgs
343
511
  const currentArgv = hookCtx.rawArgs;
344
512
  // ─── Rest split (ADR-0012) ────────────────────────
@@ -434,13 +602,21 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
434
602
  });
435
603
  // Asking how to use a CLI can't require already knowing its options.
436
604
  const answer = findBuiltInAnswer(config, findRoute(mutableCommands, commandArgv, rootName), commandArgv);
605
+ // With a tool call, the command comes from the call, not from argv.
606
+ const { configValues, configSource } = answer && !toolCall ? {} : await loadConfig([]);
437
607
  try {
438
- parsedGlobalOptions = resolveValues({
608
+ const { parsedOptions, optionSources } = resolveValues({
439
609
  parseArgsResult: { values: globalValues, positionals: [] },
440
610
  longAliasMap,
441
611
  schemas: { options: mutableGlobalOptions },
442
612
  meta: { options: mutableGlobalMeta.options },
443
- }).parsedOptions;
613
+ configValues,
614
+ configSource,
615
+ env,
616
+ });
617
+ parsedGlobalOptions = parsedOptions;
618
+ const names = Object.keys(mutableGlobalOptions);
619
+ optionEvents('global', names, parsedOptions, optionSources).forEach(trace);
444
620
  }
445
621
  catch (err) {
446
622
  const isQuestion = answer === 'help' || answer === 'version';
@@ -451,13 +627,15 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
451
627
  provided: Object.keys(globalValues),
452
628
  longAliasMap,
453
629
  optionMeta: mutableGlobalMeta.options,
630
+ configValues,
631
+ env,
454
632
  });
455
633
  // Pass remaining argv (command tokens only) to command parsing
456
634
  hookCtx.rawArgs = commandArgv;
457
635
  }
458
636
  hookCtx.globalOptions = parsedGlobalOptions;
459
637
  // ─── onGlobalFlags ────────────────────────────────
460
- const globalFlagsResult = await hookRegistry.emit('onGlobalFlags', hookCtx);
638
+ const globalFlagsResult = await emitHook('onGlobalFlags');
461
639
  if (globalFlagsResult.shortCircuited) {
462
640
  // Short-circuit: skip command resolution and execution, go to cleanup
463
641
  return;
@@ -474,50 +652,67 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
474
652
  exitWith: (code) => {
475
653
  failureExitCode = code;
476
654
  },
655
+ loadConfig,
656
+ env,
477
657
  };
478
658
  const selection = toolCall
479
659
  ? await selectFromInput(scope, toolCall)
480
660
  : await selectFromArgv(scope, rest, rootName);
481
661
  if (!selection)
482
662
  return;
483
- const { command, parsedArgs, parsedOptions } = selection;
663
+ const { command, path, parsedArgs, parsedOptions, optionSources } = selection;
664
+ const meta = namedMeta(command, path);
665
+ const optionNames = Object.keys(command.options ?? {});
666
+ optionEvents('command', optionNames, parsedOptions, optionSources).forEach(trace);
484
667
  hookCtx.args = parsedArgs;
485
668
  hookCtx.options = parsedOptions;
486
669
  // ─── afterParse ───────────────────────────────────
487
- await hookRegistry.emit('afterParse', hookCtx);
670
+ await emitHook('afterParse');
488
671
  // ─── beforeRun ────────────────────────────────────
489
- await hookRegistry.emit('beforeRun', hookCtx);
672
+ await emitHook('beforeRun');
490
673
  // ─── Middleware chain + command execution ──────────
491
674
  const runContext = {
492
675
  args: parsedArgs,
493
676
  options: parsedOptions,
494
677
  globalOptions: parsedGlobalOptions,
495
- command: command.meta,
678
+ command: meta,
496
679
  rawArgs: argv,
497
680
  rest: selection.rest,
498
681
  signal: stop.signal,
499
682
  };
500
683
  const innerFn = async () => {
501
684
  report?.({ kind: 'running' });
685
+ const started = performance.now();
502
686
  commandResult = await command.run(runContext);
687
+ trace({ step: 'run', command: path, ms: Math.round(performance.now() - started) });
503
688
  report?.({ kind: 'ran' });
504
689
  // Validate output schema if present
505
690
  if (command.output) {
506
- assertValidOutput(command, command.output, commandResult);
691
+ assertValidOutput(meta.name, command.output, commandResult);
507
692
  }
508
693
  report?.({ kind: 'result', value: commandResult });
694
+ harness?.onResult(commandResult);
695
+ // A tool hands the data to the server, which never runs format()
696
+ if (!report)
697
+ printResult(meta.name, command, commandResult, selection.asJson);
509
698
  };
510
699
  const chain = composeMiddleware(mutableMiddleware, parsedGlobalOptions, innerFn);
511
700
  await stop.guard(chain);
512
701
  // ─── afterRun ─────────────────────────────────────
513
- await hookRegistry.emit('afterRun', hookCtx);
702
+ await emitHook('afterRun');
514
703
  }
515
704
  catch (err) {
705
+ // A real exit ends here: no onError, no cleanup, no message
706
+ if (err instanceof ProcessExit) {
707
+ leftByExit = true;
708
+ throw err;
709
+ }
516
710
  lifecycleError = err instanceof Error ? err : new Error(err == null ? '' : String(err));
711
+ harness?.onError(lifecycleError);
517
712
  // ─── onError ──────────────────────────────────────
518
713
  hookCtx.error = lifecycleError;
519
714
  try {
520
- await hookRegistry.emit('onError', hookCtx);
715
+ await emitHook('onError');
521
716
  }
522
717
  catch (hookErr) {
523
718
  // ADR-0009: log to stderr, never rethrow — a broken error handler must
@@ -530,7 +725,6 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
530
725
  // `handled` means the plugin owns the output, not that the run succeeded.
531
726
  if (!hookCtx.handled) {
532
727
  const subject = hookCtx.command?.name ?? config.meta.name;
533
- const debug = isDebugEnabled();
534
728
  // An agent cannot use a hint for people, unless the operator asked for details.
535
729
  const helpCommand = report ? undefined : config.meta.name;
536
730
  console.error(report && !debug
@@ -540,36 +734,10 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
540
734
  report?.({ kind: 'failed', reason: toolFailureReason(lifecycleError) });
541
735
  }
542
736
  finally {
543
- // ─── cleanup (ALWAYS runs) ────────────────────────
544
- try {
545
- await hookRegistry.emit('cleanup', hookCtx);
546
- }
547
- catch (hookErr) {
548
- // ADR-0009: same treatment as onError — log the defect, keep cleaning up.
549
- console.error('runa: cleanup hook threw:', hookErr);
550
- }
551
- // Plugin cleanup in reverse topological order
552
- if (sortedPlugins.length > 0) {
553
- const cleanupErrors = await runPluginCleanup(sortedPlugins);
554
- if (cleanupErrors.length > 0) {
555
- for (const err of cleanupErrors) {
556
- console.error(`Plugin cleanup error: ${err.message}`);
557
- }
558
- }
559
- }
560
- stop.finish();
561
- // Exit only after cleanup: process.exit inside catch would skip the rest of
562
- // this block. It can't go after it either: a return in the try never gets there.
563
- const exitCode = stop.exitCode ?? failureExitCode;
564
- if (exitCode !== undefined) {
565
- if (hookCtx.handled) {
566
- // A plugin that truly recovered keeps control by setting process.exitCode.
567
- process.exitCode ??= exitCode;
568
- }
569
- else {
570
- process.exit(exitCode);
571
- }
572
- }
737
+ if (leftByExit)
738
+ stop.finish();
739
+ else
740
+ await finishRun();
573
741
  }
574
742
  }
575
743
  /**
@@ -620,7 +788,7 @@ function findRoute(commands, argv, rootName) {
620
788
  catch (err) {
621
789
  if (!(err instanceof RouteNotFoundError))
622
790
  throw err;
623
- return { path: err.path, isIncomplete: err.isIncomplete };
791
+ return { path: err.path, isIncomplete: err.isIncomplete, flag: err.flag };
624
792
  }
625
793
  }
626
794
  // ─── Public API ─────────────────────────────────────────────
@@ -643,8 +811,18 @@ export function defineCLI(config) {
643
811
  */
644
812
  export async function runCLI(command, options = {}) {
645
813
  const { argv = process.argv.slice(2), ...config } = options;
646
- const { name, version, description } = command.meta;
647
- await enter({ ...config, meta: { name, version, description }, commands: { [name]: command } }, argv, name);
814
+ const { name } = command.meta;
815
+ // A CLI of one command has no key to name it (ADR-0043)
816
+ if (!name) {
817
+ console.error('runCLI needs meta.name: it is the name of the CLI.');
818
+ process.exit(1);
819
+ }
820
+ await enter(rootConfig(command, name, config), argv, name);
821
+ }
822
+ /** What a CLI of one command is. Internal, shared with testCLI(). */
823
+ export function rootConfig(command, name, config) {
824
+ const { version, description } = command.meta;
825
+ return { ...config, meta: { name, version, description }, commands: { [name]: command } };
648
826
  }
649
827
  /** Every CLI starts here: as itself, or as a tool if an MCP server launched it. */
650
828
  async function enter(config, argv, rootName) {
@@ -668,4 +846,11 @@ async function enter(config, argv, rootName) {
668
846
  export async function runToolCall(config, call, options = {}) {
669
847
  await runCLILifecycle(config, options.argv ?? [], { rootName: options.rootName, toolCall: call });
670
848
  }
849
+ /**
850
+ * Run a CLI without ending the process.
851
+ * Internal, for testCLI(): not exported from the package barrel.
852
+ */
853
+ export async function runWithHarness(config, argv, rootName, harness) {
854
+ await runCLILifecycle(config, argv, { rootName, harness });
855
+ }
671
856
  //# sourceMappingURL=cli.js.map