@runa-cmd/core 0.2.0-beta.0 → 0.2.0-beta.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 (73) hide show
  1. package/README.md +125 -23
  2. package/dist/cli.d.ts +20 -4
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +361 -123
  5. package/dist/cli.js.map +1 -1
  6. package/dist/errors.d.ts +25 -0
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +72 -6
  9. package/dist/errors.js.map +1 -1
  10. package/dist/flag-name.d.ts +12 -0
  11. package/dist/flag-name.d.ts.map +1 -0
  12. package/dist/flag-name.js +19 -0
  13. package/dist/flag-name.js.map +1 -0
  14. package/dist/help.d.ts +21 -0
  15. package/dist/help.d.ts.map +1 -0
  16. package/dist/help.js +151 -0
  17. package/dist/help.js.map +1 -0
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/internal.d.ts +10 -0
  22. package/dist/internal.d.ts.map +1 -0
  23. package/dist/internal.js +9 -0
  24. package/dist/internal.js.map +1 -0
  25. package/dist/introspect.d.ts.map +1 -1
  26. package/dist/introspect.js +10 -3
  27. package/dist/introspect.js.map +1 -1
  28. package/dist/lifecycle.js +1 -1
  29. package/dist/lifecycle.js.map +1 -1
  30. package/dist/parse/adapters/zod-v4.d.ts.map +1 -1
  31. package/dist/parse/adapters/zod-v4.js +6 -3
  32. package/dist/parse/adapters/zod-v4.js.map +1 -1
  33. package/dist/parse/deprecated.d.ts +12 -0
  34. package/dist/parse/deprecated.d.ts.map +1 -0
  35. package/dist/parse/deprecated.js +21 -0
  36. package/dist/parse/deprecated.js.map +1 -0
  37. package/dist/parse/parseargs-bridge.d.ts.map +1 -1
  38. package/dist/parse/parseargs-bridge.js +14 -10
  39. package/dist/parse/parseargs-bridge.js.map +1 -1
  40. package/dist/parse/resolve.d.ts +34 -4
  41. package/dist/parse/resolve.d.ts.map +1 -1
  42. package/dist/parse/resolve.js +134 -54
  43. package/dist/parse/resolve.js.map +1 -1
  44. package/dist/parse/schema-adapter.d.ts +5 -3
  45. package/dist/parse/schema-adapter.d.ts.map +1 -1
  46. package/dist/parse/schema-adapter.js.map +1 -1
  47. package/dist/parse/schema-walker.d.ts.map +1 -1
  48. package/dist/parse/schema-walker.js +1 -0
  49. package/dist/parse/schema-walker.js.map +1 -1
  50. package/dist/parse/strict.d.ts +33 -2
  51. package/dist/parse/strict.d.ts.map +1 -1
  52. package/dist/parse/strict.js +39 -13
  53. package/dist/parse/strict.js.map +1 -1
  54. package/dist/plugin.d.ts +12 -3
  55. package/dist/plugin.d.ts.map +1 -1
  56. package/dist/plugin.js +60 -9
  57. package/dist/plugin.js.map +1 -1
  58. package/dist/stop.d.ts +25 -0
  59. package/dist/stop.d.ts.map +1 -0
  60. package/dist/stop.js +110 -0
  61. package/dist/stop.js.map +1 -0
  62. package/dist/suggest.d.ts +3 -2
  63. package/dist/suggest.d.ts.map +1 -1
  64. package/dist/suggest.js +5 -4
  65. package/dist/suggest.js.map +1 -1
  66. package/dist/tool-channel.d.ts +48 -0
  67. package/dist/tool-channel.d.ts.map +1 -0
  68. package/dist/tool-channel.js +46 -0
  69. package/dist/tool-channel.js.map +1 -0
  70. package/dist/types.d.ts +43 -7
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/types.js.map +1 -1
  73. package/package.json +5 -1
package/dist/cli.js CHANGED
@@ -12,15 +12,32 @@
12
12
  */
13
13
  import { parseArgs } from 'node:util';
14
14
  import { loadAndMergeConfig } from './config/merge.js';
