Skip to content

Instantly share code, notes, and snippets.

@CaptnCodr
Last active July 24, 2024 17:33
Show Gist options
  • Save CaptnCodr/d709b30eb1191bedda090623d04bf738 to your computer and use it in GitHub Desktop.
Save CaptnCodr/d709b30eb1191bedda090623d04bf738 to your computer and use it in GitHub Desktop.
A little story about Fli

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?

Implementation

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.

Unexpected use case

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

Fun fact

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.

Usage

The usage of Fli is very easy.

  1. Get Fli package from Nuget: https://www.nuget.org/packages/Fli/
  2. open Fli in your F# code
  3. Use it:
cli {
    Shell CMD
    Command "dotnet build -c Release"
} |> Command.exeute

Closing words

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!

@CaptnCodr
Copy link
Author

Nice work! I am going to convert a complex bash script that calls various applications into F# and your library will make it easy and clear. Thanks!

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment