Routes & Parameters
Route templates
Section titled “Route templates”A route template is the string you pass to app.Map(...). It defines both the command name and how input tokens are bound to handler parameters.
app.Map("user {id:int}", handler); // static + one dynamic segmentapp.Map("file {path:uri}", handler); // typed constraintapp.Map("search {query}", handler); // untyped, defaults to stringapp.Map("archive {date?}", handler); // optional trailing segmentapp.Map("client {id} show", handler); // dynamic segment in the middleSegments are separated by spaces. Static segments must match literally. Dynamic segments ({name}) capture an input token.
Multiple dynamic segments
Section titled “Multiple dynamic segments”A route can contain any number of dynamic segments, at any position. Each one is bound independently by name to the matching handler parameter:
// Two consecutive parametersapp.Map("settings set {key} {value}", static (string key, string value) => ...);
// Typed parameters at different positionsapp.Map("log {from:date} {to:date}", static (DateOnly from, DateOnly to) => ...);
// Dynamic between two literalsapp.Map("order {id:int} tag {label}", static (int id, string label) => ...);
// Multiple optional trailing parametersapp.Map("client add {name?} {email?:email}", static (string? name, string? email) => ...);There is no limit on the number of dynamic segments. Optional segments are the only ones that must be at the end — required segments can appear anywhere.
Constraints
Section titled “Constraints”Constraints enforce the type of a segment at parse time. An invalid value is rejected before your handler runs.
| Constraint | .NET Type | Examples accepted |
|---|---|---|
string (default) | string | any token |
alpha | string | letters only |
int | int | 42, 1_000 |
long | long | 1000000 |
bool | bool | true, false |
double | double | 3.14, -0.5 |
guid | Guid | 123e4567-e89b-... |
email | string | user@example.com |
uri | Uri | https://example.com |
url | Uri | https:// with valid host |
urn | Uri | urn:isbn:0451450523 |
date / dateonly | DateOnly | 2024-01-15 |
datetime / date-time | DateTime | 2024-01-15T10:30 |
datetimeoffset / date-time-offset | DateTimeOffset | 2024-01-15T10:30+05:00 |
time / timeonly | TimeOnly | 14:30, 14:30:45 |
timespan / time-span | TimeSpan | 2h30m, 30d, PT8H |
Write the constraint after a colon: {id:int}, {email:email}, {when:date}.
Full format details — including all accepted timestamp strings and duration syntax — are in Built-in Types & Formats.
Type inference
Section titled “Type inference”If you omit the constraint, Repl infers it from the handler parameter type:
app.Map("user {id}", (int id) => ...); // :int inferredapp.Map("open {path}", (Uri path) => ...); // :uri inferredapp.Map("since {d}", (DateOnly d) => ...); // :date inferredImplicit types not available as constraint names: FileInfo, DirectoryInfo, ReplDateRange, ReplDateTimeRange, ReplDateTimeOffsetRange.
Optional segments
Section titled “Optional segments”Optional segments must be at the end:
app.Map("list {page?}", (int? page) => ...);Custom constraints
Section titled “Custom constraints”app.Options(o => o.Parsing.AddRouteConstraint("ipv4", value => System.Net.IPAddress.TryParse(value, out var addr) && addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork));
app.Map("connect {host:ipv4}", (string host) => ...);Temporal ranges
Section titled “Temporal ranges”ReplDateRange, ReplDateTimeRange, and ReplDateTimeOffsetRange capture an interval from a single token. The type is inferred from the handler parameter — no constraint name needed.
app.Map("report {period}", (ReplDateRange period) => GetReport(period.From, period.To));
app.Map("logs {range}", (ReplDateTimeRange range) => GetLogs(range.From, range.To));Both start..end and start@duration forms are accepted:
myapp report 2024-01-01..2024-03-31 # start..endmyapp report 2024-01-01@90d # start + duration (whole days for ReplDateRange)
myapp logs 2024-03-15T08:00..2024-03-15T18:00myapp logs 2024-03-15T08:00@8hSee Built-in Types & Formats for all accepted timestamp and duration formats.
Contexts
Section titled “Contexts”app.Context(...) defines a navigable scope. Every app.Map(...) inside it produces a route prefixed by the context template.
app.Context("client", client =>{ client.Map("list", static (IClientStore store) => store.All()); client.Map("{id:int} show", static (int id, IClientStore store) => store.Get(id)); client.Map("{id:int} remove", static (int id, IClientStore store) => { store.Remove(id); return Results.Success($"Removed client {id}."); });});This registers three complete routes: client list, client {id:int} show, client {id:int} remove.
CLI — type the full path every time:
myapp client listmyapp client 42 showmyapp client 42 removeREPL — navigate into the context once; subsequent commands omit the prefix:
> client[client]> list[client]> 42 show[client]> 42 remove[client]> ..>Both modes resolve the same underlying routes — the REPL just holds the prefix in the session state.
Dynamic contexts
Section titled “Dynamic contexts”A context template can itself contain dynamic segments. The captured value is visible to every handler inside the scope:
app.Context("client {id:int}", client =>{ client.Map("show", static (int id, IClientStore store) => store.Get(id)); client.Map("remove", static (int id, IClientStore store) => store.Remove(id));});Full routes: client {id:int} show and client {id:int} remove. The id value is bound once (at context entry) and available to all inner handlers.
In the REPL, client 42 enters [client/42]> and binds id = 42 for the rest of the session at that depth.
Nested contexts
Section titled “Nested contexts”Contexts can nest freely. Flat routes and nested routes are equivalent — the two forms register the same paths:
// Nested formapp.Context("client", client =>{ client.Map("list", ...); client.Context("{id:int}", c => { c.Map("show", ...); c.Map("remove", ...); });});
// Flat form — identical routesapp.Map("client list", ...);app.Map("client {id:int} show", ...);app.Map("client {id:int} remove", ...);Choose the nested form when you want REPL navigation depth; use the flat form when a shallow command graph is sufficient.
Parameter binding
Section titled “Parameter binding”Handler parameters are resolved in this order:
CancellationToken— injected from the execution context- Explicit attributes —
[FromServices],[FromContext] - Options groups — classes decorated with
[ReplOptionsGroup] - Route values — captured from dynamic segments
- Named options —
--option valuesyntax - DI services — resolved from the container
- Positional arguments — remaining tokens consumed left-to-right
- Parameter defaults — C# default values
null— for nullable types with no other source
Named options syntax
Section titled “Named options syntax”Options can be written as --name value, --name=value, or --name:value. Option parsing is case-sensitive by default.
myapp report --format json --limit 50app.Map("report", (string format = "text", int limit = 20) => ...);Options groups — reusable parameter sets
Section titled “Options groups — reusable parameter sets”When multiple commands share the same options, extract them into a class:
[ReplOptionsGroup]public class PagingOptions{ [ReplOption(Aliases = ["-n"])] public int Limit { get; init; } = 20;
public int Page { get; init; } = 1;}
app.Map("list", (PagingOptions paging, IStore store) => store.Page(paging.Page, paging.Limit));app.Map("search {q}", (string q, PagingOptions paging, IStore store) => store.Search(q, paging));Both commands now share --limit, -n, and --page without duplicating them.
Response files
Section titled “Response files”Any argument starting with @ is treated as a response file. Tokens are read from the file, one per line:
myapp @opts.rsp# opts.rsp contains:# report# --format# json# --limit# 50Useful for long or repeatable invocations. Response files are non-recursive.
Binding mode per parameter
Section titled “Binding mode per parameter”| Mode | Accepts |
|---|---|
OptionAndPositional (default) | --name value or positional |
OptionOnly | --name value only |
ArgumentOnly | positional only |
Override with [ReplOption(Mode = ReplParameterMode.OptionOnly)] or [ReplArgument(Mode = ...)].
Route resolution
Section titled “Route resolution”Prefix matching
Section titled “Prefix matching”You never have to type full command names. Before routing, the framework expands each segment to its unique prefix match. This works identically in CLI and REPL modes.
myapp cl li # → client listmyapp cl 42 sh # → client 42 showIn the REPL:
> cl # enters [client] context[client]> li # runs list[client]> 42 sh # runs 42 showPrefix expansion is position-aware: li is matched against the candidates reachable at that position in the command tree, not against every route globally. This means the same abbreviation can mean different things at different depths.
Ambiguous prefixes
Section titled “Ambiguous prefixes”If two candidates share the same prefix at the same position, the framework reports an error listing all matches:
> client lerror: ambiguous prefix "l" — matches: list, loadType enough characters to make the prefix unique — li for list, lo for load.
Case insensitivity
Section titled “Case insensitivity”Literal segment matching is case-insensitive. Client, client, and CLIENT all resolve to the same route.
Specificity and disambiguation
Section titled “Specificity and disambiguation”When multiple routes could match the same input, the framework picks the most specific one. Specificity is determined by a scoring system:
- Literal segments score highest —
listbeats{id}for the token"list" - Typed dynamic segments score by constraint specificity:
int>long>guid>timespan>datetimeoffset>datetime>date>time>urn>url>uri>email>bool>alpha>string - Routes that match more segments score higher overall
app.Map("client list", static (...) => ...); // literal "list"app.Map("client {id:int} show", static (...) => ...); // int param + literal "show"app.Map("client {id:int} remove", static (...) => ...); // int param + literal "remove"| Input | Route matched | Reason |
|---|---|---|
client list | client list | list is a literal — scores higher than {id:int} for that token |
client 42 show | client {id:int} show | 42 satisfies :int; literal show confirms the route |
cl li | client list | cl → client, li → list via prefix expansion |
cl 42 sh | client {id:int} show | cl → client, 42 → :int match, sh → show |
Ambiguity at the route level — two registered routes that could match the same input — is detected at startup, not at runtime. The framework throws if the route graph is ambiguous, so it can never occur in production.
No match: suggestions
Section titled “No match: suggestions”When input matches nothing and no prefix can be resolved, the framework suggests similar commands using edit distance:
> clien liserror: no command found. Did you mean: client list?