15
- import { CommandNotFoundError, RunaError } from './errors.js';
15
+ import { CommandNotFoundError, describeFailure, failureReason, isDebugEnabled, isUsageError, RunaError, ValidationError, } from './errors.js';
16
+ import { flagName } from './flag-name.js';
17
+ import { commandClaims, findHelpTarget, HELP_TOKENS, renderHelp } from './help.js';
16
18
  import { getSchema } from './introspect.js';
17
19
  import { createHookRegistry } from './lifecycle.js';
20
+ import { deprecationWarnings } from './parse/deprecated.js';
18
21
  import { buildParseArgsConfig } from './parse/parseargs-bridge.js';
19
- import { resolveValues } from './parse/resolve.js';
22
+ import { distributeRest, resolveToolInput, resolveValues } from './parse/resolve.js';
20
23
  import { walkSchema } from './parse/schema-walker.js';
21
- import { assertKnownOptions } from './parse/strict.js';
22
- import { resolvePlugins, runPluginCleanup, runPluginSetup, STATIC_GLOBAL_OPTION_SOURCE, topologicalSort, validateOptionCollisions, } from './plugin.js';
24
+ import { assertKnownOptions, assertOptionValues } from './parse/strict.js';
25
+ import { resolvePlugins, runPluginCleanup, runPluginSetup, STATIC_GLOBAL_OPTION_SOURCE, topologicalSort, validateOptionCollisions, validateOptionNames, } from './plugin.js';
26
+ import { watchForStop } from './stop.js';
23
27
  import { findSuggestion } from './suggest.js';
28
+ import { sendToolReport, takeToolRequest, } from './tool-channel.js';
29
+ /** Routing stopped before a command: `path` is how far it got. */
30
+ class RouteNotFoundError extends CommandNotFoundError {
31
+ path;
32
+ isIncomplete;
33
+ constructor(name, suggestion, path,
34
+ /** Nothing was wrong, only unfinished: argv ended before reaching a command */
35
+ isIncomplete) {
36
+ super(name, suggestion);
37
+ this.path = path;
38
+ this.isIncomplete = isIncomplete;
39
+ }
40
+ }
24
41
  /**
25
42
  * Check if a value is a Command (branded object).
26
43
  */
@@ -48,21 +65,35 @@ function resolveCommand(commands, tokens) {
48
65
  if (isCommand(entry)) {
49
66
  return {
50
67
  command: entry,
68
+ path: tokens.slice(0, consumed),
51
69
  remainingArgv: tokens.slice(consumed),
52
70
  };
53
71
  }
54
72
  // It's a subtree — continue walking
55
73
  current = entry;
56
74
  }
57
- // If we're in a subtree and haven't found a command, check for a default/index command
58
- // For now, throw CommandNotFoundError
59
- const commandName = tokens[consumed] ?? tokens[consumed - 1] ?? '';
60
- const candidates = Object.keys(current);
61
- const suggestion = commandName ? findSuggestion(commandName, candidates) : undefined;
62
- if (!commandName && consumed === 0) {
63
- throw new CommandNotFoundError('(no command specified)', undefined);
75
+ const path = tokens.slice(0, consumed);
76
+ const next = tokens[consumed];
77
+ if (next === undefined) {
78
+ throw new RouteNotFoundError(path.join(' ') || '(no command specified)', undefined, path, true);
64
79
  }
65
- throw new CommandNotFoundError(tokens.slice(0, consumed + 1).join(' '), suggestion);
80
+ const suggestion = findSuggestion(next, Object.keys(current));
81
+ throw new RouteNotFoundError([...path, next].join(' '), suggestion, path, false);
82
+ }
83
+ // ─── Output Validation ──────────────────────────────────────
84
+ /** A command returning the wrong shape is an author defect, reported by field. */
85
+ const INVALID_OUTPUT = 'INVALID_OUTPUT';
86
+ function assertValidOutput(command, output, result) {
87
+ const parsed = output.safeParse(result);
88
+ if (parsed.success)
89
+ return;
90
+ 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 });
92
+ }
93
+ function toolFailureReason(error) {
94
+ if (isUsageError(error))
95
+ return 'input';
96
+ return error instanceof RunaError && error.code === INVALID_OUTPUT ? 'output' : 'other';
66
97
  }
67
98
  // ─── Middleware Composition ─────────────────────────────────
