@runa-cmd/core 0.2.0-beta.1 → 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 (60) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/README.md +151 -12
  3. package/dist/cli.d.ts +20 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +264 -70
  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 +27 -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 +15 -5
  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 +12 -0
  32. package/dist/introspect.js.map +1 -1
  33. package/dist/parse/resolve.d.ts +8 -1
  34. package/dist/parse/resolve.d.ts.map +1 -1
  35. package/dist/parse/resolve.js +40 -10
  36. package/dist/parse/resolve.js.map +1 -1
  37. package/dist/parse/strict.d.ts +3 -0
  38. package/dist/parse/strict.d.ts.map +1 -1
  39. package/dist/parse/strict.js +20 -8
  40. package/dist/parse/strict.js.map +1 -1
  41. package/dist/plugin.d.ts +6 -1
  42. package/dist/plugin.d.ts.map +1 -1
  43. package/dist/plugin.js +33 -3
  44. package/dist/plugin.js.map +1 -1
  45. package/dist/stop.d.ts +2 -1
  46. package/dist/stop.d.ts.map +1 -1
  47. package/dist/stop.js +6 -3
  48. package/dist/stop.js.map +1 -1
  49. package/dist/testing.d.ts +28 -0
  50. package/dist/testing.d.ts.map +1 -0
  51. package/dist/testing.js +151 -0
  52. package/dist/testing.js.map +1 -0
  53. package/dist/trace.d.ts +36 -0
  54. package/dist/trace.d.ts.map +1 -0
  55. package/dist/trace.js +24 -0
  56. package/dist/trace.js.map +1 -0
  57. package/dist/types.d.ts +37 -10
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/types.js.map +1 -1
  60. 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({
@@ -182,6 +247,10 @@ async function selectFromArgv(scope, rest, rootName) {
182
247
  if (config.strictOptions !== false) {
183
248
  // Command canonical names + long aliases (= keys of the parseArgs config)
184
249
  const suggestionCandidates = Object.keys(parseArgsConfig.options ?? {});
250
+ // Built-ins were handled above: if they got here, this is a mistyped one.
251
+ suggestionCandidates.push('help');
252
+ if (config.meta.version !== undefined)
253
+ suggestionCandidates.push('version');
185
254
  // Globals are extracted before command parsing, but their names must
186
255
  // still be known here: parseArgs maps a stray global short flag to its
187
256
  // bare character, and typo'd globals should suggest the global name.
@@ -203,6 +272,7 @@ async function selectFromArgv(scope, rest, rootName) {
203
272
  knownKeys,
204
273
  booleanKeys,
205
274
  suggestionCandidates,
275
+ tokens: cmdParseResult.tokens,
206
276
  });
207
277
  }
208
278
  assertOptionValues({
@@ -211,12 +281,20 @@ async function selectFromArgv(scope, rest, rootName) {
211
281
  longAliasMap,
212
282
  optionMeta: commandOptionMeta,
213
283
  });
214
- 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);
215
288
  const operands = distributeRest(cmdParseResult.positionals, rest, command.args ?? {});
216
- const { parsedArgs, parsedOptions } = resolveValues({
289
+ // Nothing reads what's left unless the command asked for it, so it can't stay
290
+ // silent: as positionals, it gets the same error as any other extra (ADR-0036).
291
+ const { parsedArgs, parsedOptions, optionSources } = resolveValues({
292
+ env: scope.env,
217
293
  parseArgsResult: {
218
294
  values: commandValues,
219
- positionals: operands.positionals,
295
+ positionals: command.rest
296
+ ? operands.positionals
297
+ : [...operands.positionals, ...operands.rest],
220
298
  },
221
299
  longAliasMap,
222
300
  schemas: {
@@ -225,14 +303,16 @@ async function selectFromArgv(scope, rest, rootName) {
225
303
  },
226
304
  meta: { options: commandOptionMeta },
227
305
  configValues,
306
+ configSource,
228
307
  });
229
308
  reportDeprecations({
230
309
  provided: Object.keys(commandValues),
231
310
  longAliasMap,
232
311
  optionMeta: commandOptionMeta,
233
312
  configValues,
313
+ env: scope.env,
234
314
  });
235
- return { command, parsedArgs, parsedOptions, rest: operands.rest };
315
+ return { command, path, parsedArgs, parsedOptions, optionSources, rest: operands.rest, asJson };
236
316
  }
237
317
  function commandAtPath(commands, path) {
238
318
  const notFound = new CommandNotFoundError(path.join(' '));
@@ -248,30 +328,41 @@ function commandAtPath(commands, path) {
248
328
  }
249
329
  /** Select from a tool call: the path names the command, the input carries its values. */
250
330
  async function selectFromInput(scope, call) {
251
- const { config, commands, hookCtx } = scope;
331
+ const { commands, hookCtx } = scope;
252
332
  const command = commandAtPath(commands, call.path);
253
- hookCtx.command = command.meta;
333
+ hookCtx.command = namedMeta(command, call.path);
254
334
  const commandOptionMeta = command.meta.options;
255
- const configValues = await loadConfigValues(config);
256
- const { parsedArgs, parsedOptions } = resolveToolInput({
335
+ const { configValues, configSource } = await scope.loadConfig(call.path);
336
+ const { parsedArgs, parsedOptions, optionSources } = resolveToolInput({
337
+ env: scope.env,
257
338
  input: call.input,
258
339
  schemas: { args: command.args, options: command.options },
259
340
  meta: { options: commandOptionMeta },
260
341
  configValues,
342
+ configSource,
261
343
  });
262
344
  reportDeprecations({
263
345
  provided: Object.keys(call.input),
264
346
  longAliasMap: {},
265
347
  optionMeta: commandOptionMeta,
266
348
  configValues,
349
+ env: scope.env,
267
350
  });
268
- 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
+ };
269
360
  }
270
361
  /**
271
362
  * Run the full CLI lifecycle. Only selecting the command and its values depends
272
363
  * on the entry point: everything else is what any run goes through.
273
364
  */
274
- async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}) {
365
+ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harness } = {}) {
275
366
  // ─── State ─────────────────────────────────────────
276
367
  const hookRegistry = createHookRegistry();
277
368
  const mutableCommands = { ...config.commands };
@@ -292,7 +383,15 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
292
383
  ...getSchema({ ...config, commands: mutableCommands }, mutableGlobalOptions, mutableGlobalMeta.options),
293
384
  root: rootName,
294
385
  });
295
- 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
+ };
296
395
  // Hook context that grows as we progress through the lifecycle
297
396
  const hookCtx = {
298
397
  cli: config.meta,
@@ -303,6 +402,81 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
303
402
  let lifecycleError;
304
403
  let failureExitCode;
305
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
+ };
306
480
  try {
307
481
  // ─── Plugin resolution ────────────────────────────
308
482
  if (config.plugins && config.plugins.length > 0) {
@@ -316,7 +490,7 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
316
490
  middleware: mutableMiddleware,
317
491
  hookRegister: (name, handler) => hookRegistry.register(name, handler),
318
492
  getSchema: currentSchema,
319
- getConfigValues: () => loadConfigValues(config),
493
+ getConfigValues: async (path) => path ? (await loadConfig(path)).configValues : (await loadCheckedConfig())?.values,
320
494
  help,
321
495
  globalOptionSources,
322
496
  };
@@ -326,10 +500,13 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
326
500
  // Post-plugin-setup is the earliest point where the full option
327
501
  // surface (plugin globals + plugin commands) exists. Runs before
328
502
  // any parsing on every invocation; throws RunaError OPTION_COLLISION.
503
+ validateCommandNames(mutableCommands, rootName);
329
504
  validateOptionNames(mutableCommands, mutableGlobalOptions);
330
505
  validateOptionCollisions(mutableCommands, mutableGlobalOptions, mutableGlobalMeta, globalOptionSources);
506
+ if (config.config || harness?.configValues)
507
+ assertConfigNames(configTarget);
331
508
  // ─── beforeParse ──────────────────────────────────
332
- await hookRegistry.emit('beforeParse', hookCtx);
509
+ await emitHook('beforeParse');
333
510
  // Handlers may have mutated hookCtx.rawArgs
334
511
  const currentArgv = hookCtx.rawArgs;
335
512
  // ─── Rest split (ADR-0012) ────────────────────────
@@ -425,13 +602,21 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
425
602
  });
