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 🙂