68
99
  /**
@@ -90,11 +121,157 @@ function composeMiddleware(middlewareList, globalOptions, innerFn) {
90
121
  }
91
122
  return chain;
92
123
  }
93
- // ─── CLI Execution Engine ───────────────────────────────────
124
+ function loadConfigValues(config) {
125
+ return config.config ? loadAndMergeConfig(config.config) : Promise.resolve(undefined);
126
+ }
127
+ function reportDeprecations(input) {
128
+ for (const warning of deprecationWarnings(input)) {
129
+ console.error(warning);
130
+ }
131
+ }
132
+ /** The exit code of a usage error (ADR-0009). */
133
+ const USAGE_EXIT_CODE = 2;
134
+ /** What core answers by itself, instead of running a command. */
135
+ function findBuiltInAnswer(config, route, argv) {
136
+ const isFree = (token) => argv.includes(token) && !(route.command && commandClaims(route.command, token));
137
+ if (config.meta.version !== undefined && isFree('--version'))
138
+ return 'version';
139
+ if (HELP_TOKENS.some(isFree))
140
+ return 'help';
141
+ return route.isIncomplete ? 'usage' : undefined;
142
+ }
143
+ /** Select from argv. Undefined means the run ended here, as with --version. */
144
+ async function selectFromArgv(scope, rest, rootName) {
145
+ const { config, commands, globalOptions, globalMeta, hookCtx, help } = scope;
146
+ const commandArgv = hookCtx.rawArgs;
147
+ // ─── Built-in --version and --help (ADR-0021) ─────
148
+ // Globals were extracted above, so an option of the CLI never gets here.
149
+ const route = findRoute(commands, commandArgv, rootName);
150
+ const answer = findBuiltInAnswer(config, route, commandArgv);
151
+ if (answer === 'version') {
152
+ console.log(config.meta.version);
153
+ return undefined;
154
+ }
155
+ if (answer) {
156
+ // Asking for help is a success. Reaching it with nothing to run is a usage
157
+ // error: stdout stays clean, and a script with an empty command fails.
158
+ const output = answer === 'help' ? process.stdout : process.stderr;
159
+ const target = findHelpTarget(scope.schema(), route.path);
160
+ if (help.show)
161
+ await help.show(target, output);
162
+ else
163
+ output.write(`${renderHelp(target)}\n`);
164
+ if (answer === 'usage')
165
+ scope.exitWith(USAGE_EXIT_CODE);
166
+ return undefined;
167
+ }
168
+ // ─── Resolve command ──────────────────────────────
169
+ const { command, remainingArgv } = resolveCommandFromTree(commands, commandArgv, rootName);
170
+ hookCtx.command = command.meta;
171
+ // ─── Parse command args + options ──────────────────
172
+ const optionMetadata = command.options ? walkSchema(command.options) : [];
173
+ const commandOptionMeta = command.meta.options;
174
+ const { parseArgsConfig, longAliasMap, knownKeys, booleanKeys } = buildParseArgsConfig(optionMetadata, commandOptionMeta, true);
175
+ const cmdParseResult = parseArgs({
176
+ ...parseArgsConfig,
177
+ args: remainingArgv,
178
+ tokens: true,
179
+ });
180
+ const commandValues = cmdParseResult.values;
181
+ // ─── Strict options check ─────────────────────────
182
+ if (config.strictOptions !== false) {
183
+ // Command canonical names + long aliases (= keys of the parseArgs config)
184
+ const suggestionCandidates = Object.keys(parseArgsConfig.options ?? {});
185
+ // Globals are extracted before command parsing, but their names must
186
+ // still be known here: parseArgs maps a stray global short flag to its
187
+ // bare character, and typo'd globals should suggest the global name.
188
+ for (const name of Object.keys(globalOptions)) {
189
+ knownKeys.add(flagName(name));
190
+ suggestionCandidates.push(flagName(name));
191
+ for (const alias of globalMeta.options[name]?.alias ?? []) {
192
+ if (alias.startsWith('--')) {
193
+ knownKeys.add(alias.slice(2));
194
+ suggestionCandidates.push(alias.slice(2));
195
+ }
196
+ else if (alias.startsWith('-') && alias.length === 2) {
197
+ knownKeys.add(alias.slice(1));
198
+ }
199
+ }
200
+ }
201
+ assertKnownOptions({
202
+ producedKeys: Object.keys(commandValues),
203
+ knownKeys,
204
+ booleanKeys,
205
+ suggestionCandidates,
206
+ });
207
+ }
208
+ assertOptionValues({
209
+ tokens: cmdParseResult.tokens,
210
+ options: parseArgsConfig.options ?? {},
211
+ longAliasMap,
212
+ optionMeta: commandOptionMeta,
213
+ });
214
+ const configValues = await loadConfigValues(config);
215
+ const operands = distributeRest(cmdParseResult.positionals, rest, command.args ?? {});
216
+ const { parsedArgs, parsedOptions } = resolveValues({
217
+ parseArgsResult: {
218
+ values: commandValues,
219
+ positionals: operands.positionals,
220
+ },
221
+ longAliasMap,
222
+ schemas: {
223
+ args: command.args,
224
+ options: command.options,
225
+ },
226
+ meta: { options: commandOptionMeta },
227
+ configValues,
228
+ });
229
+ reportDeprecations({
230
+ provided: Object.keys(commandValues),
231
+ longAliasMap,
232
+ optionMeta: commandOptionMeta,
233
+ configValues,
234
+ });
235
+ return { command, parsedArgs, parsedOptions, rest: operands.rest };
236
+ }
237
+ function commandAtPath(commands, path) {
238
+ const notFound = new CommandNotFoundError(path.join(' '));
239
+ let current = commands;
240
+ for (const segment of path) {
241
+ if (isCommand(current) || !Object.hasOwn(current, segment))
242
+ throw notFound;
243
+ current = current[segment];
244
+ }
245
+ if (!isCommand(current))
246
+ throw notFound;
247
+ return current;
248
+ }
249
+ /** Select from a tool call: the path names the command, the input carries its values. */
250
+ async function selectFromInput(scope, call) {
251
+ const { config, commands, hookCtx } = scope;
252
+ const command = commandAtPath(commands, call.path);
253
+ hookCtx.command = command.meta;
254
+ const commandOptionMeta = command.meta.options;
255
+ const configValues = await loadConfigValues(config);
256
+ const { parsedArgs, parsedOptions } = resolveToolInput({
257
+ input: call.input,
258
+ schemas: { args: command.args, options: command.options },
259
+ meta: { options: commandOptionMeta },
260
+ configValues,
261
+ });
262
+ reportDeprecations({
263
+ provided: Object.keys(call.input),
264
+ longAliasMap: {},
265
+ optionMeta: commandOptionMeta,
266
+ configValues,
267
+ });
268
+ return { command, parsedArgs, parsedOptions, rest: [] };
269
+ }
94
270
  /**
95
- * Run the full CLI lifecycle.
271
+ * Run the full CLI lifecycle. Only selecting the command and its values depends
272
+ * on the entry point: everything else is what any run goes through.
96
273
  */
