Daf.Cli
🌍 Language: English | Español
The CLI member of DAF’s application family. It ports the shape of .NET’s Host.CreateApplicationBuilder and adds what a console tool actually needs and .NET leaves to System.CommandLine: a grammar, and the edge around Run.
The whole thing
program GreetTool;
{$APPTYPE CONSOLE}
{$STRONGLINKTYPES ON}
uses
System.SysUtils,
Daf.Extensions.DependencyInjection,
Daf.CommandLine,
Daf.Configuration.CommandLine,
Daf.Cli;
// Una herramienta CLI de la familia, con gramatica de verdad. Lo que enseña es el BORDE del Run:
// --version y --help los contesta el parse, una invocacion que no encaja sale con su propio
// codigo, y lo que se escape --de la accion o de la composicion-- lo decide la politica declarada.
// El .dpr solo escribe el except que se la pasa.
//
// GreetTool --name Jorge -> 0, saluda
// GreetTool --name Jorge --shout -> 0, saluda gritando
// GreetTool --version -> 0, la version
// GreetTool --help -> 0, la ayuda
// GreetTool -> 2, falta un argumento obligatorio
// GreetTool --name boom -> 7, la politica traduce la excepcion
begin
var B := TCliApplication.CreateBuilder(CommandLineConfig.ProcessArgs);
// La politica es un callback, asi que una suite puede compartir la suya sin heredar nada mas. Va
// lo primero porque decide tambien lo que se escape de la composicion.
B.OnException(
function(const Failure: TAppFailure): Integer
begin
Writeln(ErrOutput, 'greet: ', Failure.Error.Message);
Result := 7;
end);
try
var NameOpt := CommandLine.Option<string>('--name').Description('A quien saludar').Required;
var ShoutOpt := CommandLine.Option<Boolean>('--shout', ['-s']).Description('En mayusculas');
B.RootCommand.WithVersion('2.0.0').Add([NameOpt, ShoutOpt]);
// Solo una instancia: la segunda no llega a construir el contenedor.
B.SingleInstance('daf-greet-tool');
B.SetAction(
function(const Services: IServiceProvider; const Parsed: IParseResult): Integer
begin
var Who := NameOpt.ValueIn(Parsed);
if Who = 'boom' then
raise Exception.Create('ese nombre no me gusta');
var Greeting := 'Hola, ' + Who;
if ShoutOpt.ValueIn(Parsed) then
Greeting := Greeting.ToUpper;
Writeln(Greeting);
Result := TCliExit.Ok;
end);
B.ParseArgs(CommandLineConfig.ProcessArgs);
ExitCode := B.Build.Run;
except
// Lo que se escapa antes del Run --de la composicion o del Build-- va a la misma politica.
on E: Exception do
ExitCode := B.HandleException(E);
end;
end.--version, --help, a mistyped option and whatever escapes the action are already covered: the grammar answers the first three, the declared policy takes the last, and Run turns each outcome into an exit code. None of those four lines is written in the .dpr.
The samples
| Sample | What it uses |
|---|---|
QuickStart |
the builder, the root’s action, the exit code — nothing else |
ServiceWiring |
the same, plus a service registered and resolved |
GreetTool |
the grammar, --help, exit codes by policy — the block above |
ConsoleSample |
the immediate style: the environment composes the settings path before anything is built, and IOptions<T> reaches a service by constructor |
PackageInit |
a package that registers its own IStartupTask, so the composition root names neither the task nor what it does |
The immediate style
The builder is usable the moment it is created: Configuration and Environment answer as soon as they are read, so a source can be chosen from what is already configured without a deferred callback. They are loaded at that first read, or at Build if the composition never reads them: creating the builder opens no file.
var B := TCliApplication.CreateBuilder(Args);
// The environment already responds -- this is the case the callback style existed for.
if B.Environment.IsDevelopment then
JsonConfig.AddFile(B.Configuration,
TPath.Combine(B.Environment.ContentRootPath, 'devtools.json'), [csoOptional]);
if B.Configuration.Find('Features:Beta').GetValueOrDefault('') = 'on' then
B.Services.AddModule<TBetaModule>;DAF has one builder style, and it is this one. The deferred style — registering through ConfigureServices callbacks — is gone.
The edge around Run
This is what the layer adds over the host, and what every console tool used to rewrite by hand:
| Member | What it decides | When |
|---|---|---|
SingleInstance(Id) |
Whether the process starts at all. Without it there is no lock, and several instances can run at once | After the parse, before the configuration is loaded and the container built: --help and --version answer without it |
OnException(Policy) |
What happens to what escapes, and with which exit code. Without it, ExitPolicy.LogAndFail |
Around Run, and in the .dpr’s except |
HandleException(E) |
Hands what escapes before Run — the composition, Build — to the same policy, and returns the exit code |
In the .dpr’s except |
SetAction(Action) |
What the root does | Inside Run |
SetAction(Command, Action) |
What a command does when the invocation reaches it | Inside Run |
SetStandaloneAction(Command, Action) |
What a command does without a host: no lock, no startup tasks, no hosted services, no configuration, no container | Inside Run |
RootCommand |
The grammar | Frozen at the first parse |
ParseArgs(Args) |
What the grammar parses. Without it, Build parses an empty invocation |
In the composition, before Build |
RootCommand is a Daf.CommandLine root: a banner above --help and --version goes on its HelpAction and VersionAction, as the Daf.CommandLine README shows. They run without a host: what escapes them goes to the OnException policy, with Failure.Services as nil.
The parse comes first
Build parses — an empty invocation, unless ParseArgs already parsed — before it takes the single-instance lock, loads the configuration or builds the container. --version, --help and an invocation that does not fit the grammar are answered from the parse, so neither another running instance nor a host configuration file that cannot be read keeps them from answering.
What the composition runs before Build is its own, and runs before the parse unless it calls ParseArgs first:
- reading
ConfigurationorEnvironmentloads the configuration at that read; - a source the application adds itself is opened when it is added, and fails there.
A default file of the host that cannot be read — a malformed appsettings.json — is reported through the startup channel where it is opened, and makes Build fail when the invocation reaches an action.
Each command, its action
Run starts the host, runs the action of the command the invocation reached, and stops the host; the process ends with the code the action answers. The action receives the IServiceProvider as a parameter, next to the parse result:
var Greet := CommandLine.Command('greet', 'Say hello');
var Farewell := CommandLine.Command('farewell', 'Say goodbye');
B.RootCommand.Add([Greet, Farewell]);
B.SetAction(Greet,
function(const Services: IServiceProvider; const Parsed: IParseResult): Integer
begin
Writeln(Services.GetRequiredService<IGreeter>.Greet);
Result := TCliExit.Ok;
end);SetAction(Action)declares the root’s action;SetAction(Command, Action), a command’s. The action is installed on the command there and then, so a command with subcommands and an action is an invocation of its own.- Declaring in the builder the action of a command that already has one raises
ECliCompositionErrorat that call. ACommand.SetActionwritten afterwards replaces it, as it replaces any action. - An application that declares no action is its startup tasks and its hosted services, and
RunanswersTCliExit.Ok. - What escapes the host’s startup, an action or the shutdown goes to the
OnExceptionpolicy. A startup failure arrives withFailure.ReportedasTrue, because the host has already reported it; the action does not run, and the host is stopped anyway. - A failure while stopping the host arrives with
Failure.ServicesasnilandFailure.ReportedasTrue: the container is already shut down, and the host has already reported the failure. If the startup or the action had already failed, the exit code is the one the policy gave that first failure, and the shutdown failure is only reported. - What escapes before
Run— the composition, orBuild— reaches the same policy when the.dpr’sexcepthands it toHandleException, as the sample above does. It arrives withFailure.Servicesasnil, and withFailure.ReportedasTruewhen it has already gone out through the startup channel: a configuration file that cannot be read, a hostBuildthat fails, or a report of the application’s own that carries the exception, throughBootstrapLog.LoggerFor. What the policy already let out ofRunis raised again without going through it a second time. BeforeOnExceptionis declared, the default policy decides. - An
EAbort— whatAbortraises — does not reach the policy: the process ends withTCliExit.Failureand nothing is reported. - While
Runis running, the RTL’sApplicationHandleExceptionhook points at the policy, as in a VCL application: what a queued procedure raises whenCheckSynchronizeprocesses it, and what theOnCreateorOnDestroyof a data module raises, reach it with the container. As in VCL, anOnCreatethat raises does not keep the module from being created. - The exit code is the one the policy gave the first failure, even if the action ends well afterwards.
⚠️ Use the Services parameter. An action that captures the application to read App.Services forms a reference cycle with it — the application holds the grammar, the grammar the action, the action the application — and the application is never released.
A command without a host
SetStandaloneAction(Command, Action) declares the action of a command that runs without a host. When the invocation reaches it there is no single-instance lock, no startup task, no hosted service, no configuration and no container: the action receives only the parse result, as a TCommandAction of Daf.CommandLine.
var Init := CommandLine.Command('init', 'Write the starting configuration');
B.RootCommand.Add(Init);
B.SetStandaloneAction(Init,
function(const Parsed: IParseResult): Integer
begin
TFile.WriteAllText('appsettings.json', '{}');
Result := TCliExit.Ok;
end);It is for what the arguments alone answer, such as an init that writes the configuration an ordinary run requires. Like SetAction, it raises ECliCompositionError when the command already has an action. A Command.SetAction written afterwards replaces the action, and the command stays without a host. What escapes it goes to the OnException policy, with Failure.Services as nil.
The policies that come as standard
OnException takes any callback, and the policy is the application family’s: the Web variant takes the same one. It receives a TAppFailure — the exception in Error, the container in Services, nil when there is none, and Reported when it has already been reported, through the startup channel or by the host while starting or stopping — and returns the exit code. Three are already written:
| What it does | |
|---|---|
ExitPolicy.LogAndFail |
reports through the startup channel and returns TCliExit.Failure; what arrives already reported (Failure.Reported) is not reported again. This is the one that applies when OnException is not called |
ExitPolicy.Rethrow |
lets the exception out of Run, for whoever called it. The host is stopped either way — the shutdown is in the finally |
ExitPolicy.Silent |
returns TCliExit.Failure and says nothing |
ExitPolicy is a class and its three members are virtual, so a suite of applications can derive from it to group its own.
Exit codes
| Code | Meaning |
|---|---|
TCliExit.Ok (0) |
The action succeeded, there was none, or --version / --help answered |
TCliExit.Failure (1) |
What ExitPolicy.LogAndFail returns for an unhandled exception, and how an Abort ends |
TCliExit.InvocationError (2) |
The invocation does not fit the declared grammar |
TCliExit.AlreadyRunning (3) |
Another instance holds the declared single-instance id |
Run returns the exit code, for the .dpr to assign to ExitCode.
The startup channel
What fails before a logging provider exists goes out through the startup channel: a configuration file that cannot be read, a host Build that fails, and what ExitPolicy.LogAndFail reports. Out of the box it writes to stderr and to the system’s debug output.
A file destination is added by the application, in the first lines of the .dpr and before CreateBuilder:
var Data := TPath.Combine(TPath.GetDirectoryName(ParamStr(0)), 'data');
TDirectory.CreateDirectory(Data);
BootstrapLog.AddSink(TFileBootstrapSink.Create(TPath.Combine(Data, 'startup.log')));
var B := TCliApplication.CreateBuilder(CommandLineConfig.ProcessArgs);The path cannot come from Configuration or Environment: reading either one loads the configuration, and a file that cannot be read is reported at that read, through whatever destinations exist then. The Hosting sample StartupReport does the same with the generic host.
A grammar error does not go through the channel: the grammar writes the error to stderr, and the suggestions and the help to stdout, and Run returns TCliExit.InvocationError. To have it in the file as well, the composition reports it between ParseArgs and Build:
var Parsed := B.ParseArgs(CommandLineConfig.ProcessArgs);
if not Parsed.Succeeded then
begin
for var Sink in BootstrapLog.Sinks do
if (Sink as TObject) is TStdErrBootstrapSink then
BootstrapLog.RemoveSink(Sink);
var Log := BootstrapLog.LoggerFor('mytool');
for var Error in Parsed.Errors do
Log.LogWarning('{Error}', [TValue.From<string>(Error.Message)]);
end;
ExitCode := B.Build.Run;The loop over Sinks removes the channel’s stderr destination; with it in place, the error shows twice on the console. On mytool --verbsoe, the file receives:
<date and time> #<process>:<thread> [WARN] mytool | Unrecognized command or argument '--verbsoe'.
When there is no container
Services is the container, and it answers between Build and Run. It is nil in two cases:
- The invocation was settled without building one:
--version,--help, an invocation that does not fit the grammar, a standalone command, or another instance already holding the single-instance id. - After
Runhas returned, always —Stopreleases the host.
So whatever a program needs from the container once the action has run, it keeps from inside the action, which receives the IServiceProvider.
That IServiceProvider is the root one: no scope is opened around the action. A Scoped service the action needs comes from a scope the action opens — Services.CreateScope —; with the validation on, as it is in Development, asking the root provider for it is rejected.
The parse result in the container
When the invocation reaches an action, the parse result is also in the container, as IParseResult: the same object the action receives. A service asks for it by constructor:
constructor TExportJob.Create(const Parsed: IParseResult);
begin
inherited Create;
FParsed := Parsed;
end;And a registration can depend on an argument through a factory, which reads the parse result when it resolves:
var DryRun := CommandLine.Option<Boolean>('--dry-run').Description('Report, do not write');
B.RootCommand.Add(DryRun);
B.Services.AddSingleton<IWriter>(
function(Provider: IServiceProvider): IWriter
begin
if DryRun.ValueIn(Provider.GetRequiredService<IParseResult>) then
Result := TReportWriter.Create
else
Result := TFileWriter.Create;
end);The composition decides with the parse
B.ParseArgs(Args) parses Args with the grammar and freezes it, so the composition can decide with what was parsed — which source to add, which implementation to register. B.ParseArgs(Args, Configuration) parses them with a TParserConfiguration, as rootCommand.Parse(args, configuration) does. It parses once: called again with the same arguments — element by element, case-sensitive — and the same configuration — its two switches, and whether it reads response files — it returns the same result, and so does Build; called with other arguments or another configuration, it raises ECliCompositionError.
var Parsed := B.ParseArgs(Args);
if Loud.ValueIn(Parsed) then
B.Services.AddSingleton<IGreeter, TLoudGreeter>
else
B.Services.AddSingleton<IGreeter, TGreeter>;After ParseArgs, changing the grammar raises ECommandLine; the actions can still be declared.
A composition that skips the rest when the parse answers on its own — help, version, or an invocation that does not fit — and goes straight to Build lets the grammar answer even when what it would add next cannot be read:
var Parsed := B.ParseArgs(Args);
if Parsed.Succeeded and not Parsed.IsHelpRequested and not Parsed.IsVersionRequested then
JsonConfig.AddFile(B.Configuration, 'web.json');
ExitCode := B.Build.Run;A validation that reads the configuration sees only the sources added before ParseArgs.
The arguments, in the grammar and in the configuration
CreateBuilder(Args) loads the arguments into Configuration, as .NET’s Host.CreateApplicationBuilder(args) does. The grammar (RootCommand) parses what ParseArgs(Args) receives, as rootCommand.Parse(args) does: the builder’s arguments do not reach it, and without ParseArgs it parses an empty invocation.
A console tool hands the process arguments to both, and calls ParseArgs once the grammar is declared:
var B := TCliApplication.CreateBuilder(CommandLineConfig.ProcessArgs);
// ...the grammar, the services and the actions
B.ParseArgs(CommandLineConfig.ProcessArgs);
ExitCode := B.Build.Run;A process whose arguments are not to be trusted, such as a CGI — which receives the query of a URL without = as arguments — creates the builder without them, and hands them only to the grammar with response files turned off: --help and --version answer, the configuration does not see them, and a token starting with @ arrives as a value, so no file is read.
var B := TCliApplication.CreateBuilder;
var Untrusted: TParserConfiguration;
Untrusted.ResponseFileTokenReplacer := nil;
B.ParseArgs(CommandLineConfig.ProcessArgs, Untrusted);When both receive the same arguments, the configuration reads them without the grammar. It takes --key=value and --key value, ignores bare flags and positionals, and reads a flag followed by a positional as a key and its value. On mytool --verbose report.txt:
- the grammar sees the flag
--verboseand the positional argumentreport.txt; - the configuration sees the key
verbosewith the valuereport.txt.
What the grammar declares is read from the parse result, not from Configuration.
Handed to both, a configuration key the grammar does not declare —--Db:Server=localhost— keeps the application from starting: the grammar rejects what it does not recognise by default, and Run returns TCliExit.InvocationError. To accept it, the root says so:
B.RootCommand.AllowUnmatchedTokens;Where it diverges from .NET
| .NET | Here | Why |
|---|---|---|
Extension methods (AddX, UseX) |
Methods of the record, or a class facade |
Delphi has no extension methods, and only one record helper is visible per type — a fluent API spread over units breaks silently when a uses is reordered |
IHostApplicationBuilder.Metrics |
Not ported | There is no equivalent piece in DAF to point at |
ConfigureContainer<T> |
Not ported | A generic method is not expressible in a Delphi interface — not rejected, unrepresentable. The default provider’s options, which .NET sets through it, are ServiceProviderOptions |
Command.SetAction, whose action receives the ParseResult |
B.SetAction(Command, Action), whose action also receives the IServiceProvider |
System.CommandLine has no host. Here the host starts before the action runs and its provider arrives as a parameter |
| No app-global | Same | In console there is no Application global the user expects: the action receives the IServiceProvider it needs |
The last row is the one that separates this variant from the desktop ones: VCL and FMX do offer a global, because their platform already has one.
Package dependencies
Daf.Cli requires Daf.Hosting and Daf.CommandLine. The parser is not optional here: --version and --help are answered by the grammar, so every CLI application of the family links the parser — the same price .NET pays.