Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,62 @@ Nerdbank.GitVersioning at pack time; this file groups changes by theme instead o

## Unreleased

### Added — execution outcomes and exit-code policy

- Every run that reaches the core pipeline now ends in a structured `ReplExecutionOutcome` whose
`ReplExecutionOutcomeKind` distinguishes `Success`, `Help`, `UsageError`, `BindingError`,
`HandlerError`, `HandlerExitCode`, `HandlerException`, `Cancelled`, `Interrupted` (reserved for
process-signal bridges), and `FrameworkError`. The kind is mapped to an integer by the new
`ReplOptions.ExitCodes` (`ExitCodeOptions`) table, then passed to the optional
`ExitCodes.Resolver` hook whose return value is the final exit code. An explicit `Results.Exit(n)`
keeps its code verbatim (`HandlerExitCode`) but is still visible to the resolver. See
`docs/execution-pipeline.md` (stage 12) and `docs/configuration-reference.md`.
- `ExitCodes.Cancelled` (`int?`) turns a caller-token cancellation into an exit code instead of
letting `OperationCanceledException` escape `RunAsync`. It is unset by default, which preserves
the existing throwing behaviour.
- `ReplExecutionContext.Result` exposes the handler's return value to middleware registered with
`app.Use(...)`: readable and replaceable after `await next()`, settable by a short-circuiting
middleware. `ReplNext` and the `Use` signature are unchanged.

### Changed — breaking: framework exit codes

- Framework refusals now exit `2` instead of `1`: unknown command, ambiguous prefix, invalid global
or command option, option collision, context validation failure, unknown `--output` format,
ambient-command misuse in one-shot mode (`exit` while disabled, `..`, `complete` without
`--target`), help that cannot be rendered (`UsageError`), and arguments that cannot be bound,
converted, or resolved from context/services (`BindingError`). Handler
failures (`Results.Error`/`Validation`/`NotFound`, exceptions) still exit `1`, help and success
still exit `0`. Set `ExitCodes.UsageError`/`BindingError` back to `1` to restore the old numbers.
The interactive loop reports the same resolved codes in shell-integration `D;<code>` marks.
- Every `Run`/`RunAsync` overload now checks the caller's `CancellationToken` before doing any work: a
token that is already cancelled throws `OperationCanceledException` (or returns
`ExitCodes.Cancelled` when mapped). Previously only `CoreReplApp.RunAsync` performed that check;
the `ReplApp` overloads let a token-ignoring handler run to completion.

### Compatibility notes — exit codes

- MCP tool calls (nested sub-invocations) always use the built-in exit-code defaults and ignore
`ExitCodes.Resolver`; they only test for non-zero, so `IsError` is unaffected by the policy. The
agent-visible failure text now reads "exit code 2" for usage and binding refusals.
- Hosted-service start/stop failures in `ReplApp.RunAsync` (with `HostedServiceLifecycle` enabled)
still return `1` directly and do not pass through `ExitCodes`; routing them through the policy is
deferred until the pending process-signal work in the same file lands.
- `Repl.Testing`'s per-command timeout still surfaces as `TimeoutException` when the app under test
maps `ExitCodes.Cancelled`: the handle checks its own timeout token after the run instead of
relying on the exception escaping.
- A handler-thrown `InvalidOperationException` is still rendered as a validation message, but it
is classified `HandlerException` (not `BindingError`); only exceptions raised while binding
arguments are `BindingError`.
- A handler that returns a bare `int` (or any scalar) is unchanged: the value is rendered as data
and the run is a `Success`. The documentation previously implied otherwise; `Results.Exit(n)`
remains the only return-value route to an explicit exit code.
- `Repl.Testing`'s `CommandExecution.ExitCode` follows the configured policy, so application test
suites asserting `1` for unknown commands or invalid options need to expect `2` (or configure
`ExitCodes`).
- `ReplExecutionOutcomeKind.Interrupted` has no table entry and is never produced by the core
pipeline; it is reserved for the process-signal bridge so SIGINT/SIGTERM outcomes can flow through
the same resolver.

### Added — option visibility