97
- async function runCLILifecycle(config, argv) {
274
+ async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}) {
98
275
  // ─── State ─────────────────────────────────────────
99
276
  const hookRegistry = createHookRegistry();
100
277
  const mutableCommands = { ...config.commands };
@@ -104,18 +281,27 @@ async function runCLILifecycle(config, argv) {
104
281
  };
105
282
  const mutableMiddleware = [...(config.middleware ?? [])];
106
283
  let sortedPlugins = [];
107
- // Registrant per global option name (Decision 9): statically-declared
284
+ // Registrant per global option name (ADR-0014): statically-declared
108
285
  // globals are seeded here; plugin registrations append via addGlobalOption.
109
286
  const globalOptionSources = new Map();
110
287
  for (const name of Object.keys(mutableGlobalOptions)) {
111
288
  globalOptionSources.set(name, [STATIC_GLOBAL_OPTION_SOURCE]);
112
289
  }
290
+ const help = {};
291
+ const currentSchema = () => ({
292
+ ...getSchema({ ...config, commands: mutableCommands }, mutableGlobalOptions, mutableGlobalMeta.options),
293
+ root: rootName,
294
+ });
295
+ const stop = watchForStop(report !== undefined);
113
296
  // Hook context that grows as we progress through the lifecycle
114
297
  const hookCtx = {
115
298
  cli: config.meta,
299
+ invocation: toolCall ? { surface: 'tool', tool: toolCall.path.join('_') } : { surface: 'cli' },
116
300
  rawArgs: argv,
301
+ signal: stop.signal,
117
302
  };
118
303
  let lifecycleError;
304
+ let failureExitCode;
119
305
  let commandResult;
120
306
  try {
121
307
  // ─── Plugin resolution ────────────────────────────
@@ -129,21 +315,24 @@ async function runCLILifecycle(config, argv) {
129
315
  globalMeta: mutableGlobalMeta,
130
316
  middleware: mutableMiddleware,
131
317
  hookRegister: (name, handler) => hookRegistry.register(name, handler),
132
- getSchema: () => getSchema({ ...config, commands: mutableCommands }, mutableGlobalOptions, mutableGlobalMeta.options),
318
+ getSchema: currentSchema,
319
+ getConfigValues: () => loadConfigValues(config),
320
+ help,
133
321
  globalOptionSources,
134
322
  };
135
323
  await runPluginSetup(sortedPlugins, pluginRefs);
136
324
  }
137
- // ─── Option collision validation (Decision 9) ─────
325
+ // ─── Option collision validation (ADR-0014) ─────
138
326
  // Post-plugin-setup is the earliest point where the full option
139
327
  // surface (plugin globals + plugin commands) exists. Runs before
140
328
  // any parsing on every invocation; throws RunaError OPTION_COLLISION.
329
+ validateOptionNames(mutableCommands, mutableGlobalOptions);
141
330
  validateOptionCollisions(mutableCommands, mutableGlobalOptions, mutableGlobalMeta, globalOptionSources);
142
331
  // ─── beforeParse ──────────────────────────────────
143
332
  await hookRegistry.emit('beforeParse', hookCtx);
144
333
  // Handlers may have mutated hookCtx.rawArgs
145
334
  const currentArgv = hookCtx.rawArgs;
146
- // ─── Rest split (Decision 5) ──────────────────────
335
+ // ─── Rest split (ADR-0012) ────────────────────────
147
336
  // Split at the FIRST `--`: `head` flows through the entire pipeline
148
337
  // (global extraction → resolution → parseArgs → strict check), `rest`
149
338
  // bypasses everything and lands verbatim on the run context. Because
@@ -225,17 +414,35 @@ async function runCLILifecycle(config, argv) {
225
414
  const globalParseResult = parseArgs({
226
415
  ...parseArgsConfig,
227
416
  args: globalArgv,
417
+ tokens: true,
228
418
  });
229
- const { parsedOptions } = resolveValues({
230
- parseArgsResult: {
231
- values: globalParseResult.values,
232
- positionals: [],
233
- },
419
+ const globalValues = globalParseResult.values;
420
+ assertOptionValues({
421
+ tokens: globalParseResult.tokens,
422
+ options: registeredOptions,
234
423
  longAliasMap,
235
- schemas: { options: mutableGlobalOptions },
236
- meta: { options: mutableGlobalMeta.options },
424
+ optionMeta: mutableGlobalMeta.options,
425
+ });
426
+ // Asking how to use a CLI can't require already knowing its options.
427
+ const answer = findBuiltInAnswer(config, findRoute(mutableCommands, commandArgv, rootName), commandArgv);
428
+ try {
429
+ parsedGlobalOptions = resolveValues({
430
+ parseArgsResult: { values: globalValues, positionals: [] },
431
+ longAliasMap,
432
+ schemas: { options: mutableGlobalOptions },
433
+ meta: { options: mutableGlobalMeta.options },
434
+ }).parsedOptions;
435
+ }
436
+ catch (err) {
437
+ const isQuestion = answer === 'help' || answer === 'version';
438
+ if (!isQuestion || !(err instanceof ValidationError))
439
+ throw err;
440
+ }
441
+ reportDeprecations({
442
+ provided: Object.keys(globalValues),
443
+ longAliasMap,
444
+ optionMeta: mutableGlobalMeta.options,
237
445
  });
238
- parsedGlobalOptions = parsedOptions;
239
446
  // Pass remaining argv (command tokens only) to command parsing
240
447
  hookCtx.rawArgs = commandArgv;
241
448
  }
@@ -246,62 +453,25 @@ async function runCLILifecycle(config, argv) {
246
453
  // Short-circuit: skip command resolution and execution, go to cleanup
247
454
  return;
248
455
  }
249
- // ─── Resolve command ──────────────────────────────
250
- const commandArgv = hookCtx.rawArgs;
251
- const { command, remainingArgv } = resolveCommandFromTree(mutableCommands, commandArgv, config);
252
- hookCtx.command = command.meta;
253
- // ─── Parse command args + options ──────────────────
254
- const optionMetadata = command.options ? walkSchema(command.options) : [];
255
- const { parseArgsConfig, longAliasMap, knownKeys, booleanKeys } = buildParseArgsConfig(optionMetadata, command.meta.options, !!command.args);
256
- const cmdParseResult = parseArgs({
257
- ...parseArgsConfig,
258
- args: remainingArgv,
259
- });
260
- // ─── Strict options check ─────────────────────────
261
- if (config.strictOptions !== false) {
262
- // Command canonical names + long aliases (= keys of the parseArgs config)
263
- const suggestionCandidates = Object.keys(parseArgsConfig.options ?? {});
264
- // Globals are extracted before command parsing, but their names must
265
- // still be known here: parseArgs maps a stray global short flag to its
266
- // bare character, and typo'd globals should suggest the global name.
267
- for (const name of Object.keys(mutableGlobalOptions)) {
268
- knownKeys.add(name);
269
- suggestionCandidates.push(name);
270
- for (const alias of mutableGlobalMeta.options[name]?.alias ?? []) {
271
- if (alias.startsWith('--')) {
272
- knownKeys.add(alias.slice(2));
273
- suggestionCandidates.push(alias.slice(2));
274
- }
275
- else if (alias.startsWith('-') && alias.length === 2) {
276
- knownKeys.add(alias.slice(1));
277
- }
278
- }
279
- }
280
- assertKnownOptions({
281
- producedKeys: Object.keys(cmdParseResult.values),
282
- knownKeys,
283
- booleanKeys,
284
- suggestionCandidates,
285
- });
286
- }
287
- // Load config values if configured
288
- let configValues;
289
- if (config.config) {
290
- configValues = await loadAndMergeConfig(config.config);
291
- }
292
- const { parsedArgs, parsedOptions } = resolveValues({
293
- parseArgsResult: {
294
- values: cmdParseResult.values,
295
- positionals: cmdParseResult.positionals,
456
+ // ─── Select the command and its values ────────────
457
+ const scope = {
458
+ config,
459
+ commands: mutableCommands,
460
+ globalOptions: mutableGlobalOptions,
461
+ globalMeta: mutableGlobalMeta,
462
+ hookCtx,
463
+ help,
464
+ schema: currentSchema,
465
+ exitWith: (code) => {
466
+ failureExitCode = code;
296
467
  },
297
- longAliasMap,
298
- schemas: {
299
- args: command.args,
300
- options: command.options,
301
- },
302
- meta: { options: command.meta.options },
303
- configValues,
304
- });
468
+ };
469
+ const selection = toolCall
470
+ ? await selectFromInput(scope, toolCall)
471
+ : await selectFromArgv(scope, rest, rootName);
472
+ if (!selection)
473
+ return;
474
+ const { command, parsedArgs, parsedOptions } = selection;
305
475
  hookCtx.args = parsedArgs;
306
476
  hookCtx.options = parsedOptions;
307
477
  // ─── afterParse ───────────────────────────────────
@@ -315,39 +485,50 @@ async function runCLILifecycle(config, argv) {
315
485
  globalOptions: parsedGlobalOptions,
316
486
  command: command.meta,
317
487
  rawArgs: argv,
318
- rest,
488
+ rest: selection.rest,
489
+ signal: stop.signal,
319
490
  };
320
491
  const innerFn = async () => {
492
+ report?.({ kind: 'running' });
321
493
  commandResult = await command.run(runContext);
494
+ report?.({ kind: 'ran' });
322
495
  // Validate output schema if present
323
496
  if (command.output) {
324
- command.output.parse(commandResult);
497
+ assertValidOutput(command, command.output, commandResult);
325
498
  }
499
+ report?.({ kind: 'result', value: commandResult });
326
500
  };
327
501
  const chain = composeMiddleware(mutableMiddleware, parsedGlobalOptions, innerFn);
328
- await chain();
502
+ await stop.guard(chain);
329
503
  // ─── afterRun ─────────────────────────────────────
330
504
  await hookRegistry.emit('afterRun', hookCtx);
331
505
  }
332
506
  catch (err) {
333
- lifecycleError = err instanceof Error ? err : new Error(String(err));
507
+ lifecycleError = err instanceof Error ? err : new Error(err == null ? '' : String(err));
334
508
  // ─── onError ──────────────────────────────────────
335
509
  hookCtx.error = lifecycleError;
336
510
  try {
337
511
  await hookRegistry.emit('onError', hookCtx);
338
512
  }
339
513
  catch (hookErr) {
340
- // D8: log to stderr, never rethrow — a broken error handler must
341
- // not prevent cleanup, but its own defect must not vanish either
342
- // (spec 2.5). The ORIGINAL error still drives exit behavior below.
514
+ // ADR-0009: log to stderr, never rethrow — a broken error handler must
515
+ // not prevent cleanup, but its own defect must not vanish either.
516
+ // The ORIGINAL error still drives exit behavior below.
343
517
  console.error('runa: onError handler threw:', hookErr);
344
518
  }
345
- // If error was not handled, fall back to default behavior
519
+ failureExitCode = lifecycleError instanceof RunaError ? lifecycleError.exitCode : 1;
520
+ // Report BEFORE cleanup: the reason stays visible even if cleanup hangs.
521
+ // `handled` means the plugin owns the output, not that the run succeeded.
346
522
  if (!hookCtx.handled) {
347
- const exitCode = lifecycleError instanceof RunaError ? lifecycleError.exitCode : 1;
348
- console.error(lifecycleError.message);
349
- process.exit(exitCode);
523
+ const subject = hookCtx.command?.name ?? config.meta.name;
524
+ const debug = isDebugEnabled();
525
+ // An agent cannot use a hint for people, unless the operator asked for details.
526
+ const helpCommand = report ? undefined : config.meta.name;
527
+ console.error(report && !debug
528
+ ? failureReason(lifecycleError, subject)
529
+ : describeFailure(lifecycleError, subject, debug, helpCommand));
350
530
  }
531
+ report?.({ kind: 'failed', reason: toolFailureReason(lifecycleError) });
351
532
  }
352
533
  finally {
353
534
  // ─── cleanup (ALWAYS runs) ────────────────────────
@@ -355,7 +536,7 @@ async function runCLILifecycle(config, argv) {
355
536
  await hookRegistry.emit('cleanup', hookCtx);
356
537
  }
357
538
  catch (hookErr) {
358
- // D8: same treatment as onError — log the defect, keep cleaning up.
539
+ // ADR-0009: same treatment as onError — log the defect, keep cleaning up.
359
540
  console.error('runa: cleanup hook threw:', hookErr);
360
541
  }
361
542
  // Plugin cleanup in reverse topological order
@@ -367,34 +548,72 @@ async function runCLILifecycle(config, argv) {
367
548
  }
368
549
  }
369
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
+ }
370
564
  }
371
565
  }
