Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

History

321 Commits

Repository files navigation

FSharp.SystemCommandLine

NuGet version (FSharp.SystemCommandLine)

The purpose of this library is to provide quality of life improvements when using the System.CommandLine API in F#.

Click here to view the old beta 4 README

Features

  • Mismatches between inputs and setAction handler function parameters are caught at compile time
  • Input.option helper avoids the need to use the System.CommandLine.Option type directly (which conflicts with the F# Option type)
  • Input.optionMaybe and Input.argumentMaybe helpers allow you to use F# option types in your handler function.
  • Input.context helper allows you to pass the ActionContext to your action function which is necessary for some operations.
  • Input.inject helper allows you to inject pre-resolved dependencies (e.g., loggers, services) into your action function alongside parsed CLI inputs.
  • Input.validate helper allows you to validate against parsed value using the F# Result type.

Example

openSystem.IOopenFSharp.SystemCommandLineopenInputletunzip(zipFile:FileInfo,outputDirMaybe:DirectoryInfo option)=// Default to the zip file dir if NoneletoutputDir= defaultArg outputDirMaybe zipFile.Directory
printfn $"Unzipping {zipFile.Name} to {outputDir.FullName}..."[<EntryPoint>]letmain argv = rootCommand argv {
description "Unzips a .zip file"
inputs (
argument "zipfile"|> desc "The file to unzip"|> validateFileExists
|> validate (fun zipFile ->if zipFile.Length <=500000then Ok ()else Error $"File cannot be bigger than 500 KB"),
optionMaybe "--output"|> alias "-o"|> desc "The output directory"|> validateDirectoryExists
)
setAction unzip
}

💥WARNING: You must declare inputs before setAction or else the type checking will not work properly and you will get a build error!💥

> unzip.exe "c:\test\stuff.zip"
Result: Unzipping stuff.zip to c:\test
> unzip.exe "c:\test\stuff.zip" -o "c:\test\output"
Result: Unzipping stuff.zip to c:\test\output

Notice that mismatches between the setAction and the inputs are caught as a compile time error:fs scl demo

Input API

The new Input module contains functions for the underlying System.CommandLine Option and Argument properties.

Inputs

  • context passes an ActionContext containing a ParseResult and CancellationToken to the action
  • argument creates a named Argument<'T>
  • argumentMaybe creates a named Argument<'T option> that defaults to None.
  • option creates a named Option<'T>
  • optionMaybe creates a named Option<'T option> that defaults to None.
  • inject wraps a pre-resolved dependency value for injection into the action inputs tuple.

Input Properties

  • acceptLegalFileNamesOnly sets the option or argument to accept only values representing legal file names.
  • acceptLegalFilePathsOnly sets the option or argument to accept only values representing legal file paths.
  • alias adds an Alias to an Option
  • aliases adds one or more aliases to an Option
  • desc adds a description to an Option or Argument
  • defaultValue or def provides a default value to an Option or Argument
  • defFactory assigns a default value factor to an Option or Argument
  • helpName adds the name used in help output to describe the option or argument.
  • required marks an Option as required
  • recursive when set the option is applied to the immiediate command and recursively to subcommands.
  • validate allows you to return a Result<unit, string> for the parsed value
  • validateFileExists ensures that the FileInfo exists
  • validateDirectoryExists ensures that the DirectoryInfo exists
  • addValidator allows you to add a validator to the underlying Option or Argument
  • acceptOnlyFromAmong validates the allowed values for an Option or Argument
  • customParser allows you to parse the input tokens using a custom parser function.
  • tryParse allows you to parse the input tokens using a custom parser Result<'T, string> function.
  • arity sets the arity of an Option or Argument
  • allowMultipleArgumentsPerToken allows multiple values for an Option or Argument. (Defaults to 'false' if not set.)
  • hidden hides an option or argument from the help output
  • editOption allows you to pass a function to edit the underlying Option
  • editArgument allows you to pass a function to edit the underlying Argument
  • ofOption allows you to pass a manually created Option
  • ofArgument allows you to pass a manually created Argument

Extensibility

You can easily compose your own custom Input functions with editOption and editArgument. For example, this is how the existing alias and desc functions were created:

letalias(alias:string)(input:ActionInput<'T>)= input
|> editOption (fun o -> o.Aliases.Add alias)letdesc(description:string)(input:ActionInput<'T>)= input |> editOption (fun o -> o.Description <- description)|> editArgument (fun a -> a.Description <- description)
  • Since alias can only apply to Option, it only calls editOption
  • Since desc can apply to both Option and Argument, you need to use both

Here is the definition of the built-in Input.validateFileExists function which was built with the existing validate function:

letvalidateFileExists(input:ActionInput<System.IO.FileInfo>)= input |> Input.validate (fun file ->if file.Exists then Ok ()else Error $"File '{file.FullName}' does not exist.")

And then use it like this:

letzipFile=
argument "zipfile"|> desc "The file to unzip"|> validateFileExists

More Examples

Returning a Status Code

You may optionally return a status code from your handler function by returning an int.

App with SubCommands

openSystem.IOopenFSharp.SystemCommandLineopenInput// Ex: fsm.exe list "c:\temp"letlistCmd=letaction(dir:DirectoryInfo)=if dir.Exists then dir.EnumerateFiles()|> Seq.iter (fun f -> printfn "%s" f.Name)else printfn $"{dir.FullName} does not exist."
command "list"{
description "lists contents of a directory"
inputs (argument "dir"|> desc "The directory to list")
setAction action
}// Ex: fsm.exe delete "c:\temp" --recursiveletdeleteCmd=letaction(dir:DirectoryInfo,recursive:bool)=if dir.Exists thenif recursive
then printfn $"Recursively deleting {dir.FullName}"else printfn $"Deleting {dir.FullName}"else printfn $"{dir.FullName} does not exist."letdir= argument "dir"|> desc "The directory to delete"letrecursive= option "--recursive"|> def false
command "delete"{
description "deletes a directory"
inputs (dir, recursive)
setAction action
}[<EntryPoint>]letmain argv = rootCommand argv {
description "File System Manager"
noAction
// if using async task sub commands:// noActionAsync
addCommand listCmd
addCommand deleteCmd
}
> fsm.exe list "c:\_github\FSharp.SystemCommandLine\src\FSharp.SystemCommandLine"
CommandBuilders.fs
FSharp.SystemCommandLine.fsproj
pack.cmd
Types.fs
> fsm.exe delete "c:\_github\FSharp.SystemCommandLine\src\FSharp.SystemCommandLine"
Deleting c:\_github\FSharp.SystemCommandLine\src\FSharp.SystemCommandLine
> fsm.exe delete "c:\_github\FSharp.SystemCommandLine\src\FSharp.SystemCommandLine" --recursive
Recursively deleting c:\_github\FSharp.SystemCommandLine\src\FSharp.SystemCommandLine

Passing Context to Action

You may need to pass the ActionContext to your handler function for the following reasons:

  • You need access to the CancellationToken for an asynchronous action.
  • You need to manually parse values via the ParseResult. (This is necessary if you have more than 8 inputs.)

You can pass the ActionContext via the Input.context value.

letapp(ctx:ActionContext,words:string array,separator:string)=task{letcancel= ctx.CancellationToken
// Use cancellation token for async work...}[<EntryPoint>]letmain argv =letctx= Input.context
letwords= Input.option "--word"|> alias "-w"|> desc "A list of words to be appended"letseparator= Input.option "--separator"|> alias "-s"|> defaultValue ", "
rootCommand argv {
description "Appends words together"
inputs (ctx, words, separator)
setAction app
}|> Async.AwaitTask
|> Async.RunSynchronously

Showing Help as the Default

A common design is to show help information if no commands have been passed:

[<EntryPoint>]letmain argv =
rootCommand argv {
description "Shows help by default."
inputs Input.context
helpAction
addCommand helloCmd
}

Advanced Examples

More than 8 inputs

Currently, a command handler function is limited to accept a tuple with no more than eight inputs. If you need more, you can pass in the ActionContext to your action handler and manually get as many input values as you like (assuming they have been registered in the command builder's addInputs operation).

moduleProgramopenFSharp.SystemCommandLineopenInputmoduleParameters =letwords= option "--word"|> alias "-w"|> desc "A list of words to be appended"letseparator= optionMaybe "--separator"|> alias "-s"|> desc "A character that will separate the joined words."letapp ctx =// Manually parse as many parameters as you needletwords= Parameters.words.GetValue ctx.ParseResult
letseparator= Parameters.separator.GetValue ctx.ParseResult
// Do workletseparator= separator |> Option.defaultValue ", "
System.String.Join(separator, words)|> printfn "Result: %s"0[<EntryPoint>]letmain argv =
rootCommand argv {
description "Appends words together"
inputs Input.context
setAction app
addInputs [ Parameters.words; Parameters.separator ]}
Injecting Dependencies

You can use Input.inject to pass pre-resolved dependencies into your action handler alongside parsed CLI inputs. This is useful for injecting loggers, database connections, or any other service.

openSerilogopenFSharp.SystemCommandLineopenInput[<EntryPoint>]letmain argv =letlogger=
LoggerConfiguration()
.WriteTo.Console()
.CreateLogger()|> Input.inject
letname= option<string>"--name"|> desc "Your name"
rootCommand argv {
description "Greets a user"
inputs (logger, name)
setAction (fun(logger: ILogger,name)->
logger.Information("Hello, {Name}!", name))}
Microsoft.Extensions.Hosting

This example requires the following nuget packages:

  • Microsoft.Extensions.Configuration
  • Microsoft.Extensions.Hosting
  • Serilog.Extensions.Hosting
  • Serilog.Sinks.Console
  • Serilog.Sinks.File
openSystemopenSystem.IOopenFSharp.SystemCommandLineopenInputopenMicrosoft.Extensions.DependencyInjectionopenMicrosoft.Extensions.ConfigurationopenMicrosoft.Extensions.HostingopenMicrosoft.Extensions.LoggingopenSerilogletbuildHost(argv:string[])=
Host.CreateDefaultBuilder(argv)
.ConfigureHostConfiguration(fun configHost ->
configHost.SetBasePath(Directory.GetCurrentDirectory())|> ignore
configHost.AddJsonFile("appsettings.json", optional =false)|> ignore
)
.UseSerilog(fun hostingContext configureLogger -> configureLogger
.MinimumLevel.Information()
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File(
path ="logs/log.txt", rollingInterval = RollingInterval.Year
)|> ignore
)
.Build()letexport(logger:ILogger,connStr:string,outputDir:DirectoryInfo,startDate:DateTime,endDate:DateTime)=task{
logger.Information($"Querying from {StartDate} to {EndDate}", startDate, endDate)// Do export stuff...}[<EntryPoint>]letmain argv =lethost= buildHost argv
letcfg= host.Services.GetRequiredService<IConfiguration>()\
letlogger= host.Services.GetRequiredService<ILogger>()|> Input.inject
letconnStr=
Input.option "--connection-string"|> Input.alias "-c"|> Input.defaultValue (cfg["ConnectionStrings:DB"])|> Input.desc "Database connection string"letoutputDir=
Input.option "--output-directory"|> Input.alias "-o"|> Input.defaultValue (DirectoryInfo(cfg["DefaultOutputDirectory"]))|> desc "Output directory folder."letstartDate=
Input.option "--start-date"|> Input.defaultValue (DateTime.Today.AddDays(-7))|> desc "Start date (defaults to 1 week ago from today)"letendDate=
Input.option "--end-date"|> Input.defaultValue DateTime.Today
|> Input.desc "End date (defaults to today)"
rootCommand argv {
description "Data Export"
inputs (logger, connStr, outputDir, startDate, endDate)
setAction export
}|> Async.AwaitTask
|> Async.RunSynchronously
Global Options

This example shows how to create global options for all child commands.

moduleProgramNestedSubCommandsopenSystem.IOopenFSharp.SystemCommandLineopenInputmoduleGlobal =letenableLogging= option "--enable-logging"|> def falseletlogFile= option "--log-file"|> def (FileInfo @"c:\temp\default.log")typeOptions={ EnableLogging:bool; LogFile:FileInfo }letoptions:ActionInput seq =[ enableLogging; logFile ]letbind(ctx:ActionContext)={ EnableLogging = enableLogging.GetValue ctx.ParseResult
LogFile = logFile.GetValue ctx.ParseResult }letlistCmd=letaction(ctx:ActionContext,dir:DirectoryInfo)=letoptions= Global.bind ctx
if options.EnableLogging then printfn $"Logging enabled to {options.LogFile.FullName}"if dir.Exists then
dir.EnumerateFiles()|> Seq.iter (fun f -> printfn "%s" f.FullName)else
printfn $"{dir.FullName} does not exist."
command "list"{
description "lists contents of a directory"
inputs (
Input.context,
argument "directory"|> def (DirectoryInfo @"c:\default"))
setAction action
addAlias "ls"}letdeleteCmd=letaction(ctx:ActionContext,dir:DirectoryInfo,recursive:bool)=letoptions= Global.bind ctx
if options.EnableLogging then printfn $"Logging enabled to {options.LogFile.FullName}"if dir.Exists thenif recursive then
printfn $"Recursively deleting {dir.FullName}"else
printfn $"Deleting {dir.FullName}"else
printfn $"{dir.FullName} does not exist."letdir= Input.argument "directory"|> def (DirectoryInfo @"c:\default")letrecursive= Input.option "--recursive"|> def false
command "delete"{
description "deletes a directory"
inputs (Input.context, dir, recursive)
setAction action
addAlias "del"}letioCmd= command "io"{
description "Contains IO related subcommands."
noAction
addCommands [ deleteCmd; listCmd ]}[<EntryPoint>]letmain(argv:string array)=letcfg= commandLineConfiguration {
description "Sample app for System.CommandLine"
noAction
addGlobalOptions Global.options
addCommand ioCmd
}letparseResult= cfg.Parse(argv)// Get global option value from the parseResultletloggingEnabled= Global.enableLogging.GetValue parseResult
printfn $"ROOT: Logging enabled: {loggingEnabled}"
parseResult.Invoke()
Database Migrations Example

This real-life example for running database migrations demonstrates the following features:

  • Uses Microsoft.Extensions.Hosting.
  • Uses async/task commands.
  • Passes the ILogger dependency to the commands.
  • Shows help if no command is passed.
moduleProgramopenEvolveDbopenSystem.Data.SqlClientopenSystem.IOopenMicrosoft.Extensions.HostingopenMicrosoft.Extensions.ConfigurationopenMicrosoft.Extensions.DependencyInjectionopenSerilogopenEvolveDb.ConfigurationopenFSharp.SystemCommandLineopenInputopenSystem.CommandLine.InvocationopenSystem.CommandLine.HelpletbuildHost(argv:string[])=
Host.CreateDefaultBuilder(argv)
.ConfigureHostConfiguration(fun configHost ->
configHost.SetBasePath(Directory.GetCurrentDirectory())|> ignore
configHost.AddJsonFile("appsettings.json", optional =false)|> ignore
)
.UseSerilog(fun hostingContext configureLogger ->
configureLogger
.MinimumLevel.Information()
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File(
path ="logs/log.txt", rollingInterval = RollingInterval.Year
)|> ignore
)
.Build()letrepairCmd(logger:ILogger)=letaction(env:string)=task{
logger.Information($"Environment: {env}")
logger.Information("Starting EvolveDb Repair (correcting checksums).")let!connStr= KeyVault.getConnectionString env
use conn =new SqlConnection(connStr)letevolve= Evolve(conn,fun msg -> printfn "%s" msg) evolve.TransactionMode <- TransactionKind.CommitAll
evolve.Locations <-[|"Scripts"|]
evolve.IsEraseDisabled <-true
evolve.MetadataTableName <-"_EvolveChangelog"
evolve.Repair()}
command "repair"{
description "Corrects checksums in the database."
inputs (argument "env"|> desc "The keyvault environment: [dev, beta, prod].")
setAction action
}letmigrateCmd(logger:ILogger)=letaction(env:string)=task{
logger.Information($"Environment: {env}")
logger.Information("Starting EvolveDb Migrate.")let!connStr= KeyVault.getConnectionString env
use conn =new SqlConnection(connStr)letevolve= Evolve(conn,fun msg -> printfn "%s" msg) evolve.TransactionMode <- TransactionKind.CommitAll
evolve.Locations <-[|"Scripts"|]
evolve.IsEraseDisabled <-true
evolve.MetadataTableName <-"_EvolveChangelog"
evolve.Migrate()}
command "migrate"{
description "Migrates the database."
inputs (argument "env"|> desc "The keyvault environment: [dev, beta, prod].")
setAction action
}[<EntryPoint>]letmain argv =lethost= buildHost argv
letlogger= host.Services.GetService<ILogger>()
rootCommand argv {
description "Database Migrations"
inputs Input.context // Required input for helpAction
helpAction // Show --help if no sub-command is called
addCommand (fun()-> repairCmd logger)
addCommand (fun()-> migrateCmd logger)}
Manually Invoking a Root Command

If you want to manually invoke your root command, use the ManualInvocation.rootCommand computation expression.

NOTES:

  • ManualInvocation.rootCommand does not take the CLI args as an input.
  • ManualInvocation.rootCommand does not auto-execute.
openFSharp.SystemCommandLineopenInputopenSystem.CommandLine.Parsingletapp(words:string array,separator:string option)=letseparator= defaultArg separator ", "
System.String.Join(separator, words)|> printfn "Result: %s"0[<EntryPoint>]letmain argv =letwords= option "--word"|> alias "-w"|> desc "A list of words to be appended"letseparator= optionMaybe "--separator"|> alias "-s"|> desc "A character that will separate the joined words."letcmd= ManualInvocation.rootCommand {
description "Appends words together"
inputs (words, separator)
setAction app
}letparseResult= cmd.Parse(argv)// parseResult.InvokeAsync()
parseResult.Invoke()

Notes about invocation:

  • At this point, you can call parseResult.Invoke() or parseResult.InvokeAsync()
  • You can optionally pass in an InvocationConfiguration:
    • parseResult.Invoke(InvocationConfiguration(EnableDefaultExceptionHandler = false))

Configuration

System.CommandLine (>= v2 beta7) has ParserConfiguration and InvocationConfiguration to allow the user to customize various behaviors.

  • FSharp.SystemCommandLine configureParser gives you access to the underlying ParserConfiguration.
  • FSharp.SystemCommandLine configureInvocation gives you access to the underlying InvocationConfiguration. NOTE: This operation is not available on the ManualInvocation.rootCommand..

For example, the default behavior intercepts input strings that start with a "@" character via the "TryReplaceToken" feature. This will cause an issue if you need to accept input that starts with "@". Fortunately, you can disable this via usePipeline:

moduleTokenReplacerExampleopenFSharp.SystemCommandLineopenInputletapp(package:string)=if package.StartsWith("@")then
printfn $"{package}"0else
eprintfn "The package name does not start with a leading @"1[<EntryPoint>]letmain argv =// The package option needs to accept strings that start with "@" symbol.// For example, "--package @shoelace-style/shoelace".// To accomplish this, we will need to modify the configuration below.letpackage= option "--package"|> alias "-p"|> desc "A package name that may have a leading '@' character."
rootCommand argv {
description "Can be called with a leading '@' package"
configureParser (fun cfg ->// Override default token replacer to ignore `@` processing
cfg.ResponseFileTokenReplacer <-null)
configureInvocation (fun cfg ->
cfg.EnableDefaultExceptionHandler <-false)
inputs package
setAction app
}

About

No description, website, or topics provided.

Resources

Stars

137 stars

Watchers

5 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages