@runa-cmd/core 0.2.0-beta.5 → 0.2.0-beta.7
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 +162 -0
- package/README.md +100 -9
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +102 -14
- package/dist/cli.js.map +1 -1
- package/dist/command.d.ts +1 -1
- package/dist/command.d.ts.map +1 -1
- package/dist/command.js +3 -3
- package/dist/command.js.map +1 -1
- package/dist/config/loader.d.ts +2 -2
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +8 -5
- package/dist/config/loader.js.map +1 -1
- package/dist/config/scope.js +2 -2
- package/dist/config/scope.js.map +1 -1
- package/dist/errors.d.ts +83 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +114 -11
- package/dist/errors.js.map +1 -1
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +29 -8
- 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/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/parse/resolve.d.ts +7 -0
- package/dist/parse/resolve.d.ts.map +1 -1
- package/dist/parse/resolve.js +140 -25
- package/dist/parse/resolve.js.map +1 -1
- package/dist/parse/schema-adapter.d.ts +2 -0
- package/dist/parse/schema-adapter.d.ts.map +1 -1
- package/dist/parse/schema-adapter.js.map +1 -1
- package/dist/parse/variadic.js +3 -3
- package/dist/parse/variadic.js.map +1 -1
- package/dist/plugin.d.ts +14 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +111 -20
- package/dist/plugin.js.map +1 -1
- package/dist/stop.d.ts +2 -0
- package/dist/stop.d.ts.map +1 -1
- package/dist/stop.js +19 -2
- package/dist/stop.js.map +1 -1
- package/dist/testing.d.ts +7 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +13 -0
- package/dist/testing.js.map +1 -1
- package/dist/tool-channel.d.ts +5 -1
- package/dist/tool-channel.d.ts.map +1 -1
- package/dist/tool-channel.js.map +1 -1
- package/dist/trace.d.ts +24 -0
- package/dist/trace.d.ts.map +1 -1
- package/dist/trace.js.map +1 -1
- package/dist/types.d.ts +164 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,167 @@
|
|
|
1
1
|
# @runa-cmd/core
|
|
2
2
|
|
|
3
|
+
## 0.2.0-beta.7
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3d8584a: An alias that can't work stops the CLI at startup, with `INVALID_OPTION_ALIAS` and the way to write it:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Alias 'e' of option 'env' of command 'deploy' must be '-e'.
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
An alias is one character as `-e`, or a word as `--environment`. Before, `alias: ['e']` or `['-ab']` showed in the help, and `-e` was an unknown option, with nothing saying why.
|
|
14
|
+
|
|
15
|
+
**Breaking:** a CLI with such an alias doesn't start until it's fixed. The alias never worked, and the error shows up the first time its author runs it.
|
|
16
|
+
|
|
17
|
+
- 277d21d: **Breaking:** a `z.boolean()` option without `.default()` or `.optional()` stops the CLI at startup, with `INVALID_BOOLEAN_OPTION`:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Boolean option 'shout' of command 'hello' has no default: a flag that isn't passed needs one. Use z.boolean().default(false), or .optional().
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Before, it was required: `greet hello Ana` failed with `Missing required option: --shout` on every run, and the person running the CLI couldn't fix it. Now its author sees it the first time it runs, with the fix.
|
|
24
|
+
|
|
25
|
+
- 7fd5dd6: Error codes are now a list you can import. `ERROR_CODES` from `@runa-cmd/core` has every code core throws, and the README says when each one comes up. `isRunaError(error, code?)` tells if something is a `RunaError` with that code, and works even when two copies of `@runa-cmd/core` are installed, where `instanceof` fails. It takes your own codes too, as a `CommandError`'s:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
if (isRunaError(error, ERROR_CODES.INVALID_CONFIG)) {
|
|
29
|
+
/* the config file is wrong */
|
|
30
|
+
}
|
|
31
|
+
if (isRunaError(error, "DEPLOY_FAILED")) {
|
|
32
|
+
/* your own code */
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`@runa-cmd/mcp` exports `ERROR_CLASSES`, the `class` values of a failed call, and `MCP_ERROR_CODES`, its own startup codes. Its README lists each class with its `effect`.
|
|
37
|
+
|
|
38
|
+
**Breaking:** three codes are renamed, before they're fixed for 1.0.
|
|
39
|
+
|
|
40
|
+
| Before | Now |
|
|
41
|
+
| ----------------------------------------------------------------- | ----------------------------------- |
|
|
42
|
+
| `CONFIG_INVALID` (a `ConfigError`) | `INVALID_CONFIG` |
|
|
43
|
+
| `OPTION_NAME`, when two keys give the same flag | `OPTION_COLLISION`, as with globals |
|
|
44
|
+
| `OPTION_NAME`, when a boolean `noCache` clashes with `--no-cache` | `INVALID_OPTION_NAME` |
|
|
45
|
+
|
|
46
|
+
**Migration:** compare against the new names, or against `ERROR_CODES`. Messages and exit codes don't change.
|
|
47
|
+
|
|
48
|
+
- 2c5e9f0: An error from another copy of `@runa-cmd/core`, as one a plugin brings, ends as one of ours. A `CommandError` keeps its exit code and its message, at the terminal and through MCP, and a `ValidationError` is a usage error. `isRunaError` already recognized them, but the exit code and the message were decided by `instanceof`.
|
|
49
|
+
|
|
50
|
+
**Breaking:** a `CommandError` with exit code 3 from another copy used to come out as `Unexpected error in 'boom': …` with exit 1. It now exits with 3 and its message as written, as the docs always said.
|
|
51
|
+
|
|
52
|
+
- 485220a: `RunResult` is exported: the type of what `format` receives, so a `format` written apart from `defineCommand` can name both of its parameters, with `FormatContext`.
|
|
53
|
+
- 447f156: `help` works where a command goes, as in commander, yargs, cac and oclif. `tool help` is `tool --help`, `tool help db migrate` is `tool db migrate --help`, and `tool db help` shows the help of the group, on stdout with exit 0. Before, `tool help` exited with 127 and suggested another command, and a model that tried it lost a turn.
|
|
54
|
+
|
|
55
|
+
A command of yours named `help` still runs, and inside a command `help` is a value: `tool greet help` passes it to the argument. With `runCLI` nothing changes, since the command is the CLI. A name that doesn't exist after it is reported as any other: `tool help deplo` exits with 127 and suggests `deploy`. It isn't a command, so it's not in the list, nor an MCP tool.
|
|
56
|
+
|
|
57
|
+
- 11818a4: The help of a command lists its own options first, and the global ones, as `--help`, `--version` or `--mcp`, apart under `GLOBAL OPTIONS`. `OptionSchema` gains an optional `inherited` that marks them, so a custom renderer still gets every option and can tell them apart. With `runCLI` nothing moves: the command is the CLI.
|
|
58
|
+
|
|
59
|
+
Also:
|
|
60
|
+
|
|
61
|
+
- A usage error points to the help of its command: `Run 'tool build --help' for usage.`, instead of the CLI's.
|
|
62
|
+
- The core help shows the values of a positional enum, as the help plugin does: `target What to build for (web | node)`.
|
|
63
|
+
- `bold()`, `fg()` and the other ANSI helpers of `@runa-cmd/help` say in their docs that they always write codes; `styled()` respects the output.
|
|
64
|
+
|
|
65
|
+
- 4ecfba3: A middleware that forgets `next()`, or calls it without `await`, now fails instead of passing in silence:
|
|
66
|
+
|
|
67
|
+
- Returning without calling `next()` fails with `MIDDLEWARE_DID_NOT_CALL_NEXT`, exit 1. Before, the command didn't run and the CLI exited with 0. Through MCP, such a call now reports `class: 'rejected'` instead of `'not_run'`, with the same `effect: 'none'`, and its text says why.
|
|
68
|
+
- Calling `next()` without `await` fails with `MIDDLEWARE_DID_NOT_AWAIT_NEXT`, after the command ends. Before, `afterRun` and `cleanup` ran while the command still worked.
|
|
69
|
+
|
|
70
|
+
To skip a command on purpose, throw: `new CommandError('Dry run: would deploy api', { exitCode: 0 })`. `RunaError` also takes a `cause`.
|
|
71
|
+
|
|
72
|
+
- f69c9fc: **Breaking:** a command that doesn't exist exits with 127 even when a required global option is missing, and nothing to run shows the help on stderr with exit 2. Before, with a required global, `tool nope`, `tool db nope` and `tool` all said `Missing required option: --token`, with exit 2. A command that exists still says the global is missing, with exit 2.
|
|
73
|
+
- 597a8fd: Inside a command, two options that claim the same token stop the CLI at startup with `OPTION_COLLISION`, as its docs always said:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
Options 'extra' and 'env' of command 'deploy' both use '-e'.
|
|
77
|
+
Option 'env' of command 'deploy' has '--env' as an alias, which is already its flag. Remove the alias.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Before, only collisions with a global option were caught. Two options with `-e` kept the first one; an alias equal to another option's flag, or to the `--no-` of a boolean, took it over; and an alias equal to its own option's flag threw the typed value away, so `--env prod` gave the default, with exit 0.
|
|
81
|
+
|
|
82
|
+
**Breaking:** a CLI with one of those aliases doesn't start until it's fixed. None of them worked as written.
|
|
83
|
+
|
|
84
|
+
- db60a2d: A tool call that's missing a value only the operator gives, as an option's env var, now fails with `class: "not_configured"`, not `invalid_input`. Its text starts with `The operator of this server has to set NOTES_FILE. It isn't a value you can send.`, so a model doesn't take it as its own mistake, nor ask the user for it. The server also says it at startup, on stderr, one line per tool, and keeps serving:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
mcpPlugin: tool 'add' can't run until the operator sets NOTES_FILE (--file).
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
An input error the model can fix names the key it sent, as `Invalid value for 'limit': must be greater than 0`, instead of `--limit`, a flag it never saw. The line about an operator's value still names the flag and the variable, for whoever runs the server.
|
|
91
|
+
|
|
92
|
+
**Breaking:** a plain `Error` thrown from `run()` is now `class: "crashed"`, with `The command did not finish.`, instead of `command_failed`. Only a `CommandError` is `command_failed`, with its message as you wrote it. A client can now tell a failure you foresaw from a bug, as the README always said.
|
|
93
|
+
|
|
94
|
+
**Migration:** to have the model read your message as is, throw a `CommandError`.
|
|
95
|
+
|
|
96
|
+
- 52ef44c: A command of `defineCLI` with `meta.version` stops the CLI at startup, with `INVALID_COMMAND_VERSION`:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
Command 'deploy' declares meta.version: 'version' only applies with runCLI; set it in defineCLI's meta.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
There it did nothing, without a word: `--version` prints the CLI's version, from `defineCLI`'s `meta`. On the command of `runCLI`, which is the CLI, it still works as before.
|
|
103
|
+
|
|
104
|
+
**Breaking:** a CLI with a command like that doesn't start until the version moves to `defineCLI`'s `meta`.
|
|
105
|
+
|
|
106
|
+
### Patch Changes
|
|
107
|
+
|
|
108
|
+
- 402ef3f: A wrong config file says what's wrong the same way in every case, and with the words the terminal uses. Each error names the file first:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
my-tool.config.json: invalid value for 'clear.limit': must be greater than 0
|
|
112
|
+
my-tool.config.json: unknown key 'clear.limt'. Did you mean 'limit'?
|
|
113
|
+
my-tool.config.json: invalid JSON: Unexpected token '}', ..."force":tru}}" is not valid JSON
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Before, a value said `Too small: expected number to be >0`, and broken JSON came in another format, over several lines. A boolean that gets anything else now says `expected true or false`, from the file, from env or from the terminal, instead of `expected boolean`. The README says which values a file takes: its JSON type, or the text you'd type at the terminal, as `"5"` or `"true"`.
|
|
117
|
+
|
|
118
|
+
- 1ba1f73: Every public type, function and option now has a description, so your editor shows what each one is for. `HookContext.rawArgs` says what it holds at each step: the whole argv in `beforeParse`, and only the command's part from `onGlobalFlags` on. No code changed.
|
|
119
|
+
- ff89521: Help and errors without noise:
|
|
120
|
+
|
|
121
|
+
- A boolean whose default is `true` shows as `--[no-]cache`, so people see how to turn it off.
|
|
122
|
+
- An empty list as a default no longer shows `(default: )`.
|
|
123
|
+
- A value that breaks a rule of its schema names the flag in plain words: `Invalid value for --limit: must be greater than 0`, instead of `limit: Too small: expected number to be >0`.
|
|
124
|
+
- Shell completions offer `--no-x` only for a boolean that starts on: `--no-help`, `--no-version` and `--no-mcp` are gone.
|
|
125
|
+
- The custom renderer example of the help README compiles, and handles the help of a command too.
|
|
126
|
+
|
|
127
|
+
- 53208e9: The help README said a command's own `-h` stops the CLI with `OPTION_COLLISION`, and that `db migrate --help` shows the help of `db`. Neither is true: a command keeps its `-h`, and nested commands get their own help at any depth. The core README now explains what `ctx.rawArgs` holds in each hook, and `ctx.invocation`.
|
|
128
|
+
- a8edd77: A required value that's missing says so, whatever its type. A missing `z.enum` or `z.literal` said `Invalid value for --env. Expected one of: staging, production`, so the main example of the README didn't give the error the same page promises. Now it's `Missing required option: --env, or set DEPLOY_ENV`, as for any other type, and a missing enum argument, as the shell of `completions`, is `Missing required argument: <shell>`. A wrong value is still an invalid value.
|
|
129
|
+
- 23c3c19: A command without `meta.description` has one type error, the one that says so. TypeScript also reported `Type 'string' is not assignable to type 'void | Promise<void>'` on `run`, which looked like a problem with what the command returns: when a field fails, TypeScript can't infer the types of `defineCommand` and uses their defaults, and the default of what `run()` returns was `void`. It's `unknown` now. `description` is still required: it's what a model reads to choose a tool.
|
|
130
|
+
- ba4bdee: **Breaking:** a tool call that fails because a value the operator gave breaks its schema, in env or in the config file, now comes with `class: "not_configured"`, as a missing value does, instead of `invalid_input`. Its text says what the operator has to fix, never the value, so a model doesn't take it as its own mistake:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
The operator of this server has to fix 'deploy.replicas' in the config file. It isn't a value you can send.
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The server also says it at startup, on stderr, with the reason, and keeps serving the other tools:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
mcpPlugin: tool 'deploy' can't run until the operator fixes DEPLOY_ENV (--env): expected one of: staging, production.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Values are checked with the rules the CLI uses, so `"5"` for a number in the config file is fine.
|
|
143
|
+
|
|
144
|
+
- 1304aa0: A wrong value is repeated in its error, as commander and oclif do, so you see what you typed:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
Invalid value 'abc' for --times: expected number
|
|
148
|
+
Invalid value 'mars' for --env. Expected one of: staging, production
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Only a value typed at the terminal or sent by a model, cut after 40 characters and kept on one line. A value from env or from the config file is never shown, nor the value of an option that declares `env`, even typed, since those often hold a secret and an error can end up in a CI log.
|
|
152
|
+
|
|
153
|
+
- 5e21a16: `testCLI()` rejects when given `config` for a CLI that doesn't declare `config` in `defineCLI`, instead of applying the values. The real program would read no file, so the test passed while the CLI didn't work. The message says what to add. For a single command, `config` still stands for the file of `runCLI(command, { config })`.
|
|
154
|
+
|
|
155
|
+
## 0.2.0-beta.6
|
|
156
|
+
|
|
157
|
+
### Minor Changes
|
|
158
|
+
|
|
159
|
+
- 07bb292: A run stopped by `SIGINT` or `SIGTERM` ends by that same signal, after cleanup, instead of `exit(130)` or `exit(143)`. The shell still sees 130 or 143, and systemd now counts a stopped service as a clean stop instead of `failed`. A program that starts your CLI with `spawn` sees `signal: 'SIGTERM'` instead of `code: 143`. On Windows nothing changes.
|
|
160
|
+
|
|
161
|
+
If `run()` ends well after the signal, it's no longer reported as an error: no `Interrupted.` and no `onError`. A command that fails because of the abort, or doesn't end in time, still prints `Interrupted.`.
|
|
162
|
+
|
|
163
|
+
**Breaking for code that starts a Runa CLI with `spawn` and reads `code`:** after a signal it's now `null`, with the signal in `signal`. To get the number a shell shows, use `code ?? 128 + os.constants.signals[signal]`. Scripts that read `$?` are not affected. (Added after the release: the published entry didn't say it.)
|
|
164
|
+
|
|
3
165
|
## 0.2.0-beta.5
|
|
4
166
|
|
|
5
167
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ Operational metadata lives in `meta.options`, separate from the Zod schema:
|
|
|
96
96
|
|
|
97
97
|
| Property | Type | Description |
|
|
98
98
|
|---|---|---|
|
|
99
|
-
| `alias` | `string[]` |
|
|
99
|
+
| `alias` | `string[]` | Other flags for the option: one character as `'-e'`, or a word as `'--environment'`. Any other form, as `'e'` or `'-ab'`, stops the CLI at startup and says how to write it |
|
|
100
100
|
| `env` | `string` | Environment variable name, e.g. `'DEPLOY_ENV'`. If a required option is missing, the error names it: `Missing required option: --env, or set DEPLOY_ENV` |
|
|
101
101
|
| `configurable` | `boolean` | `false` if a config file can't set it, as with a guardrail. Default: `true` |
|
|
102
102
|
| `group` | `string` | Group name for help display, e.g. `'Scaling'` |
|
|
@@ -273,10 +273,24 @@ Write `darkColor` in code and `--dark-color` at the terminal. `run()`, config fi
|
|
|
273
273
|
| `outputURL` | `--output-url` |
|
|
274
274
|
| `dark-color` | `--dark-color`, a key with hyphens stays as written |
|
|
275
275
|
|
|
276
|
-
`--darkColor` is a usage error that points to `--dark-color`. `--no-dark-mode` negates `darkMode`.
|
|
276
|
+
`--darkColor` is a usage error that points to `--dark-color`. `--no-dark-mode` negates `darkMode`. A boolean whose default is `true` shows in help as `--[no-]dark-mode`, and is the only kind that shell completions offer a `--no-` for: one that starts off has nothing to turn off.
|
|
277
|
+
|
|
278
|
+
A value that breaks a rule of its schema names the flag, repeats what was typed, and says what's wrong: `.positive()` gives `Invalid value '0' for --limit: must be greater than 0`, and `.min(3)` on a string `must have at least 3 characters`. Write your own message for other rules, as with `.regex(re, '--color must start with #')`, and it's shown as you wrote it.
|
|
279
|
+
|
|
280
|
+
The value is only repeated if it was typed at the terminal or sent by a model, and cut after 40 characters. A value from env or from the config file is never shown, nor the value of an option that declares `env`, even typed: those often hold a secret, and an error can end up in a CI log.
|
|
277
281
|
|
|
278
282
|
A boolean named `noCache` stops the CLI at startup, because `--no-cache` already negates `cache`. Declare `cache` with `.default(true)` instead.
|
|
279
283
|
|
|
284
|
+
A boolean needs `.default()` or `.optional()`, or the CLI stops at startup: a flag that isn't passed would fail every run. To make someone confirm with `--yes`, let it default to `false` and check it in `run()`, with a message of yours:
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
options: { yes: z.boolean().default(false).describe('Confirm the deletion') },
|
|
288
|
+
run({ options }) {
|
|
289
|
+
if (!options.yes) throw new CommandError('This deletes everything. Pass --yes to confirm.', { exitCode: 2 });
|
|
290
|
+
// ...
|
|
291
|
+
},
|
|
292
|
+
```
|
|
293
|
+
|
|
280
294
|
### Environment variables
|
|
281
295
|
|
|
282
296
|
An option reads a variable only if it declares one, in `meta.options`. Nothing is read by a prefix, so the environment can't turn on an option you didn't mean it to, like `--force`:
|
|
@@ -298,7 +312,11 @@ You choose the name. A convention that helps:
|
|
|
298
312
|
|
|
299
313
|
### `--help`
|
|
300
314
|
|
|
301
|
-
Every CLI has `--help` and `-h`, with no plugin: `tool --help` shows the CLI or the root command, and `tool db migrate --help` shows that command,
|
|
315
|
+
Every CLI has `--help` and `-h`, with no plugin: `tool --help` shows the CLI or the root command, and `tool db migrate --help` shows that command. Its own options come first, and the global options that work on it, as `--help` or `--mcp`, go apart under `GLOBAL OPTIONS`; a custom renderer tells them apart by `inherited` on each option. With `runCLI`, the command is the CLI, so they're its own options.
|
|
316
|
+
|
|
317
|
+
`help` works where a command goes, as people and models often type it: `tool help` is `tool --help`, `tool help db migrate` is `tool db migrate --help`, and `tool db help` shows the group. A command of yours named `help` wins, and inside a command `help` is a value: `tool greet help` passes it to the argument. It isn't a command, so it doesn't show in the list, nor as an MCP tool.
|
|
318
|
+
|
|
319
|
+
After a usage error, the hint points to the help that answers it: `tool build --limit 0` ends with `Run 'tool build --help' for usage.`
|
|
302
320
|
|
|
303
321
|
Each flag yields separately. If a command declares `-h`, for `--host` say, it keeps it and `--help` still shows help. For themes and layout, add `@runa-cmd/help`. To show help your own way from a plugin, declare the `replaceHelp` capability:
|
|
304
322
|
|
|
@@ -316,7 +334,7 @@ definePlugin({
|
|
|
316
334
|
|
|
317
335
|
With nothing to run, as in `tool` on a CLI with several commands or `tool db` on a group, help goes to stderr and the exit code is 2. A script with an empty command fails instead of passing. A name that doesn't exist is still a 127, with a suggestion.
|
|
318
336
|
|
|
319
|
-
`--help` and `--version` work even if a required global option is missing.
|
|
337
|
+
`--help` and `--version` work even if a required global option is missing. So does any run where nothing would run: a command that doesn't exist is still a 127, and nothing to run still shows the help with exit 2. A command that exists says the global is missing.
|
|
320
338
|
|
|
321
339
|
### `--version`
|
|
322
340
|
|
|
@@ -358,6 +376,8 @@ const auth = defineMiddleware(async ({ next, globalOptions }) => {
|
|
|
358
376
|
});
|
|
359
377
|
```
|
|
360
378
|
|
|
379
|
+
A middleware must `await next()`, or throw to stop the command. Returning without calling it fails with `MIDDLEWARE_DID_NOT_CALL_NEXT`, because the command would not run and the CLI would still exit 0. Calling it without `await` fails with `MIDDLEWARE_DID_NOT_AWAIT_NEXT`, after the command ends, so `afterRun` and `cleanup` never run while it still works. To skip a command on purpose, as a dry run does, throw `new CommandError('Dry run: would deploy api', { exitCode: 0 })`.
|
|
380
|
+
|
|
361
381
|
Middleware, hooks and `run()` get the command that runs as `command`. `command.name` is its own name, and `command.path` the whole path, which is what a permissions or audit check needs: `tag add` and `add` share the name `add`.
|
|
362
382
|
|
|
363
383
|
```ts
|
|
@@ -444,7 +464,17 @@ A key at the root is a global option, or a command. Groups nest, and an option u
|
|
|
444
464
|
|
|
445
465
|
A value only reaches the command it's under. With `runCLI` there's no command to name, so its options go at the root.
|
|
446
466
|
|
|
447
|
-
|
|
467
|
+
A value can be its JSON type, or the text you'd type at the terminal: `"limit": 5` and `"limit": "5"` are the same, and so are `"force": true` and `"force": "true"`. Text is accepted because a loader may only produce text, as one for an INI file. A number is not a boolean: `"force": 1` is an error that says `expected true or false`.
|
|
468
|
+
|
|
469
|
+
Nothing is ignored. Every error names the file first, and says what's wrong with the words the terminal uses:
|
|
470
|
+
|
|
471
|
+
```
|
|
472
|
+
my-tool.config.json: invalid value for 'clear.limit': must be greater than 0
|
|
473
|
+
my-tool.config.json: unknown key 'clear.limt'. Did you mean 'limit'?
|
|
474
|
+
my-tool.config.json: invalid JSON: Unexpected token '}', ..."force":tru}}" is not valid JSON
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
All exit with 2. The code says what failed, and the message says where: a value that breaks its schema is a `VALIDATION_ERROR`, as it would be from the terminal or from env, and a file that can't be read as config, with broken JSON or an unknown key, is an `INVALID_CONFIG`. `$schema` is the only key that isn't checked. A global option can't share a name with a command when the CLI has a config file: that fails at startup, for you to rename one.
|
|
448
478
|
|
|
449
479
|
**The current directory comes first:** a file in `.` can set options, like `force` above, for anyone who runs your CLI there. To protect one option, set `meta.options.force.configurable` to `false`: only the terminal can set it, and the file gets an error. To protect them all, use `searchPaths: ['~']`.
|
|
450
480
|
|
|
@@ -463,6 +493,54 @@ import {
|
|
|
463
493
|
throw new CommandError('Deployment failed', { code: 'DEPLOY_FAILED', exitCode: 3 });
|
|
464
494
|
```
|
|
465
495
|
|
|
496
|
+
#### Error codes
|
|
497
|
+
|
|
498
|
+
Tell errors apart by their `code`, not by the message: codes are part of the [contract](../../VERSIONING.md), and messages change. `isRunaError(error, code?)` checks both, and works even when two copies of `@runa-cmd/core` are installed, where `instanceof` fails. `ERROR_CODES` lists Runa's codes, and a `CommandError` takes your own:
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
import { ERROR_CODES, isRunaError } from '@runa-cmd/core';
|
|
502
|
+
|
|
503
|
+
if (isRunaError(error, ERROR_CODES.INVALID_CONFIG)) { /* the config file is wrong */ }
|
|
504
|
+
if (isRunaError(error, 'DEPLOY_FAILED')) { /* your own code */ }
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
The value that's wrong has one code wherever it comes from, terminal, env or config file, and the message says where. These come up while someone runs the CLI:
|
|
508
|
+
|
|
509
|
+
| Code | Exit | When |
|
|
510
|
+
|---|---|---|
|
|
511
|
+
| `VALIDATION_ERROR` | 2 | An argument or option is missing, or breaks its schema. `ValidationError` |
|
|
512
|
+
| `INVALID_CONFIG` | 2 | The config file can't be used: broken JSON, an unknown key, or a value where an object goes. `ConfigError` |
|
|
513
|
+
| `COMMAND_NOT_FOUND` | 127 | No command has that name. `CommandNotFoundError` |
|
|
514
|
+
| `COMMAND_ERROR` | 1 | A `CommandError` without its own code |
|
|
515
|
+
| `INTERRUPTED` | The signal's | `SIGINT` or `SIGTERM` stopped the run, and `run()` failed because of it or didn't end in time |
|
|
516
|
+
| `INVALID_OUTPUT` | 1 | `run()` returned a value its `output` schema rejects |
|
|
517
|
+
| `MISSING_RESULT` | 1 | The command declares `format`, and `run()` returned nothing |
|
|
518
|
+
| `MIDDLEWARE_DID_NOT_CALL_NEXT` | 1 | A middleware returned without calling `next()`, so the command didn't run |
|
|
519
|
+
| `MIDDLEWARE_DID_NOT_AWAIT_NEXT` | 1 | A middleware called `next()` without awaiting it |
|
|
520
|
+
|
|
521
|
+
These stop the CLI at startup, all with exit 1. They point at the CLI's code, so its author sees them the first time it runs:
|
|
522
|
+
|
|
523
|
+
| Code | When |
|
|
524
|
+
|---|---|
|
|
525
|
+
| `OPTION_COLLISION` | Two options end up as the same flag or alias: two keys or two aliases of a command, an alias equal to another option's flag or `--no-` negation, two globals, a global and a command's option, or an option where `format` adds `--json`. Also an alias equal to its own option's flag |
|
|
526
|
+
| `INVALID_OPTION_NAME` | A boolean `noCache` would clash with `--no-cache`, which already negates `cache` |
|
|
527
|
+
| `INVALID_BOOLEAN_OPTION` | A `z.boolean()` without `.default()` or `.optional()`: a flag that isn't passed would fail every run. The message says how to fix it |
|
|
528
|
+
| `INVALID_OPTION_ALIAS` | An alias isn't `-x`, one character, nor `--word`, as `'e'` or `'-ab'`: the message says how to write it |
|
|
529
|
+
| `INVALID_META_OPTIONS` | `meta.options` names a key that `options` doesn't have |
|
|
530
|
+
| `INVALID_VARIADIC_ARGS` | A command has more than one list argument |
|
|
531
|
+
| `INVALID_VARIADIC_POSITION` | The list argument isn't the last one |
|
|
532
|
+
| `INVALID_COMMAND_NAME` | A command key isn't lowercase words joined by dashes, or doesn't match its `meta.name` |
|
|
533
|
+
| `INVALID_COMMAND_VERSION` | A command of `defineCLI` declares `meta.version`, which only applies to the command of `runCLI`: the CLI's version goes in `defineCLI`'s `meta` |
|
|
534
|
+
| `CONFIG_COLLISION` | A global option and a command share a name, so a config file can't tell them apart |
|
|
535
|
+
| `INVALID_PLUGIN` | A plugin without `meta.name` or `setup()` |
|
|
536
|
+
| `PLUGIN_LOAD_ERROR` | A lazy plugin failed to load, or its module has no default export |
|
|
537
|
+
| `PLUGIN_MISSING_DEPENDENCY` | A plugin depends on one that isn't registered |
|
|
538
|
+
| `PLUGIN_CIRCULAR_DEPENDENCY` | Plugins depend on each other in a cycle |
|
|
539
|
+
| `PLUGIN_CAPABILITY_VIOLATION` | A plugin used part of the API without declaring its capability |
|
|
540
|
+
| `HELP_ALREADY_REPLACED` | Two plugins replace the help |
|
|
541
|
+
|
|
542
|
+
`@runa-cmd/mcp` adds its own, listed in [its README](../mcp/README.md#errors).
|
|
543
|
+
|
|
466
544
|
#### How a failed run ends
|
|
467
545
|
|
|
468
546
|
No failure exits silently. The reason goes to stderr, `cleanup` runs, and then the process exits.
|
|
@@ -483,7 +561,11 @@ No failure exits silently. The reason goes to stderr, `cleanup` runs, and then t
|
|
|
483
561
|
|
|
484
562
|
#### How a run stops
|
|
485
563
|
|
|
486
|
-
On `SIGINT` or `SIGTERM`, Runa aborts `ctx.signal` and runs `cleanup`, and the process
|
|
564
|
+
On `SIGINT` or `SIGTERM`, Runa aborts `ctx.signal` and runs `cleanup`, and the process ends by that same signal, so the shell sees 130 or 143. A command that ignores the signal stops being waited for after 2 seconds, and the process ends after 5 at most. A second signal leaves at once.
|
|
565
|
+
|
|
566
|
+
If `run()` ends well after the signal, as a server that closes when told to, that's not an error: nothing is printed and `onError` doesn't run. If it fails because of the abort, or doesn't end in time, stderr says `Interrupted.`.
|
|
567
|
+
|
|
568
|
+
Ending by the signal, instead of with exit code 143, is what makes a stop clean for a service manager: systemd counts death by `SIGTERM` as clean, and exit 143 as a failure. A program that starts your CLI with `spawn` sees `signal: 'SIGTERM'`, not `code: 143`, and gets the number a shell shows with `code ?? 128 + os.constants.signals[signal]`. On Windows, which has no signals, it exits with the code. Under launchd, stop a service with `launchctl bootout`: with `KeepAlive` → `SuccessfulExit: false`, a `launchctl kill` restarts it, as it does with any daemon that dies by the signal.
|
|
487
569
|
|
|
488
570
|
Pass `signal` to whatever the command starts, so the stop reaches it too:
|
|
489
571
|
|
|
@@ -498,7 +580,9 @@ A command that blocks the event loop can't react to a signal until it yields. Us
|
|
|
498
580
|
### Constants
|
|
499
581
|
|
|
500
582
|
```ts
|
|
501
|
-
import { HOOK_NAMES, PARAM_TYPES } from '@runa-cmd/core';
|
|
583
|
+
import { ERROR_CODES, HOOK_NAMES, PARAM_TYPES } from '@runa-cmd/core';
|
|
584
|
+
|
|
585
|
+
ERROR_CODES.VALIDATION_ERROR // 'VALIDATION_ERROR', and every code in Error codes
|
|
502
586
|
|
|
503
587
|
HOOK_NAMES.BEFORE_PARSE // 'beforeParse'
|
|
504
588
|
HOOK_NAMES.ON_GLOBAL_FLAGS // 'onGlobalFlags'
|
|
@@ -517,7 +601,7 @@ PARAM_TYPES.ARRAY // 'array'
|
|
|
517
601
|
|
|
518
602
|
### Testing: `testCLI()`
|
|
519
603
|
|
|
520
|
-
Run a command through its real lifecycle, without ending the process
|
|
604
|
+
Run a command through its real lifecycle, without ending the process. [Testing a CLI](https://github.com/jrodrigopuca/runa-cmd/blob/main/guides/testing.md) shows it test by test, and what needs a real process instead.
|
|
521
605
|
|
|
522
606
|
```ts
|
|
523
607
|
import { testCLI } from '@runa-cmd/core/testing';
|
|
@@ -552,6 +636,8 @@ A value from env is left out, because it can be a secret. The shape of `trace` i
|
|
|
552
636
|
|
|
553
637
|
**What's isolated:** the test doesn't read the real environment or config files, and several runs can share a process, as with `it.concurrent`.
|
|
554
638
|
|
|
639
|
+
**`config` needs a CLI that reads one:** if the CLI doesn't declare `config` in `defineCLI`, `testCLI()` rejects with a message saying so, because the real program would read no file. For a single command, `config` stands for the file of `runCLI(command, { config })`.
|
|
640
|
+
|
|
555
641
|
**What isn't:** a command that reads `process.env` by itself sees the real one. A child process, or a write straight to a file descriptor, isn't captured. `process.exitCode` is global, so it isn't reliable with concurrent runs: exit or throw instead.
|
|
556
642
|
|
|
557
643
|
A `process.exit` inside a command ends the run with its code and skips `cleanup`, as in a real run.
|
|
@@ -595,6 +681,11 @@ cleanup ← (Runs on errors and on signals, within the stop deadline)
|
|
|
595
681
|
On error → onError hook fires before cleanup
|
|
596
682
|
```
|
|
597
683
|
|
|
684
|
+
What a hook gets grows as the run goes on: `globalOptions` from `onGlobalFlags`, and `command`, `args` and `options` from `afterParse`. Two fields differ from what `run()` gets:
|
|
685
|
+
|
|
686
|
+
- **`ctx.rawArgs`** is the whole argv in `beforeParse`, where a hook may rewrite it before parsing. From `onGlobalFlags` on, it's only the command's part, without the global options and without what follows `--`. `run()` gets the original argv as it was.
|
|
687
|
+
- **`ctx.invocation`** says who started the run: `{ surface: 'cli' }` for a person or a script, and `{ surface: 'tool', tool }` for a call through MCP, where everything written reaches the model. `run()` doesn't get it, on purpose: a command does the same work for both, and what only people read goes in `format`, which doesn't run for a tool.
|
|
688
|
+
|
|
598
689
|
## Value Resolution
|
|
599
690
|
|
|
600
691
|
Options are resolved in this priority order:
|
|
@@ -622,7 +713,7 @@ import type {
|
|
|
622
713
|
HookContext, HookHandler, HookName,
|
|
623
714
|
Middleware, MiddlewareContext, MiddlewareFn,
|
|
624
715
|
PluginAPI, PluginCapabilities, PluginConfig, PluginMeta,
|
|
625
|
-
RunContext, RunReturn,
|
|
716
|
+
RunContext, RunReturn, RunResult, FormatContext,
|
|
626
717
|
ArgSchema, OptionSchema, OptionMeta,
|
|
627
718
|
InferArgs, InferOptions,
|
|
628
719
|
ConfigLoader, ParamMetadata, ParamType,
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAiEA,OAAO,EAA2B,KAAK,UAAU,EAAoB,MAAM,YAAY,CAAC;AACxF,OAAO,KAAK,EACX,GAAG,EACH,SAAS,EAET,OAAO,EAUP,aAAa,EACb,MAAM,YAAY,CAAC;AA8NpB,yEAAyE;AACzE,MAAM,WAAW,QAAQ;IACxB,uDAAuD;IACvD,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AA4RD;;;GAGG;AACH,MAAM,WAAW,UAAU;IAC1B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACxC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,KAAK,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IACnC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAChC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC/B;AA2hBD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,SAAS,GAAG,GAAG,CAQhD;AAED;;;GAGG;AACH,wBAAsB,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAWzF;AAED,qEAAqE;AACrE,wBAAgB,UAAU,CACzB,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,UAAU,CAAC,GAC1C,SAAS,CAGX;AAkBD;;;;;GAKG;AACH,wBAAsB,WAAW,CAChC,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE,QAAQ,EACd,OAAO,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClD,OAAO,CAAC,IAAI,CAAC,CAEf;AAED;;;GAGG;AACH,wBAAsB,cAAc,CACnC,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE,MAAM,EAAE,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,OAAO,EAAE,UAAU,GACjB,OAAO,CAAC,IAAI,CAAC,CAEf"}
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import { parseArgs } from 'node:util';
|
|
|
14
14
|
import { JSON_OPTION, JSON_SCHEMA } from './command.js';
|
|
15
15
|
import { loadConfigFile } from './config/merge.js';
|
|
16
16
|
import { assertConfigNames, assertValidConfig, commandValues, } from './config/scope.js';
|
|
17
|
-
import { CommandNotFoundError, describeFailure, failureReason, isDebugEnabled, isUsageError, ProcessExit, RunaError, ValidationError, } from './errors.js';
|
|
17
|
+
import { CommandError, CommandNotFoundError, describeFailure, ERROR_CODES, failureReason, isDebugEnabled, isRunaError, isUsageError, ProcessExit, RunaError, ValidationError, } from './errors.js';
|
|
18
18
|
import { flagName } from './flag-name.js';
|
|
19
19
|
import { commandClaims, findHelpTarget, HELP_TOKENS, renderHelp } from './help.js';
|
|
20
20
|
import { getSchema } from './introspect.js';
|
|
@@ -24,7 +24,7 @@ import { buildParseArgsConfig } from './parse/parseargs-bridge.js';
|
|
|
24
24
|
import { distributeRest, resolveToolInput, resolveValues } from './parse/resolve.js';
|
|
25
25
|
import { walkSchema } from './parse/schema-walker.js';
|
|
26
26
|
import { assertKnownOptions, assertOptionValues } from './parse/strict.js';
|
|
27
|
-
import { resolvePlugins, runPluginCleanup, runPluginSetup, STATIC_GLOBAL_OPTION_SOURCE, topologicalSort, validateCommandNames, validateOptionCollisions, validateOptionNames, } from './plugin.js';
|
|
27
|
+
import { resolvePlugins, runPluginCleanup, runPluginSetup, STATIC_GLOBAL_OPTION_SOURCE, topologicalSort, validateCommandNames, validateCommandTokens, validateOptionAliases, validateOptionCollisions, validateOptionNames, } from './plugin.js';
|
|
28
28
|
import { watchForStop } from './stop.js';
|
|
29
29
|
import { findSuggestion } from './suggest.js';
|
|
30
30
|
import { sendToolReport, takeToolRequest, } from './tool-channel.js';
|
|
@@ -90,19 +90,18 @@ function resolveCommand(commands, tokens) {
|
|
|
90
90
|
throw new RouteNotFoundError([...path, next].join(' '), suggestion, path, false, flag);
|
|
91
91
|
}
|
|
92
92
|
// ─── Output Validation ──────────────────────────────────────
|
|
93
|
-
/** A command returning the wrong shape is an author defect, reported by field. */
|
|
94
|
-
const INVALID_OUTPUT = 'INVALID_OUTPUT';
|
|
95
93
|
/** The key names a command, so that's its name once a run reaches it (ADR-0043). */
|
|
96
94
|
function namedMeta(command, path) {
|
|
97
95
|
return { ...command.meta, name: path.at(-1), path: [...path] };
|
|
98
96
|
}
|
|
97
|
+
/** A command returning the wrong shape is an author defect, reported by field. */
|
|
99
98
|
function assertValidOutput(name, output, result) {
|
|
100
99
|
const parsed = output.safeParse(result);
|
|
101
100
|
if (parsed.success)
|
|
102
101
|
return;
|
|
103
102
|
const issues = parsed.error.issues.map((issue) => ` ${issue.path.join('.') || '(root)'}: ${issue.message}`);
|
|
104
103
|
throw new RunaError(`Command '${name}' returned invalid output:\n${issues.join('\n')}`, {
|
|
105
|
-
code: INVALID_OUTPUT,
|
|
104
|
+
code: ERROR_CODES.INVALID_OUTPUT,
|
|
106
105
|
exitCode: 1,
|
|
107
106
|
});
|
|
108
107
|
}
|
|
@@ -113,7 +112,7 @@ function resultText(command, result, run) {
|
|
|
113
112
|
if (!command.format)
|
|
114
113
|
return undefined;
|
|
115
114
|
throw new RunaError(`Command '${run.name}' declares format but returned nothing.`, {
|
|
116
|
-
code:
|
|
115
|
+
code: ERROR_CODES.MISSING_RESULT,
|
|
117
116
|
exitCode: 1,
|
|
118
117
|
});
|
|
119
118
|
}
|
|
@@ -132,7 +131,11 @@ function printResult(command, result, run) {
|
|
|
132
131
|
function toolFailureReason(error) {
|
|
133
132
|
if (isUsageError(error))
|
|
134
133
|
return 'input';
|
|
135
|
-
|
|
134
|
+
if (isRunaError(error, ERROR_CODES.INVALID_OUTPUT))
|
|
135
|
+
return 'output';
|
|
136
|
+
// By name too: a CommandError from another copy of core isn't an instance of this one
|
|
137
|
+
const wrote = error instanceof CommandError || (isRunaError(error) && error.name === 'CommandError');
|
|
138
|
+
return wrote ? 'command' : 'other';
|
|
136
139
|
}
|
|
137
140
|
// ─── Middleware Composition ─────────────────────────────────
|
|
138
141
|
/**
|
|
@@ -141,8 +144,23 @@ function toolFailureReason(error) {
|
|
|
141
144
|
* Innermost next() calls the command execution function.
|
|
142
145
|
*/
|
|
143
146
|
function composeMiddleware(middlewareList, globalOptions, command, innerFn) {
|
|
147
|
+
if (middlewareList.length === 0)
|
|
148
|
+
return innerFn;
|
|
149
|
+
// Know whether a middleware ran the command, and whether it waited for it:
|
|
150
|
+
// either mistake would otherwise pass in silence
|
|
151
|
+
let running;
|
|
152
|
+
let settled = false;
|
|
153
|
+
const inner = () => {
|
|
154
|
+
running = innerFn();
|
|
155
|
+
running.then(() => {
|
|
156
|
+
settled = true;
|
|
157
|
+
}, () => {
|
|
158
|
+
settled = true;
|
|
159
|
+
});
|
|
160
|
+
return running;
|
|
161
|
+
};
|
|
144
162
|
// Build the chain from inside out
|
|
145
|
-
let chain =
|
|
163
|
+
let chain = inner;
|
|
146
164
|
for (let i = middlewareList.length - 1; i >= 0; i--) {
|
|
147
165
|
const mw = middlewareList[i];
|
|
148
166
|
if (!mw)
|
|
@@ -159,7 +177,22 @@ function composeMiddleware(middlewareList, globalOptions, command, innerFn) {
|
|
|
159
177
|
return mw.handler(ctx);
|
|
160
178
|
};
|
|
161
179
|
}
|
|
162
|
-
|
|
180
|
+
const name = command.path.join(' ');
|
|
181
|
+
const outer = chain;
|
|
182
|
+
return async () => {
|
|
183
|
+
await outer();
|
|
184
|
+
if (!running) {
|
|
185
|
+
throw new RunaError(`A middleware returned without calling next(): '${name}' didn't run. Call await next(), or throw an error to stop the command.`, { code: ERROR_CODES.MIDDLEWARE_DID_NOT_CALL_NEXT, exitCode: 1 });
|
|
186
|
+
}
|
|
187
|
+
if (!settled) {
|
|
188
|
+
// Wait for it, so afterRun and cleanup don't run while it still works
|
|
189
|
+
let cause;
|
|
190
|
+
await running.catch((error) => {
|
|
191
|
+
cause = error;
|
|
192
|
+
});
|
|
193
|
+
throw new RunaError(`A middleware didn't await next(): '${name}' was still running when it returned. Use await next().`, { code: ERROR_CODES.MIDDLEWARE_DID_NOT_AWAIT_NEXT, exitCode: 1, cause });
|
|
194
|
+
}
|
|
195
|
+
};
|
|
163
196
|
}
|
|
164
197
|
function reportDeprecations(input) {
|
|
165
198
|
for (const warning of deprecationWarnings(input)) {
|
|
@@ -434,6 +467,10 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
|
|
|
434
467
|
// A plugin that truly recovered keeps control by setting process.exitCode.
|
|
435
468
|
process.exitCode ??= exitCode;
|
|
436
469
|
}
|
|
470
|
+
else if (stop.exitCode !== undefined) {
|
|
471
|
+
// A stopped run ends by its signal where there is one (ADR-0046)
|
|
472
|
+
stop.leave();
|
|
473
|
+
}
|
|
437
474
|
else {
|
|
438
475
|
process.exit(exitCode);
|
|
439
476
|
}
|
|
@@ -503,6 +540,8 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
|
|
|
503
540
|
// any parsing on every invocation; throws RunaError OPTION_COLLISION.
|
|
504
541
|
validateCommandNames(mutableCommands, rootName);
|
|
505
542
|
validateOptionNames(mutableCommands, mutableGlobalOptions);
|
|
543
|
+
validateOptionAliases(mutableCommands, mutableGlobalMeta);
|
|
544
|
+
validateCommandTokens(mutableCommands);
|
|
506
545
|
validateOptionCollisions(mutableCommands, mutableGlobalOptions, mutableGlobalMeta, globalOptionSources);
|
|
507
546
|
if (config.config || harness?.configValues)
|
|
508
547
|
assertConfigNames(configTarget);
|
|
@@ -588,6 +627,8 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
|
|
|
588
627
|
i++;
|
|
589
628
|
}
|
|
590
629
|
}
|
|
630
|
+
// Once, on the words left without globals: `tool --verbose help` too
|
|
631
|
+
commandArgv.splice(0, commandArgv.length, ...helpAsFlag(mutableCommands, commandArgv, rootName));
|
|
591
632
|
// Now parse ONLY the extracted global tokens with parseArgs
|
|
592
633
|
const globalParseResult = parseArgs({
|
|
593
634
|
...parseArgsConfig,
|
|
@@ -602,7 +643,10 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
|
|
|
602
643
|
optionMeta: mutableGlobalMeta.options,
|
|
603
644
|
});
|
|
604
645
|
// Asking how to use a CLI can't require already knowing its options.
|
|
605
|
-
const
|
|
646
|
+
const route = findRoute(mutableCommands, commandArgv, rootName);
|
|
647
|
+
const answer = findBuiltInAnswer(config, route, commandArgv);
|
|
648
|
+
// Nor a command that doesn't exist: nothing runs, and its 127 is the answer (T-253)
|
|
649
|
+
const leadsNowhere = !toolCall && !route.command && !route.isIncomplete && !route.flag;
|
|
606
650
|
// With a tool call, the command comes from the call, not from argv.
|
|
607
651
|
const { configValues, configSource } = answer && !toolCall ? {} : await loadConfig([]);
|
|
608
652
|
try {
|
|
@@ -620,8 +664,9 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
|
|
|
620
664
|
optionEvents('global', names, parsedOptions, optionSources).forEach(trace);
|
|
621
665
|
}
|
|
622
666
|
catch (err) {
|
|
623
|
-
|
|
624
|
-
|
|
667
|
+
// 'usage' too: with nothing to run, the help on stderr is the answer
|
|
668
|
+
const runsNothing = answer !== undefined || leadsNowhere;
|
|
669
|
+
if (!runsNothing || !(err instanceof ValidationError))
|
|
625
670
|
throw err;
|
|
626
671
|
}
|
|
627
672
|
reportDeprecations({
|
|
@@ -634,6 +679,9 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
|
|
|
634
679
|
// Pass remaining argv (command tokens only) to command parsing
|
|
635
680
|
hookCtx.rawArgs = commandArgv;
|
|
636
681
|
}
|
|
682
|
+
else {
|
|
683
|
+
hookCtx.rawArgs = helpAsFlag(mutableCommands, head, rootName);
|
|
684
|
+
}
|
|
637
685
|
hookCtx.globalOptions = parsedGlobalOptions;
|
|
638
686
|
// ─── onGlobalFlags ────────────────────────────────
|
|
639
687
|
const globalFlagsResult = await emitHook('onGlobalFlags');
|
|
@@ -727,13 +775,16 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
|
|
|
727
775
|
// The ORIGINAL error still drives exit behavior below.
|
|
728
776
|
console.error('runa: onError handler threw:', hookErr);
|
|
729
777
|
}
|
|
730
|
-
failureExitCode = lifecycleError
|
|
778
|
+
failureExitCode = isRunaError(lifecycleError) ? lifecycleError.exitCode : 1;
|
|
731
779
|
// Report BEFORE cleanup: the reason stays visible even if cleanup hangs.
|
|
732
780
|
// `handled` means the plugin owns the output, not that the run succeeded.
|
|
733
781
|
if (!hookCtx.handled) {
|
|
734
782
|
const subject = hookCtx.command?.name ?? config.meta.name;
|
|
735
783
|
// An agent cannot use a hint for people, unless the operator asked for details.
|
|
736
|
-
|
|
784
|
+
// The help that answers it is the command's, unless the command is the CLI.
|
|
785
|
+
const path = hookCtx.command?.path;
|
|
786
|
+
const ownHelp = path && path[0] !== rootName ? [config.meta.name, ...path] : undefined;
|
|
787
|
+
const helpCommand = report ? undefined : (ownHelp?.join(' ') ?? config.meta.name);
|
|
737
788
|
console.error(report && !debug
|
|
738
789
|
? failureReason(lifecycleError, subject)
|
|
739
790
|
: describeFailure(lifecycleError, subject, debug, helpCommand));
|
|
@@ -766,6 +817,43 @@ function resolveCommandFromTree(commands, argv, rootName) {
|
|
|
766
817
|
}
|
|
767
818
|
return resolveCommand(commands, argv);
|
|
768
819
|
}
|
|
820
|
+
/**
|
|
821
|
+
* `help` where a command name goes is `--help` (ADR-0048): `tool help db
|
|
822
|
+
* migrate` is `tool db migrate --help`, and `tool db help` is `tool db --help`.
|
|
823
|
+
* A command named help wins, and inside a command help is a value. With
|
|
824
|
+
* runCLI, the command takes every word, so nothing changes.
|
|
825
|
+
*/
|
|
826
|
+
function helpAsFlag(commands, argv, rootName) {
|
|
827
|
+
if (rootName !== undefined)
|
|
828
|
+
return argv;
|
|
829
|
+
let current = commands;
|
|
830
|
+
for (const [index, token] of argv.entries()) {
|
|
831
|
+
if (token.startsWith('-'))
|
|
832
|
+
return argv;
|
|
833
|
+
if (token === 'help' && !Object.hasOwn(current, 'help')) {
|
|
834
|
+
const target = [...argv.slice(0, index), ...argv.slice(index + 1)];
|
|
835
|
+
let command;
|
|
836
|
+
try {
|
|
837
|
+
command = resolveCommand(commands, target).command;
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
if (!(err instanceof RouteNotFoundError))
|
|
841
|
+
throw err;
|
|
842
|
+
// `tool nope --help` shows the root's help: let the 127 name `nope`
|
|
843
|
+
if (!err.isIncomplete && !err.flag)
|
|
844
|
+
return target;
|
|
845
|
+
}
|
|
846
|
+
// A command that took --help as an option yields -h, if it's free
|
|
847
|
+
const flag = HELP_TOKENS.find((token) => !(command && commandClaims(command, token)));
|
|
848
|
+
return flag ? [...target, flag] : argv;
|
|
849
|
+
}
|
|
850
|
+
const entry = current[token];
|
|
851
|
+
if (!entry || isCommand(entry))
|
|
852
|
+
return argv;
|
|
853
|
+
current = entry;
|
|
854
|
+
}
|
|
855
|
+
return argv;
|
|
856
|
+
}
|
|
769
857
|
function findRoute(commands, argv, rootName) {
|
|
770
858
|
try {
|
|
771
859
|
const { command, path } = resolveCommandFromTree(commands, argv, rootName);
|