Three Ruby gems for dry-cli

Build CLIs that feel finished.

I'm Konstantin Gredeskoul (@kigster), and this site describes three gems I wrote that decorate dry-cli : shell completion, help screens worth reading, and a terminal UI for commands that take a while.

dry-cli is the best gem I have found for building command-line tools in Ruby: commands, subcommands, arguments and options, with the best balance between simplicity and power. Each gem here adds one thing it leaves out, and none of them asks you to change a command you already wrote.

~/src/mycli — zsh
$ mycli d<TAB>
deploy db
$ mycli --help
mycli
 
Compile, validate and deploy rules.
 
USAGE
mycli COMMAND [OPTIONS]
 
COMMANDS
deploy Deploy the application to an environment,
with a confirmation unless --force is given
db Show pending migrations
version Print the version
 
$ mycli db migrate
✓ Loading migrations (0.3s)
[⠹] Migrating [◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ ] 64% 16/25 ETA 1.2s
Why?

Over years of building CLI gems on dry-cli I wrote the same missing pieces again and again. Eventually they became three gems, each small enough to add or remove without touching a command.

  1. The global --help has no title and no description

    It lists subcommands and nothing else, so the first screen a user sees never says what the tool is for.

    dry-cli-help
  2. Descriptions cannot span lines, and long ones never wrap

    A one-sentence description runs off the right edge of the terminal instead of wrapping under its column.

    dry-cli-help
  3. There is no color

    Headings, commands and options all print in the same plain text, so nothing guides the eye.

    dry-cli-help
  4. No shell completion for commands and options

    Your registry knows every subcommand, option and value. The shell knows none of them.

    dry-cli-autocomplete
  5. Nothing to show while a slow command runs

    Migrations, imports and deploys need spinners, progress bars and clear failures, and there is no built-in way to draw them.

    dry-cli-ui

dry-cli-autocomplete v0.5.0

TAB, without Ruby in the way.

Native bash and zsh completion generated from your registry. Pressing TAB never starts Ruby.

gem install dry-cli-autocomplete

Ruby 4.0+, bash 3.2+ or zsh
GitHub · RubyGems

Register one command in your CLI. Require the command file, not the gem: it pulls in no emitter and no generator, so your CLI pays nothing at boot for a command that runs once per shell.

require "dry/cli/autocomplete/command"

module MyCLI
  extend Dry::CLI::Registry

  register "version", Version
  register "deploy", Deploy
  register "completion", Dry::CLI::Autocomplete::Command[MyCLI]
end

Your users generate the script once and source it. Every completion it will ever offer is already inside, so pressing Tab spawns nothing and costs nothing.

mycli completion bash > /usr/local/etc/bash_completion.d/mycli

The command reads the program name from $PROGRAM_NAME. If your executable can run under another name, such as through a binstub, pin it:

register "completion", Dry::CLI::Autocomplete::Command[MyCLI, program_name: "mycli"]

A shell with the mycli app from this page. Type part of a command and press Tab, or use the button. Switch the script off to see what the shell does without it.

bash

Try mycli db , mycli version --format , mycli deploy or mycli db migrate . Press Esc then Tab to leave the field.

Your CLI knows its own commands, options, aliases and enum values. The shell does not. A dry-cli app with nested subcommands gives it nothing to work with, so mycli db <TAB> completes file names from the current directory, which is never what you wanted.

rngtng/dry-cli-completion already solves part of this, and it is worth reading first. It falls short in four ways, and each one is an acceptance criterion here.

A group with both a command and children loses the children

Register an overview command at a group's bare name so mycli db --help can explain the group, and its subcommands stop completing. This gem keeps both.

register "db", DbStatus         # the node now has a command
register "db migrate", Migrate  # ...and children, which never get walked

File arguments vanish

Completing a path is the single most common thing a user wants from a CLI. Here a file argument gets compgen -f in bash and _files in zsh.

The entry point is not free

Requiring a completion gem that loads its generator costs every invocation: 160ms for require "dry/cli" becomes 190ms. Here the command file loads nothing else until someone runs mycli completion.

zsh is a bashcompinit shim

zsh users deserve per-option descriptions. This gem prints a native #compdef script. It depends on dry-cli and dry-inflector, and nothing else.

Given this registry:

class Version < Dry::CLI::Command
  desc "Print the version"
  option :format, values: %w[json plain], desc: "Output format"
end

class Deploy < Dry::CLI::Command
  desc "Deploy the application"
  option :force, type: :boolean, aliases: ["-f"], desc: "Skip confirmation"
  argument :environment, values: %w[staging production], required: true, desc: "Target environment"
end

class DbStatus < Dry::CLI::Command
  desc "Show pending migrations"
  option :verbose, type: :boolean, desc: "Print full migration history"
end

class DbMigrate < Dry::CLI::Command
  desc "Run pending migrations"
  option :step, desc: "Migrate to a specific step"
  argument :file, desc: "Migration file to run"
end

register "version", Version
register "deploy", Deploy
register "db", DbStatus do |prefix|
  prefix.register "migrate", DbMigrate
end
register "secret", Secret, hidden: true

mycli completion bash prints a complete -F function. It finds the command path under the cursor, answers option values first, then offers that path's words:

