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