A little story about Fli
This is my first contribution to the FsAdvent on Sergey Tihon's Blog, thank you Sergey for making this possible every year.
Back in September 2022, a wonderful idea came to my mind. I kept thinking more and more over it and took a note, here is the original gist:
type Shell =
| BASH
| POWERSHELL
| CMD
cli {
shell CMD
command "/C copy xyz.fs"
} |> Command.execute
So, what am I talking about? A computation expression which is used to execute CLI commands.
Main motivation: I really enjoy consuming/wrapping public APIs to run it as an F# tool in my CLI. As a concrete use case, I came across the GitHub REST API and wanted to make it available to my CLI. I started fiddling with it and came to that point that many endpoints are for authenticated users, e.g.: information of a user. My first need for building this library was to get those information without any authentication in my application.
The second requirement was that I wanted to run a simple command inside my program, e.g. to run a 3rd party program easily. I didn't want to overload a single function with a ton of parameters, imagine the following code snippet with more and more variables:
open System.Diagnostics
...
Process process = new Process()
ProcessStartInfo startInfo = new ProcessStartInfo(
WindowStyle = ProcessWindowStyle.Hidden
FileName = "cmd.exe"
Arguments = "/C dotnet build -c Release"
)
process.Start(startInfo)
...
That would get messy really quick! Additionally, as a user I want to run other shells than just cmd.exe. Not all users have Windows systems, some want to run those commands in bash or powershell ...
First of all, I wanted a version that fits these two important needs.
How was the first version build and how to use it?
I defined a new module with all my domain types that I need:
module Domain =
type ICommandContext<'a> =
abstract member Context: 'a
type ShellConfig = { Shell: Shells; Command: string }
and Shells =
| CMD
| PS
| PWSH
| BASH
type ProgramConfig = { Program: string; Arguments: string }
type Config =
{ ShellConfig: ShellConfig
ProgramConfig: ProgramConfig }
type ShellContext =
{ config: ShellConfig }
interface ICommandContext<ShellContext> with
member this.Context = this
type ProgramContext =
{ config: ProgramConfig }
interface ICommandContext<ProgramContext> with
member this.Context = this
module Command =
open Domain
open System.Diagnostics
let private shellToProcess =
function
| CMD -> "cmd.exe", "/C"
| PS -> "powershell.exe", "-Command"
| PWSH -> "pwsh.exe", "-Command"
| BASH -> "bash", "-c"
let private startProcess (``process``: string) (argumentString: string) =
let info = ProcessStartInfo(``process``, argumentString)
info.WindowStyle <- ProcessWindowStyle.Hidden
// more assignments
Process.Start(info).StandardOutput.ReadToEnd()
type Command =
static member execute(context: ShellContext) =
let (proc, flag) = context.config.Shell |> shellToProcess
(proc, $"{flag} {context.config.Command}") ||> startProcess
static member toString(context: ShellContext) =
let (proc, flag) = context.config.Shell |> shellToProcess
$"{proc} {flag} {context.config.Command}"
static member execute(context: ProgramContext) =
(context.config.Program, context.config.Arguments) ||> startProcess
static member toString(context: ProgramContext) =
$"{context.config.Program} {context.config.Arguments}"
The Command
module with its type with the same name is for handling the contexts (execute
, toString
).
module Cli =
let shell (shell: Shells) (config: Config) : ShellContext =
{ config = { config.ShellConfig with Shell = shell } }
let command (command: string) (context: ShellContext) : ShellContext =
{ context with config = { context.config with Command = command } }
module Program =
let program (program: string) (config: Config) : ProgramContext =
{ config = { config.ProgramConfig with Program = program } }
let arguments (arguments: string) (context: ProgramContext) : ProgramContext =
{ context with config = { context.config with Arguments = arguments } }
This module above is for setting configurations in the current context, either ShellContext
or ProgramContext
.
module CE =
open Domain
open System.Collections
type ICommandContext<'a> with
member this.Yield(_) = this
let private defaults =
{ ShellConfig = { Shell = CMD; Command = "" }
ProgramConfig = { Program = ""; Arguments = "" } }
type StartingContext =
{ config: Config option }
member this.CurrentConfig = this.config |> Option.defaultValue defaults
interface ICommandContext<StartingContext> with
member this.Context = this
let cli = { config = None }
/// Extensions for Shell context
type ICommandContext<'a> with
[<CustomOperation("Shell")>]
member this.Cli(context: ICommandContext<StartingContext>, shell) =
Cli.shell shell context.Context.CurrentConfig
[<CustomOperation("Command")>]
member this.Command(context: ICommandContext<ShellContext>, command) =
Cli.command command context.Context
/// Extensions for Exec context
type ICommandContext<'a> with
[<CustomOperation("Exec")>]
member this.Exec(context: ICommandContext<StartingContext>, program) =
Program.program program context.Context.CurrentConfig
[<CustomOperation("Arguments")>]
member this.Arguments(context: ICommandContext<ProgramContext>, arguments) =
let args =
match box (arguments) with
| // blanked out / not relevant
| _ -> failwith "Cannot convert arguments to a string!"
Program.arguments args context.Context
In the CE module, the extension methods are using the overloaded CustomOperation
attribute that are introduced in F#6 with RFC FS-1056. With that feature, it's very easy for developers to design a native and fluent computation expression with custom function names in it.
For example, the user writes this code:
cli {
Shell CMD
Command "echo 123"
} |> Command.exeute
The CE module calls Cli.Shell
with CMD
and sets the configuration of the context with its value. Same procedure with Cli.Command
in the CustomOperation
"Command" and sets the string to the actual configuration. When the context is ready, the Command
module will be called, in this case the function execute (context: ShellContext)
. CMD
will be matched to the tuple ("cmd.exe", "/C")
.
cmd.exe
is the process to start and /C
is the flag for cmd.exe
to run the following command and exit process after execution. The execution happens in Command.startProcess
, beyond that the output of that started process will be returned and the user gets this. And that's all for the first deployed version 0.2.0.
For the first five versions, I manually built the Fli package. I was thinking about changing that. I forgot sometimes one command or something else.
Then I remembered that I am currently developing a library for executing CLI commands. 😅
So I started to write a quick building & packing script. That took not long but it took a few attempts until it finally worked.
Now I can build, pack and upload Fli with a single command: dotnet fsi ./build.fsx
At first, I named my little project FsCli
, I thought that was a good name.
After developing the first version, I built the package and wanted to upload my package to Nuget. I got a message on my display that this package name FsCli
is already given and cannot be used.
Together with a friend we did some brain storming. I thought the best name for my project was Fli
, a combination between F# and CLI.
The usage of Fli is very easy.
- Get Fli package from Nuget: https://www.nuget.org/packages/Fli/
open Fli
in your F# code- Use it:
cli {
Shell CMD
Command "dotnet build -c Release"
} |> Command.exeute
It was fun building this CE. I consider building a CE in the future again but that must be well thought out as this one.
Since the first released version I released 6 more versions. Today, I'm announcing Fli v1.0.0.
Happy holidays!
Thank you for commenting!
When you have any questions or ideas, don't hesitate to raise an issue or start a discussion in the repo of Fli: https://github.com/CaptnCodr/Fli