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
inputsandsetActionhandler function parameters are caught at compile time Input.optionhelper avoids the need to use theSystem.CommandLine.Optiontype directly (which conflicts with the F#Optiontype)Input.optionMaybeandInput.argumentMaybehelpers allow you to use F#optiontypes in your handler function.Input.contexthelper allows you to pass theActionContextto your action function which is necessary for some operations.Input.injecthelper allows you to inject pre-resolved dependencies (e.g., loggers, services) into your action function alongside parsed CLI inputs.Input.validatehelper allows you to validate against parsed value using the F#Resulttype.
Example
open System.IO open FSharp.SystemCommandLine open Input let unzip (zipFile: FileInfo, outputDirMaybe: DirectoryInfo option) = // Default to the zip file dir if None let outputDir = defaultArg outputDirMaybe zipFile.Directory printfn $"Unzipping {zipFile.Name} to {outputDir.FullName}..." [<EntryPoint>] let main argv = rootCommand argv { description "Unzips a .zip file" inputs ( argument "zipfile" |> desc "The file to unzip" |> validateFileExists |> validate (fun zipFile -> if zipFile.Length <= 500000 then 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:

Input API
The new Input module contains functions for the underlying System.CommandLine Option and Argument properties.
Inputs
contextpasses anActionContextcontaining aParseResultandCancellationTokento the actionargumentcreates a namedArgument<'T>argumentMaybecreates a namedArgument<'T option>that defaults toNone.optioncreates a namedOption<'T>optionMaybecreates a namedOption<'T option>that defaults toNone.injectwraps a pre-resolved dependency value for injection into the action inputs tuple.
Input Properties
acceptLegalFileNamesOnlysets the option or argument to accept only values representing legal file names.acceptLegalFilePathsOnlysets the option or argument to accept only values representing legal file paths.aliasadds anAliasto anOptionaliasesadds one or more aliases to anOptiondescadds a description to anOptionorArgumentdefaultValueordefprovides a default value to anOptionorArgumentdefFactoryassigns a default value factor to anOptionorArgumenthelpNameadds the name used in help output to describe the option or argument.requiredmarks anOptionas requiredrecursivewhen set the option is applied to the immiediate command and recursively to subcommands.validateallows you to return aResult<unit, string>for the parsed valuevalidateFileExistsensures that theFileInfoexistsvalidateDirectoryExistsensures that theDirectoryInfoexistsaddValidatorallows you to add a validator to the underlyingOptionorArgumentacceptOnlyFromAmongvalidates the allowed values for anOptionorArgumentcustomParserallows you to parse the input tokens using a custom parser function.tryParseallows you to parse the input tokens using a custom parserResult<'T, string>function.aritysets the arity of anOptionorArgumentallowMultipleArgumentsPerTokenallows multiple values for anOptionorArgument. (Defaults to 'false' if not set.)hiddenhides an option or argument from the help outputeditOptionallows you to pass a function to edit the underlyingOptioneditArgumentallows you to pass a function to edit the underlyingArgumentofOptionallows you to pass a manually createdOptionofArgumentallows you to pass a manually createdArgument
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:
let alias (alias: string) (input: ActionInput<'T>) = input |> editOption (fun o -> o.Aliases.Add alias) let desc (description: string) (input: ActionInput<'T>) = input |> editOption (fun o -> o.Description <- description) |> editArgument (fun a -> a.Description <- description)
- Since
aliascan only apply toOption, it only callseditOption - Since
desccan apply to bothOptionandArgument, you need to use both
Here is the definition of the built-in Input.validateFileExists function which was built with the existing validate function:
let validateFileExists (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:
let zipFile = 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
open System.IO open FSharp.SystemCommandLine open Input // Ex: fsm.exe list "c:\temp" let listCmd = let action (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" --recursive let deleteCmd = let action (dir: DirectoryInfo, recursive: bool) = if dir.Exists then if recursive then printfn $"Recursively deleting {dir.FullName}" else printfn $"Deleting {dir.FullName}" else printfn $"{dir.FullName} does not exist." let dir = argument "dir" |> desc "The directory to delete" let recursive = option "--recursive" |> def false command "delete" { description "deletes a directory" inputs (dir, recursive) setAction action } [<EntryPoint>] let main 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
CancellationTokenfor 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.
let app (ctx: ActionContext, words: string array, separator: string) = task { let cancel = ctx.CancellationToken // Use cancellation token for async work... } [<EntryPoint>] let main argv = let ctx = Input.context let words = Input.option "--word" |> alias "-w" |> desc "A list of words to be appended" let separator = 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>] let main 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).
module Program open FSharp.SystemCommandLine open Input module Parameters = let words = option "--word" |> alias "-w" |> desc "A list of words to be appended" let separator = optionMaybe "--separator" |> alias "-s" |> desc "A character that will separate the joined words." let app ctx = // Manually parse as many parameters as you need let words = Parameters.words.GetValue ctx.ParseResult let separator = Parameters.separator.GetValue ctx.ParseResult // Do work let separator = separator |> Option.defaultValue ", " System.String.Join(separator, words) |> printfn "Result: %s" 0 [<EntryPoint>] let main 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.
open Serilog open FSharp.SystemCommandLine open Input [<EntryPoint>] let main argv = let logger = LoggerConfiguration() .WriteTo.Console() .CreateLogger() |> Input.inject let name = 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
open System open System.IO open FSharp.SystemCommandLine open Input open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Configuration open Microsoft.Extensions.Hosting open Microsoft.Extensions.Logging open Serilog let buildHost (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() let export (logger: ILogger, connStr: string, outputDir: DirectoryInfo, startDate: DateTime, endDate: DateTime) = task { logger.Information($"Querying from {StartDate} to {EndDate}", startDate, endDate) // Do export stuff... } [<EntryPoint>] let main argv = let host = buildHost argv let cfg = host.Services.GetRequiredService<IConfiguration>()\ let logger = host.Services.GetRequiredService<ILogger>() |> Input.inject let connStr = Input.option "--connection-string" |> Input.alias "-c" |> Input.defaultValue (cfg["ConnectionStrings:DB"]) |> Input.desc "Database connection string" let outputDir = Input.option "--output-directory" |> Input.alias "-o" |> Input.defaultValue (DirectoryInfo(cfg["DefaultOutputDirectory"])) |> desc "Output directory folder." let startDate = Input.option "--start-date" |> Input.defaultValue (DateTime.Today.AddDays(-7)) |> desc "Start date (defaults to 1 week ago from today)" let endDate = 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.
module ProgramNestedSubCommands open System.IO open FSharp.SystemCommandLine open Input module Global = let enableLogging = option "--enable-logging" |> def false let logFile = option "--log-file" |> def (FileInfo @"c:\temp\default.log") type Options = { EnableLogging: bool; LogFile: FileInfo } let options: ActionInput seq = [ enableLogging; logFile ] let bind (ctx: ActionContext) = { EnableLogging = enableLogging.GetValue ctx.ParseResult LogFile = logFile.GetValue ctx.ParseResult } let listCmd = let action (ctx: ActionContext, dir: DirectoryInfo) = let options = 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" } let deleteCmd = let action (ctx: ActionContext, dir: DirectoryInfo, recursive: bool) = let options = Global.bind ctx if options.EnableLogging then printfn $"Logging enabled to {options.LogFile.FullName}" if dir.Exists then if recursive then printfn $"Recursively deleting {dir.FullName}" else printfn $"Deleting {dir.FullName}" else printfn $"{dir.FullName} does not exist." let dir = Input.argument "directory" |> def (DirectoryInfo @"c:\default") let recursive = Input.option "--recursive" |> def false command "delete" { description "deletes a directory" inputs (Input.context, dir, recursive) setAction action addAlias "del" } let ioCmd = command "io" { description "Contains IO related subcommands." noAction addCommands [ deleteCmd; listCmd ] } [<EntryPoint>] let main (argv: string array) = let cfg = commandLineConfiguration { description "Sample app for System.CommandLine" noAction addGlobalOptions Global.options addCommand ioCmd } let parseResult = cfg.Parse(argv) // Get global option value from the parseResult let loggingEnabled = 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
ILoggerdependency to the commands. - Shows help if no command is passed.
module Program open EvolveDb open System.Data.SqlClient open System.IO open Microsoft.Extensions.Hosting open Microsoft.Extensions.Configuration open Microsoft.Extensions.DependencyInjection open Serilog open EvolveDb.Configuration open FSharp.SystemCommandLine open Input open System.CommandLine.Invocation open System.CommandLine.Help let buildHost (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() let repairCmd (logger: ILogger) = let action (env: string) = task { logger.Information($"Environment: {env}") logger.Information("Starting EvolveDb Repair (correcting checksums).") let! connStr = KeyVault.getConnectionString env use conn = new SqlConnection(connStr) let evolve = 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 } let migrateCmd (logger: ILogger) = let action (env: string) = task { logger.Information($"Environment: {env}") logger.Information("Starting EvolveDb Migrate.") let! connStr = KeyVault.getConnectionString env use conn = new SqlConnection(connStr) let evolve = 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>] let main argv = let host = buildHost argv let logger = 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.rootCommanddoes not take the CLI args as an input.ManualInvocation.rootCommanddoes not auto-execute.
open FSharp.SystemCommandLine open Input open System.CommandLine.Parsing let app (words: string array, separator: string option) = let separator = defaultArg separator ", " System.String.Join(separator, words) |> printfn "Result: %s" 0 [<EntryPoint>] let main argv = let words = option "--word" |> alias "-w" |> desc "A list of words to be appended" let separator = optionMaybe "--separator" |> alias "-s" |> desc "A character that will separate the joined words." let cmd = ManualInvocation.rootCommand { description "Appends words together" inputs (words, separator) setAction app } let parseResult = cmd.Parse(argv) // parseResult.InvokeAsync() parseResult.Invoke()
Notes about invocation:
- At this point, you can call
parseResult.Invoke()orparseResult.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
configureParsergives you access to the underlyingParserConfiguration. - FSharp.SystemCommandLine
configureInvocationgives you access to the underlyingInvocationConfiguration. NOTE: This operation is not available on theManualInvocation.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:
module TokenReplacerExample open FSharp.SystemCommandLine open Input let app (package: string) = if package.StartsWith("@") then printfn $"{package}" 0 else eprintfn "The package name does not start with a leading @" 1 [<EntryPoint>] let main 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. let package = 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 }