426
603
  // Asking how to use a CLI can't require already knowing its options.
427
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([]);
428
607
  try {
429
- parsedGlobalOptions = resolveValues({
608
+ const { parsedOptions, optionSources } = resolveValues({
430
609
  parseArgsResult: { values: globalValues, positionals: [] },
431
610
  longAliasMap,
432
611
  schemas: { options: mutableGlobalOptions },
433
612
  meta: { options: mutableGlobalMeta.options },
434
- }).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);
435
620
  }
436
621
  catch (err) {
437
622
  const isQuestion = answer === 'help' || answer === 'version';
@@ -442,13 +627,15 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
442
627
  provided: Object.keys(globalValues),
443
628
  longAliasMap,
444
629
  optionMeta: mutableGlobalMeta.options,
630
+ configValues,
631
+ env,
445
632
  });
446
633
  // Pass remaining argv (command tokens only) to command parsing
447
634
  hookCtx.rawArgs = commandArgv;
448
635
  }
449
636
  hookCtx.globalOptions = parsedGlobalOptions;
450
637
  // ─── onGlobalFlags ────────────────────────────────
451
- const globalFlagsResult = await hookRegistry.emit('onGlobalFlags', hookCtx);
638
+ const globalFlagsResult = await emitHook('onGlobalFlags');
452
639
  if (globalFlagsResult.shortCircuited) {
453
640
  // Short-circuit: skip command resolution and execution, go to cleanup
454
641
  return;
@@ -465,50 +652,67 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
465
652
  exitWith: (code) => {
466
653
  failureExitCode = code;
467
654
  },
655
+ loadConfig,
656
+ env,
468
657
  };