_mycli_completions() {
  # ...walks COMP_WORDS to find the current command path...

  case "$path:$prev" in
    "version:--format") COMPREPLY=($(compgen -W "json plain" -- "$cur")); return ;;
  esac

  words=""
  case "$path" in
    "") words="version deploy db" ;;
    "version") words="--format" ;;
    "deploy") words="--force -f staging production" ;;
    "db") words="migrate --verbose" ;;
    "db migrate") words="--step" ;;
  esac

  COMPREPLY=($(compgen -W "$words" -- "$cur"))
  case "$path" in
    "db migrate") COMPREPLY+=($(compgen -f -- "$cur")) ;;
  esac
}
complete -F _mycli_completions mycli

What that output proves:

  • db offers migrate alongside its own --verbose.
  • db migrate gets real file completion.
  • secret is absent, because hidden commands stay hidden.
  • The -f alias on deploy is there because you declared it.
  • mycli version --format <TAB> offers json plain, and nothing else.
  • mycli deploy <TAB> offers staging production.

The script uses no associative arrays, so it runs under the bash 3.2 that macOS ships as /bin/bash.

mycli completion zsh prints a native script. Options go through _arguments with their desc as help text, subcommands through _describe, declared values become a value list, and file arguments use _files:

('deploy')
      _arguments -s \
        '--force[Skip confirmation]' \
        '-f[Skip confirmation]' \
        '*:Target environment:(staging production)' && ret=0
      ;;
    ('db')
      _arguments -s \
        '--verbose[Print full migration history]' && ret=0
      commands=(
        'migrate:Run pending migrations'
      )
      _describe -t commands 'db command' commands && ret=0
      ;;

Values declared on an option or argument come through at no cost:

option :format, values: %w[json yaml table]    # completes json yaml table after --format
argument :component, values: %w[major minor]   # completes major minor

An argument completes file paths when it declares file: true. Without that key, any argument whose name contains file or path is treated as one. Declare file: false to opt out of the guess:

argument :output, file: true     # completes paths
argument :path, file: false      # does not, despite the name
argument :config_file            # completes paths, by name

Shell function names derive from the program name: a program installed as my-tool gets _my_tool_completions in bash and _my_tool in zsh.

The command takes one argument, bash or zsh, and prints the script to standard output. For bash:

# write it once
mycli completion bash > /usr/local/etc/bash_completion.d/mycli

# or evaluate it from .bashrc
eval "$(mycli completion bash)"

For zsh, where the script calls compdef to register itself:

# anywhere on your $fpath
mycli completion zsh > "${fpath[1]}/_mycli"

# or from .zshrc, after compinit
eval "$(mycli completion zsh)"

Regenerate it when you add or rename commands. Nothing watches for changes, by design.

Cobra and clap route every TAB press to a hidden __complete subcommand. That is the right call for a Go or Rust binary that starts in 10ms. It is the wrong call for Ruby:

Measurement Time
Bare ruby -e '' 100ms
require "dry/cli" 160ms
require "dry/cli" + dry-cli-completion + completely 190ms
require "tax_engine" (a heavy host) 520ms
First touch of that host's data store +239ms
Registry walk and full completion spec build 0.067ms
Generated bash script for 27 commands 257 lines, 9KB

Half a second of dead air per keystroke is unusable, and no amount of lazy loading gets under the host's own require cost. So there is no __complete command. The generator is not optimised either: at 0.067ms it is 0.01% of the cheapest possible invocation.

  • Values your host has to compute. The walk touches only objects dry-cli already holds. Declare values on the option, where dry-cli validates against them anyway.
  • fish, PowerShell or nushell, yet. The emitter interface is built so a fourth shell is a new class.
  • Watch your registry. Regenerating is your call, in your release process.
About

I'm Konstantin Gredeskoul. I have built several command-line tools on dry-cli, and these three gems are the pieces I kept needing. Each one is MIT licensed, requires Ruby 4.0 or newer, and has its source, specification and issues on GitHub:

Not endorsed by dry-rb

The dry- prefix and the Dry::CLI namespaces name the gem these extend. They do not imply endorsement by dry-rb, Hanami or Hanakai, and this site borrows nothing from their look. dry-cli itself is MIT licensed, © the Hanakai team.

A note to the dry-rb maintainers

First, hats off to all of you who built one of the most valuable collections of libraries in the Ruby ecosystem. Extensions let me express what my tools needed and experiment freely. If any of this is useful to dry-cli itself, I would be honored to contribute it, to take feedback and refactor it, or to roll any part of it into a pull request, in whatever shape suits you. Ideas for dry-cli start on the Hanami forum, so that is where the conversation would begin.

Standing on shoulders

dry-cli-ui is built on the TTY Toolkit by Piotr Murach: pastel, tty-box, tty-spinner, tty-progressbar, tty-table, tty-prompt, tty-cursor, tty-screen and strings. dry-cli-help uses pastel for its colors. What each one does.

Written with Claude Code

The gems were written in collaboration with Claude Code. Every line was reviewed and co-written by a human. The part Claude authored most is the zsh completion script in dry-cli-autocomplete. If you prefer not to use gems with AI contributions, the READMEs say so up front, and so does this page.