Skip to content

v2.2.0

Latest

Choose a tag to compare

@JordanMarr JordanMarr released this 23 Aug 19:19
· 11 commits to main since this release

✨ New: Compose inputs with the input { } computation expression

The new input { } CE composes any number of inputs into a single composite input using applicative let!/and! bindings. Each value is named right at its binding site, and your action receives whatever shape you return — typically an anonymous record. No more 8‑input tuple limit, and no more keeping the inputs tuple and setAction parameters in positional sync.

Declare inputs inline

[<EntryPoint>]
let main argv =
    rootCommand argv {
        description "Appends words together"
        input {
            let! words = option<string[]> "--word" |> alias "-w" |> desc "A list of words to be appended"
            and! separator = optionMaybe "--separator" |> alias "-s" |> desc "A character that will separate the joined words."
            return {| Words = words; Separator = separator |}
        }
        setAction (fun io ->
            let separator = defaultArg io.Separator ", "
            io.Words |> String.concat separator |> printfn "%s"
        )
    }

Or declare separately and pass to inputs

The composite input is a normal ActionInput<'T>, so everything that accepts an input accepts it — including the existing inputs operation, alone or mixed into a tuple alongside plain inputs:

let io = input {
    let! words = option<string[]> "--word" |> alias "-w"
    and! separator = option "--separator" |> alias "-s" |> def ", "
    return {| Words = words; Separator = separator |}
}

rootCommand argv {
    description "Appends words together"
    inputs (io, option<bool> "--verbose")
    setAction (fun (io, verbose) ->
        if verbose then printfn $"Joining {io.Words.Length} words"
        io.Words |> String.concat io.Separator |> printfn "%s"
    )
}

Share common options between commands

Composites nest inside other composites, which makes shared option groups trivial — define the group once, bind it from any command that needs it. Shared options are registered only once per command:

let common = input {
    let! verbose = option<bool> "--verbose"
    and! config = option "--configuration" |> def "Release"
    return {| Verbose = verbose; Config = config |}
}

let buildCmd = command "build" {
    description "Builds the solution"
    input {
        let! common = common
        and! quick = option<bool> "--quick"
        return {| Common = common; Quick = quick |}
    }
    setAction (fun io -> build io.Common.Config (io.Quick || not io.Common.Verbose))
}

let testCmd = command "test" {
    description "Runs the tests"
    input {
        let! common = common
        and! filter = optionMaybe<string> "--filter"
        return {| Common = common; Filter = filter |}
    }
    setAction (fun io -> test io.Common.Config io.Filter)
}

Works with your existing action functions

Prefer calling an action function declared above the command? The CE can return any shape — return a tuple and your existing function wires up unchanged:

let unzip (zipFile: FileInfo, outputDirMaybe: DirectoryInfo option) =
    let outputDir = defaultArg outputDirMaybe zipFile.Directory
    printfn $"Unzipping {zipFile.Name} to {outputDir.FullName}..."

[<EntryPoint>]
let main argv =
    rootCommand argv {
        description "Unzips a .zip file"
        input {
            let! zipFile = argument "zipfile" |> desc "The file to unzip" |> validateFileExists
            and! outputDir = optionMaybe "--output" |> alias "-o" |> desc "The output directory"
            return (zipFile, outputDir)
        }
        setAction unzip
    }

Or give the command a named contract with a record type — likely the go-to pattern for larger commands, since the action's signature becomes self-documenting:

type UnzipArgs = {
    ZipFile: FileInfo
    OutputDir: DirectoryInfo option
}

let unzip (args: UnzipArgs) =
    let outputDir = defaultArg args.OutputDir args.ZipFile.Directory
    printfn $"Unzipping {args.ZipFile.Name} to {outputDir.FullName}..."

// ...same command as above, with:  return { ZipFile = zipFile; OutputDir = outputDir }

Rule of thumb: inline action → anonymous record; standalone function → tuple, or a named record type once the inputs grow.

Everything is purely additive — the existing inputs tuple API is unchanged. See the README section for the full set of examples and rules (bind all sources in one let!/and! group; one input { } block per command, before setAction).

🛠️ Fixes

  • ActionInput.GetValue now has a CancellationToken overload, and its Context case constructs a proper ActionContext (previously it would fail if invoked directly).

❤️ Credits

This feature was proposed and first prototyped by @shayanhabibi (#34), who designed the applicative inputs CE for Partas.Build — his fusion of FSharp.SystemCommandLine and Fun.Build. The design here builds directly on his idea. Thank you! 🥇