dry-cli-autocomplete
Shell completion for dry-cli applications, with no Ruby in the TAB path.
Note
For the original specification of this gem see SPECIFICATION
Warning
This gem was written with a collaboration with Claude Code. Most of the ruby was written by a human (myself), reviewed and pushed to GitHub by Claude (anyone loves writing commit descriptions?). The part where Claude authored the most code is the ZSH autocompletion code as I'm less familiar with it than BASH. If you prefer not to use gems that had some AI contributions that were reviewed by a human, do not use this gem.
Your CLI knows its own commands, options, aliases and enum values. The shell does not. This gem walks your registry once, prints a bash or zsh script, and you source it from your profile. Pressing TAB then spawns nothing and costs nothing, because every completion the script will ever offer is already inside it.
mycli completion bash > /usr/local/etc/bash_completion.d/mycliThe problem
A dry-cli app with nested subcommands gives the shell nothing to work with. mycli db <TAB> completes filenames from the current directory, which is never what you wanted.
rngtng/dry-cli-completion already solves part of this, and it is worth reading before you reach for this gem. It falls short in four ways, and each one is an acceptance criterion here.
A group that has 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:
register "db", DbStatus # the node now has a command
register "db migrate", Migrate # ...and children, which never get walkedmycli db <TAB> then offers --help and nothing else. This is what any app does when it wants group-level help.
File arguments vanish. Input#input_line returns early on <file>, so a command with a path argument produces no compgen -f, no -o default, no _filedir. Completing a path is the single most common thing a user wants from a CLI, and it is the one thing that does not work.
The entry point is not free. command.rb opens with a require that pulls the generator, which pulls completely. Every host pays for that at boot. Measured: require "dry/cli" costs 160ms, and adding the completion gem takes it to 190ms. Thirty milliseconds on every single invocation, for a command that runs once per shell.
zsh is a bashcompinit shim. It emits autoload -Uz +X bashcompinit && bashcompinit followed by bash. That works, but zsh users get no per-option descriptions and none of the behaviour they expect from a native completion.
There is a dependency argument too. completely pulls colsole, docopt_ng and mister_bin, and mister_bin is itself a CLI framework. That is four gems, one of them a second CLI framework, to print a shell script. This gem 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: truemycli completion bash prints a complete -F function. It walks COMP_WORDS to find 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 mycliRead what that output proves:
-
dboffersmigratealongside its own--verbose, so a group with both a command and children keeps both. -
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 values declared on the positional.
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 #compdef script. Options go through _arguments with their desc as help text, subcommands go through _describe with the command's desc, 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
;;Enum values
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 minorFile arguments
An argument completes file paths when it declares file: true. Without that key, the generator treats any argument whose name contains file or path as a file argument. 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 nameProgram names
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.
Installation
gem install dry-cli-autocompleteOr add it to your Gemfile.
Then register the command in your CLI. Require the command file, not the gem: it pulls in no emitter and no generator, so a host 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]
endThat require pulls in the command class and nothing else. No emitter loads until someone actually runs mycli completion.
The command reads the program name from $PROGRAM_NAME when it runs. If your executable can be invoked under a different name, such as through a wrapper or a binstub, pin it:
register "completion", Dry::CLI::Autocomplete::Command[MyCLI, program_name: "mycli"]The command takes one required argument, bash or zsh, and prints the script to standard output.
Then have your users write the script once and source it. For bash:
mycli completion bash > /usr/local/etc/bash_completion.d/mycliOr evaluate it from .bashrc:
eval "$(mycli completion bash)"For zsh, put it anywhere on your $fpath:
mycli completion zsh > "${fpath[1]}/_mycli"Sourcing it from .zshrc works too, if you would rather not manage a file. Place the line after compinit, since the script calls compdef to register itself:
eval "$(mycli completion zsh)"The script tells the two apart and registers itself either way.
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 here.
| 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. It was considered, costed at roughly 90 lines, and rejected on that table.
The same table explains two other decisions. The generator will not be optimised, because at 0.067ms it is 0.01% of the cheapest possible invocation and all the time goes to interpreter startup. Native extensions were rejected for the same reason, plus they would put a compiled artifact in every consumer's dependency chain.
What it will not do
Values your host has to compute. The walk touches only objects dry-cli already holds. The moment an option's values: calls into your data layer, that cost lands at class-definition time on every invocation, not just completion. In the profiled host that meant 239ms of YAML parsing added to shell startup. Declare the values on the option, where dry-cli validates against them anyway and the generator sees them free.
fish, PowerShell, nushell. Worth adding later. The emitter interface is built so a fourth shell is a new class rather than a new branch in an existing one.
Watch your registry. Regenerating is your call, in your release process.
Development
Ruby 4.0 or newer, matching the gemspec and CI. This repository uses rbenv, so activate it first:
eval "$(rbenv init -)"
bundle install
bundle exec rspec # the suite
bundle exec rubocop # the linter
bundle exec rake # the suite, and the default task
bundle exec rake doc # YARD documentation
bin/console # IRB with the gem loadedTwo conventions in the suite are worth knowing before you add to it. Fixtures include registries this project did not write, because a generator tested against one CLI quietly encodes that CLI's shape. And generated scripts are validated by the shells themselves, with bash -n and zsh -n parsing without executing, since a regex over the output proves nothing about whether it runs.
Author
- Konstantin Gredeskoul pairing with Claude Code. Every line has been reviewed and co-written by a human. The commits were pushed by Claude to save time writing comment descriptions.
Contributing
Bug reports and pull requests are welcome at https://github.com/kigster/dry-cli-autocomplete.
Warning
A quick note on the name. The dry- prefix and the Dry::CLI::Autocomplete namespace do not imply endorsement by dry-rb. This is an independent gem that extends theirs. I hope this functionality will make it into dry-cli one day, however.
License
MIT. See LICENSE.txt.