Get started with Runic Command Line
On this page
Runic Command Line turns typed C# methods into commands with help, validation and human or JSON output. This page takes you from an empty folder to a working command.
It covers release 0.7.0-preview.2. Every link to the
Command Line repository
points at that release, so the code matches the packages you install.
What it is #
Runic Command Line is a command framework for .NET 10 that works with
NativeAOT. You write a static method and mark it with [Command]. A source
generator, which ships inside the core package, reads those methods at build
time and creates a command catalog, GeneratedCommandCatalog. CommandApp
runs that catalog: it parses the arguments, shows help and the version,
executes the command and maps the outcome to an exit code. There is no runtime
reflection, so the result can be published as a small native executable.
The library does not depend on a UI framework, a Generic Host or a particular parser. Presentation with Spectre.Console is an optional package.
Packages #
All packages release together. Keep the optional packages on exactly the same version as the core package.
| Package | Purpose |
|---|---|
Runic.CommandLine |
Commands, parsing, execution, hosting and output. Includes the generator. |
Runic.CommandLine.Spectre |
Optional Spectre.Console presentation: styled help, tables, progress, prompts. |
Runic.CommandLine.Processes |
Optional bounded execution of local child processes. |
Runic.CommandLine.Testing |
Optional in-memory test helpers, such as CommandAppTester. |
Your first command #
Create a console project and add the core package:
dotnet new console --framework net10.0 --name HelloCli
cd HelloCli
dotnet add package Runic.CommandLine --version 0.7.0-preview.2
Replace Program.cs with this program. It is the maintained
hello-world/Program.cs
example:
using Runic.CommandLine;
using Runic.CommandLine.Generated;
return await new CommandApp(GeneratedCommandCatalog.Create()) { Name = "hello" }.RunAsync(args);
internal static class Commands
{
[Command("greet", Description = "Say hello."), DefaultCommand]
internal static string Greet([Argument] string name = "world",
[Option("--count", "-n", Minimum = 1, Maximum = 100)] int count = 1) =>
string.Join('\n', Enumerable.Repeat($"Hello, {name}!", count));
}
The parts you need to know:
[Command("greet")]makes the method a command. A name with a space, such as"config show", creates a command group automatically.[DefaultCommand]runsgreetwhen no command name is given, sohello Adaandhello greet Adado the same.[Argument]is a positional value;[Option("--count", "-n")]is a named option with an alias. A C# default makes it optional. A non-nullable option without a default is required.MinimumandMaximumare checked before the method runs.- The method's return value is the result.
string,void,TaskandValueTaskneed no extra setup. Anintresult is data, not an exit code.
A method can also take a CancellationToken, a CommandExecutionContext or an
ICommandConsole; they are passed in automatically. Other services use
[FromServices].
Build and run #
dotnet run -- Ada --count 2
dotnet run -- Ada --count 2 --output=json
dotnet run -- --help
The first command prints Hello, Ada! twice. The second prints one JSON
response instead; see
Output for people and for scripts.
To ship a native executable, publish with NativeAOT for your platform, as the release's package check does:
dotnet publish -c Release -r linux-x64 -p:PublishAot=true
Help and validation #
CommandApp builds help from the catalog. hello --help, hello help greet
and hello greet --help all work, and --version prints CommandApp.Version.
Set Description on commands, arguments and options to fill the help text.
Input is checked in two places:
- At build time. The generator reports
RCLI9xxxcompiler errors for a[Command]method it cannot use, for example a parameter without[Argument],[Option]or[FromServices]. The command is skipped until you fix the error. Each ID is explained in source generator diagnostics. - At run time. Unknown options, missing required values, values outside
Minimum/Maximum,Choices,MustExistpaths andRequiresorConflictsWithrules fail before the method runs. The error names the parameter but never echoes its value. Close typos get suggestions.
Shared options and environment variables #
An option can fall back to an environment variable. An explicit argument wins over the environment, which wins over the C# default. This command comes from the release's command tree example:
[Command("config show", Description = "Show the chosen environment.")]
internal static string Config([Option("--environment", EnvironmentVariable = "HELLO_ENV", Choices = ["local", "production"])] string environment = "local") => environment;
An option that every command accepts, such as --verbose, is added to the
catalog with GlobalOption. It may appear before or after the command name:
GeneratedCommandCatalog.Create(builder => builder
.GlobalOption("verbose", "--verbose", CommandArity.Zero, new CommandHelp("Show detailed progress."), "-v"))
Output for people and for scripts #
By default a command writes human text. --output json, or the environment
variable RUNIC_COMMANDLINE_OUTPUT=json, switches to machine output: exactly
one JSON response on stdout, with the protocol runic.commandline/1. It holds
success, exitCode, the typed payload on success and a fault on failure.
In JSON mode the injected ICommandConsole sends other output, such as
progress, to stderr, so stdout stays parseable.
Results other than strings declare a payload identity and a source-generated
JSON context with [CommandResult("example.result/1", typeof(JsonContext))].
The
protocol specification
describes every field.
Exit codes #
A command's outcome has a category, CommandExitCategory, and each category
has a default exit code in CommandExitCodes. The defaults below come from
that type and the protocol specification:
| Exit code | Category | Meaning |
|---|---|---|
| 0 | Success |
The command succeeded. |
| 2 | Usage |
The arguments could not be parsed. |
| 3 | Validation |
A value failed validation. |
| 4 | Cancelled |
The command was cancelled, for example Ctrl+C. |
| 5 | Unavailable |
A required resource or service was missing. |
| 10 | CommandFailure |
The command reported an expected failure. |
| 70 | HostFailure |
The host or framework failed unexpectedly. |
A second Ctrl+C, SIGTERM or SIGQUIT while the command still runs ends the
process at once with 128 plus the signal number: 130, 143 or 131. Set
CommandApp.ExitCodePolicy to map categories to other nonzero codes.
Styled output with Spectre #
Add Runic.CommandLine.Spectre at the same version, then set the console and
help presenter. The parsing and the JSON output stay the same:
return await new CommandApp(GeneratedCommandCatalog.Create())
{
Name = "hello",
Console = new SpectreCommandConsole(),
HelpPresenter = new SpectreHelpPresenter()
}.RunAsync(args);
Colors are turned off for redirected output, NO_COLOR and TERM=dumb.
Commands next to an existing application #
An application that already owns its startup, services and UI can run the
same generated commands with CommandLineHostingAdapter. It decides whether
the arguments ask for a command, help or the UI, and runs a command in a
service scope that your application supplies. When the first word is a close
match for a visible command, such as lst for list, the launch still opens
the UI, and decision.SuggestedCommand names the command. Call
adapter.WriteSuggestionAsync(decision) before opening the UI to write the
standalone "Did you mean 'list'?" line to standard error. This is new in
0.6.0-preview.5. The
hosted example
shows the full flow, and
adding a CLI to a WPF application
applies it to a desktop app.
A Runic Desktop host must start on the main thread before any await, so a
command such as my-app ui hands its start back to Main with
ContinueOnCallingThread, and Main returns CommandApp.Run(args), which
runs it after the command, with SDK 0.8.0-preview.4:
// A synchronous Main, or no await before this line: the thread is the process main thread.
return new CommandApp(GeneratedCommandCatalog.Create()) { Name = "my-app" }.Run(args);
internal static class WindowCommands
{
[Command("ui", Description = "Open the main window.")]
internal static async Task Ui(CommandExecutionContext context,
[Option("--settings")] string? path = null, CancellationToken cancellationToken = default)
{
// Asynchronous work and its failures belong to the command, before the host starts.
AppSettings settings = await AppSettings.LoadAsync(path, cancellationToken);
context.ContinueOnCallingThread(() => DesktopEventLoop.Run(new DesktopHostOptions(), async desktop =>
{
await using var provider = new ServiceCollection()
.AddSingleton(settings)
.AddScoped<MainViewModel>()
.AddRunicViews()
.BuildServiceProvider();
var host = new RunicDesktopHost(provider, desktop);
await using var window = await host.OpenWindowAsync<MainWindow>();
await window.WaitForCloseAsync();
return 0;
}));
}
}
Capture values such as settings, not the command's scoped services.
See from a command line
and
start a UI host on the main thread.
Translate a CLI #
Help, parse errors and faults are English by default. The
Runic.Translations.CommandLine adapter, which Runic Translations publishes,
translates them with a Translations catalog. It
ships English and German text for every framework key, and it looks up your
own command descriptions in your catalog, including the prompt text that
Command Line 0.7.0-preview.1 added. Translations 0.7.0-preview.4 needs
Command Line 0.7.0-preview.1 or later.
Add the adapter and the build package to the project from
your first command:
dotnet add package Runic.Translations.CommandLine --version 0.7.0-preview.4
dotnet add package Runic.Translations.Build --version 0.7.0-preview.4
The build package compiles a translations/ folder next to the project file.
Create translations/runic.json:
{
"schemaVersion": 1,
"catalog": "hello",
"code": { "namespace": "HelloCli.Translations", "className": "HelloText" },
"baseLocale": "en",
"locales": ["en", "de"]
}
translations/en.rmf2 defines every message in English:
commands {
greet = Say hello.
}
greeting =
.input {$name :string}
{{Hello, {$name}!}}
translations/de.rmf2 has the same keys in German:
commands {
greet = Grüßt eine Person.
}
greeting =
.input {$name :string}
{{Hallo, {$name}!}}
Then give CommandApp the resolver and the commands a translation manager:
using System.Globalization;
using HelloCli.Translations;
using Runic.CommandLine;
using Runic.CommandLine.Generated;
using Runic.Translations;
using Runic.Translations.CommandLine;
// Under LANG=C the invariant culture has no name; the catalog then uses its base locale.
ITranslationManager manager = HelloTextCatalog.CreateManager(
initialLocale: CultureInfo.CurrentUICulture.Name);
return await new CommandApp(GeneratedCommandCatalog.Create())
{
Name = "hello",
TextResolver = new TranslationCommandTextResolver(manager),
ScopeFactory = CommandScopes.FromServices(CommandServices.Empty.With(manager)),
}.RunAsync(args);
internal static class Commands
{
[Command("greet", Description = "Say hello.", DescriptionKey = "commands.greet"), DefaultCommand]
internal static string Greet([Argument] string name, [FromServices] ITranslationManager translations) =>
new HelloText(translations).Messages.greeting(name);
}
With a German display language, hello --help starts with
Aufruf: hello <Befehl> [Optionen] and lists greet as Grüßt eine Person.,
and hello Ada prints Hallo, Ada!. A locale the catalog does not have, such
as French, gets English. The same text appears in the JSON message fields;
command names, option spellings, codes and messageKey values never change.
- Resolver.
TranslationCommandTextResolverlooks up each key in your catalog first, so the catalog can also override framework text, then in its English and German text, and otherwise leaves Command Line's English text. Pass the manager to follow locale switches, or anITranslationSnapshotfor one fixed locale. A message name is the key with each hyphen removed and the next letter capitalized:help.show-helpishelp.showHelp. The framework text keys list every key and its arguments. CommandScopes.CommandScopes.FromServicesmakes application-owned objects available to[FromServices]parameters without a dependency-injection package. UseCommandScopes.FromServices(provider)for an existingIServiceProvider, orCommandScopes.Create(provider.CreateAsyncScope, scope => scope.ServiceProvider)for one scope per invocation. TakeITranslationManagerand createHelloTextin the method: the Command Line generator cannot use a type that another source generator creates, such asHelloText, as a parameter (RCLI9003).RUNIC_COMMANDLINE_DEBUG. ADescriptionKeythat the catalog does not have falls back toDescriptionwithout an error. SetRUNIC_COMMANDLINE_DEBUG=1to have help print each one to stderr as anRCLI0023warning. In a test,new CommandTextContext(culture, resolver).ValidateDescriptionKeys(catalog)returns the same issues. The same variable writes the exception of anRCLI5000host failure to stderr.
The
Runic.Translations.CommandLine README
describes the resolver in full.
Next steps #
- From one command to an application grows the example step by step: command trees, file input, services, progress, tests, child processes and localized help.
- The
Runic.CommandLinepackage README lists every supported parameter type and attribute property. - The grammar and protocol specifications define parsing and the JSON response.
- Source generator diagnostics
explains each
RCLI9xxxerror. - Release notes for 0.7.0-preview.2 list what changed in this release, and those for 0.7.0-preview.1 what the 0.7 line changed.