469
658
  const selection = toolCall
470
659
  ? await selectFromInput(scope, toolCall)
471
660
  : await selectFromArgv(scope, rest, rootName);
472
661
  if (!selection)
473
662
  return;
474
- 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);
475
667
  hookCtx.args = parsedArgs;
476
668
  hookCtx.options = parsedOptions;
477
669
  // ─── afterParse ───────────────────────────────────
478
- await hookRegistry.emit('afterParse', hookCtx);
670
+ await emitHook('afterParse');
479
671
  // ─── beforeRun ────────────────────────────────────
480
- await hookRegistry.emit('beforeRun', hookCtx);
672
+ await emitHook('beforeRun');
481
673
  // ─── Middleware chain + command execution ──────────
482
674
  const runContext = {
483
675
  args: parsedArgs,
484
676
  options: parsedOptions,
485
677
  globalOptions: parsedGlobalOptions,
486
- command: command.meta,
678
+ command: meta,
487
679
  rawArgs: argv,
488
680
  rest: selection.rest,
489
681
  signal: stop.signal,
490
682
  };
491
683
  const innerFn = async () => {
492
684
  report?.({ kind: 'running' });
685
+ const started = performance.now();
493
686
  commandResult = await command.run(runContext);
687
+ trace({ step: 'run', command: path, ms: Math.round(performance.now() - started) });
494
688
  report?.({ kind: 'ran' });
495
689
  // Validate output schema if present
496
690
  if (command.output) {
497
- assertValidOutput(command, command.output, commandResult);
691
+ assertValidOutput(meta.name, command.output, commandResult);
498
692
  }
499
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);
500
698
  };
501
699
  const chain = composeMiddleware(mutableMiddleware, parsedGlobalOptions, innerFn);
502
700
  await stop.guard(chain);
503
701
  // ─── afterRun ─────────────────────────────────────
