Taking a detour to CLI-land
I’ve taken a break from Dagger to build a library for writing CLI apps in Elixir. When I started building Dagger I knew I would eventually need a CLI interface. I hoped someone had already built the Elixir equivalent of Click, a full featured CLI app library written in Python.
Sadly, my search came up empty. My first instinct was to ignore the siren call of Yet Another Problem and build the simplest thing I needed to make progress with Dagger.
I built a minimal app using OptionParser
and some scaffolding
to support sub-commands. It worked but the code was not pretty. Handling optional and required CLI options
and enforcing option types were especially hacky. After a couple of days of work it became clear to me a more
principled approach was necessary.
I decided to treat my first attempt as a learning experience and thought about what features I needed.
- Typed CLI options w/validation
- Support for optional and required options
- Sub-command support
- Easy generation of pretty terminal output
I gave myself a week to build a library which met these requirements. Clik is the result.
Introducing Clik⌗
Clik is designed to simplify writing CLI apps in Elixir packaged as self-contained escripts. App authors
build commands by implementing the Clik.Command
behavior. Instances of the Clik.Option
struct model
CLI options including short names, long names, data type, and optional vs. required.
Clik also includes very basic templating inspired by Elixir’s Inspect.Algebra
. App authors can use
it to generate lines of text with correct line terminators (think Unix vs. Windows) and tables. I’m
especially happy tables are responsive to terminal width and will elide text longer than column width with
ellipses.
Hello, Clik⌗
This is the complete source to a self-contained CLI app using Clik. The complete Mix project is available on Github here.
defmodule HelloClik do
use Clik.Command
alias Clik.{Command, CommandEnvironment, Configuration, Option, Platform}
alias Clik.Renderable
alias Clik.Output.Table
def help_text(), do: "Say hello to the world"
def options() do
%{
verbose: Option.new!(:verbose, short: :v, type: :boolean, help: "Be verbose"),
table: Option.new!(:table, short: :t, type: :boolean, help: "Use a table")
}
end
def run(env) do
use_tables = Keyword.get(env.options, :table, false)
greeting =
if Keyword.get(env.options, :verbose, false) == true do
"Greetings and salutations!"
else
"Hello!"
end
write_output(use_tables, greeting, env.output)
end
defp write_output(false, greeting, out) do
IO.puts(out, greeting)
end
defp write_output(true, greeting, out) do
t =
Table.empty(2, ["Type", "Phrase"])
|> Table.add_row(["Greeting", greeting])
Renderable.render(t, out)
end
def main(args) do
config =
Configuration.add_command!(
Configuration.new(),
Command.new!(:hello, __MODULE__)
)
env = CommandEnvironment.new(Platform.script_name())
Clik.run(config, args, env)
end
end
HelloClik
exercises all Clik’s major features. Let’s break it down.
defmodule HelloClik do
use Clik.Command
.
.
.
def help_text(), do: "Say hello to the world"
def options() do
%{
verbose: Option.new!(:verbose, short: :v, type: :boolean, help: "Be verbose"),
table: Option.new!(:table, short: :t, type: :boolean, help: "Use a table")
}
end
def run(env) do
.
.
.
end
end
The Clik.Command
behavior defines 3 possible callback functions: help_text/0
, options/0
, and run/1
.
Of these only run/1
is required. Clik.Command
provides overrideable do-nothing implementations of
help_text/0
and options/0
. run/1
is where the command implementation executes its main logic. Clik
passes a Clik.CommandEnvironment
struct which contains parsed options, arguments, input, output, and error
IO streams.
If a command has specific options it should implement options/0
and return a map of Clik.Option
structs.
def options() do
%{
verbose: Option.new!(:verbose, short: :v, type: :boolean, help: "Be verbose"),
table: Option.new!(:table, short: :t, type: :boolean, help: "Use a table")
}
end
Clik supports option types of string (default), integer, boolean, and count. Count options increment their value on each occurrence. This is meant for cases where a command wants to specify a level based on the number of times an option occurs. Options can have help text, single-letter short name aliases, indicate when they are mandatory, and provide default values.
The Clik.Output
package contains basic output templating. Commands can build formatted output via Clik.Output.Document
and Clik.Output.Table
. The example command formats its output in a table if the command is called with the -t
or
--table
options. Clik’s tables are automatically responsive to terminal width.
defp write_output(true, greeting, out) do
t =
Table.empty(2, ["Type", "Phrase"])
|> Table.add_row(["Greeting", greeting])
Renderable.render(t, out)
end
Clik is available on Github on the develop branch. As soon as I’m done writing docs I’ll cut a release and publish a hex package.
The best place to ask questions is the Fediverse since I’m online pretty much all the time.