Quick start #
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"]
Press TAB #
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.
Try mycli db , mycli version --format , mycli deploy
or
mycli db migrate . Press Esc then Tab to leave the field.
The problem #
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.
What it generates #
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:
-
dboffersmigratealongside its own--verbose. -
db migrategets real file completion. -
secretis absent, because hidden commands stay hidden. -
The
-falias ondeployis there because you declared it. -
mycli version --format <TAB>offersjson plain, and nothing else. -
mycli deploy <TAB>offersstaging 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 and files #
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.
Installing the script #
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.
Why the script is static #
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.
What it will not do #
- 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.