Background img

Craft Terminal Experiences

Write elegant CLIs using Elixir

Dependency-free
Powerful macros
Cmd generator (planned)

Current release v0.4.0

# 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

Building blocks for command line interfaces

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.

Library Features

  • Tux.Dispatcher - define escript entry points or group commands with macros.
  • Tux.Command - implement consistent command modules.
  • Tux.Result - return command results which can be displayed in a user-friendly fashion.
  • Tux.Prompt - display prompts for user input, such as confirmations, integers, floats and more.
  • Tux.Help - construct styled command help messages.
  • Tux.Case - test your command implementations with ease using macros.
  • Preloads - run arbitrary functions before command execution and collect their results (e.g. reading a config file before executing a command).

Library Limitations

  • Argument Parsing - in its current version, tux does not provide any facilities for defining or parsing command line arguments. Args are simply passed to your command module's main function and can be parsed using Elixir's OptionParser.
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

A basic Elixir calculator to add/sub two numbers

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.

Calculator Commands

Here's a summary of the commands our demo program defines:

  • add - add two numbers
  • sub - subtract two numbers (not implemented)

Command Modules

The calculator's functionality is implemented in the following modules:

  • Demo.AddCmd - command module for the add command.
  • Demo.CLI - dispatcher module to control which command module gets executed in reponse to an invoked command.

Executables

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

Implement commands as Elixir 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.

Required Callbacks

  • main/2 - returns the actual command result

Optional Callbacks

  • about/0 - returns short command description
  • help/0 - returns command help message

Callback main/2

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

Create entry points or command groups

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.

cmd/3

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.

Dispatcher Options

When building a dispatcher usingTux.Dispatcher, you can provide options to customize its behaviour:

  • :rescue - a boolean flag to rescue the program when a command crashes (default is `false`)
  • :device - where to send the command output (can be another process) (default is to `:stdio`)
  • :colors - a boolean flag specify whether to print the output with colors (default is `true`)
  • and many more -- see the docs
# 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

Run common code prior to command execution

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).

Preload Registration

Preloads can be registered the following ways:

  • 1. Under :preloads option, passed to the cmd/3 macro.
  • 2. As the first argument passed to the pre/2 macro, intended to register a series of preloads for multiple commands at once.
  • Of course, you can always mix and match the preloads for cmd and pre.

Registration Format

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:

  • preloads: [:fun, ...]
  • preloads: [key: :fun, ...]
  • preloads: [{mod, fun, args}, ...]
  • preloads: [key: {mod, fun, args}, ...]

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

Control command output with shaped responses

One important area of concern in creating user-friendly CLIs is the display of success and error messages in a visually appealing fashion.

Success Outputs

  • :ok - successful command execution with no output and zero exit code.
  • {:ok, term} - successful command execution with output and zero exit code.

Failure Outputs

  • {:error, String | Tux.Explainable} - unsuccessful command execution with error output and non-zero exit code.
# 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

Construct help messages for your commands

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.

  • Help.new() -> start with this.
  • ... build your message here ...
  • Help.ok() <- end with this.
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

Enjoy testing your commands with macros

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.

Macros

The following macros are available when using the Tux.Case module.

  • scenario/4 - create test scenarios with command invocations exactly as you would type them on the command line.
# 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