@runa-cmd/core 0.2.0-beta.6 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +96 -9
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +98 -14
  5. package/dist/cli.js.map +1 -1
  6. package/dist/command.d.ts +1 -1
  7. package/dist/command.d.ts.map +1 -1
  8. package/dist/command.js +3 -3
  9. package/dist/command.js.map +1 -1
  10. package/dist/config/loader.d.ts +2 -2
  11. package/dist/config/loader.d.ts.map +1 -1
  12. package/dist/config/loader.js +8 -5
  13. package/dist/config/loader.js.map +1 -1
  14. package/dist/config/scope.js +2 -2
  15. package/dist/config/scope.js.map +1 -1
  16. package/dist/errors.d.ts +83 -0
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +114 -11
  19. package/dist/errors.js.map +1 -1
  20. package/dist/help.d.ts.map +1 -1
  21. package/dist/help.js +29 -8
  22. package/dist/help.js.map +1 -1
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal.d.ts +1 -0
  28. package/dist/internal.d.ts.map +1 -1
  29. package/dist/internal.js +1 -0
  30. package/dist/internal.js.map +1 -1
  31. package/dist/parse/resolve.d.ts +7 -0
  32. package/dist/parse/resolve.d.ts.map +1 -1
  33. package/dist/parse/resolve.js +140 -25
  34. package/dist/parse/resolve.js.map +1 -1
  35. package/dist/parse/schema-adapter.d.ts +2 -0
  36. package/dist/parse/schema-adapter.d.ts.map +1 -1
  37. package/dist/parse/schema-adapter.js.map +1 -1
  38. package/dist/parse/variadic.js +3 -3
  39. package/dist/parse/variadic.js.map +1 -1
  40. package/dist/plugin.d.ts +14 -0
  41. package/dist/plugin.d.ts.map +1 -1
  42. package/dist/plugin.js +111 -20
  43. package/dist/plugin.js.map +1 -1
  44. package/dist/testing.d.ts +7 -0
  45. package/dist/testing.d.ts.map +1 -1
  46. package/dist/testing.js +13 -0
  47. package/dist/testing.js.map +1 -1
  48. package/dist/tool-channel.d.ts +5 -1
  49. package/dist/tool-channel.d.ts.map +1 -1
  50. package/dist/tool-channel.js.map +1 -1
  51. package/dist/trace.d.ts +24 -0
  52. package/dist/trace.d.ts.map +1 -1
  53. package/dist/trace.js.map +1 -1
  54. package/dist/types.d.ts +164 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js +14 -0
  57. package/dist/types.js.map +1 -1
  58. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,157 @@
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
+
3
155
  ## 0.2.0-beta.6
4
156
 
5
157
  ### Minor Changes
@@ -8,6 +160,8 @@
8
160
 
9
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.`.
10
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
+
11
165
  ## 0.2.0-beta.5
12
166
 
13
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[]` | Short flags, e.g. `['-e']` (must include dash prefix) |
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, with the global options that work on it.
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
- Nothing is ignored. An unknown key exits with 2, naming the file and the key, and `$schema` is the only exception. 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.
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.
@@ -487,7 +565,7 @@ On `SIGINT` or `SIGTERM`, Runa aborts `ctx.signal` and runs `cleanup`, and the p
487
565
 
488
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.`.
489
567
 
490
- 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`. 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.
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.
491
569
 
492
570
  Pass `signal` to whatever the command starts, so the stop reaches it too:
493
571
 
@@ -502,7 +580,9 @@ A command that blocks the event loop can't react to a signal until it yields. Us
502
580
  ### Constants
503
581
 
504
582
  ```ts
505
- 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
506
586
 
507
587
  HOOK_NAMES.BEFORE_PARSE // 'beforeParse'
508
588
  HOOK_NAMES.ON_GLOBAL_FLAGS // 'onGlobalFlags'
@@ -521,7 +601,7 @@ PARAM_TYPES.ARRAY // 'array'
521
601
 
522
602
  ### Testing: `testCLI()`
523
603
 
524
- 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.
525
605
 
526
606
  ```ts
527
607
  import { testCLI } from '@runa-cmd/core/testing';
@@ -556,6 +636,8 @@ A value from env is left out, because it can be a secret. The shape of `trace` i
556
636
 
557
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`.
558
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
+
559
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.
560
642
 
561
643
  A `process.exit` inside a command ends the run with its code and skips `cleanup`, as in a real run.
@@ -599,6 +681,11 @@ cleanup ← (Runs on errors and on signals, within the stop deadline)
599
681
  On error → onError hook fires before cleanup
600
682
  ```
601
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
+
602
689
  ## Value Resolution
603
690
 
604
691
  Options are resolved in this priority order:
