United Kingdom: +44 (0)208 088 8978

Running shell commands from F# with Owl.cli

Dragos takes a look at Owl.cli a library that facilitates the use of shell from F#

We're hiring Software Developers

Click here to find out more

Introduction

Welcome to an exploration of the "Owl.cli" library, a dynamic F# tool that offers an accessible and efficient method to run console commands and fetch their output right at your fingertips.

Overview

A key feature of "Owl.cli" is its use of computation expressions. With the power to create their own custom operations, F# computation expressions provide a highly effective and smooth way to handle console commands. Let's have a look at how this works in action:

open Owl.cli.pwsh

use builder = pwsh () {
  exec "cd /bin"
  exec "ls ./" into r
  printfn $"%s{r}"
}

In the above example, the exec operation executes console commands within the computation expression. We're also using into to fetch the result, making it straightforward to work with the command output.

To gracefully terminate the computation expression, we use the exit operation as follows:

use builder = pwsh () {
  exec "cd /bin"
  exec "ls ./"
  exit
}

And if you want to get hold of the results for further processing, it's as simple as:

use builder = pwsh () {
  exec "cd /bin"
  exec "ls ./"
  exit
}

builder.results |> Array.iter (printfn "%A")

Code Samples

It's time now to dive deeper into the code and unravel its magic

Let's look at how the CE is being built along with the operations mentioned previously

open System.Runtime.InteropServices

[<System.Runtime.Versioning.SupportedOSPlatform("Windows")>]
[<System.Runtime.Versioning.SupportedOSPlatform("macOS")>]
module pwsh =
  let inline private get'install'path () =
    use key = Microsoft.Win32.Registry.LocalMachine
    use sub = key.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\pwsh.exe")
    if sub = Unchecked.defaultof<_> || sub.GetValue("") = Unchecked.defaultof<_>
      then raise (exn "'pwsh' is not installed.")
      else sub.GetValue("") |> string

  let private pwsh' =
    if RuntimeInformation.IsOSPlatform OSPlatform.Windows
      then get'install'path ()
    elif RuntimeInformation.IsOSPlatform OSPlatform.OSX
      then "/usr/local/bin/pwsh"
    else
      raise (System.NotSupportedException "Only supports Windows and macOS.")

  let private psi' = System.Diagnostics.ProcessStartInfo (pwsh', 
    // enable commnads input and reading of output
    UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true,
    // hide console window
    WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden, CreateNoWindow = true)

  [<System.Runtime.Versioning.SupportedOSPlatform("Windows")>]
  [<System.Runtime.Versioning.SupportedOSPlatform("macOS")>]
  type PwshBuilder () =
    inherit MsshellBuilder(psi')

  [<System.Runtime.Versioning.SupportedOSPlatform("Windows")>]
  [<System.Runtime.Versioning.SupportedOSPlatform("macOS")>]
  let pwsh () = new PwshBuilder()

Our journey starts with the pwsh module, responsible for setting up a PowerShell environment for running commands.
First up is get'install'path (), a private function that uses encapsulation for exclusive use within this module. Its mission? To locate the installation path of PowerShell on the local machine. It ventures into the depths of the Windows Registry, expecting to find PowerShell nestled in its default location. If its quest comes up short, it signals an error with an exception.

Next, we have psi', a binding that holds the ProcessStartInfo object. Its role is to lay the groundwork for launching a new process, in this case, PowerShell. It’s a vital cog in the machine, set up to both accept inputs and dish out outputs. It also operates behind the scenes, running without creating a window.

Finally, the PwshBuilder type, is inherited from MsshellBuilder. The former takes psi' as an argument when it's called, suggesting that MsshellBuilder relies on psi' to control and create the PowerShell process.

Let's have a look at MsshelBuilder and see how exec, exit and results are being used:

open System
open System.Diagnostics
open System.Text
open Owl.cli.common

[<AbstractClass>]
type MsshellBuilder (psi: ProcessStartInfo) =
  let eoc' = "echo \"Owl.cli.console: End of command\""
  let output' = ResizeArray<Output>()
  let prc' = Process.Start psi
  let mutable state' = Stop
  do
    state' <- Running
    prc'.StandardInput.WriteLine(eoc')
    let mutable s = prc'.StandardOutput.ReadLine()
    while s <> Unchecked.defaultof<_> && not <| s.EndsWith eoc' do
      s <- prc'.StandardOutput.ReadLine()

  member __.Yield (x) = x
  member __.For (x, f) = f x
  member __.Zero () = __  

  member __.results with get () = output'.ToArray()

  [<CustomOperation("exec", AllowIntoPattern=true)>]
  member __.exec (v, [<ProjectionParameter>] cmd: unit -> string) =
    let cmd = cmd()
    let acc = StringBuilder()
    if state' = Running
      then           
        prc'.StandardInput.WriteLine (cmd)
        prc'.StandardInput.WriteLine (eoc')
        let mutable s = prc'.StandardOutput.ReadLine()

        // Discard command string (cmd) logic.
        while not <| s.EndsWith cmd do
          s <- prc'.StandardOutput.ReadLine()
        s <- prc'.StandardOutput.ReadLine()

        while s <> Unchecked.defaultof<_> && not <| s.EndsWith eoc' do
          acc.Append $"{s}{Environment.NewLine}" |> ignore
          s <- prc'.StandardOutput.ReadLine()
        prc'.StandardOutput.ReadLine() |> ignore // Discard command string (echo).
        output'.Add { cmd = cmd; result = acc.ToString() }
    acc.ToString()

  [<CustomOperation("exit")>]
  member __.exit (state: obj) =
    let ret = __.exec (state, fun () -> "exit")
    state' <- Stop;
    __

  interface IDisposable with
    member __.Dispose() = __.exit(__) |> ignore; prc'.Dispose ()

This abstract class is meant to be extended by different types that are customized for different shell (in the previous code we saw it for PowerShell)

The results member allows for accessing the collected command outputs.

The exec member is a custom operation for the computation expression that executes a command. It writes the command to the process's standard input, reads the output, and adds it to the output' collection. It's decorated with the AllowIntoPattern attribute, which means that it can be used with the into keyword in the computation expression to bind the result of the command to a variable.

The exit member is another custom operation that sends an "exit" command to the process and changes the state to Stop.

Conclusion

In this blog post we had a look Owl.cli and how it allows us to use shell commands directly from F#. I hope you enjoy it this blog post 🙂