504
- await hookRegistry.emit('afterRun', hookCtx);
702
+ await emitHook('afterRun');
505
703
  }
506
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
+ }
507
710
  lifecycleError = err instanceof Error ? err : new Error(err == null ? '' : String(err));
711
+ harness?.onError(lifecycleError);
508
712
  // ─── onError ──────────────────────────────────────
509
713
  hookCtx.error = lifecycleError;
510
714
  try {
511
- await hookRegistry.emit('onError', hookCtx);
715
+ await emitHook('onError');
512
716
  }
513
717
  catch (hookErr) {
514
718
  // ADR-0009: log to stderr, never rethrow — a broken error handler must
@@ -521,7 +725,6 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
521
725
  // `handled` means the plugin owns the output, not that the run succeeded.
522
726
  if (!hookCtx.handled) {
523
727
  const subject = hookCtx.command?.name ?? config.meta.name;
524
- const debug = isDebugEnabled();
525
728
  // An agent cannot use a hint for people, unless the operator asked for details.
526
729
  const helpCommand = report ? undefined : config.meta.name;
527
730
  console.error(report && !debug
@@ -531,36 +734,10 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}
531
734
  report?.({ kind: 'failed', reason: toolFailureReason(lifecycleError) });
532
735
  }
533
736
  finally {
534
- // ─── cleanup (ALWAYS runs) ────────────────────────
535
- try {
536
- await hookRegistry.emit('cleanup', hookCtx);
537
- }
538
- catch (hookErr) {
539
- // ADR-0009: same treatment as onError — log the defect, keep cleaning up.
540
- console.error('runa: cleanup hook threw:', hookErr);
541
- }
542
- // Plugin cleanup in reverse topological order
543
- if (sortedPlugins.length > 0) {
544
- const cleanupErrors = await runPluginCleanup(sortedPlugins);
545
- if (cleanupErrors.length > 0) {
546
- for (const err of cleanupErrors) {
547
- console.error(`Plugin cleanup error: ${err.message}`);
548
- }
549
- }
550
- }
551
- stop.finish();
552
- // Exit only after cleanup: process.exit inside catch would skip the rest of
553
- // this block. It can't go after it either: a return in the try never gets there.
554
- const exitCode = stop.exitCode ?? failureExitCode;
555
- if (exitCode !== undefined) {
556
- if (hookCtx.handled) {
557
- // A plugin that truly recovered keeps control by setting process.exitCode.
558
- process.exitCode ??= exitCode;
559
- }
560
- else {
561
- process.exit(exitCode);
562
- }
563
- }
737
+ if (leftByExit)
738
+ stop.finish();
739
+ else
740
+ await finishRun();
564
741
  }
565
742
  }
566
743
  /**
@@ -611,7 +788,7 @@ function findRoute(commands, argv, rootName) {
611
788
  catch (err) {
612
789
  if (!(err instanceof RouteNotFoundError))
613
790
  throw err;
614
- return { path: err.path, isIncomplete: err.isIncomplete };
791
+ return { path: err.path, isIncomplete: err.isIncomplete, flag: err.flag };
615
792
  }
616
793
  }
617
794
  // ─── Public API ─────────────────────────────────────────────
@@ -634,8 +811,18 @@ export function defineCLI(config) {
634
811
  */
635
812
  export async function runCLI(command, options = {}) {
636
813
  const { argv = process.argv.slice(2), ...config } = options;
637
- const { name, version, description } = command.meta;
638
- 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 } };
639
826
  }
640
827
  /** Every CLI starts here: as itself, or as a tool if an MCP server launched it. */
641
828
  async function enter(config, argv, rootName) {
@@ -659,4 +846,11 @@ async function enter(config, argv, rootName) {
659
846
  export async function runToolCall(config, call, options = {}) {
660
847
  await runCLILifecycle(config, options.argv ?? [], { rootName: options.rootName, toolCall: call });
661
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
+ }
662
856
  //# sourceMappingURL=cli.js.map