372
566
  /**
373
- * Resolve command with single-command mode support.
374
- * If there's exactly one command, route directly without requiring
375
- * the command name in argv.
567
+ * Route argv to a command.
568
+ *
569
+ * Root mode (runCLI): the root command takes everything and consumes nothing,
570
+ * so a value equal to its name is data. Only another command's name routes
571
+ * away from it, and `--` passes even that as data (ADR-0023).
376
572
  */
377
- function resolveCommandFromTree(commands, argv, _config) {
378
- const commandEntries = Object.entries(commands);
379
- // Single-command mode: if exactly one command and first token doesn't match it,
380
- // route directly to that command
381
- if (commandEntries.length === 1) {
382
- const firstEntry = commandEntries[0];
383
- if (firstEntry) {
384
- const [name, entry] = firstEntry;
385
- if (isCommand(entry)) {
386
- // If argv is empty or first token doesn't match the command name
387
- if (argv.length === 0 || argv[0] !== name) {
388
- return { command: entry, remainingArgv: argv };
389
- }
390
- // First token matches — consume it
391
- return { command: entry, remainingArgv: argv.slice(1) };
392
- }
573
+ function resolveCommandFromTree(commands, argv, rootName) {
574
+ if (rootName !== undefined) {
575
+ const root = commands[rootName];
576
+ const first = argv[0] ?? '';
577
+ const isSubcommand = first !== rootName && Object.hasOwn(commands, first);
578
+ if (root && isCommand(root) && !isSubcommand) {
579
+ return { command: root, path: [rootName], remainingArgv: argv };
393
580
  }
581
+ return resolveCommand(commands, argv);
582
+ }
583
+ // Implicit single-command mode, retired before 1.0: it depends on counting
584
+ // commands, so a plugin that adds one silently turns it off.
585
+ const entries = Object.entries(commands);
586
+ const [name, entry] = entries[0] ?? [];
587
+ if (entries.length === 1 && entry && isCommand(entry)) {
588
+ const path = [name];
589
+ if (argv[0] === name)
590
+ return { command: entry, path, remainingArgv: argv.slice(1) };
591
+ warnImplicitMode();
592
+ return { command: entry, path, remainingArgv: argv };
394
593
  }
395
- // Multi-command mode: resolve from tree
396
594
  return resolveCommand(commands, argv);
397
595
  }
596
+ let warnedImplicitMode = false;
597
+ function warnImplicitMode() {
598
+ if (warnedImplicitMode)
599
+ return;
600
+ warnedImplicitMode = true;
601
+ process.emitWarning('defineCLI() with a single command is deprecated. Use runCLI().', {
602
+ type: 'DeprecationWarning',
603
+ code: 'RUNA_IMPLICIT_SINGLE_COMMAND',
604
+ });
605
+ }
606
+ function findRoute(commands, argv, rootName) {
607
+ try {
608
+ const { command, path } = resolveCommandFromTree(commands, argv, rootName);
609
+ return { command, path, isIncomplete: false };
610
+ }
611
+ catch (err) {
612
+ if (!(err instanceof RouteNotFoundError))
613
+ throw err;
614
+ return { path: err.path, isIncomplete: err.isIncomplete };
615
+ }
616
+ }
398
617
  // ─── Public API ─────────────────────────────────────────────
399
618
  /**
400
619
  * Create a CLI from a configuration.
@@ -405,20 +624,39 @@ export function defineCLI(config) {
405
624
  _type: 'runa:cli',
406
625
  config,
407
626
  async run(argv) {
408
- const effectiveArgv = argv ?? process.argv.slice(2);
409
- await runCLILifecycle(config, effectiveArgv);
627
+ await enter(config, argv ?? process.argv.slice(2));
410
628
  },
411
629
  };
412
630
  }
413
631
  /**
414
- * Single-command shorthand.
415
- * Wraps a command in defineCLI() and runs it.
632
+ * Run a CLI made of one command. Accepts everything defineCLI() does except
633
+ * meta and commands, which come from the command: plugins, middleware, config.
416
634
  */
417
- export async function runCLI(command, argv) {
418
- const cli = defineCLI({
419
- meta: { name: command.meta.name, description: command.meta.description },
420
- commands: { [command.meta.name]: command },
635
+ export async function runCLI(command, options = {}) {
636
+ 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);
639
+ }
640
+ /** Every CLI starts here: as itself, or as a tool if an MCP server launched it. */
641
+ async function enter(config, argv, rootName) {
642
+ const request = takeToolRequest();
643
+ if (request?.kind === 'ping') {
644
+ sendToolReport({ kind: 'pong' });
645
+ return;
646
+ }
647
+ await runCLILifecycle(config, argv, {
648
+ rootName,
649
+ toolCall: request,
650
+ report: request && ((report) => sendToolReport(report, request.maxResultBytes)),
421
651
  });
422
- await cli.run(argv);
652
+ }
653
+ /**
654
+ * Run a command from an object, through the same lifecycle as a CLI run.
655
+ * Internal, for @runa-cmd/mcp: not exported from the package barrel.
656
+ *
657
+ * @param options.argv - Startup arguments, read for global options only
658
+ */
659
+ export async function runToolCall(config, call, options = {}) {
660
+ await runCLILifecycle(config, options.argv ?? [], { rootName: options.rootName, toolCall: call });
423
661
  }
424
662
  //# sourceMappingURL=cli.js.map