@runa-cmd/core 0.2.0-beta.0 → 0.2.0-beta.2
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/README.md +137 -23
- package/dist/cli.d.ts +20 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +370 -123
- package/dist/cli.js.map +1 -1
- package/dist/command.d.ts.map +1 -1
- package/dist/command.js +1 -0
- package/dist/command.js.map +1 -1
- package/dist/errors.d.ts +25 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +72 -6
- package/dist/errors.js.map +1 -1
- package/dist/flag-name.d.ts +12 -0
- package/dist/flag-name.d.ts.map +1 -0
- package/dist/flag-name.js +19 -0
- package/dist/flag-name.js.map +1 -0
- package/dist/help.d.ts +21 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +152 -0
- package/dist/help.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +10 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +9 -0
- package/dist/internal.js.map +1 -0
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +12 -3
- package/dist/introspect.js.map +1 -1
- package/dist/lifecycle.js +1 -1
- package/dist/lifecycle.js.map +1 -1
- package/dist/parse/adapters/zod-v4.d.ts.map +1 -1
- package/dist/parse/adapters/zod-v4.js +6 -3
- package/dist/parse/adapters/zod-v4.js.map +1 -1
- package/dist/parse/deprecated.d.ts +12 -0
- package/dist/parse/deprecated.d.ts.map +1 -0
- package/dist/parse/deprecated.js +21 -0
- package/dist/parse/deprecated.js.map +1 -0
- package/dist/parse/parseargs-bridge.d.ts.map +1 -1
- package/dist/parse/parseargs-bridge.js +14 -10
- package/dist/parse/parseargs-bridge.js.map +1 -1
- package/dist/parse/resolve.d.ts +34 -4
- package/dist/parse/resolve.d.ts.map +1 -1
- package/dist/parse/resolve.js +134 -54
- package/dist/parse/resolve.js.map +1 -1
- package/dist/parse/schema-adapter.d.ts +5 -3
- package/dist/parse/schema-adapter.d.ts.map +1 -1
- package/dist/parse/schema-adapter.js.map +1 -1
- package/dist/parse/schema-walker.d.ts.map +1 -1
- package/dist/parse/schema-walker.js +1 -0
- package/dist/parse/schema-walker.js.map +1 -1
- package/dist/parse/strict.d.ts +36 -2
- package/dist/parse/strict.d.ts.map +1 -1
- package/dist/parse/strict.js +53 -15
- package/dist/parse/strict.js.map +1 -1
- package/dist/plugin.d.ts +12 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +60 -9
- package/dist/plugin.js.map +1 -1
- package/dist/stop.d.ts +25 -0
- package/dist/stop.d.ts.map +1 -0
- package/dist/stop.js +110 -0
- package/dist/stop.js.map +1 -0
- package/dist/suggest.d.ts +3 -2
- package/dist/suggest.d.ts.map +1 -1
- package/dist/suggest.js +5 -4
- package/dist/suggest.js.map +1 -1
- package/dist/tool-channel.d.ts +48 -0
- package/dist/tool-channel.d.ts.map +1 -0
- package/dist/tool-channel.js +46 -0
- package/dist/tool-channel.js.map +1 -0
- package/dist/types.d.ts +51 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- 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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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,166 @@ function composeMiddleware(middlewareList, globalOptions, innerFn) {
|
|
|
90
121
|
}
|
|
91
122
|
return chain;
|
|
92
123
|
}
|
|
93
|
-
|
|
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
|
+
// Built-ins were handled above: if they got here, this is a mistyped one.
|
|
186
|
+
suggestionCandidates.push('help');
|
|
187
|
+
if (config.meta.version !== undefined)
|
|
188
|
+
suggestionCandidates.push('version');
|
|
189
|
+
// Globals are extracted before command parsing, but their names must
|
|
190
|
+
// still be known here: parseArgs maps a stray global short flag to its
|
|
191
|
+
// bare character, and typo'd globals should suggest the global name.
|
|
192
|
+
for (const name of Object.keys(globalOptions)) {
|
|
193
|
+
knownKeys.add(flagName(name));
|
|
194
|
+
suggestionCandidates.push(flagName(name));
|
|
195
|
+
for (const alias of globalMeta.options[name]?.alias ?? []) {
|
|
196
|
+
if (alias.startsWith('--')) {
|
|
197
|
+
knownKeys.add(alias.slice(2));
|
|
198
|
+
suggestionCandidates.push(alias.slice(2));
|
|
199
|
+
}
|
|
200
|
+
else if (alias.startsWith('-') && alias.length === 2) {
|
|
201
|
+
knownKeys.add(alias.slice(1));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
assertKnownOptions({
|
|
206
|
+
producedKeys: Object.keys(commandValues),
|
|
207
|
+
knownKeys,
|
|
208
|
+
booleanKeys,
|
|
209
|
+
suggestionCandidates,
|
|
210
|
+
tokens: cmdParseResult.tokens,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
assertOptionValues({
|
|
214
|
+
tokens: cmdParseResult.tokens,
|
|
215
|
+
options: parseArgsConfig.options ?? {},
|
|
216
|
+
longAliasMap,
|
|
217
|
+
optionMeta: commandOptionMeta,
|
|
218
|
+
});
|
|
219
|
+
const configValues = await loadConfigValues(config);
|
|
220
|
+
const operands = distributeRest(cmdParseResult.positionals, rest, command.args ?? {});
|
|
221
|
+
// Nothing reads what's left unless the command asked for it, so it can't stay
|
|
222
|
+
// silent: as positionals, it gets the same error as any other extra (ADR-0036).
|
|
223
|
+
const { parsedArgs, parsedOptions } = resolveValues({
|
|
224
|
+
parseArgsResult: {
|
|
225
|
+
values: commandValues,
|
|
226
|
+
positionals: command.rest
|
|
227
|
+
? operands.positionals
|
|
228
|
+
: [...operands.positionals, ...operands.rest],
|
|
229
|
+
},
|
|
230
|
+
longAliasMap,
|
|
231
|
+
schemas: {
|
|
232
|
+
args: command.args,
|
|
233
|
+
options: command.options,
|
|
234
|
+
},
|
|
235
|
+
meta: { options: commandOptionMeta },
|
|
236
|
+
configValues,
|
|
237
|
+
});
|
|
238
|
+
reportDeprecations({
|
|
239
|
+
provided: Object.keys(commandValues),
|
|
240
|
+
longAliasMap,
|
|
241
|
+
optionMeta: commandOptionMeta,
|
|
242
|
+
configValues,
|
|
243
|
+
});
|
|
244
|
+
return { command, parsedArgs, parsedOptions, rest: operands.rest };
|
|
245
|
+
}
|
|
246
|
+
function commandAtPath(commands, path) {
|
|
247
|
+
const notFound = new CommandNotFoundError(path.join(' '));
|
|
248
|
+
let current = commands;
|
|
249
|
+
for (const segment of path) {
|
|
250
|
+
if (isCommand(current) || !Object.hasOwn(current, segment))
|
|
251
|
+
throw notFound;
|
|
252
|
+
current = current[segment];
|
|
253
|
+
}
|
|
254
|
+
if (!isCommand(current))
|
|
255
|
+
throw notFound;
|
|
256
|
+
return current;
|
|
257
|
+
}
|
|
258
|
+
/** Select from a tool call: the path names the command, the input carries its values. */
|
|
259
|
+
async function selectFromInput(scope, call) {
|
|
260
|
+
const { config, commands, hookCtx } = scope;
|
|
261
|
+
const command = commandAtPath(commands, call.path);
|
|
262
|
+
hookCtx.command = command.meta;
|
|
263
|
+
const commandOptionMeta = command.meta.options;
|
|
264
|
+
const configValues = await loadConfigValues(config);
|
|
265
|
+
const { parsedArgs, parsedOptions } = resolveToolInput({
|
|
266
|
+
input: call.input,
|
|
267
|
+
schemas: { args: command.args, options: command.options },
|
|
268
|
+
meta: { options: commandOptionMeta },
|
|
269
|
+
configValues,
|
|
270
|
+
});
|
|
271
|
+
reportDeprecations({
|
|
272
|
+
provided: Object.keys(call.input),
|
|
273
|
+
longAliasMap: {},
|
|
274
|
+
optionMeta: commandOptionMeta,
|
|
275
|
+
configValues,
|
|
276
|
+
});
|
|
277
|
+
return { command, parsedArgs, parsedOptions, rest: [] };
|
|
278
|
+
}
|
|
94
279
|
/**
|
|
95
|
-
* Run the full CLI lifecycle.
|
|
280
|
+
* Run the full CLI lifecycle. Only selecting the command and its values depends
|
|
281
|
+
* on the entry point: everything else is what any run goes through.
|
|
96
282
|
*/
|
|
97
|
-
async function runCLILifecycle(config, argv) {
|
|
283
|
+
async function runCLILifecycle(config, argv, { rootName, toolCall, report } = {}) {
|
|
98
284
|
// ─── State ─────────────────────────────────────────
|
|
99
285
|
const hookRegistry = createHookRegistry();
|
|
100
286
|
const mutableCommands = { ...config.commands };
|
|
@@ -104,18 +290,27 @@ async function runCLILifecycle(config, argv) {
|
|
|
104
290
|
};
|
|
105
291
|
const mutableMiddleware = [...(config.middleware ?? [])];
|
|
106
292
|
let sortedPlugins = [];
|
|
107
|
-
// Registrant per global option name (
|
|
293
|
+
// Registrant per global option name (ADR-0014): statically-declared
|
|
108
294
|
// globals are seeded here; plugin registrations append via addGlobalOption.
|
|
109
295
|
const globalOptionSources = new Map();
|
|
110
296
|
for (const name of Object.keys(mutableGlobalOptions)) {
|
|
111
297
|
globalOptionSources.set(name, [STATIC_GLOBAL_OPTION_SOURCE]);
|
|
112
298
|
}
|
|
299
|
+
const help = {};
|
|
300
|
+
const currentSchema = () => ({
|
|
301
|
+
...getSchema({ ...config, commands: mutableCommands }, mutableGlobalOptions, mutableGlobalMeta.options),
|
|
302
|
+
root: rootName,
|
|
303
|
+
});
|
|
304
|
+
const stop = watchForStop(report !== undefined);
|
|
113
305
|
// Hook context that grows as we progress through the lifecycle
|
|
114
306
|
const hookCtx = {
|
|
115
307
|
cli: config.meta,
|
|
308
|
+
invocation: toolCall ? { surface: 'tool', tool: toolCall.path.join('_') } : { surface: 'cli' },
|
|
116
309
|
rawArgs: argv,
|
|
310
|
+
signal: stop.signal,
|
|
117
311
|
};
|
|
118
312
|
let lifecycleError;
|
|
313
|
+
let failureExitCode;
|
|
119
314
|
let commandResult;
|
|
120
315
|
try {
|
|
121
316
|
// ─── Plugin resolution ────────────────────────────
|
|
@@ -129,21 +324,24 @@ async function runCLILifecycle(config, argv) {
|
|
|
129
324
|
globalMeta: mutableGlobalMeta,
|
|
130
325
|
middleware: mutableMiddleware,
|
|
131
326
|
hookRegister: (name, handler) => hookRegistry.register(name, handler),
|
|
132
|
-
getSchema:
|
|
327
|
+
getSchema: currentSchema,
|
|
328
|
+
getConfigValues: () => loadConfigValues(config),
|
|
329
|
+
help,
|
|
133
330
|
globalOptionSources,
|
|
134
331
|
};
|
|
135
332
|
await runPluginSetup(sortedPlugins, pluginRefs);
|
|
136
333
|
}
|
|
137
|
-
// ─── Option collision validation (
|
|
334
|
+
// ─── Option collision validation (ADR-0014) ─────
|
|
138
335
|
// Post-plugin-setup is the earliest point where the full option
|
|
139
336
|
// surface (plugin globals + plugin commands) exists. Runs before
|
|
140
337
|
// any parsing on every invocation; throws RunaError OPTION_COLLISION.
|
|
338
|
+
validateOptionNames(mutableCommands, mutableGlobalOptions);
|
|
141
339
|
validateOptionCollisions(mutableCommands, mutableGlobalOptions, mutableGlobalMeta, globalOptionSources);
|
|
142
340
|
// ─── beforeParse ──────────────────────────────────
|
|
143
341
|
await hookRegistry.emit('beforeParse', hookCtx);
|
|
144
342
|
// Handlers may have mutated hookCtx.rawArgs
|
|
145
343
|
const currentArgv = hookCtx.rawArgs;
|
|
146
|
-
// ─── Rest split (
|
|
344
|
+
// ─── Rest split (ADR-0012) ────────────────────────
|
|
147
345
|
// Split at the FIRST `--`: `head` flows through the entire pipeline
|
|
148
346
|
// (global extraction → resolution → parseArgs → strict check), `rest`
|
|
149
347
|
// bypasses everything and lands verbatim on the run context. Because
|
|
@@ -225,17 +423,35 @@ async function runCLILifecycle(config, argv) {
|
|
|
225
423
|
const globalParseResult = parseArgs({
|
|
226
424
|
...parseArgsConfig,
|
|
227
425
|
args: globalArgv,
|
|
426
|
+
tokens: true,
|
|
228
427
|
});
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
},
|
|
428
|
+
const globalValues = globalParseResult.values;
|
|
429
|
+
assertOptionValues({
|
|
430
|
+
tokens: globalParseResult.tokens,
|
|
431
|
+
options: registeredOptions,
|
|
234
432
|
longAliasMap,
|
|
235
|
-
|
|
236
|
-
|
|
433
|
+
optionMeta: mutableGlobalMeta.options,
|
|
434
|
+
});
|
|
435
|
+
// Asking how to use a CLI can't require already knowing its options.
|
|
436
|
+
const answer = findBuiltInAnswer(config, findRoute(mutableCommands, commandArgv, rootName), commandArgv);
|
|
437
|
+
try {
|
|
438
|
+
parsedGlobalOptions = resolveValues({
|
|
439
|
+
parseArgsResult: { values: globalValues, positionals: [] },
|
|
440
|
+
longAliasMap,
|
|
441
|
+
schemas: { options: mutableGlobalOptions },
|
|
442
|
+
meta: { options: mutableGlobalMeta.options },
|
|
443
|
+
}).parsedOptions;
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
const isQuestion = answer === 'help' || answer === 'version';
|
|
447
|
+
if (!isQuestion || !(err instanceof ValidationError))
|
|
448
|
+
throw err;
|
|
449
|
+
}
|
|
450
|
+
reportDeprecations({
|
|
451
|
+
provided: Object.keys(globalValues),
|
|
452
|
+
longAliasMap,
|
|
453
|
+
optionMeta: mutableGlobalMeta.options,
|
|
237
454
|
});
|
|
238
|
-
parsedGlobalOptions = parsedOptions;
|
|
239
455
|
// Pass remaining argv (command tokens only) to command parsing
|
|
240
456
|
hookCtx.rawArgs = commandArgv;
|
|
241
457
|
}
|
|
@@ -246,62 +462,25 @@ async function runCLILifecycle(config, argv) {
|
|
|
246
462
|
// Short-circuit: skip command resolution and execution, go to cleanup
|
|
247
463
|
return;
|
|
248
464
|
}
|
|
249
|
-
// ───
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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,
|
|
465
|
+
// ─── Select the command and its values ────────────
|
|
466
|
+
const scope = {
|
|
467
|
+
config,
|
|
468
|
+
commands: mutableCommands,
|
|
469
|
+
globalOptions: mutableGlobalOptions,
|
|
470
|
+
globalMeta: mutableGlobalMeta,
|
|
471
|
+
hookCtx,
|
|
472
|
+
help,
|
|
473
|
+
schema: currentSchema,
|
|
474
|
+
exitWith: (code) => {
|
|
475
|
+
failureExitCode = code;
|
|
296
476
|
},
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
});
|
|
477
|
+
};
|
|
478
|
+
const selection = toolCall
|
|
479
|
+
? await selectFromInput(scope, toolCall)
|
|
480
|
+
: await selectFromArgv(scope, rest, rootName);
|
|
481
|
+
if (!selection)
|
|
482
|
+
return;
|
|
483
|
+
const { command, parsedArgs, parsedOptions } = selection;
|
|
305
484
|
hookCtx.args = parsedArgs;
|
|
306
485
|
hookCtx.options = parsedOptions;
|
|
307
486
|
// ─── afterParse ───────────────────────────────────
|
|
@@ -315,39 +494,50 @@ async function runCLILifecycle(config, argv) {
|
|
|
315
494
|
globalOptions: parsedGlobalOptions,
|
|
316
495
|
command: command.meta,
|
|
317
496
|
rawArgs: argv,
|
|
318
|
-
rest,
|
|
497
|
+
rest: selection.rest,
|
|
498
|
+
signal: stop.signal,
|
|
319
499
|
};
|
|
320
500
|
const innerFn = async () => {
|
|
501
|
+
report?.({ kind: 'running' });
|
|
321
502
|
commandResult = await command.run(runContext);
|
|
503
|
+
report?.({ kind: 'ran' });
|
|
322
504
|
// Validate output schema if present
|
|
323
505
|
if (command.output) {
|
|
324
|
-
command.output
|
|
506
|
+
assertValidOutput(command, command.output, commandResult);
|
|
325
507
|
}
|
|
508
|
+
report?.({ kind: 'result', value: commandResult });
|
|
326
509
|
};
|
|
327
510
|
const chain = composeMiddleware(mutableMiddleware, parsedGlobalOptions, innerFn);
|
|
328
|
-
await chain
|
|
511
|
+
await stop.guard(chain);
|
|
329
512
|
// ─── afterRun ─────────────────────────────────────
|
|
330
513
|
await hookRegistry.emit('afterRun', hookCtx);
|
|
331
514
|
}
|
|
332
515
|
catch (err) {
|
|
333
|
-
lifecycleError = err instanceof Error ? err : new Error(String(err));
|
|
516
|
+
lifecycleError = err instanceof Error ? err : new Error(err == null ? '' : String(err));
|
|
334
517
|
// ─── onError ──────────────────────────────────────
|
|
335
518
|
hookCtx.error = lifecycleError;
|
|
336
519
|
try {
|
|
337
520
|
await hookRegistry.emit('onError', hookCtx);
|
|
338
521
|
}
|
|
339
522
|
catch (hookErr) {
|
|
340
|
-
//
|
|
341
|
-
// not prevent cleanup, but its own defect must not vanish either
|
|
342
|
-
//
|
|
523
|
+
// ADR-0009: log to stderr, never rethrow — a broken error handler must
|
|
524
|
+
// not prevent cleanup, but its own defect must not vanish either.
|
|
525
|
+
// The ORIGINAL error still drives exit behavior below.
|
|
343
526
|
console.error('runa: onError handler threw:', hookErr);
|
|
344
527
|
}
|
|
345
|
-
|
|
528
|
+
failureExitCode = lifecycleError instanceof RunaError ? lifecycleError.exitCode : 1;
|
|
529
|
+
// Report BEFORE cleanup: the reason stays visible even if cleanup hangs.
|
|
530
|
+
// `handled` means the plugin owns the output, not that the run succeeded.
|
|
346
531
|
if (!hookCtx.handled) {
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
532
|
+
const subject = hookCtx.command?.name ?? config.meta.name;
|
|
533
|
+
const debug = isDebugEnabled();
|
|
534
|
+
// An agent cannot use a hint for people, unless the operator asked for details.
|
|
535
|
+
const helpCommand = report ? undefined : config.meta.name;
|
|
536
|
+
console.error(report && !debug
|
|
537
|
+
? failureReason(lifecycleError, subject)
|
|
538
|
+
: describeFailure(lifecycleError, subject, debug, helpCommand));
|
|
350
539
|
}
|
|
540
|
+
report?.({ kind: 'failed', reason: toolFailureReason(lifecycleError) });
|
|
351
541
|
}
|
|
352
542
|
finally {
|
|
353
543
|
// ─── cleanup (ALWAYS runs) ────────────────────────
|
|
@@ -355,7 +545,7 @@ async function runCLILifecycle(config, argv) {
|
|
|
355
545
|
await hookRegistry.emit('cleanup', hookCtx);
|
|
356
546
|
}
|
|
357
547
|
catch (hookErr) {
|
|
358
|
-
//
|
|
548
|
+
// ADR-0009: same treatment as onError — log the defect, keep cleaning up.
|
|
359
549
|
console.error('runa: cleanup hook threw:', hookErr);
|
|
360
550
|
}
|
|
361
551
|
// Plugin cleanup in reverse topological order
|
|
@@ -367,34 +557,72 @@ async function runCLILifecycle(config, argv) {
|
|
|
367
557
|
}
|
|
368
558
|
}
|
|
369
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
|
+
}
|
|
370
573
|
}
|
|
371
574
|
}
|
|
372
575
|
/**
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
* the command
|
|
576
|
+
* Route argv to a command.
|
|
577
|
+
*
|
|
578
|
+
* Root mode (runCLI): the root command takes everything and consumes nothing,
|
|
579
|
+
* so a value equal to its name is data. Only another command's name routes
|
|
580
|
+
* away from it, and `--` passes even that as data (ADR-0023).
|
|
376
581
|
*/
|
|
377
|
-
function resolveCommandFromTree(commands, argv,
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
}
|
|
582
|
+
function resolveCommandFromTree(commands, argv, rootName) {
|
|
583
|
+
if (rootName !== undefined) {
|
|
584
|
+
const root = commands[rootName];
|
|
585
|
+
const first = argv[0] ?? '';
|
|
586
|
+
const isSubcommand = first !== rootName && Object.hasOwn(commands, first);
|
|
587
|
+
if (root && isCommand(root) && !isSubcommand) {
|
|
588
|
+
return { command: root, path: [rootName], remainingArgv: argv };
|
|
393
589
|
}
|
|
590
|
+
return resolveCommand(commands, argv);
|
|
591
|
+
}
|
|
592
|
+
// Implicit single-command mode, retired before 1.0: it depends on counting
|
|
593
|
+
// commands, so a plugin that adds one silently turns it off.
|
|
594
|
+
const entries = Object.entries(commands);
|
|
595
|
+
const [name, entry] = entries[0] ?? [];
|
|
596
|
+
if (entries.length === 1 && entry && isCommand(entry)) {
|
|
597
|
+
const path = [name];
|
|
598
|
+
if (argv[0] === name)
|
|
599
|
+
return { command: entry, path, remainingArgv: argv.slice(1) };
|
|
600
|
+
warnImplicitMode();
|
|
601
|
+
return { command: entry, path, remainingArgv: argv };
|
|
394
602
|
}
|
|
395
|
-
// Multi-command mode: resolve from tree
|
|
396
603
|
return resolveCommand(commands, argv);
|
|
397
604
|
}
|
|
605
|
+
let warnedImplicitMode = false;
|
|
606
|
+
function warnImplicitMode() {
|
|
607
|
+
if (warnedImplicitMode)
|
|
608
|
+
return;
|
|
609
|
+
warnedImplicitMode = true;
|
|
610
|
+
process.emitWarning('defineCLI() with a single command is deprecated. Use runCLI().', {
|
|
611
|
+
type: 'DeprecationWarning',
|
|
612
|
+
code: 'RUNA_IMPLICIT_SINGLE_COMMAND',
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
function findRoute(commands, argv, rootName) {
|
|
616
|
+
try {
|
|
617
|
+
const { command, path } = resolveCommandFromTree(commands, argv, rootName);
|
|
618
|
+
return { command, path, isIncomplete: false };
|
|
619
|
+
}
|
|
620
|
+
catch (err) {
|
|
621
|
+
if (!(err instanceof RouteNotFoundError))
|
|
622
|
+
throw err;
|
|
623
|
+
return { path: err.path, isIncomplete: err.isIncomplete };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
398
626
|
// ─── Public API ─────────────────────────────────────────────
|
|
399
627
|
/**
|
|
400
628
|
* Create a CLI from a configuration.
|
|
@@ -405,20 +633,39 @@ export function defineCLI(config) {
|
|
|
405
633
|
_type: 'runa:cli',
|
|
406
634
|
config,
|
|
407
635
|
async run(argv) {
|
|
408
|
-
|
|
409
|
-
await runCLILifecycle(config, effectiveArgv);
|
|
636
|
+
await enter(config, argv ?? process.argv.slice(2));
|
|
410
637
|
},
|
|
411
638
|
};
|
|
412
639
|
}
|
|
413
640
|
/**
|
|
414
|
-
*
|
|
415
|
-
*
|
|
641
|
+
* Run a CLI made of one command. Accepts everything defineCLI() does except
|
|
642
|
+
* meta and commands, which come from the command: plugins, middleware, config.
|
|
416
643
|
*/
|
|
417
|
-
export async function runCLI(command,
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
644
|
+
export async function runCLI(command, options = {}) {
|
|
645
|
+
const { argv = process.argv.slice(2), ...config } = options;
|
|
646
|
+
const { name, version, description } = command.meta;
|
|
647
|
+
await enter({ ...config, meta: { name, version, description }, commands: { [name]: command } }, argv, name);
|
|
648
|
+
}
|
|
649
|
+
/** Every CLI starts here: as itself, or as a tool if an MCP server launched it. */
|
|
650
|
+
async function enter(config, argv, rootName) {
|
|
651
|
+
const request = takeToolRequest();
|
|
652
|
+
if (request?.kind === 'ping') {
|
|
653
|
+
sendToolReport({ kind: 'pong' });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
await runCLILifecycle(config, argv, {
|
|
657
|
+
rootName,
|
|
658
|
+
toolCall: request,
|
|
659
|
+
report: request && ((report) => sendToolReport(report, request.maxResultBytes)),
|
|
421
660
|
});
|
|
422
|
-
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Run a command from an object, through the same lifecycle as a CLI run.
|
|
664
|
+
* Internal, for @runa-cmd/mcp: not exported from the package barrel.
|
|
665
|
+
*
|
|
666
|
+
* @param options.argv - Startup arguments, read for global options only
|
|
667
|
+
*/
|
|
668
|
+
export async function runToolCall(config, call, options = {}) {
|
|
669
|
+
await runCLILifecycle(config, options.argv ?? [], { rootName: options.rootName, toolCall: call });
|
|
423
670
|
}
|
|
424
671
|
//# sourceMappingURL=cli.js.map
|