@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.
- package/CHANGELOG.md +338 -0
- package/README.md +130 -3
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +254 -69
- package/dist/cli.js.map +1 -1
- package/dist/command.d.ts +5 -2
- package/dist/command.d.ts.map +1 -1
- package/dist/command.js +26 -0
- package/dist/command.js.map +1 -1
- package/dist/config/merge.d.ts +8 -10
- package/dist/config/merge.d.ts.map +1 -1
- package/dist/config/merge.js +26 -10
- package/dist/config/merge.js.map +1 -1
- package/dist/config/scope.d.ts +41 -0
- package/dist/config/scope.d.ts.map +1 -0
- package/dist/config/scope.js +102 -0
- package/dist/config/scope.js.map +1 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +24 -0
- package/dist/errors.js.map +1 -1
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +12 -3
- package/dist/help.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +10 -0
- package/dist/introspect.js.map +1 -1
- package/dist/parse/resolve.d.ts +7 -0
- package/dist/parse/resolve.d.ts.map +1 -1
- package/dist/parse/resolve.js +39 -9
- package/dist/parse/resolve.js.map +1 -1
- package/dist/plugin.d.ts +6 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +33 -3
- package/dist/plugin.js.map +1 -1
- package/dist/stop.d.ts +2 -1
- package/dist/stop.d.ts.map +1 -1
- package/dist/stop.js +6 -3
- package/dist/stop.js.map +1 -1
- package/dist/testing.d.ts +28 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +151 -0
- package/dist/testing.js.map +1 -0
- package/dist/trace.d.ts +36 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +24 -0
- package/dist/trace.js.map +1 -0
- package/dist/types.d.ts +29 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- 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 {
|
|
15
|
-
import {
|
|
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
|
-
|
|
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 '${
|
|
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
|
|
229
|
+
const { command, path, remainingArgv } = resolveCommandFromTree(commands, commandArgv, rootName);
|
|
230
|
+
hookCtx.command = namedMeta(command, path);
|
|
171
231
|
// ─── Parse command args + options ──────────────────
|
|
172
|
-
|
|
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
|
|
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 {
|
|
331
|
+
const { commands, hookCtx } = scope;
|
|
261
332
|
const command = commandAtPath(commands, call.path);
|
|
262
|
-
hookCtx.command = command.
|
|
333
|
+
hookCtx.command = namedMeta(command, call.path);
|
|
263
334
|
const commandOptionMeta = command.meta.options;
|
|
264
|
-
const configValues = await
|
|
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 {
|
|
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: () =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
670
|
+
await emitHook('afterParse');
|
|
488
671
|
// ─── beforeRun ────────────────────────────────────
|
|
489
|
-
await
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
647
|
-
|
|
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
|