- `.Hidden(bool isHidden = true)` on the option builder (`WithOption(name, option => option.Hidden())`)
Expand Down
28 changes: 28 additions & 0 deletions docs/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,34 @@ app.Map("delete {id:int}", handler)
.WithAnswer("confirm", "bool", "Confirm the deletion");
```

## Make exit codes scriptable

A headless tool — one command per process, spawned by CI or by a parent program — is judged by its
exit code. Repl classifies every run into a `ReplExecutionOutcomeKind` and maps it through
`ReplOptions.ExitCodes`, so the contract is configured once instead of being re-implemented in every
handler:

```csharp
app.Options(options =>
{
options.ExitCodes.Help = 3; // a bare invocation printed help and did no work
options.ExitCodes.UsageError = 64; // EX_USAGE: the caller typed it wrong
options.ExitCodes.Cancelled = 130; // return 128+SIGINT instead of throwing
});
```

- Keep usage errors (`2` by default) distinct from handler failures (`1`) so a pipeline can tell
"the invocation was wrong" from "the tool broke".
- Map `Help` to a non-zero code when a bare invocation must not pass a CI step that forgot its
arguments.
- Use `Results.Exit(code)` for codes a specific command owns; use `ExitCodes.Resolver` to apply an
organisation-wide convention to every final outcome — it also sees the `Result` object and the
`Exception`, so it can map on an error code rather than on a message.
- A handler's `int` return value is **data**, rendered like any other value; it never becomes the
exit code.
- Set `ExitCodes.Cancelled` when the caller owns a `CancellationToken` and wants an integer rather
than an `OperationCanceledException` escaping `RunAsync`.

## Write deterministic tests

Use `ReplTestHost` for integration tests with typed results:
Expand Down
17 changes: 15 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,9 +372,10 @@ Handlers can return any type. The framework renders the return value through the
| `ReplPage<T>` | Rendered as the current page plus `PageInfo`; JSON uses `{ items, pageInfo }` |
| `IReplResult` | Structured result with kind prefix (`Results.Ok`, `Error`, `NotFound`...) |
| `ReplNavigationResult` | Renders payload and navigates scope (`Results.NavigateUp`, `NavigateTo`) |
| `IExitResult` | Renders optional payload and sets process exit code (`Results.Exit`) |
| `IExitResult` | Renders optional payload and sets the process exit code verbatim (`Results.Exit`); the only return type that carries an exit code — bypasses the `ReplOptions.ExitCodes` table, still visible to `ExitCodes.Resolver` |
| `EnterInteractiveResult` | Renders optional payload and enters interactive REPL mode (`Results.EnterInteractive`) |
| `void` / `null` | No output |
| `int` and other scalars | Rendered as data like any other value — **never** interpreted as an exit code (`int Count() => 3` prints `3`, exits `0`) |

### Result factory helpers

Expand Down Expand Up @@ -411,10 +412,22 @@ Tuple semantics:
- each element is rendered as a separate output block
- navigation results (`NavigateUp`, `NavigateTo`) are only applied on the **last** element
- `EnterInteractive` as the last element enters interactive mode after rendering prior elements
- exit code is determined by the last element
- the execution outcome (and therefore the exit code) is determined by the last element
- null elements are silently skipped
- nested tuples are not flattened — use a flat tuple instead

### Exit codes

The process exit code is not read off the handler's return value; it is selected from the
structured outcome of the whole run (`ReplExecutionOutcomeKind`) through `ReplOptions.ExitCodes`.
By default a success or help invocation exits `0`, framework refusals — unknown command, invalid
option, unbindable argument — exit `2`, and a handler failure (`Results.Error`, an exception)
exits `1`. `Results.Exit(code)` is the only way for a handler to pick a code directly, and
`ExitCodes.Resolver` is the one place an application can remap every final outcome. A middleware
registered with `app.Use(...)` can read or replace the handler's return value through
`ReplExecutionContext.Result` after awaiting `next()`. See
[execution pipeline — exit code](execution-pipeline.md#12-exit-code) for the full table.

## Paging large results

Handlers that may return large result sets can request `IReplPagingContext`:
Expand Down
18 changes: 18 additions & 0 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,24 @@ Accessed via `ReplOptions.Capabilities`.

- `SupportsAnsi` (`bool`, default: `true`) — Declare whether the terminal supports ANSI escape sequences.

## ExitCodeOptions

Accessed via `ReplOptions.ExitCodes`. Maps each `ReplExecutionOutcomeKind` to the process exit code
of a top-level run; nested MCP sub-invocations always use the defaults and skip the resolver.

- `Success` (`int`, default: `0`) — Success-like handler result or clean interactive exit.
- `Help` (`int`, default: `0`) — `--help`, a bare invocation that prints help, scoped-context help.
- `UsageError` (`int`, default: `2`) — Unknown command, ambiguous prefix, invalid option, context validation failure, unknown output format.
- `BindingError` (`int`, default: `2`) — A handler argument could not be bound: token conversion failed or was missing, or a binder-resolved value (context value, `[FromServices]` dependency, typed global options service) was unavailable.
- `HandlerError` (`int`, default: `1`) — Handler returned an error-like `IReplResult`.
- `HandlerException` (`int`, default: `1`) — Handler or middleware threw.
- `Cancelled` (`int?`, default: `null`) — Caller-token cancellation. `null` rethrows the `OperationCanceledException`; a value (typically `130`) is returned instead.
- `FrameworkError` (`int`, default: `1`) — Incompatible programmatic adapter or unsupported hosting capability.
- `Resolver` (`Func<ReplExecutionOutcome, int>?`, default: `null`) — Final interception hook. Receives the outcome with its table-mapped `ExitCode`; its return value is the process exit code. Also sees `HandlerExitCode` outcomes (explicit `Results.Exit`), which bypass the table.

`ReplExecutionOutcomeKind.Interrupted` is reserved for process-signal bridges (SIGINT/SIGTERM) and has
no table entry; the core pipeline never produces it.

## AmbientCommandOptions

Accessed via `ReplOptions.AmbientCommands`.
Expand Down
73 changes: 59 additions & 14 deletions docs/execution-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,21 +181,30 @@ handler invocation and produce an error result.
via `app.Use()`, then invokes the pipeline. The final stage calls
`CommandInvoker.InvokeAsync()` which executes the handler delegate.

The invoker supports multiple return types:
The invoker supports synchronous, `Task`, `Task<T>`, `ValueTask`, and `ValueTask<T>` handlers:

```csharp
// Synchronous
int Run() => 0;
// Synchronous, returns data
int Count() => items.Length;