@@ -626,7 +713,7 @@ import type {
626
713
  HookContext, HookHandler, HookName,
627
714
  Middleware, MiddlewareContext, MiddlewareFn,
628
715
  PluginAPI, PluginCapabilities, PluginConfig, PluginMeta,
629
- RunContext, RunReturn,
716
+ RunContext, RunReturn, RunResult, FormatContext,
630
717
  ArgSchema, OptionSchema, OptionMeta,
631
718
  InferArgs, InferOptions,
632
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":"AA4DA,OAAO,EAA2B,KAAK,UAAU,EAAoB,MAAM,YAAY,CAAC;AACxF,OAAO,KAAK,EACX,GAAG,EACH,SAAS,EAET,OAAO,EAUP,aAAa,EACb,MAAM,YAAY,CAAC;AAoLpB,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;AA2eD;;;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"}
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: 'MISSING_RESULT',
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
- return error instanceof RunaError && error.code === INVALID_OUTPUT ? 'output' : 'other';
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 = innerFn;
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
- return chain;
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)) {
@@ -507,6 +540,8 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
507
540
  // any parsing on every invocation; throws RunaError OPTION_COLLISION.
508
541
  validateCommandNames(mutableCommands, rootName);
509
542
  validateOptionNames(mutableCommands, mutableGlobalOptions);
543
+ validateOptionAliases(mutableCommands, mutableGlobalMeta);
544
+ validateCommandTokens(mutableCommands);
510
545
  validateOptionCollisions(mutableCommands, mutableGlobalOptions, mutableGlobalMeta, globalOptionSources);
511
546
  if (config.config || harness?.configValues)
512
547
  assertConfigNames(configTarget);
@@ -592,6 +627,8 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
592
627
  i++;
593
628
  }
594
629
  }
630
+ // Once, on the words left without globals: `tool --verbose help` too
631
+ commandArgv.splice(0, commandArgv.length, ...helpAsFlag(mutableCommands, commandArgv, rootName));
595
632
  // Now parse ONLY the extracted global tokens with parseArgs
596
633
  const globalParseResult = parseArgs({
597
634
  ...parseArgsConfig,
@@ -606,7 +643,10 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
606
643
  optionMeta: mutableGlobalMeta.options,
607
644
  });
608
645
  // Asking how to use a CLI can't require already knowing its options.
609
- const answer = findBuiltInAnswer(config, findRoute(mutableCommands, commandArgv, rootName), commandArgv);
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;
610
650
  // With a tool call, the command comes from the call, not from argv.
611
651
  const { configValues, configSource } = answer && !toolCall ? {} : await loadConfig([]);
612
652
  try {
@@ -624,8 +664,9 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
624
664
  optionEvents('global', names, parsedOptions, optionSources).forEach(trace);
625
665
  }
626
666
  catch (err) {
627
- const isQuestion = answer === 'help' || answer === 'version';
628
- if (!isQuestion || !(err instanceof ValidationError))
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))
629
670
  throw err;
630
671
  }
631
672
  reportDeprecations({
@@ -638,6 +679,9 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
638
679
  // Pass remaining argv (command tokens only) to command parsing
639
680
  hookCtx.rawArgs = commandArgv;
640
681
  }
682
+ else {
683
+ hookCtx.rawArgs = helpAsFlag(mutableCommands, head, rootName);
684
+ }
641
685
  hookCtx.globalOptions = parsedGlobalOptions;
642
686
  // ─── onGlobalFlags ────────────────────────────────
643
687
  const globalFlagsResult = await emitHook('onGlobalFlags');
@@ -731,13 +775,16 @@ async function runCLILifecycle(config, argv, { rootName, toolCall, report, harne
731
775
  // The ORIGINAL error still drives exit behavior below.
732
776
  console.error('runa: onError handler threw:', hookErr);
733
777
  }
734
- failureExitCode = lifecycleError instanceof RunaError ? lifecycleError.exitCode : 1;
778
+ failureExitCode = isRunaError(lifecycleError) ? lifecycleError.exitCode : 1;
735
779
  // Report BEFORE cleanup: the reason stays visible even if cleanup hangs.
736
780
  // `handled` means the plugin owns the output, not that the run succeeded.
737
781
  if (!hookCtx.handled) {
738
782
  const subject = hookCtx.command?.name ?? config.meta.name;
739
783
  // An agent cannot use a hint for people, unless the operator asked for details.
740
- const helpCommand = report ? undefined : config.meta.name;
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);
741
788
  console.error(report && !debug
742
789
  ? failureReason(lifecycleError, subject)
743
790
  : describeFailure(lifecycleError, subject, debug, helpCommand));
@@ -770,6 +817,43 @@ function resolveCommandFromTree(commands, argv, rootName) {
770
817
  }
771
818
  return resolveCommand(commands, argv);
772
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
+ }
773
857
  function findRoute(commands, argv, rootName) {
774
858
  try {
775
859
  const { command, path } = resolveCommandFromTree(commands, argv, rootName);