@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.
- package/CHANGELOG.md +338 -0
- package/README.md +151 -12
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +264 -70
- 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 +27 -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 +15 -5
- 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 +12 -0
- package/dist/introspect.js.map +1 -1
- package/dist/parse/resolve.d.ts +8 -1
- package/dist/parse/resolve.d.ts.map +1 -1
- package/dist/parse/resolve.js +40 -10
- package/dist/parse/resolve.js.map +1 -1
- package/dist/parse/strict.d.ts +3 -0
- package/dist/parse/strict.d.ts.map +1 -1
- package/dist/parse/strict.js +20 -8
- package/dist/parse/strict.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 +37 -10
- 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({
|
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
331
|
+
const { commands, hookCtx } = scope;
|
|
252
332
|
const command = commandAtPath(commands, call.path);
|
|
253
|
-
hookCtx.command = command.
|
|
333
|
+
hookCtx.command = namedMeta(command, call.path);
|
|
254
334
|
const commandOptionMeta = command.meta.options;
|
|
255
|
-
const configValues = await
|
|
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 {
|
|
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: () =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
670
|
+
await emitHook('afterParse');
|
|
479
671
|
// ─── beforeRun ────────────────────────────────────
|
|
480
|
-
await
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
638
|
-
|
|
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
|