@runa-cmd/core 0.2.0-beta.7 → 1.0.0

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 (3) hide show
  1. package/CHANGELOG.md +513 -0
  2. package/README.md +2 -2
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,518 @@
1
1
  # @runa-cmd/core
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - a6bdd11: **Runa 1.0.** The API is stable: from here on, what [`VERSIONING.md`](https://github.com/jrodrigopuca/runa-cmd/blob/main/VERSIONING.md) covers only breaks in a major version.
8
+
9
+ Coming from a `0.2.0` beta, there's nothing new to do in this version. Coming from `0.1.0`, read the entries of the betas below: each one says what changed and how to migrate. The main changes since `0.1.0` are:
10
+
11
+ - Commands, options and arguments are Zod schemas, and the same schema validates the terminal, env, the config file and an MCP tool call.
12
+ - `--help` and `--version` work without plugins; `help` works where a command goes; usage errors exit with 2, and a command that doesn't exist with 127.
13
+ - A config file per CLI, with one shape and errors that name the file and the key.
14
+ - `testCLI()` runs a CLI in the same process, and gives back its exit code, output, result and error.
15
+ - Error codes are a list, `ERROR_CODES`, read with `isRunaError()`.
16
+ - Mistakes in a CLI's own definition stop it at startup, with a message that says how to fix them.
17
+
18
+ `@runa-cmd/mcp` stays at `0.x` for now, since it's where the API may still change: see its changelog.
19
+
20
+ ### Minor Changes
21
+
22
+ - 3d8584a: An alias that can't work stops the CLI at startup, with `INVALID_OPTION_ALIAS` and the way to write it:
23
+
24
+ ```
25
+ Alias 'e' of option 'env' of command 'deploy' must be '-e'.
26
+ ```
27
+
28
+ 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.
29
+
30
+ **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.
31
+
32
+ - 5f03fdf: Production hardening for the first beta. Five behavior changes, one platform change, and a set of additive APIs. Read the migration notes — two of the changes can turn previously-silent misbehavior into hard errors.
33
+
34
+ **Behavior changes (migration guidance included)**
35
+
36
+ 1. **Unknown options are now rejected (exit 2)** — previously a typo'd flag (`--forse`) was silently ignored. Parsing now fails with `Unknown option: --forse` plus a did-you-mean suggestion when a defined flag is within edit distance 3. Escape hatch: `defineCLI({ ..., strictOptions: false })` restores the old lenient behavior one line at a time while you fix call sites.
37
+
38
+ 2. **Empty-string values for number options are now rejected (exit 2)** — previously `--port ""`, an empty env var, or an empty config value coerced to `0` (`Number('') === 0`). All three resolution sources (CLI arg, env var, config file) now fail with `Invalid value for --port: expected number`. No escape hatch: the old behavior fabricated data. A legitimate `0` (e.g. `--port 0` or a config number `0`) still passes.
39
+
40
+ 3. **`helpPlugin()` no longer calls `process.exit(0)`** — after rendering help it short-circuits the lifecycle and lets the process exit naturally with code 0. Terminal users see no difference; embedders (tests, MCP hosts, programmatic `cli.run()` callers) now get the promise resolved and cleanup hooks always run. Restore the old forced exit with `helpPlugin({ exitOnHelp: true })`.
41
+
42
+ 4. **Tokens after `--` are no longer positionals** — everything after the first standalone `--` is delivered verbatim in the new `ctx.rest: string[]` (empty array when no `--` is present), unvalidated and exempt from strict option checking. Global flags after `--` are no longer intercepted (`mycli exec -- --help` runs `exec` instead of rendering help). CLIs that accidentally relied on post-`--` tokens filling schema-validated positionals must drop the `--`.
43
+
44
+ 5. **Global/command option collisions now throw at startup** — if a plugin-registered (or statically declared) global option's name or alias overlaps any command's option name or alias, `cli.run()` fails fast with a `RunaError` (code `OPTION_COLLISION`, exit 1) naming the option, the plugin, and the command — previously the global extractor silently stole the token. Fix: rename one of the two options or drop the conflicting alias.
45
+
46
+ **Platform change**
47
+
48
+ 6. **Node >= 24 required** — `engines` is now `">=24.0.0"` across all packages. Node 18, 20, and 22 are dropped: 18 and 20 are end-of-life, and the toolchain floor (vitest 4) cannot even test them. CI runs the full suite on Linux and Windows under Node 24.
49
+
50
+ **Additive API surface**
51
+
52
+ - `ctx.rest: string[]` on `RunContext` — pass-through tokens after `--`.
53
+ - `CLIConfig.strictOptions?: boolean` (default `true`) — the rollback hatch for change 1.
54
+ - `HelpPluginOptions.exitOnHelp?: boolean` (default `false`) — the rollback hatch for change 3.
55
+ - `SchemaAdapter` / `SchemaDescription` exported as types from `@runa-cmd/core` — Zod v4 introspection is now confined behind an adapter seam (internal refactor, no public behavior change).
56
+ - `OPTION_COLLISION` error code on `RunaError` (change 5).
57
+ - Failures inside `onError` and `cleanup` hook handlers are now logged to stderr (`runa: onError handler threw: ...`) instead of being silently swallowed; the original error still drives the exit code and cleanup still runs.
58
+
59
+ - 277d21d: **Breaking:** a `z.boolean()` option without `.default()` or `.optional()` stops the CLI at startup, with `INVALID_BOOLEAN_OPTION`:
60
+
61
+ ```
62
+ Boolean option 'shout' of command 'hello' has no default: a flag that isn't passed needs one. Use z.boolean().default(false), or .optional().
63
+ ```
64
+
65
+ 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.
66
+
67
+ - 1ddc91c: Boolean options now follow CLI > env > config > default.
68
+
69
+ - **Env and config are honored.** A boolean with `.default()` used to ignore both silently: `MINIFY=true` gave `false`. The schema default no longer reaches `parseArgs`, so it applies last.
70
+ - **Booleans accept `true`, `1`, `false` and `0`,** in any case, from every source. `MINIFY=true` on an optional boolean used to fail, and `--minify=false` used to exit 2.
71
+ - **Any other value is a usage error (exit 2).** Before, `MINIFY=quizas` on a boolean with a default was silently ignored.
72
+
73
+ **Migration:** an env var or config value that was being ignored now takes effect, and an invalid one now fails. Check the boolean options of your CLI that declare `env`.
74
+
75
+ - 1d071a9: Each package now ships its `CHANGELOG.md`, so you can read what changed from `node_modules`.
76
+ - 763a1a2: Authors now choose what an MCP server exposes. `mcpPlugin({ exclude: ['deploy'] })` removes a tool, so it can't be listed or called. Per tool, `tools: { deploy: { description, annotations } }` replaces what the model reads and passes MCP annotations like `destructiveHint` to the client.
77
+
78
+ Annotations are hints, not access control: `exclude` is the only real boundary. An unknown name in `exclude`, an excluded tool with settings, or a tool both read-only and destructive stop the server at startup.
79
+
80
+ - edfb79e: Clearer usage errors, and an API where every field does something.
81
+
82
+ - **Missing values are reported as such.** `--label` without a value now says `Option --label requires a value`. `--tag --force` no longer takes `--force` as the value of `--tag`; it used to drop the flag silently.
83
+ - **Extra positionals on a command without args exit 2** instead of 1, with the usual message.
84
+ - **Suggestions scale with the input,** so `--nope` no longer suggests `--size`, while real typos still get one.
85
+ - **Shorter errors:** `Missing required argument: <content>`, without repeating the name or the description. The same goes for missing options.
86
+ - **Usage errors point to the next step** with `Run '<cli> --help' for usage.`
87
+ - **Invalid `output` names the command and the field** instead of dumping Zod's JSON. It uses the code `INVALID_OUTPUT`.
88
+ - **`deprecated` warns when the option is used,** from the CLI, env or config. The command still runs.
89
+ - **Help omits `[default: false]`.**
90
+
91
+ **Breaking changes in the API**
92
+
93
+ - **`hint` is gone** from `OptionMeta` and `OptionSchema`: it had no effect. Remove it from your `meta.options`.
94
+ - **`PluginMeta.version` is optional,** and the official plugins no longer declare it. Nothing reads it; use `peerDependencies` for compatibility.
95
+
96
+ **Migration:** a value that looks like a registered flag now needs the `=` form: `--label=--force`. If you match error text, note the shorter messages.
97
+
98
+ - 2b3d392: Hooks, middleware and `run()` now get the whole path of the command, as `command.path`. `tag add` and `add` both had the name `add`, so a permissions or audit plugin couldn't tell them apart. Middleware also gets `command`, which it didn't have. `command.name` doesn't change.
99
+ - 4b6b197: `tool: false` keeps a command out of the MCP server, from its definition. It's for commands that only make sense at a terminal.
100
+
101
+ **Breaking:** `completions` is no longer a tool. A shell script means nothing to a model, and it took an `exclude` to hide it. An `exclude` that names it stays valid, and settings for it in `tools` now fail at startup.
102
+
103
+ - 6ae4563: Completions now work the way they're suggested, with any text.
104
+
105
+ `eval "$(tool completions zsh)"` used to fail on every shell start. The script now registers itself with `compdef`, so it works in a `.zshrc` after `compinit`, as well as from a file in `fpath`.
106
+
107
+ In zsh, a `]` in a description used to end it early, and what followed was run as a command when pressing TAB, so `Mode [x]: fast` left the option without completion and could run `mode`. Brackets and colons are now escaped.
108
+
109
+ An enum value with a space, like `very slow`, used to be split in two candidates in bash, zsh and fish. It's now one.
110
+
111
+ CI runs the generated scripts in the real shells.
112
+
113
+ - eed18ff: A value in the config file now reaches one command, and nothing is ignored. A key used to apply to every option with that name: `{"force":true}` turned on `--force` in any command.
114
+
115
+ **Breaking:** the shape of the file changed. A key at the root is a global option or a command, and groups nest. With `runCLI`, options stay at the root.
116
+
117
+ **Migration:** move each option under its command.
118
+
119
+ ```json
120
+ // Before
121
+ { "force": true, "priority": "high" }
122
+
123
+ // After
124
+ { "clear": { "force": true }, "tag": { "add": { "priority": "high" } } }
125
+ ```
126
+
127
+ An unknown key now exits with 2, naming the file and the key, with a suggestion if there's a close one. `$schema` is accepted. An invalid value names the file and the key too, as in `notes.config.json: invalid value for 'list.priority'`, and broken JSON is reported as a problem of the file, not as an unexpected error.
128
+
129
+ A global option can't share a name with a command in a CLI with a config file: it fails at startup with `CONFIG_COLLISION`. Rename one.
130
+
131
+ Global options now read the config file. They used to ignore it without a word, even with a value of the wrong type, so a `store` in the file left the CLI writing somewhere else. They follow the same order as any option: CLI, env, config, default.
132
+
133
+ `meta.options.<key>.configurable: false` keeps an option out of the file, so a guardrail can't be turned off by a file in the current directory. `--mcp` uses it: only the terminal can start a server.
134
+
135
+ `api.getConfigValues(path)` returns the values of one command. `@runa-cmd/mcp` uses it, so a value only keeps the option of that tool away from the model.
136
+
137
+ - 1643f9d: `meta.options.<key>.defaultDescription` sets what help shows as the default. A default built from the home folder can show as `~/.tool/control.sock`, instead of the path of whoever ran `--help`. The command still gets the real value.
138
+ - ee5d2ef: An option that declares `env` never reaches an MCP tool's schema, even if the variable isn't set. A forgotten `API_TOKEN` no longer becomes a parameter a model can ask a person for: the call fails with the usual error.
139
+
140
+ **Migration:** to let the model choose an option that declares `env`, add it to `mcpPlugin({ tools: { deploy: { expose: ['env'] } } })`.
141
+
142
+ - 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:
143
+
144
+ ```ts
145
+ if (isRunaError(error, ERROR_CODES.INVALID_CONFIG)) {
146
+ /* the config file is wrong */
147
+ }
148
+ if (isRunaError(error, "DEPLOY_FAILED")) {
149
+ /* your own code */
150
+ }
151
+ ```
152
+
153
+ `@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`.
154
+
155
+ **Breaking:** three codes are renamed, before they're fixed for 1.0.
156
+
157
+ | Before | Now |
158
+ | ----------------------------------------------------------------- | ----------------------------------- |
159
+ | `CONFIG_INVALID` (a `ConfigError`) | `INVALID_CONFIG` |
160
+ | `OPTION_NAME`, when two keys give the same flag | `OPTION_COLLISION`, as with globals |
161
+ | `OPTION_NAME`, when a boolean `noCache` clashes with `--no-cache` | `INVALID_OPTION_NAME` |
162
+
163
+ **Migration:** compare against the new names, or against `ERROR_CODES`. Messages and exit codes don't change.
164
+
165
+ - 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`.
166
+
167
+ **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.
168
+
169
+ - 1ed131e: A failed MCP tool call now says what happened to the command's effects, so a model doesn't repeat what already ran. The text ends with that sentence, and `_meta['runa/error']` carries `{ class, effect, safeToRetry }`, where `effect` is `none`, `unknown` or `completed`. A command that fails partway reports `unknown`, and an invalid `output` reports that the command already ran.
170
+
171
+ Tool errors no longer include hints for people, like `RUNA_DEBUG` or `--help`, unless the operator sets `RUNA_DEBUG`. A call whose `run()` finished is now an error if the process exits with an error afterwards, and a skipped command reads `did not run` instead of `did not finish`.
172
+
173
+ - e367c5c: Cleanup always runs, and no failure exits silently.
174
+
175
+ - **`cleanup` runs on unhandled errors.** The `cleanup` hook and plugin `cleanup()` used to be skipped because `process.exit` ran first. They now finish, including async ones, before the process exits.
176
+ - **Every failure explains itself.** Typed errors (`RunaError` and subclasses) print as written. Any other error prints `Unexpected error in '<command>': <message>` and how to see more. An error without a message prints `'<command>' failed without an error message (<type>)` instead of nothing.
177
+ - **`RUNA_DEBUG=1`** adds the stack trace, error code and cause.
178
+ - **`ctx.handled` only controls the output.** Marking an error as handled used to exit 0, reporting a failure as success. The run now keeps the error's exit code. A plugin that truly recovered can set `process.exitCode = 0` in `onError`.
179
+
180
+ **Migration:** if your `onError` sets `ctx.handled = true` and your scripts rely on exit 0, set `process.exitCode = 0` there. If you match the exact text of an unexpected error on stderr, it now has a prefix.
181
+
182
+ - 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`.
183
+ - 8b3b8b4: A failed tool call now says what happened, not what to do. An expected `CommandError` used to tell the model "it may have taken effect. Check before retrying", even in a read-only tool, and `safeToRetry` said nothing about how often or when.
184
+
185
+ **Breaking:** `_meta['runa/error']` no longer has `safeToRetry`. It keeps `class` and `effect`.
186
+
187
+ The text only states facts: `The command did not run.`, `The command already ran.` or `The command did not finish.`. A `CommandError` reaches the model as you wrote it, with `effect: "unknown"`.
188
+
189
+ **Migration:** a client that read `safeToRetry` can read `effect`, where `none` means `run()` never started. To guide a model, say it in your error message. Retrying is up to you and whoever approves the calls: if a command fails partway, a model may run it again, so make that safe.
190
+
191
+ - ab1321c: `format` now receives the args and options, as `format(result, { args, options })`, with their types. An option that only changes the display, like `--no-truncate`, no longer has to travel in what `run()` returns, where a model and `--json` would get it too. `format(result)` keeps working.
192
+ - 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.
193
+
194
+ 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.
195
+
196
+ - e92ca91: `--help` and `-h` now work with no plugin: `tool --help` and `tool db migrate --help` show plain help, with the global options that work on that command. Help and completions list `--help`, and `--version` when the CLI has a version. Usage errors always end with `Run '<cli> --help' for usage.`
197
+
198
+ A command that declares `-h` or `--help` keeps it, and the CLI starts, with or without `@runa-cmd/help`. Each flag yields separately, so `--help` still shows help on a command that took `-h`.
199
+
200
+ `getSchema()` now includes `help`, and `version` when the CLI has one, in `globalOptions`.
201
+
202
+ **Migration:** `helpPlugin` no longer registers a `help` global option, so `ctx.globalOptions.help` is gone: a plugin that read it to show its own help now uses `api.replaceHelp(show)`, with the `replaceHelp` capability. `@runa-cmd/help` no longer has `zod` as a peer dependency.
203
+
204
+ - 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.
205
+
206
+ Also:
207
+
208
+ - A usage error points to the help of its command: `Run 'tool build --help' for usage.`, instead of the CLI's.
209
+ - The core help shows the values of a positional enum, as the help plugin does: `target What to build for (web | node)`.
210
+ - `bold()`, `fg()` and the other ANSI helpers of `@runa-cmd/help` say in their docs that they always write codes; `styled()` respects the output.
211
+
212
+ - ce76e51: The help of a command can now be copied and run. It used to show `USAGE deploy [options]`, without the CLI: now it's `tool deploy [options]`, in the built-in help and in the plugin. A variadic shows its dots, as in `<files...>`.
213
+
214
+ `styled()` and the help plugin no longer write escape codes to a pipe or with `NO_COLOR`. Colors were left out, but bold, dim, italic and underline weren't, so `tool --help > file` or `| less` got raw codes. Padding stays: it's layout.
215
+
216
+ `detectColorDepth(stream)` takes the stream to ask about, and the plugin asks about the one it writes to, which is stderr when there was nothing to run.
217
+
218
+ `CommandSchema.commandLine` says what a person types to run a command. It's set for help: `fullPath` doesn't change.
219
+
220
+ - 4d81cd4: Hooks can tell where a run comes from: `ctx.invocation` is `{ surface: 'cli' }`, or `{ surface: 'tool', tool }` for a tool call. Selecting the command and its values is now separate from running it, which doesn't change how a CLI behaves.
221
+ - 9040481: Option keys in camelCase now become kebab-case flags: `darkColor` is `--dark-color`, and `outputURL` is `--output-url`. `run()`, config files and MCP tools keep the key. A key with hyphens stays as written. Help, errors and completions show the flag, which `getSchema()` exposes in the new `flag` field of each option.
222
+
223
+ **Breaking:** `--darkColor` no longer works: it's a usage error that points to `--dark-color`. Scripts that use the camelCase form need the new one.
224
+
225
+ A boolean named `noCache` now stops the CLI at startup, because `--no-cache` already negates `cache`. Two keys that give the same flag do too.
226
+
227
+ **Migration:** rename a boolean like `noCache: z.boolean().default(false)` to `cache: z.boolean().default(true)`. Code that builds an `OptionSchema` by hand now has to include `flag`.
228
+
229
+ - 4ecfba3: A middleware that forgets `next()`, or calls it without `await`, now fails instead of passing in silence:
230
+
231
+ - 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.
232
+ - 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.
233
+
234
+ To skip a command on purpose, throw: `new CommandError('Dry run: would deploy api', { exitCode: 0 })`. `RunaError` also takes a `cause`.
235
+
236
+ - 5a40030: **Upgrading from `0.2.0-beta.0`.** Update all four packages together: `help`, `mcp` and `completions` now require this version of `core`, and npm refuses an older one.
237
+
238
+ **What people typing your CLI will notice**
239
+
240
+ | Before | Now | What to do |
241
+ | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
242
+ | `--darkColor` for a key `darkColor` | `--dark-color`; the camelCase form is a usage error that points to it | Update scripts and docs |
243
+ | Ctrl+C ended the process at once, with no `cleanup` | Runs `cleanup` and exits with 130; `SIGTERM` exits with 143. A command that ignores `signal` is waited for 2 seconds, and a second Ctrl+C leaves at once | Pass `signal` to what your command starts |
244
+ | `tool` with no command, or `tool db` on a group, exited with 127 | Help on stderr, exit 2 | Check scripts that compare the exit code |
245
+ | `--tag --force` took `--force` as the value of `--tag` | `Option --tag requires a value` | Use `--tag=--force` for that value |
246
+ | An extra positional on a command without args exited with 1 | Exit 2 | Check scripts that compare the exit code |
247
+ | Exact text of usage errors | Shorter, and they end with `Run '<cli> --help' for usage.` | Update anything that matches this text |
248
+
249
+ **What you need to change in your code**
250
+
251
+ | Before | Now | What to do |
252
+ | ------------------------------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------- |
253
+ | `runCLI(cmd, argv)` | `runCLI(cmd, { argv })` | Change the call |
254
+ | A boolean named `noCache` | The CLI stops at startup | Declare `cache: z.boolean().default(true)` |
255
+ | `hint` in `meta.options` | Removed: it had no effect | Delete it |
256
+ | `ctx.globalOptions.help` | Gone: `helpPlugin` no longer adds a global option | Use `api.replaceHelp(show)` with the `replaceHelp` capability |
257
+ | `RunContext`, `HookContext` or `OptionSchema` built by hand, as in tests | They now require `signal`, and `flag` | Add them |
258
+ | `onError` with `ctx.handled = true` exited with 0 | Keeps the error's exit code | Set `process.exitCode = 0` if the plugin truly recovered |
259
+ | `defineCLI` with a single command | Still works, and warns once | Move to `runCLI`: it will be removed before 1.0 |
260
+
261
+ **Values that used to be ignored**
262
+
263
+ | Before | Now | What to do |
264
+ | -------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------ |
265
+ | A boolean with `.default()` ignored env and config | They take effect, and an invalid value exits with 2 | Check boolean options that declare `env` |
266
+ | `exec [target] -- ls -la` left `ls` in `ctx.rest` | `ls` fills `target`, and what's left goes to `ctx.rest` | Don't declare a positional for a wrapped command |
267
+ | `z.array()` options always failed | They repeat, and env splits by commas | Nothing, unless a value in env has a comma |
268
+
269
+ **If you expose your CLI over MCP**
270
+
271
+ | Before | Now | What to do |
272
+ | --------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- |
273
+ | Tools ran inside the server | Each call runs your CLI as a new process, about 140 ms in a minimal CLI | Don't count on state between calls |
274
+ | Options with `env` were tool parameters | They never reach the schema | Use `tools.<name>.expose` for those the model should choose |
275
+ | Output had no limit | 100 KiB by default, and a larger returned value fails with `output_too_large` | Set `maxOutput` if you need more |
276
+ | Every command was a tool | Still the default, now with `exclude` | Review which commands an agent should have, plugin commands included |
277
+
278
+ - 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.
279
+ - 8030fbb: With nothing to run, a CLI now shows help on stderr and exits with 2, instead of failing with 127. That covers `tool` on a CLI with several commands, and `tool db` on a group. A name that doesn't exist is still a 127, with its suggestion.
280
+
281
+ Help now uses the same routing as running a command, so what it shows can't differ from what would run. `defineCLI` with a single command shows that command's help, and `--help` and `--version` work even if a required global option is missing.
282
+
283
+ A process stopped by a signal now exits with 130 or 143 also when the lifecycle ended early, as an MCP server does.
284
+
285
+ **Migration:** `api.replaceHelp` handlers receive a second argument, the stream to write to. Write there instead of to `process.stdout`.
286
+
287
+ - 64d3962: `defineCLI` no longer skips the command name when there's only one command. That mode depended on counting commands, so adding a plugin with a command of its own broke `tool hola`, and `tool tool` took a value for the name. It was deprecated with `RUNA_IMPLICIT_SINGLE_COMMAND`.
288
+
289
+ **Breaking:** a `defineCLI` with one command now routes as any other: `tool hola` exits with 127.
290
+
291
+ **Migration:** for a CLI of one command, use `runCLI`.
292
+
293
+ ```ts
294
+ // Before
295
+ await defineCLI({ meta: { name: "tool" }, commands: { tool } }).run();
296
+
297
+ // After
298
+ await runCLI(tool);
299
+ ```
300
+
301
+ If you'd rather keep `defineCLI`, write the command name: `tool tool hola`.
302
+
303
+ - 30e962f: A command now has one name, its key, written the same way everywhere. `meta.name` used to be required and do nothing in `defineCLI`: `commands: { withOutput }` ran as `withOutput`, whatever `meta.name` said.
304
+
305
+ **Breaking:** a key only takes lowercase letters, numbers and dashes, and anything else fails at startup with `INVALID_COMMAND_NAME`, saying the right form. `meta.name` has to match the key if you write it. An MCP tool takes the name of its command, so a renamed command is a renamed tool.
306
+
307
+ **Migration:** rename the keys that don't fit.
308
+
309
+ ```ts
310
+ // Before
311
+ commands: { withOutput }
312
+
313
+ // After
314
+ commands: { 'with-output': withOutput }
315
+ ```
316
+
317
+ `meta.name` is now optional in `defineCLI`, and `run()` and hooks still get `command.name`. `runCLI` requires it, because it names the CLI.
318
+
319
+ Options don't change: `run()` reads them by key, so `darkColor` is still `--dark-color`.
320
+
321
+ - 597a8fd: Inside a command, two options that claim the same token stop the CLI at startup with `OPTION_COLLISION`, as its docs always said:
322
+
323
+ ```
324
+ Options 'extra' and 'env' of command 'deploy' both use '-e'.
325
+ Option 'env' of command 'deploy' has '--env' as an alias, which is already its flag. Remove the alias.
326
+ ```
327
+
328
+ 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.
329
+
330
+ **Breaking:** a CLI with one of those aliases doesn't start until it's fixed. None of them worked as written.
331
+
332
+ - 03f4e76: In MCP, whoever starts the server beats the model.
333
+
334
+ - **Fixed values leave the tool's schema.** An option with a value from an env var or the config file is hidden, and if a model sends it anyway, it's dropped. A required option that already has a value no longer asks the model for anything.
335
+ - **`mcpPlugin({ tools })` adjusts this per tool:** `expose` leaves an option to the model, and `serverOnly` makes the server refuse to start without a value.
336
+ - **Fixed values are redacted** from the stdout and stderr returned to the model, when they have 8 characters or more. So are the global options the server started with. What `run()` returns is left as it is.
337
+ - **`api.getConfigValues()`** lets a plugin read the config file of the CLI.
338
+
339
+ **Migration:** an option an agent used to set may disappear from its tool if env or config give it a value. Use `tools.<name>.expose` to keep it.
340
+
341
+ - 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:
342
+
343
+ ```
344
+ mcpPlugin: tool 'add' can't run until the operator sets NOTES_FILE (--file).
345
+ ```
346
+
347
+ 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.
348
+
349
+ **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.
350
+
351
+ **Migration:** to have the model read your message as is, throw a `CommandError`.
352
+
353
+ - 1b0d6c1: `z.array()` options now work: `--tag a --tag b` gives `['a', 'b']`, and each element is converted to its type, so `z.array(z.number())` gives numbers. Before, they always failed, even with one value.
354
+
355
+ Env values are split by commas and trimmed, so `TAGS=a, b` gives `['a', 'b']` and `TAGS=` gives `[]`. Config files use a JSON array. Each source replaces the ones below it, without adding up.
356
+
357
+ Help shows `--tag <string>...`. zsh keeps offering the option, fish says it can be repeated, and bash and fish offer the values of a `z.array(z.enum(...))`. `getSchema()` adds `itemType` to these options.
358
+
359
+ - d31ad33: A positional now gets the type of its schema. `args: { id: z.number() }` used to fail at the terminal with `expected number, received string`, although TypeScript said it was a number and it worked through MCP.
360
+
361
+ Numbers and booleans convert as in an option, in each item of a variadic too, and an empty string is still never read as 0. An invalid value says `Invalid value for <id>: expected number`.
362
+
363
+ **Migration:** if you used `z.coerce.number()` as a workaround, `z.number()` is enough now.
364
+
365
+ - b158ee6: What's left after `--` is now a usage error, unless the command asks for it. `tool a -- b` used to ignore `b` with exit 0, so data got lost in silence. `--` still fills empty positionals.
366
+
367
+ **Breaking:** a command that reads `ctx.rest` has to declare `rest: true`. Without it, `ctx.rest` is empty and the extra tokens exit with 2 and `Unexpected positional argument`.
368
+
369
+ **Migration:** add `rest: true` to commands that wrap another program. If you validated `ctx.rest` by hand to reject extras, delete that code.
370
+
371
+ `meta.options.<key>.valueName` names the value in help: `valueName: 'hex'` shows `--color <hex>` instead of `--color <string>`. It works in the built-in help and in `@runa-cmd/help`.
372
+
373
+ Clearer errors for mistyped flags:
374
+
375
+ - `--no-color` on an unknown option now says `Unknown option: --no-color`, not `--color`.
376
+ - `--no-style` on an option that takes a value now says it can't be negated.
377
+ - A typo in `--help` or `--version` gets a suggestion, and so does `-V`, which points to `--version`.
378
+
379
+ - f15d0cd: What a command returns now reaches the terminal. It used to reach MCP tools only, so at the terminal a command that returned data showed nothing and exited with 0.
380
+
381
+ A string goes to stdout as it is, and anything else as JSON. `run()` can return a value without `output`: this used to be a type error, even though it worked.
382
+
383
+ `format: (result) => string` says what a person reads. It receives the type of what `run()` returned, doesn't run for a tool, and gives the command a `--json` flag that prints the data. A command with `format` that returns nothing fails with `MISSING_RESULT`.
384
+
385
+ **Breaking:** a command that prints and also returns shows both. A command with `format` can't declare an option called `json`, and no global option can take that name while such a command exists.
386
+
387
+ **Migration:** move the printing to `format`, and delete your own `json` option.
388
+
389
+ ```ts
390
+ // Before
391
+ options: { json: z.boolean().default(false) },
392
+ run({ options }) {
393
+ const status = read();
394
+ console.log(options.json ? JSON.stringify(status) : `Port ${status.port}`);
395
+ return status;
396
+ },
397
+
398
+ // After
399
+ run: () => read(),
400
+ format: (status) => `Port ${status.port}`,
401
+ ```
402
+
403
+ Progress belongs on stderr, with `console.error`, so `--json` stays clean to pipe.
404
+
405
+ - 81ac2d6: A CLI made of one command is now first class, with `--version` and a POSIX `--`.
406
+
407
+ - **`runCLI(command, options?)` takes plugins,** plus `middleware`, `config`, `globalOptions`, `globalMeta` and `strictOptions`. Help, MCP and completions all work with it.
408
+ - **`runCLI` runs in an explicit root mode.** A value equal to the command name is data: `tool tool` passes `"tool"`. A subcommand added by a plugin routes by name, and `--` passes that name as data.
409
+ - **`--version` is built in** when `meta.version` is set, in `defineCLI` and `runCLI`. It has no short alias, and a command's own `--version` option wins.
410
+ - **`--` fills empty positionals,** as POSIX operands. A variadic counts as one, and what's left goes to `ctx.rest`.
411
+ - **`defineCLI` with one command is deprecated** when it routes implicitly. It keeps working and warns once. It will be removed before 1.0.
412
+
413
+ `CLISchema` gains an optional `root`, and `RunCLIOptions` is exported.
414
+
415
+ **Migration:**
416
+
417
+ - `runCLI(cmd, argv)` is now `runCLI(cmd, { argv })`.
418
+ - With `exec [target] -- ls -la`, `ls` now fills `target`. Commands that wrap another one should declare no positional for it, or read it from `ctx.rest`.
419
+ - With a variadic and nothing before `--`, as in `lint -- --fix`, the token now lands in the variadic instead of `ctx.rest`.
420
+
421
+ - 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.
422
+
423
+ 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.`.
424
+
425
+ - 374b25f: Stopping a run now reaches everything it started. `run()` and hooks receive `signal`, an `AbortSignal` that fires on `SIGINT`, `SIGTERM` or a cancelled tool. `cleanup` runs in all those cases, and a CLI exits with 130 or 143.
426
+
427
+ **Behavior change:** Ctrl+C no longer ends a CLI at once. A command that ignores `signal` stops being waited for after 2 seconds, the process ends after 5 at most, and a second Ctrl+C leaves at once. A command that blocks the event loop can't react until it yields: `Ctrl+\` still ends it.
428
+
429
+ **Migration:** code that builds a `RunContext` or a `HookContext` by hand, as in unit tests, now has to include `signal`.
430
+
431
+ In MCP, cancelling a call or closing the server ends the child and what it started, and a child stops by itself if the server dies. Tool output is limited to 100 KiB by default: longer output keeps its start and end with a notice, and `maxOutput` changes the limit globally or per tool. A returned value over the limit fails with `output_too_large`.
432
+
433
+ - 0b62ebb: `testCLI()`, from `@runa-cmd/core/testing`, runs a CLI or a command through its real lifecycle without ending the process. A failing command used to kill the test runner, and capturing its output took a page of patches.
434
+
435
+ It returns the exit code, stdout, stderr, what `run()` returned, the original error, and a `trace` of what happened: where each value came from, which hooks ran and for how long, and everything written, in order. It takes `env` and `config` instead of reading the machine, and several runs can share a process without mixing their output, with `node --test` and with Vitest. A value from env stays out of `trace`, because it can be a secret.
436
+
437
+ `RUNA_DEBUG=1` now shows those steps on stderr while they happen. It never prints a value.
438
+
439
+ - 25ce70b: MCP tools now run as a child process of your own CLI, so a tool is the same command a person runs.
440
+
441
+ - **The whole lifecycle applies:** env vars, config files, startup global options, hooks and middleware. A middleware can reject a call.
442
+ - **Commands can print.** What they write no longer reaches the protocol. A returned value is the result, and without one, stdout is.
443
+ - **Calls are isolated.** Simultaneous calls don't mix, and a `process.exit` in a command only ends that call.
444
+ - **No false success.** If a command never finished, as when a middleware skips it, the tool returns an error.
445
+ - **Any input travels intact,** including arrays, objects and large values, and validation runs once, in the same place as the CLI.
446
+ - **`cleanup` runs when the server closes,** not before the first call.
447
+ - **The server checks at startup that it can run itself.** `mcpPlugin({ selfCommand })` sets how, if the check fails.
448
+
449
+ **Migration:** each call is a new process, so don't count on state shared between calls. A call now takes about 140 ms in a minimal CLI, plus whatever your CLI takes to start. `exports` of `@runa-cmd/core` gains `./internal`, which is not public API.
450
+
451
+ - b7a504b: A flag where a command should be is now a usage error. `tool --bogus` used to say `Command not found: '--bogus'` and exit with 127.
452
+
453
+ **Breaking:** it now says `Unknown option: --bogus` and exits with 2, with the same suggestions as a flag after a command, so `--verison` points to `--version`. A script that checked for 127 here has to check for 2. An unknown command still exits with 127.
454
+
455
+ - 52ef44c: A command of `defineCLI` with `meta.version` stops the CLI at startup, with `INVALID_COMMAND_VERSION`:
456
+
457
+ ```
458
+ Command 'deploy' declares meta.version: 'version' only applies with runCLI; set it in defineCLI's meta.
459
+ ```
460
+
461
+ 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.
462
+
463
+ **Breaking:** a CLI with a command like that doesn't start until the version moves to `defineCLI`'s `meta`.
464
+
465
+ ### Patch Changes
466
+
467
+ - 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:
468
+
469
+ ```
470
+ my-tool.config.json: invalid value for 'clear.limit': must be greater than 0
471
+ my-tool.config.json: unknown key 'clear.limt'. Did you mean 'limit'?
472
+ my-tool.config.json: invalid JSON: Unexpected token '}', ..."force":tru}}" is not valid JSON
473
+ ```
474
+
475
+ 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"`.
476
+
477
+ - 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.
478
+ - ff89521: Help and errors without noise:
479
+
480
+ - A boolean whose default is `true` shows as `--[no-]cache`, so people see how to turn it off.
481
+ - An empty list as a default no longer shows `(default: )`.
482
+ - 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`.
483
+ - Shell completions offer `--no-x` only for a boolean that starts on: `--no-help`, `--no-version` and `--no-mcp` are gone.
484
+ - The custom renderer example of the help README compiles, and handles the help of a command too.
485
+
486
+ - 2d372e1: The built-in help no longer shows `(default: false)`, as `@runa-cmd/help` already didn't.
487
+ - 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`.
488
+ - 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.
489
+ - 33d14dd: A missing required option that declares `env` names its variable: `Missing required option: --database, or set TOOL_DATABASE_URL`. It names the variable, never a value.
490
+ - 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.
491
+ - 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:
492
+
493
+ ```
494
+ The operator of this server has to fix 'deploy.replicas' in the config file. It isn't a value you can send.
495
+ ```
496
+
497
+ The server also says it at startup, on stderr, with the reason, and keeps serving the other tools:
498
+
499
+ ```
500
+ mcpPlugin: tool 'deploy' can't run until the operator fixes DEPLOY_ENV (--env): expected one of: staging, production.
501
+ ```
502
+
503
+ Values are checked with the rules the CLI uses, so `"5"` for a number in the config file is fine.
504
+
505
+ - 1304aa0: A wrong value is repeated in its error, as commander and oclif do, so you see what you typed:
506
+
507
+ ```
508
+ Invalid value 'abc' for --times: expected number
509
+ Invalid value 'mars' for --env. Expected one of: staging, production
510
+ ```
511
+
512
+ 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.
513
+
514
+ - 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 })`.
515
+
3
516
  ## 0.2.0-beta.7
4
517
 
5
518
  ### Minor Changes
package/README.md CHANGED
@@ -9,10 +9,10 @@ Define your CLI with Zod schemas and get full type inference, validation, and me
9
9
  ## Install
10
10
 
11
11
  ```bash
12
- pnpm add @runa-cmd/core@beta zod
12
+ pnpm add @runa-cmd/core zod
13
13
  ```
14
14
 
15
- > Requires Node 24+. Zod v4 is a required peer dependency. Without `@beta`, npm installs the older `0.1.0`.
15
+ > Requires Node 24+. Zod v4 is a required peer dependency.
16
16
 
17
17
  ## Quick Start
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runa-cmd/core",
3
- "version": "0.2.0-beta.7",
3
+ "version": "1.0.0",
4
4
  "description": "Schema-driven, TypeScript-first CLI engine — commands, parsing, plugins, middleware. Zero runtime deps.",
5
5
  "license": "MIT",
6
6
  "type": "module",