// Async
async Task<int> RunAsync() => 0;
async ValueTask<int> RunAsync() => 0;
// Async, returns data
async Task<Contact[]> ListAsync() => await store.ListAsync();
async ValueTask<string> DescribeAsync() => "ready";

// Void (implicit success)
void Run() { }
async Task RunAsync() { }
```

> **A handler's return value is always rendered as data — including `int`.** It never becomes the
> process exit code: `int Count() => 3` prints `3` and exits `0`. To set an explicit exit code,
> return `Results.Exit(code)` (or any `IExitResult`).

Once the handler has run, its return value is exposed as `ReplExecutionContext.Result`, so a
middleware registered with `app.Use(...)` can inspect or replace it after awaiting `next()`. A
middleware that short-circuits (never calls `next()`) may set `context.Result` to supply the result
that is rendered and classified in the handler's place.

### 10. Result Processing

The raw handler return value is unwrapped and interpreted:
Expand All @@ -219,13 +228,48 @@ The matching `IOutputTransformer` formats the result and writes it to stdout.

### 12. Exit Code

The final exit code is derived from the result:
Every run that reaches the core pipeline ends in exactly one `ReplExecutionOutcomeKind`, decided
once after every pipeline layer has run. (The one path outside the pipeline is a hosted-service
start/stop failure in `ReplApp.RunAsync` with `HostedServiceLifecycle` enabled, which still returns
`1` directly.) The kind is mapped to an integer by `ReplOptions.ExitCodes`
(`ExitCodeOptions`), then handed to the optional `ExitCodes.Resolver` hook whose return value is
final:

| Kind | Produced by | Default code |
|---|---|---|
| `Success` | success-like handler result (`text`/`success`, plain data, `void`), ambient commands (`exit`, `..`) that did their job, clean interactive exit | `0` |
| `Help` | `--help`, bare invocation that prints help, scoped-context help | `0` |
| `UsageError` | unknown command, ambiguous prefix, invalid global or command option, context validation failure, unknown `--output` format | `2` |
| `BindingError` | a handler argument could not be bound: token conversion failed or was missing, or a binder-resolved value (context value, `[FromServices]` dependency, typed global options service) was unavailable | `2` |
| `HandlerError` | handler returned `Results.Error` / `Validation` / `NotFound` / `Cancelled` | `1` |
| `HandlerExitCode` | handler returned an `IExitResult` — its code is used verbatim, the table is bypassed | `IExitResult.ExitCode` |
| `HandlerException` | the handler or a middleware threw | `1` |
| `Cancelled` | the run ended with an `OperationCanceledException` — the caller's `CancellationToken`, a cancelled prompt, or a handler that threw it | unmapped: the exception propagates; set `ExitCodes.Cancelled` (e.g. `130`) to return a code instead |
| `Interrupted` | reserved for process-signal bridges (SIGINT/SIGTERM); the core pipeline never produces it | — |
| `FrameworkError` | incompatible programmatic adapter, unsupported hosting capability | `1` |

```csharp
app.Options(options =>
{
options.ExitCodes.UsageError = 64; // EX_USAGE for scripts that follow sysexits
options.ExitCodes.Help = 3; // a bare invocation must not look like success in CI
options.ExitCodes.Cancelled = 130; // return 128+SIGINT instead of throwing
options.ExitCodes.Resolver = outcome => // final say, sees the structured outcome
outcome.Kind == ReplExecutionOutcomeKind.HandlerException ? 70 : outcome.ExitCode;
});
```

The resolver receives a `ReplExecutionOutcome` (`Kind`, table-mapped `ExitCode`, the final `Result`
object when one exists, and the `Exception` that ended the run when applicable). It runs once per
one-shot run and is not invoked when `Kind` is `Cancelled` and `ExitCodes.Cancelled` is unset — the
exception is rethrown instead. In an interactive session it is invoked once per committed command
(to compute the shell-integration mark code, where a Ctrl+C cancellation carries the conventional
`130` unless `ExitCodes.Cancelled` overrides it) and once more when the session exits.

| Result | Exit Code |
|---|---|
| Success (or void) | `0` |
| Failure | `1` |
| `IExitResult` | `IExitResult.ExitCode` |
Nested sub-invocations (MCP tool calls executed through the Repl pipeline) always use the built-in
defaults and skip the resolver: the policy describes the *process* exit, and nested callers only test
for non-zero. The interactive loop applies the same table and resolver when it reports a command's
exit code in shell-integration marks, so the terminal decoration and the CLI agree.

## Error Handling

Expand All @@ -238,8 +282,9 @@ Errors at each stage produce targeted diagnostics:
- **Binding errors** — renders a message identifying the missing or invalid parameter.
- **Handler exceptions** — caught and unwrapped from `TargetInvocationException`,
then rendered as an error to stderr.
- **Cancellation** — `OperationCanceledException` is either propagated to the caller
or rendered as a cancellation message, depending on context.
- **Cancellation** — in one-shot mode `OperationCanceledException` propagates to the caller
unless `ReplOptions.ExitCodes.Cancelled` is set, in which case the run returns that code with
`ReplExecutionOutcomeKind.Cancelled`; the interactive loop renders a cancellation message instead.

## Interactive Session Loop

Expand Down
2 changes: 1 addition & 1 deletion docs/interactive-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ app.Map("setup", () => Results.EnterInteractive()); // explicit transition
5. Execute the command through the pipeline.
6. Repeat until exit.

When [terminal shell integration](terminal-shell-integration.md) is enabled, the loop brackets each cycle with semantic marks: prompt start before step 1, input start before step 2, the command-line report (VS Code) and output start between steps 3 and 5, and a single command-end mark carrying the exit code after step 5.
When [terminal shell integration](terminal-shell-integration.md) is enabled, the loop brackets each cycle with semantic marks: prompt start before step 1, input start before step 2, the command-line report (VS Code) and output start between steps 3 and 5, and a single command-end mark carrying the exit code (resolved through `ReplOptions.ExitCodes`, like a one-shot run) after step 5.

## Prompt and Autocompletion

Expand Down
2 changes: 1 addition & 1 deletion docs/terminal-shell-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ In interactive REPL mode, each prompt cycle is delimited with the FinalTerm sema
| Right before command execution | `C` (output start) |
| After the command completes | `D;<exit code>` (command end) |

Exit codes follow shell conventions: `0` for success, `1` for errors (failed results, unknown commands, validation failures), and `130` (128+SIGINT) when a command is cancelled with Ctrl+C. The interruption decoration is intentionally broad: a handler that throws `OperationCanceledException` for any other reason (its own timeout, a linked token) also reports `130`, because the loop cannot tell who requested the cancellation. An abandoned cycle — Escape at the prompt, an empty line, or end of input — reports `D` without an exit code, the FinalTerm "command aborted" form.
Exit codes follow the same `ReplOptions.ExitCodes` policy as a one-shot run — by default `0` for success, `1` for a failed handler result or exception, `2` for usage errors (unknown commands, ambiguous prefixes, invalid options), and `130` (128+SIGINT) when a command is cancelled with Ctrl+C (`ExitCodes.Cancelled` overrides it when set). The interruption decoration is intentionally broad: a handler that throws `OperationCanceledException` for any other reason (its own timeout, a linked token) also reports `130`, because the loop cannot tell who requested the cancellation. An abandoned cycle — Escape at the prompt, an empty line, or end of input — reports `D` without an exit code, the FinalTerm "command aborted" form.

The VS Code `E` mark reports the exact committed command line (with protocol escaping), which makes VS Code's command detection independent of what is visible on screen. Note the privacy implication: whatever was typed at the prompt — including secrets passed as command arguments — is transmitted verbatim to the terminal, which may persist it for command detection and history. This mirrors what VS Code's own shell integration does for regular shells; if commands take secrets, prefer prompting for them interactively instead of passing them as arguments.

Expand Down
2 changes: 1 addition & 1 deletion docs/testing-toolkit.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ var execution = await session.RunCommandAsync("hello --no-logo");
```csharp
var execution = await session.RunCommandAsync("contact show --json --no-logo");

var exitCode = execution.ExitCode; // numeric process-style status
var exitCode = execution.ExitCode; // process-style status, follows ReplOptions.ExitCodes
var text = execution.OutputText; // rendered output text
var duration = execution.Duration; // elapsed command time
```
Expand Down
Loading
Loading