Craft Terminal Experiences
# A command module implements a command
defmodule PingCmd do
use Tux.Command
@impl true
def about(), do: "Reply with pong"
@impl true
def main(env, args), do: {:ok, "pong"}
end
# A dispatcher registers modules for commands
defmodule CLI do
use Tux.Dispatcher
# Register ping module
cmd "ping", PingCmd
# Register bench module with preloads
cmd "bench", BenchCmd, preloads: [:data, :warmup]
end
Rationale
To properly structure command-line interfaces, developers need to write lots of boilerplate code, and this can slow down development.
To speed up the development of CLI programs in Elixir, tux abstracts some of this boilerplate complexity and helps you deliver well structured, superior terminal user experiences, faster.
KEY CONCEPTS
* Command Name
# The name/keyword which identifies a command.
* Command Dispatcher
# Module responsible for controlling/grouping commands.
* Command Module
# The implementation of a command.
* Command Environment
# Holds command preloads and other info.
* Command Result
# The return shape of a command module.
KEY MODULES
┌────────────────(1a)Prepares────────┐
│ │
│ ┌──────────(2)Executes────────┐ │
│ │ │ │
│ │ ┌──(4)Shows─────┐ │ │
│ │ │ │ │ │
┌──┴─┴────▼──────┐ ┌─────┴──────┐ │ │ Preloads
IO ┤ Tux.Dispatcher │ │ Tux.Result │ │ ┌─▼────┴──┐
└────┬───────────┘ └──▲─────────┘ │ │ Tux.Env │
(2)│Executes (3)│Returns │ └────────┬┘
│ ┌───────────┴─┐ ┌──────▼─────────┐ │
└────►│ Tux.Command │◄─┤ Tux.Dispatcher │ │
└─────▲───────┘ └────────────────┘ │
│ │
└─────(1b)Receives────────────┘
# Notice that in the above example the main dispatcher contains both
# a command module and also another dispatcher.
# This is to illustrate the functionality for
# nesting or grouping commands.
0. Example
Here's a minimal example intended to illustrate the mechanics and capabilities of the tux library.
The demo program is simply a basic math calculator which at the top level defines just two commands, add and sub respectively.
Do note that with tux you can construct nested command hierarchies, so commands can have their own subcommands, and so on.
Here's a summary of the commands our demo program defines:
The calculator's functionality is implemented in the following modules:
In Elixir, to turn an app into an executable you need to define it as an escript. For more details about this, see the official docs for mix escript.build.
But essentially, you need to add add the following directive escript: [main_module: YourMainModule] to your app's :project section in the mix.exs file, thus for our demo program we shall add escript: [main_module: Demo].
# Command module for the `add` subcommand
defmodule Demo.AddCmd do
use Tux.Command
@impl true
def about(), do: "Add two numbers"
@impl true
def main(env, args = [x, y]) do
case env.pre.user do
"@tux" ->
{x, ""} = Integer.parse(x)
{y, ""} = Integer.parse(y)
{:ok, x+y}
_ ->
{:error, "Only tux can run this command"}
end
end
def main(_, _),
do: {:error, "Two arguments required"}
end
# Top-level escript module
defmodule Demo do
defmodule CLI do
use Tux.Dispatcher
cmd "add", Demo.AddCmd, preloads: [:user]
cmd "sub", NotImplemented
def user(_env), do: "@tux"
end
# An escript expects `main/1`
defdelegate main(args), to: CLI
end
# Create executable
$ mix escript.build
# Invoke the add command
$ ./demo add 1 2
3
1. Command Modules
The tux philosophy is: "One module per command".
Thus, in the tux universe, you implement your program's commands as Elixir modules, using the built-in Tux.Command helper.
Simply use Tux.Command in your command module, then implement the required and optional callbacks.
Do note that the command module's main callback accepts two arguments, env and args respectively.
env contains the preloads and other information (see docs), whereas args holds the arguments as typed on the command line.
# Command module construction
defmodule CommandModule do
use Tux.Command
# ... your command code goes here ...
end
# An example of a command module
defmodule HelloCmd do
use Tux.Command
@impl true
def about(), do: "Display hello world"
@impl true
def main(env, args), do: "Hello world"
@impl true
def help() do
Help.new()
|> Help.about(about())
# ... other `Help` functions
|> Help.ok()
end
end
2. Command Dispatchers
A dispatcher is the main entry point to your CLI programs, or to command groups. Its main role is to execute the correct command module whenever a command is invoked.
Use the cmd/3 macro to associate a command name with a corresponding command module, so the dispatcher knows how to control the program execution.
When building a dispatcher usingTux.Dispatcher, you can provide options to customize its behaviour:
# Dispatcher module construction
defmodule CommandDispatcher do
use Tux.Dispatcher, options
# ... register your command modules here ...
end
# Registration of command modules within a dispatcher
defmodule Program do
defmodule CLI do
use Tux.Dispatcher, rescue: true
# Register simple command modules
cmd "ping", PingCmd
cmd "pong", PongCmd
# Register another dispatcher with its own commands
cmd "many", AnotherDispatcher
end
defdelegate main(args), to: CLI
end
3. Command Preloads
When you register command modules, you can optionally supply a series of preloads, which are functions to be executed prior to the invocation of a command module's main function.
The results of all preloads will be available to the command module in the env.pre field.
Preloads can be useful whenever you need commands to execute some routines prior to actual command execution (e.g. reading a config file, logging some action, etc).
Preloads can be registered the following ways:
Preloads must be functions which take at least one argument – the Tux.Env – and which return a result to be passed down the to command module. The results of all preloads registered for a given command can be access as env.pre, keyed under their registered name.
Preloads can be supplied in the following way:
Do note that when the preload is given with a key, that preload's result will be stored under env.pre.key, otherwise it will be stored under the same name as the preload's function name, e.g. env.pre.fun.
# Preloads are passed to `cmd` or `pre` macros
defmodule CommandDispatcher do
use Tux.Dispatcher
# Option 1
cmd "cmd1", CmdModule, preloads: preloads
# Option 2
pre preloads do
cmd "cmd1", CmdModule
cmd "cmd2", CmdModule
end
end
# Option 1: Preloads passed to `cmd` macro
defmodule CLI do
use Tux.Dispatcher
cmd "ping", PingCmd, preloads: [:conf]
cmd "fetch", FetchCmd, preloads: [{Cli, :jitter, [100]}]
# Result will be available under `env.pre.conf`
def conf(env), do: %ConfigFile{}
# Result will be available under `env.pre.jitter`
def jitter(env, ms), do: :timer.sleep(ms) && ms
end
# Option 2: Preloads passed to the `pre` macro
defmodule CLI do
use Tux.Dispatcher
pre [:conf] do
cmd "ping", PingCmd, preloads: [:log]
cmd "fetch", FetchCmd
end
def conf(env), do: %ConfigFile{}
def log(env), do: IO.puts("Command #{env.cmd}")
end
4. Command Outputs
One important area of concern in creating user-friendly CLIs is the display of success and error messages in a visually appealing fashion.
# Display simple error messages
defmodule MyCmd do
use Tux.Command
def main(_, ["1"]), do: {:error, "Failed to execute"}
end
# Display more detailed error messages
defmodule MyCmd do
use Tux.Command
def main(_, ["2"]), do: {:error,
%Tux.Error{
message: "Cmd failure"
details: "This command failed to execute because"
}}
end
5. Help Messages
If you need total control over your command help messages it's you can implement your own, otherwise for simple cases you can use Tux.Help structs to construct consistently looking command help messages.
defmodule MyCmd do
use Tux.Command
@impl true
def help() do
Help.new()
|> Help.about("My first command")
|> Help.options([
{"--flag1", "Some flag description"}
{"--flag2", "Some other flag description"}
])
|> Help.ok()
end
end
6. Command Testing
A library designed to aid in command line development wouldn't be complete if it wouldn't provide some sort of testing facilities.
Making use of Elixir's powerful macro system, tux comes with macros for quick and elegant testing of your program's commands.
The following macros are available when using the Tux.Case module.
# Note that this example assumes the `MyProgram` module
# implements or delegates to a `main/1` function,
# which is usually the entry point to your escript.
defmodule MyCommandTest do
use Tux.Case
scenario "hello cmd works",
using: MyProgram,
invoke: "hello",
expect: [exactly: "hello world"]
scenario "version cmd works",
using: MyProgram,
invoke: "version",
expect: [approx: "0.1"]
scenario "bye cmd works",
using: MyProgram,
invoke: "bye --some-flag --some-other-flag=value",
expect: fn output ->
assert "bye" in output
end
end
tux Write elegant CLIs using Elixir