Runic.CommandLine
0.7.0-preview.2
dotnet add package Runic.CommandLine --version 0.7.0-preview.2
NuGet\Install-Package Runic.CommandLine -Version 0.7.0-preview.2
<PackageReference Include="Runic.CommandLine" Version="0.7.0-preview.2" />
<PackageVersion Include="Runic.CommandLine" Version="0.7.0-preview.2" />
<PackageReference Include="Runic.CommandLine" />
paket add Runic.CommandLine --version 0.7.0-preview.2
#r "nuget: Runic.CommandLine, 0.7.0-preview.2"
#:package Runic.CommandLine@0.7.0-preview.2
#addin nuget:?package=Runic.CommandLine&version=0.7.0-preview.2&prerelease
#tool nuget:?package=Runic.CommandLine&version=0.7.0-preview.2&prerelease
Runic.CommandLine
Runic.CommandLine is the command kernel for .NET 10 applications that need
portable command behavior and predictable output. Define an immutable catalog,
bind parsed values into typed options, execute a closed handler, and emit human
text or a machine-readable response without coupling the public model to a
parser, Generic Host, or UI framework.
Install
dotnet add package Runic.CommandLine --prerelease
The package targets net10.0 and includes its portable contracts and host
launch classification. It is a preview package: test updates before adopting
them in a production command contract.
A complete command-line application
The package includes its incremental generator. Define a static method and run the catalog; no custom options binder, result DTO, JSON context, service provider or Generic Host is needed for this example:
using Runic.CommandLine;
using Runic.CommandLine.Generated;
return await new CommandApp(GeneratedCommandCatalog.Create())
{
Name = "hello",
Version = "1.0.0",
}.RunAsync(args);
internal static class Commands
{
[Command("greet", Description = "Say hello.", Examples = ["hello greet Ada"])]
[DefaultCommand]
internal static string Greet([Argument] string name = "world") => $"Hello, {name}!";
}
hello, hello Ada, and hello greet Ada execute the default command.
hello help greet and hello greet --help show catalog help. hello --output json greet Ada emits one versioned response. An application without a default command
shows help for an empty invocation; the lower-level parser retains its existing
empty-input classification for hosted UI launch decisions.
An application whose grammar is one command marks it [RootCommand] instead.
Its name is then not a command spelling, so hello run passes run to it as an
argument, and hello --help is that command's help: its description,
arguments, options and examples, followed by the application's other commands.
The name stays the command's identity in JSON output ("command": "greet").
Hand-built catalogs call builder.RootCommand("greet").
See the runnable command-line example.
Keep an existing --json flag
An application that already has a --json flag can keep its users' command
lines by declaring the flag as an alias of the output selector:
CommandCatalog catalog = GeneratedCommandCatalog.Create(builder => builder.JsonOutputAlias("--json"));
hello greet Ada --json is then hello greet Ada --output json. The flag takes
no value, is recognized wherever the selector is (also before the command, never
after --), maps to a host's transport option such as --runic-output, and is
listed in help and completions. Giving both --json and --output is a usage
error (RCLI1016), and a command option spelled --json fails catalog
validation (RCLI0024).
When the existing flag's consumers read the bare document rather than an envelope, declare it with the payload shape:
CommandCatalog catalog = GeneratedCommandCatalog.Create(
builder => builder.JsonOutputAlias("--json", JsonOutputShape.Payload));
hello greet Ada --json then writes "Hello, Ada!" and one LF to standard
output, the payload serialized with the command's result metadata and nothing
else. A command without a result writes nothing. A failed or cancelled command
writes the payload of its declared failure data (see "Declared domain failure
and recovery data") the same way, or nothing when it declared none. Its
diagnostics are human text on standard error, followed by its humanOutput, written
exactly in place of the CODE: message fault line, so a legacy contract keeps its
own error text; without humanOutput the fault line is written. The exit code is the one the
envelope would report. Help, version and usage errors on a command line that
gives the flag are human text, so hello --bogus --json writes its RCLI1001
line to standard error. --output json and RUNIC_COMMANDLINE_OUTPUT=json keep
writing the runic.commandline/1 envelope, and CommandExecutionContext.OutputShape
tells a handler or a custom outcome sink which shape was selected. Failure data
keeps its 65,536-byte bound, because the same outcome can also be written as an
envelope; a success payload is not bound by the protocol's frame limit.
Method-first inputs and results
- Scalars: strings, integers, long integers, decimals, doubles, GUIDs, enums and nullable scalars. Boolean options are presence flags; nullable Boolean options accept an explicit Boolean value.
- C# defaults and nullable inputs are optional. A non-nullable scalar option with
no default is required.
Required = trueexplicitly requires flags and lists, and on an[Argument]requires at least one value of a list argument or a value of a nullable one. - Arrays and
IReadOnlyList<T>support the scalar types above. Options append one value per occurrence;AllowMultipleValues = truealso accepts several values after one occurrence. A variadic positional must be last. [TrailingArguments] IReadOnlyList<string> nativereceives every token after--, for arguments a command forwards to another tool. Positional arguments then receive only the tokens before--, sotool build home -- --option xbindshometo the command's words and--option xtonative, and the forwarded tokens never fail the words' choices. Help shows[-- native...], and completion after--offers the trailing arguments' choices. Hand-built catalogs callTrailingArguments(id, name)on the command builder.[Option(..., Description = "...", ValueName = "PATH", Choices = ["a", "b"], EnvironmentVariable = "APP_VALUE", Sensitive = true)]supplies shared metadata. Explicit input wins over the environment, then C# defaults. A flag's environment value may betrue/false,yes/noor1/0in any case. Choices match case-insensitively and bind the declared spelling. Sensitive defaults and choices are excluded from help and completion.- Generated numeric metadata permits separated negative numbers. Other
option-looking data still requires
--or an equals value. Unknown options and duplicate scalar options remain errors. [Command("config show")]creates a help-onlyconfiggroup automatically. Describe it with[CommandGroup("config", Description = "Show and change settings.")]on any class that declares commands (alsoDescriptionKey,LongDescriptionandHidden), or withbuilder.Group("config", new CommandHelp("...")).GeneratedCommandCatalog.Create(builder => builder.GlobalOption("verbose", "--verbose", CommandArity.Zero))adds an option to all commands; it may precede the command. Read shared bindings fromParsedInvocation.Optionsin a binder.[ConvertWith(typeof(Converter))]selects anICommandValueConverter<T>with staticParse(string).[ValidateWith(typeof(Validator))]selects anICommandValueValidator<T>with staticIsValid(T). Converted values are stored once during binding and reused by the handler.string,void,TaskandValueTaskresults have built-in codecs, as doesCommandUnit, also asTask<CommandUnit>,ValueTask<CommandUnit>orCommandOutcome<CommandUnit>. ACommandUnitcommand behaves like avoidone: payload typerunic.unit/1, no human output, and nothing on standard output for a payload-shaped JSON alias. Typed application payloads retain[CommandResult("example.result/1", typeof(JsonContext))]and source-generated JSON metadata. Anintresult is payload data, never an implicit exit code. UseCommandOutcome<T>and an exit policy for domain exits.
CancellationToken, CommandExecutionContext and ICommandConsole parameters are
injected automatically; other services use [FromServices]. Declare the token
last as CancellationToken cancellationToken = default so it can follow
defaulted options, as CA1068 recommends. Instance methods and runtime assembly
discovery are intentionally outside the generated model.
Human output of a typed result lists its JSON properties as name: value rows.
Booleans print as true/false, lists of scalars are joined with commas, and
nested objects and lists of objects are indented below their label with one
- entry per item. Register a presenter with builder.Present (see
custom results) for a bespoke layout.
Services and scopes
Handlers receive [FromServices] parameters from the invocation scope that
CommandApp.ScopeFactory creates. CommandScopes covers the usual cases
without a dependency-injection package:
// Application-owned instances; an invocation resolves them and disposes nothing.
ScopeFactory = CommandScopes.FromServices(CommandServices.Empty.With<IReportService>(reports)),
// Any IServiceProvider the application already has, shared the same way.
ScopeFactory = CommandScopes.FromServices(serviceProvider),
// Microsoft.Extensions.DependencyInjection: one service scope per invocation, disposed afterwards.
ScopeFactory = CommandScopes.Create(serviceProvider.CreateAsyncScope, scope => scope.ServiceProvider),
CommandScopes.Create accepts any container scope type and disposes it
asynchronously when it implements IAsyncDisposable, otherwise through
IDisposable. A missing service fails the invocation with the sanitized
RCLI5000 fault. Implement ICommandExecutionScopeFactory directly only for
invocation-specific services, or use CommandApp.CreateScopeFactory.
Presentation and testing
CommandApp owns parsing, output environment selection, help, version, process
signals, execution and exit mapping. Configure ScopeFactory, ExitCodePolicy,
OutcomeSink, or PresentFrameworkRequest to preserve existing application
contracts during migration.
Ctrl+C (SIGINT), SIGTERM and SIGQUIT (Ctrl+Break on Windows) cancel the
invocation's token, so a handler that honors it stops gracefully and returns the
Cancelled exit code; service managers that send SIGTERM get the same graceful
stop. A second signal while the invocation still runs exits the process at once
with 128 plus the signal number (130 for Ctrl+C, 143 for SIGTERM, 131 for
SIGQUIT). Set HandleCancelKeyPress = false when an embedding host already owns
process signals; the runtime's default signal handling then applies.
The exit codes follow the Unix 128-plus-signal convention; Windows uses the same values. On Windows, Ctrl+C and Ctrl+Break map to SIGINT and SIGQUIT, and SIGTERM corresponds to the system shutdown event. Windows may end the process as soon as the shutdown handler returns, so cleanup that must finish before shutdown cannot rely on the cancelled handler completing.
Help wraps descriptions at word boundaries and continues them under the
description column. Width is measured in terminal cells: East Asian wide and
fullwidth characters take two cells and may break between characters; combining
marks take none. A LongDescription line that is indented or contains a run of
three or more spaces, such as a list, aligned columns or a code block, is
printed as written; other text, including every summary and parameter
description, wraps (an explicit newline still starts a new line). On an interactive terminal help uses the terminal width
less one column; redirected, machine and test output use
CommandHelpFormatter.DefaultWidth (80 columns). SpectreHelpPresenter uses its
SpectreCommandConsole width instead: a width passed to its constructor as
given, otherwise the terminal width less one column, or 100 columns when output
is redirected. CommandHelpFormatter.Format accepts an
explicit width.
Install optional Runic.CommandLine.Spectre, then set Console = new SpectreCommandConsole() and HelpPresenter = new SpectreHelpPresenter().
The adapter supports literal text, Spectre renderables, progress, and prompts.
ANSI is disabled for redirected output, NO_COLOR, and TERM=dumb.
For handler presentation, wrap the injected console so the current invocation's
capabilities and stream routing are retained.
Handlers that write directly use the injected ICommandConsole.
WriteOutAsync("text"), WriteOutLineAsync("text"), WriteErrorAsync and
WriteErrorLineAsync accept strings; lines end with LF on every platform.
JSON output writes letters and symbols of every script as UTF-8 ("Ungültige Eingabe", not "Ung\u00FCltige Eingabe"). HTML-sensitive characters (< > & ' " +
and the backtick), control characters, U+2028/U+2029 and bidirectional or
zero-width formatting characters stay escaped, so a frame remains valid JSON
that is safe to embed and cannot reorder text in a viewer.
completion bash|zsh|fish|powershell generates context-aware completion scripts
from the catalog. Set CompletionExecutableName when the help-facing name contains
spaces (for example, dotnet runic). Candidates follow the current command path,
its visible options and the choices or path hints for the current value; the
scripts do not perform service lookups. See
discovery, validation and custom results
for the shell details.
An application whose users already run tool --completions bash declares that
option instead of the command:
CommandCatalog catalog = GeneratedCommandCatalog.Create(builder => builder.CompletionOption("--completions"));
The option is recognized as the first argument, as --completions bash or
--completions=bash, appears in root help and in the generated scripts, and
replaces the built-in completion command, so a default command can take
completion as an ordinary argument. A missing or unknown shell is a usage
error. A command option spelled like it fails catalog validation with RCLI0025.
Runic.CommandLine.Testing supplies CommandAppTester, a configurable
TestCommandConsole with queued input, and strict JSON frame assertions. Supply
explicit ParseSettings to keep environment-dependent tests deterministic.
Interactive prompts
CommandPrompts asks questions in the style of @clack/prompts: select,
multi-select, confirm and validated text, plus a spinner, a task log, and intro
and outro lines. Create
it from the handler's CommandExecutionContext. Prompts observe the
invocation's cancellation token from the context, and also any token passed to
a call:
[Command("init", Description = "Create a project.")]
internal static async Task<string> Init(CommandExecutionContext context, CancellationToken cancellationToken,
[Option("--template")] string? template = null, [Option("--yes", "-y")] bool yes = false)
{
var prompts = new CommandPrompts(context, interactive: !yes);
await prompts.IntroAsync("create-app", cancellationToken);
template ??= await prompts.SelectAsync<string>("Template",
[new("web", "Web app", "Vite and Svelte"), new("cli", "Command line")], defaultIndex: 0, cancellationToken);
string name = await prompts.TextAsync("Project name", validate: value =>
value.Length == 0 ? "Enter a name." : value.Contains(' ') ? "Use no spaces." : null, cancellationToken: cancellationToken);
if (!await prompts.ConfirmAsync($"Create {name}?", defaultValue: true, cancellationToken)) return "Nothing created.";
await using CommandTaskLog log = await prompts.StartTaskLogAsync("Installing", visibleLines: 6, cancellationToken);
await log.WriteLineAsync("added 120 packages", cancellationToken); // for example from a process output handler
await log.SetStatusAsync("12s elapsed", cancellationToken);
await log.SucceedAsync("Installed", cancellationToken: cancellationToken); // clears the log lines
await prompts.OutroAsync($"cd {name} and run it.", cancellationToken);
return $"Created {name} from {template}.";
}
StartSpinnerAsync(message) is a task log without visible lines. A task log
keeps the last visibleLines lines under its title while it runs; SucceedAsync
replaces the block with one summary line (the log stays with showLog: true),
and FailAsync keeps up to the last 1000 lines below the summary. Lines are
stripped of control characters and ANSI sequences, and members may be called
from process output callbacks.
IntroAsync(title) writes ┌ title before the first prompt, and
OutroAsync(message) writes │ and └ message after the last one, as Clack's
intro and outro do. Like a task log they take no input: they are styled on a
terminal that can redraw, also when prompts are disabled, and are plain lines
otherwise. Their text is stripped of control characters and ANSI sequences.
To show a child process's live output in a task log, as nix-manager does,
pass each line from Runic.CommandLine.Processes to the log:
sealed class TaskLogLines(CommandTaskLog log) : IProcessOutputHandler
{
public ValueTask OnLineAsync(ProcessOutputLine line, CancellationToken cancellationToken) =>
log.WriteLineAsync(line.Text, cancellationToken);
}
await using CommandTaskLog log = await prompts.StartTaskLogAsync("nix build", visibleLines: 6, cancellationToken);
ProcessResult result = await runner.RunAsync(request, new TaskLogLines(log), cancellationToken);
if (result.State == ProcessState.Exited && result.ExitCode == 0) await log.SucceedAsync(cancellationToken: cancellationToken);
else await log.FailAsync($"nix build ended in {result.State}", cancellationToken: cancellationToken);
On a redrawing terminal WriteLineAsync only queues the line for the next
frame, so it does not slow the child; without one it writes the line to
standard error, and the runner's backpressure applies.
When prompts ask. A prompt asks only when IsInteractive is true:
| Situation | Prompt with a default | Prompt without a default |
|---|---|---|
| Interactive console | Asks; Enter alone accepts the default | Asks |
JSON output (--output json, RUNIC_COMMANDLINE_OUTPUT=json or a declared --json alias) |
Returns the default | Exit code 2, RCLI4001 prompt-json-output |
| Console not interactive (standard input or standard error redirected) | Returns the default | Exit code 2, RCLI4001 prompt-requires-terminal |
RUNIC_COMMANDLINE_NO_INPUT is 1, true or yes, or interactive: false |
Returns the default | Exit code 2, RCLI4001 prompt-disabled |
Interactivity depends on standard input and standard error, where prompts are
written, not on standard output: cd "$(tool pick-dir)" and tool new | tee log still ask, and only the command's result goes through the pipe.
SystemCommandConsole.IsInteractive follows this rule. The CI variable is not
checked: a CI job that runs with a terminal should set
RUNIC_COMMANDLINE_NO_INPUT=1.
Noninteractive runs write nothing for a prompt that returns its default. A
multi-select's default is its defaultIndexes, which may be empty unless
required is set. A text default is returned without validation. With JSON
output, standard output still holds exactly one frame; the failure is the
frame's RCLI4001 fault and diagnostic, whose argument is the question.
CommandApp reads RUNIC_COMMANDLINE_NO_INPUT through its environment reader,
so explicit ParseSettings without GetEnvironmentVariable ignore it. Add your
own --yes or --no-input option and pass it as interactive: false.
Disabling prompts does not affect spinners, task logs, intros and outros, which
take no input: they still animate or are styled on a terminal that can redraw,
and write plain lines only with JSON output or without such a terminal.
Cancelling. Ctrl+C while a prompt waits cancels the command with exit code
4 (RCLI4000); so do Escape and the end of input (Ctrl+D) at a line prompt. The
prompt leaves a "Cancelled" summary line and shows the cursor again. The cursor
also comes back when a second Ctrl+C exits the process at once, and with
HandleCancelKeyPress = false, where SIGINT, SIGTERM and SIGQUIT keep their
default action. The .NET runtime restores the terminal's input mode when the
process exits normally or on such a signal. After SIGKILL or a crash of the
runtime nothing can run: if the cursor stays hidden, tput cnorm or reset
recovers the terminal.
A line prompt cancelled while it waits leaves its read of standard input running, because the process console cannot abandon a read. The next line prompt on the same console takes over that read, so the line the user types next answers it rather than being lost.
Rendering and accessibility. Prompts write to standard error. When the
console is an ICommandTerminal whose SupportsAnsi is true, they redraw in
place and take arrow keys, Space, Enter and Escape. Lines are measured in
terminal columns (CJK and emoji take two, combining marks none) and cut on a
character boundary to the terminal width, which is read for every frame, so a
resized terminal is followed; a block stays shorter than the terminal height,
and long choice lists scroll. SupportsUnicode selects ◆ ● ◻ or ASCII
symbols.
SystemCommandConsole supports ANSI when standard input and standard error are
terminals, TERM is set and is not dumb, NO_COLOR is not set and, on
Windows, inside Windows Terminal; SpectreCommandConsole applies its own colour,
Unicode and width settings and detects ANSI in other Windows consoles. Otherwise,
including with NO_COLOR and TERM=dumb, every prompt asks one plain question
that a screen reader reads in order and that is answered with one line:
create-app
Template
1. Web app - Vite and Svelte
2. Command line
Enter a number from 1 to 2 [Web app]: 2
Project name: demo
Create demo? [Y/n]: y
Installing
added 120 packages
Installing: 12s elapsed
Installing: Installed
cd demo and run it.
A select accepts a number or a label; a multi-select accepts numbers or labels
separated by commas; a confirm accepts the translated Yes or No, their first
letters, or y and n. An unrecognized answer asks again. Without a terminal,
including with JSON output, a task log writes its title, lines, status changes
and summary as plain lines to standard error, and an intro or outro writes its
text as one plain line there.
Translation. Hints, Yes and No, errors and the RCLI4001 diagnostics
resolve through CommandApp.TextResolver; the prompts.* keys are listed in
the text keys. Questions and
labels are your text: resolve them with
new CommandTextContext(context.Culture, context.TextResolver).Resolve(key, fallback).
Testing. Give CommandAppTester.CreateConsole an interactive
TestCommandConsole and queue one line per prompt with QueueInput; a queued
null is the end of input. Set SupportsAnsi = true and use QueueKey and
QueueText to drive the redrawn prompts. PendingInputCount and
PendingKeyCount show whether every queued answer was used.
var tester = new CommandAppTester(console => new CommandApp(catalog) { Console = console, ParseSettings = ParseSettings.Default })
{
CreateConsole = () =>
{
var console = new TestCommandConsole { IsInteractive = true };
console.QueueInput("2"); // Template: Command line
console.QueueInput("demo"); // Project name
console.QueueInput(""); // Create demo? accepts the default
return console;
},
};
CommandAppTestResult result = await tester.RunAsync(["init"]);
Exit codes
CommandApp.RunAsync returns the process exit code. The default
IExitCodePolicy maps each CommandExitCategory as follows; constants are on
CommandExitCodes.
| Code | Category | Typical cause |
|---|---|---|
| 0 | Success |
The command succeeded, or help, version or a completion script was written. |
| 2 | Usage |
Unknown command or option, missing, malformed or out-of-range input (RCLI1xxx, RCLI2xxx), or a prompt that cannot ask (RCLI4001). |
| 3 | Validation |
A handler rejected well-formed input, for example a business rule. |
| 4 | Cancelled |
Ctrl+C, SIGTERM, SIGQUIT or the caller's token cancelled the invocation, or the user cancelled a prompt. |
| 5 | Unavailable |
A required resource or service was unavailable, including "not found". |
| 10 | CommandFailure |
The command reported another expected failure. |
| 70 | HostFailure |
An unexpected exception or infrastructure failure (RCLI5000). |
| 130, 131, 143 | none | A second termination signal while the invocation still runs (128 plus the signal number). |
A handler selects a failure category with CommandOutcome.Failure<T>(category, fault). A custom IExitCodePolicy may remap the failure codes, but zero always
means success.
Set HelpUri on a CommandFault or CommandDiagnostic to link its code to
your documentation, for example new CommandFault("RAS1001", "The archive could not be read.") { HelpUri = new Uri("https://docs.example.com/errors#ras1001") }.
JSON output adds a helpUri member and human output writes
Help for RAS1001: <link> after the line. The link must be an absolute https
address; unlike messages and details, it is not redacted, so never build it
from user input.
Declared domain failure and recovery data
When a failed or cancelled command must return an exact retained directory or target identity, declare a bounded domain DTO separately from presentation text:
var recovery = CommandFailureData.Create(
"sample.clone-recovery/1", report, RecoveryJsonContext.Default.CloneRecovery);
return CommandOutcome.FailureWithData<CloneResult>(
CommandExitCategory.Cancelled,
new CommandFault("CLONE_CANCELLED", "The clone was cancelled. Inspect the retained directory."),
recovery,
humanOutput: "Inspect the retained directory before retrying.\n");
RecoveryJsonContext is the application's source-generated JsonSerializerContext
with [JsonSerializable(typeof(CloneRecovery))]; use its Default metadata to
preserve configured property names and other JSON defaults. A generated command
keeps its existing [CommandResult] declaration for its success DTO. The failure
factory explicitly declares the separate identity and JSON contract; no extra
generator attribute or reflection is required.
The immutable snapshot preserves declared domain values exactly, including paths
and target names that resemble technical text. Include only fields intended for
the command's consumer. Keep secrets, exception/log text, and unrelated internal
state out of the domain DTO. Ordinary fault messages, details, and diagnostics
continue through their sanitizer. Human output uses the existing fault and
optional humanOutput; data is not printed automatically.
JSON uses the optional fault.data member:
{"type":"sample.clone-recovery/1","payload":{...}}. The protocol remains
runic.commandline/1, with failure payloadType and payload both null and a
nonzero exit. Existing version-1 readers ignore the extension. Ordinary failures
retain their exact wire format. Upgrade producer and consumer independently;
retain any application-specific legacy detail encoding only while old consumers
still require it.
CommandJsonEnvelopeReader.Read retains validated data on response.FailureData.
Decode only an identity the application understands:
if (response.FailureData is { } data &&
data.TryGet("sample.clone-recovery/1", RecoveryJsonContext.Default.CloneRecovery,
out CloneRecovery? recovery))
{
// Interpret the application's recovery contract; retain the failed exit.
}
TryGet returns false for an unknown identity so callers can present the ordinary
fault. A matching identity with an incompatible shape raises
CommandProtocolException with failure-data-shape-mismatch. Decoding follows
the supplied context's settings: mark required fields and
configure nullability or unknown-member handling there when the domain contract
requires those checks. A type identity alone does not make missing fields invalid.
Snapshots are limited to 65,536 compact UTF-8 JSON bytes and 24 nested objects/arrays; the complete
response keeps its one-mebibyte/32-level limits. Malformed, duplicate, or
oversized data is refused without truncation. Create and the reader raise the
same CommandProtocolException kinds: failure-data-byte-limit,
failure-data-depth-limit, duplicate-property, or invalid-failure-data
(including serializer and encoding failures). Invalid UTF-16 (an unpaired
surrogate) in any string value or property name, including dictionary keys, raises
invalid-failure-data instead of being replaced with U+FFFD. Recovery data and retryable never
authorize an automatic retry.
Discovery, validation and custom results
Hidden = true on [Command], [Option] or [Argument] omits that entry from
help listings and completion. Hidden commands/options still parse when explicitly
specified; this is discoverability metadata, not access control. A command's
LongDescription adds extended help below its summary. Close command and option
typos receive suggestions from visible catalog spellings only. Suggestions never
execute corrections and never include option values.
FileInfo and DirectoryInfo parameters bind directly and infer file/directory
completion metadata. Strings can opt in with PathKind = CommandPathKind.File
or .Directory. MustExist = true checks the requested path kind before the
handler runs. These are input checks, not security guarantees: the handler still
opens the file and handles permissions, replacement and filesystem races.
Use Minimum/Maximum for inclusive numeric input bounds. Requires and
ConflictsWith refer to stable option IDs, not spellings: parameter dryRun
gets ID dry-run. Presence includes captured environment fallback; an environment
flag set to false is absent. Dependencies do not make an optional flag implicit.
The builder uses the same CommandHelp properties. Path and relationship checks
run during execution before binding. Each generated value is then converted,
checked against Minimum/Maximum, and only then passed to its [ValidateWith]
validator, so --limit abc reports a type error (RCLI2005, "--limit requires a
whole number.") rather than a range error, and a validator never sees a value
outside the declared range. For a hand-written ICommandOptionsBinder, bounds
are checked after the binder succeeds. Hosted classification remains free of
filesystem reads. Invalid input returns a safe RCLI2002 usage fault that names
the option spelling or <ARGUMENT>, without echoing its value, for example
"--limit requires a number between 1 and 50.". Both faults carry a matching
binding diagnostic, and a value that came from an environment fallback adds an
RCLI2006 information diagnostic naming the variable. Invalid choices list the
allowed values unless the parameter is Sensitive or the list is long.
The source generator reports the catalog rules it can see in attributes at build
time, for example a required argument after an optional one (RCLI9035, the
runtime RCLI0015), Minimum/Maximum on a non-numeric parameter, misnamed
Requires/ConflictsWith IDs and a nested [DefaultCommand]. Rules it cannot
see still fail when the catalog is built; CommandCatalogValidationException
lists each issue's code, command path and message in its Message.
[Command("copy", Description = "Copy a file.")]
internal static Task Copy(
[Option("--source", MustExist = true)] FileInfo source,
[Option("--destination", PathKind = CommandPathKind.File)] string destination,
[Option("--overwrite", ConflictsWith = ["dry-run"])] bool overwrite,
[Option("--dry-run")] bool dryRun,
CancellationToken cancellationToken) => /* application operation */ Task.CompletedTask;
Customize a generated command's human result without another handler or codec:
var catalog = GeneratedCommandCatalog.Create(builder =>
builder.Present<Report>("report", (report, console, culture, token) =>
console.WriteOutAsync($"Processed {report.Count} items\n".AsMemory(), token)));
The command's declared result payload identity and source-generated JSON metadata remain unchanged. The delegate only runs in human mode. Register against a canonical command path; a mismatched result type is a catalog validation error.
Completion scripts use catalog context to select commands and aliases, visible
options and the choices for the current option or positional argument. Native
Bash, Zsh, fish and PowerShell wrappers support separated and --option=value
forms, safely dequote typed prefixes and preserve literal choices containing
spaces. Filesystem completion follows visible, non-sensitive path metadata on
options and positional arguments, including aliases and directory-only hints.
The generated scripts request files through catalog path hints; shells may apply
their own default filesystem fallback. The generated scripts require no Python
runtime. Install scripts explicitly in your shell's
completion setup (Zsh requires compinit); Runic never modifies shell profiles.
CommandCompletion.Query(catalog, words, outputOptionName) provides the same
context query for integrations. Exclude the executable from words; the final
word is the current prefix, or an empty string after trailing whitespace. Existing
Generate overloads remain available.
See the complete examples.
Converters and validators must be closed, accessible class or struct types
implementing ICommandValueConverter<T> or ICommandValueValidator<T> for the
exact parameter type, with concrete static implementations. Generator diagnostics
point to invalid attributes. Boolean options are presence flags: they support
validators but reject converters. List binding converts supported element types
and rejects aggregate converters; list validators receive the full bound list.
Explicit culture and localized presentation
Set CommandApp.Culture and CommandApp.TextResolver to resolve help and
framework/execution diagnostics without selecting a localization library. The
culture defaults to CultureInfo.CurrentUICulture, the user's display language,
captured when an invocation starts; framework prose remains English when no resolver is installed. Direct
CommandHelpFormatter.Format calls retain an English default and have an
additive overload accepting CommandTextContext. Hosted presentation uses the
culture passed to CommandLineHostingAdapter.PresentAsync and the resolver on
its CommandPresentation. Configure CommandOutputDispatcher.TextResolver for
a custom or hosted execution sink.
ICommandTextResolver.Resolve receives a key, culture and ordered safe argument
list, and returns null when it cannot resolve the key. Command, argument and
option attributes accept DescriptionKey; Description remains the literal
fallback. Diagnostic messages resolve their MessageKey and Arguments.
Faults without a matching diagnostic use faults.{code} with no arguments; a
diagnostic whose own key is unresolved also falls back to faults.{code}.
CommandTextKeys lists every framework key with constants such as
CommandTextKeys.HelpUsage, and CommandTextKeys.All gives each key's ordered
argument names; the framework text key table
shows them with their English text. Value errors use diagnostics.{kind} keys
such as diagnostics.invalid-integer or diagnostics.out-of-range, whose first
argument is the option spelling or argument placeholder. A typo suggestion is a
trailing argument with its own key, such as diagnostics.unknown-option-suggestion.
CommandTextContext.ValidateDescriptionKeys(catalog)
reports each DescriptionKey the resolver does not resolve (RCLI0023), so a
test can catch a typo that would otherwise show the literal fallback; with
RUNIC_COMMANDLINE_DEBUG=1, help also prints them to stderr as warnings.
Translations change presentation text, including JSON messages, while command
spellings, canonical paths, diagnostic codes/keys and protocol/payload identities
remain unchanged. The existing sanitizers still apply to resolved text.
With Runic Translations, set TextResolver = new TranslationCommandTextResolver(snapshot)
from the Runic.Translations.CommandLine package: it looks up each key in your
catalog by name, falls back to its built-in English and German framework text,
and needs no key mapping. The Runic.Translations.CommandLine package README shows an English/German setup.
Both are optional; this package does not reference Translations.
Commands inside a Runic application
Use CommandLineHostingAdapter when the application already owns startup,
services, cancellation and the choice between CLI and UI. It uses the same
catalog, generated binders, executor and framework presentation as CommandApp.
It does not start a Generic Host, subscribe to Ctrl+C, or dispose your application.
Create it from the application's CommandApp so name, version, text resolver,
help presenter, scope factory, exit policy and observers are declared once:
using Runic.CommandLine;
using Runic.CommandLine.Hosting;
var cli = new CommandLineHostingAdapter(CreateCommandApp(services));
var launch = new HostedCommandLineLaunchInput(args,
outputEnvironmentValue: Environment.GetEnvironmentVariable("RUNIC_COMMANDLINE_OUTPUT"),
emptyInputFallback: EmptyInputFallback.UserInterface)
{
EnvironmentVariables = new Dictionary<string, string?>
{
["MY_APP_ENV"] = Environment.GetEnvironmentVariable("MY_APP_ENV"),
},
};
var decision = cli.Classify(launch);
if (!decision.IsCommandLineRequest)
{
await cli.WriteSuggestionAsync(decision, cancellationToken: applicationStopping);
return await OpenApplicationUiAsync(applicationStopping);
}
return await cli.RunAsync(decision, cancellationToken: applicationStopping);
Classify is synchronous, so a desktop entry point can decide before its UI
loop starts. IsCommandLineRequest is true for an invocation, help, version or
completion request, and for an invalid launch whose leading arguments name a
known command or group (MatchedPath, MatchesKnownCommand). Everything else,
such as a document path or an unknown word, belongs to the UI; no list of
command names needs to be maintained. When an unknown word is a close match for a
visible command, such as lst for list, SuggestedCommand names it and
WriteSuggestionAsync writes the same "Did you mean 'list'?" line the standalone
command line shows to standard error, so the typo is not silent; the UI still
opens, because the word may be a document. RunAsync presents or executes the
decision on the app's Console (or the console you pass) in CommandApp.Culture
or CultureInfo.CurrentUICulture, and returns the exit code.
The application captures only the environment values its options declare.
EnvironmentVariables copies that dictionary; missing keys never fall back to the
process environment. Explicit arguments override captured values, which override
handler defaults. Output-format selection continues to use the separate captured
outputEnvironmentValue. Names in the parameter snapshot are matched exactly.
For full control, construct the adapter from a catalog and a CommandExecutor,
set Presentation, and call PresentAsync and ExecuteAsync yourself:
var cli = new CommandLineHostingAdapter(catalog, new CommandExecutor(applicationCommandScopes))
{
Presentation = new() { Name = "my-app", Version = version, ExceptionObserver = RecordInternalException },
};
var decision = cli.Classify(launch);
if (!decision.CanExecute)
return await cli.PresentAsync(decision, console, culture, correlationId, applicationStopping);
return (await cli.ExecuteAsync(new(decision, console, culture, correlationId, new CommandOutputDispatcher())
{
ExceptionObserver = RecordInternalException,
}, applicationStopping)).ExitCode;
Classify creates no service scope. PresentAsync handles scoped help, version,
usage failures and completion bash|zsh|fish|powershell, also without a scope.
Help and errors respect human/JSON output selection; completion intentionally
writes the raw script, the same context-aware script CommandApp produces. Existing hosts
may keep their own presenters and use the decision's public diagnostics instead.
PresentAsync accepts framework decisions created by that same adapter and
rejects UI and invocation decisions. It is available on the concrete adapter;
the existing IHostedCommandLineAdapter classify/execute contract is unchanged.
ExecuteAsync owns only the invocation scope returned by your scope factory.
Use [FromServices] for handler dependencies. Pass your host's cancellation token
and set ExceptionObserver to your internal logging callback; exception details
stay out of public faults. If you customize exit codes, supply the same policy to
CommandExecutor and Presentation.ExitCodePolicy. An explicit empty-input UI
policy wins even when the catalog declares a default command.
See the runnable hosted example for service injection and the complete launch flow. The example's UI branch is a console placeholder for an application's existing UI launcher.
Start a UI host on the main thread
Some hosts must start on the process's main thread before any await, such as a
desktop host whose event loop owns that thread. CommandApp.RunAsync parses and
executes asynchronously, so a command cannot start such a host itself. Instead it
hands a synchronous continuation back to Main with
CommandExecutionContext.ContinueOnCallingThread, and Main runs the application
with CommandApp.Run, which blocks until the invocation completes and then
invokes the continuation on the thread that called it:
// Program.cs: a synchronous Main, so this thread is the process's main thread.
return CreateCommandApp().Run(args);
internal static class DashboardCommands
{
[Command("ui", Description = "Open the dashboard.")]
internal static async Task Ui(CommandExecutionContext context,
[Option("--flake")] string? flake = null, CancellationToken cancellationToken = default)
{
// Load and validate asynchronously; a failure here is an ordinary command fault.
DashboardSettings settings = await DashboardSettings.LoadAsync(flake, cancellationToken);
context.ContinueOnCallingThread(() =>
DesktopEventLoop.Run(DashboardHost.Options(), desktop => Dashboard.RunAsync(settings, desktop)));
}
}
Run returns the continuation's result as the exit code. It runs the continuation
only when the invocation succeeded and was not cancelled, after its output was
written and its scope disposed, so capture values rather than scoped services.
Help, version and usage errors never reach the command. The invocation runs
without the caller's SynchronizationContext, and HandleCancelKeyPress signal
handling ends before the continuation starts; the host handles its own shutdown.
An exception from the continuation propagates from Run.
Test such a command with CommandAppTester.Run, the synchronous counterpart of
RunAsync: it runs the continuation on the calling thread and returns its exit
code with the output captured after it returned.
CanContinueOnCallingThread is false under RunAsync, the hosting adapter's
RunAsync and a directly used CommandExecutor, where ContinueOnCallingThread
throws InvalidOperationException (a RCLI5000 host failure). A command that can
also run there checks it first.
The hosting adapter does not own the entry point or its thread, so it never runs
a continuation itself. A host that can run one opts in with
AcceptsContinuation on HostedCommandLineExecutionInput and receives it in
HostedCommandLineExecutionResult.Continuation, after the command's output was
written and its scope disposed, and only when the command succeeded and the
host's token was not cancelled. The host runs it on the thread it chooses and
orders it against its own shutdown:
static int Main(string[] args)
{
var adapter = new CommandLineHostingAdapter(app);
HostedCommandLineDecision decision = adapter.Classify(new HostedCommandLineLaunchInput(args));
if (!decision.CanExecute) return StartHostOrPresent(decision);
HostedCommandLineExecutionResult result = adapter.ExecuteAsync(
new HostedCommandLineExecutionInput(decision, console, culture, correlationId, sink) { AcceptsContinuation = true })
.AsTask().GetAwaiter().GetResult();
return result.Continuation is { } next ? next() : result.ExitCode;
}
Main must not await before calling Run: after an await, a top-level program
resumes on a thread-pool thread. The mechanism has no UI dependency and works
with generated and explicitly registered commands and NativeAOT.
Framework presentation failures and command ownership
Help, version, usage-error and completion presentation honor cancellation in both
runners. Cancellation returns the configured Cancelled exit code. Other nonfatal
presentation failures return the configured HostFailure exit code and notify
CommandApp.ExceptionObserver or, for hosted presentation,
CommandPresentation.ExceptionObserver. Execution continues to use the observer
on HostedCommandLineExecutionInput. Observer failures do not replace the original
exit result. Fatal runtime failures propagate.
A presenter or output stream may already have written partial output when it
fails. The framework therefore does not retry output or append a second error
frame; use the exit code and your internal observer to detect these failures.
Argument/decision ownership errors in PresentAsync remain API usage exceptions.
A catalog-owned root command or alias named completion takes precedence over the
built-in script command, and a declared CompletionOption replaces it. This works for manual and generated catalogs. Built-in
scripts use the configured transport selector (for example --runic-output).
With a custom selector, they retain --output only if it is an actual catalog
option. Direct callers can use
CommandCompletion.Generate(catalog, executable, shell, outputOptionName).
When a global option reuses a command-local definition, its name, arity and alias
set must match. Alias ordering is irrelevant; mismatches fail catalog validation
with RCLI0019, rather than producing command-dependent parsing behavior. The
issue names the option ID and both definitions' names and alias sets.
Migrating from 0.2
Existing explicit catalogs, payload identities and exit policies continue to work.
Replace duplicated startup with CommandApp progressively. Retain domain outcome
sinks and custom framework presenters when scripts depend on an existing envelope.
Generated nullable inputs now bind absence as null, optional positional defaults
are honored, and required non-nullable scalar options fail during parsing. Binding
errors identify the parameter and expected type without echoing its value. help <command-path>, prefix output selection, and empty default-command invocations
are now accepted. The version-1 machine envelope has not changed.
For applications with their own --output option, keep
new ParseSettings(transportOutputOptionName: "--runic-output"). Set its
GetEnvironmentVariable callback if that application also declares parameter
environment fallbacks. ParseSettings itself never reads the process environment.
Lower-level registration
Register a command with its binder, handler factory, and source-generated result
codec. Parse captured arguments, then pass a successful invocation to the
executor and CommandOutputDispatcher.
CommandCatalog catalog = new CommandCatalogBuilder()
.Command<HelloOptions, HelloHandler, Greeting>("hello", command => command
.Describe("command.hello")
.BindWith(HelloBinder.Instance)
.CreateHandlerWith(HelloHandlerFactory.Instance)
.Produces(GreetingCodec.Instance))
.Build();
ParseOutcome parse = PortableCommandSyntaxAdapter.Instance.Parse(
catalog,
args,
new ParseSettings(Environment.GetEnvironmentVariable(
CommandOutputClassifier.EnvironmentVariableName)));
if (parse.Kind == ParseOutcomeKind.Invocation && parse.Invocation is not null)
{
var request = new CommandExecutionRequest(
parse.Invocation, console, CultureInfo.InvariantCulture, "request-42");
CommandExecutionResult result = await executor.ExecuteAsync(
request, new CommandOutputDispatcher(), cancellationToken);
return result.ExitCode;
}
console is your ICommandConsole implementation and executor is a
CommandExecutor configured with your ICommandExecutionScopeFactory. See the
complete runnable example
for implementations of the binder, handler, source-generated codec, scope, and
console.
Set RUNIC_COMMANDLINE_OUTPUT=json to write a single UTF-8 JSON response frame
to stdout; the default is human output. The portable adapter also recognizes an
explicit --output human or --output json value, which takes precedence over
the captured environment value.
When to use it
Choose this package for the command model, host launch classification, and
execution pipeline. Add
Runic.CommandLine.Processes
when commands start child processes, and
Runic.CommandLine.Spectre
for rich terminal output. The portable contracts remain directly available from
this package when your own integration exposes or implements them.
Catalog validation reports invalid names, duplicate spellings, invalid arity, and incomplete registrations together in deterministic definition order. Execution creates and disposes exactly one scope for each valid invocation; a success is the only semantic outcome that maps to exit code zero.
Documentation and support
Read the Runic Command Line documentation,
see examples,
look up an RCLI diagnostic code
(generator errors and catalog validation messages link to their section),
or report an issue.
Runic.CommandLine is maintained by Runic Artifex and licensed under the
MIT License.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages (5)
Showing the top 5 NuGet packages that depend on Runic.CommandLine:
| Package | Downloads |
|---|---|
|
Runic.CommandLine.Spectre
Spectre.Console presentation for Runic Command Line applications. |
|
|
Runic.Translations.Tooling
MF2 compiler, interchange, and transactional authoring facade for Runic Translations. |
|
|
Runic.CommandLine.Processes
Bounded and cancellable local process execution for command-line clients. |
|
|
Runic.CommandLine.Testing
Deterministic test helpers for Runic Command Line applications. |
|
|
Runic.Translations.CommandLine
Translates Runic.CommandLine help, errors and faults with Runic Translations, with built-in English and German text. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.7.0-preview.2 | 262 | 10/17/2026 |
| 0.7.0-preview.1 | 906 | 10/14/2026 |
| 0.6.0-preview.5 | 243 | 10/12/2026 |
| 0.6.0-preview.4 | 441 | 10/12/2026 |
| 0.6.0-preview.3 | 682 | 10/9/2026 |
| 0.6.0-preview.2 | 218 | 10/7/2026 |
| 0.6.0-preview.1 | 2,468 | 10/5/2026 |
| 0.5.0-preview.3 | 81 | 10/1/2026 |
| 0.5.0-preview.2 | 78 | 9/25/2026 |
| 0.4.0-preview.1 | 81 | 9/22/2026 |
| 0.3.0-preview.1 | 94 | 9/11/2026 |
| 0.2.0-preview.1 | 98 | 9/9/2026 |