#0183: RLWRAP and Elixir: A Pragmatic Approach to CLI Readline Support

How to add professional readline features to your Elixir CLI without losing your mind

Tags: braingasm, elixir, erlang, rlwrap, cli, readline, mix, iex, pragmatic, 2025

geoff-oliver-avuhFpqkH8o-unsplash.jpg Photo by Geoff Oliver on Unsplash

4-out-of-5-hats.png [ED: Sometimes the best solution isn’t the most “pure” one. It’s the one that solves the problem, ships to users, and lets you move on to building actual features.]

Braingasm

RLWRAP and Elixir: A Pragmatic Approach to CLI Readline Support

If you’ve built a CLI tool or REPL in Elixir, you’ve probably noticed something frustrating: no command history, no line editing, no tab completion. Press the up arrow and you get ^[[A instead of your previous command. Meanwhile, IEx sitting right there, mocking you with its fully-functional readline support.

So how does IEx do it? And more importantly, how can we get the same features in our Mix tasks without drowning in Erlang internals? Let me tell you a story about pragmatism winning over perfection.

The Missing Readline

Here’s what users expect from a modern CLI:

  • Command history - Up/down arrows navigate through previous commands
  • Line editing - Left/right arrows, Ctrl+A for beginning, Ctrl+E for end
  • Tab completion - Because who remembers every command?
  • Search - Ctrl+R to search command history

Here’s what you get with a basic Elixir CLI using IO.gets/1:

defmodule MyCLI do
  def loop do
    input = IO.gets("prompt> ")
    # Process input...
    loop()
  end
end

Press up arrow? ^[[A. Press tab? Literal tab character. It’s 2025 and we’re living like it’s 1975.

The IEx Enigma

“But IEx has readline support!” you might say. “Let’s just use whatever IEx uses!”

Here’s where things get interesting. IEx achieves its readline magic through a deep integration with Erlang’s terminal handling system:

  • edlin - The line editor module (finally public API as of OTP 26.1!)
  • user_drv - The terminal driver that manages I/O
  • group.erl - The shell I/O server that coordinates everything

These components are woven into the fabric of the Erlang shell. IEx doesn’t just call a function to get readline support - it’s launched with special flags, runs within the shell infrastructure, and has its I/O system specially configured.

Could you replicate this in your Mix task? Technically, yes. Practically? You’d be reimplementing significant portions of the Erlang shell. The documentation is… sparse. The edge cases are numerous. The maintenance burden is real.

Enter RLWRAP: The Pragmatic Hero

RLWRAP (readline wrapper) is a beautiful piece of Unix philosophy: do one thing and do it well. It wraps any command-line program with GNU readline support. It’s been around since 2000, it’s battle-tested, and it just works.

Here’s the thing: sometimes the best solution isn’t the most “pure” one. Sometimes it’s the one that solves the problem, ships to users, and lets you move on to building actual features.

The Three-Tier Solution

After experimenting with various approaches, we settled on a three-tier approach:

Tier 1: Basic Mode

defmodule Mix.Tasks.Repl do
  use Mix.Task
  
  def run(args) do
    System.put_env("REPL_MODE", "true")  # Suppress framework logging
    Mix.Task.run("app.config")
    Mix.Task.run("app.start")
    CLI.main(["repl"] ++ args)
  end
end

Run with: mix repl

This works everywhere, requires nothing extra, but has no readline features. It’s the fallback.

Tier 2: Manual RLWRAP

rlwrap mix repl

The user explicitly wraps the command. They get full readline support, but no custom completions. Power users love this.

Tier 3: The Smart Script

#!/bin/sh
# scripts/repl

cd "$(dirname "$0")/.." || exit 1

COMPLETIONS_FILE="./scripts/completions/completions.txt"

if command -v rlwrap > /dev/null; then
  RLWRAP_OPTS=""
  if [ -f "$COMPLETIONS_FILE" ]; then
    RLWRAP_OPTS="-f $COMPLETIONS_FILE --complete-filenames"
  fi
  rlwrap $RLWRAP_OPTS mix repl $*
else
  echo "Tip: Install rlwrap for better experience" >&2
  mix repl $*
fi

This is the recommended approach. It auto-detects RLWRAP, includes your custom completions, and falls back gracefully.

The Completions File

One of RLWRAP’s best features is custom completions. Create a simple text file:

# scripts/completions/completions.txt
user.list
user.show
user.create
profile.export
help
exit

Now tab completion knows about your commands. It’s that simple.

Making It User-Friendly

The key is making the experience discoverable. Our Mix task detects the environment and provides helpful hints:

defp maybe_suggest_rlwrap do
  if not running_under_rlwrap?() and rlwrap_available?() do
    IO.puts(:stderr, "💡 Tip: Use './scripts/repl' for command history")
  end
end

defp running_under_rlwrap? do
  System.get_env("RLWRAP_COMMAND") != nil
end

defp rlwrap_available? do
  System.find_executable("rlwrap") != nil
end

Users discover the better experience naturally, without requiring documentation deep-dives.

The Trade-offs

Let’s be honest about what we’re giving up:

Cons:

  • External dependency (though easily installed)
  • Can’t enhance readline from within Elixir
  • Requires user installation step

Pros:

  • Works immediately
  • Zero maintenance burden
  • Battle-tested across thousands of programs
  • Users probably already have it installed
  • We ship features instead of building infrastructure

The Lesson

We spent time exploring “proper” solutions:

  • Could we use edlin directly? (Sparse docs, complex integration)
  • Could we embed IEx? (Would change entire interaction model)
  • Could we write a NIF? (Massive overkill)

Then we remembered: we’re building a product, not a readline library. RLWRAP exists. It’s good. Our users are happy. We moved on to building actual features.

Sometimes the best engineering decision is to use the boring, proven solution that already exists. Save your innovation tokens for the problems that actually differentiate your product.

Implementation Recipe

Want to add RLWRAP support to your Elixir CLI? Here’s the recipe:

  1. Create your Mix task with environment detection
  2. Write a wrapper script that handles RLWRAP
  3. Add a completions file with your commands
  4. Document three usage modes in your README
  5. Ship it

The complete implementation is about 50 lines of code. It took us longer to explore the alternatives than to implement the solution.

Conclusion

Perfect is the enemy of good, and in the case of readline support for Elixir CLIs, RLWRAP is more than good - it’s pragmatic, proven, and productive. While we could spend weeks diving into Erlang’s terminal handling internals, our users just want their arrow keys to work.

The next time you’re building a CLI in Elixir, skip the rabbit hole. Use RLWRAP. Your users will thank you, and you’ll thank yourself when you’re maintaining the code a year from now.

Regards,
M@


[ED: If you’d like to sign up for this content as an email, click here to join the mailing list.]

First published on matthewsinclair.com and cross-posted on Medium.

hello@matthewsinclair.com | matthewsinclair.com | bsky.app/@matthewsinclair.com | masto.ai/@matthewsinclair | medium.com/@matthewsinclair | xitter/@matthewsinclair

Stay up to date

Get notified when I publish something new.

Scan the QR-code to sign uo to matthewsinclair.com
Scan to sign up to matthewsinclair.com