Skip to content

Repository files navigation

dry-cli-autocomplete

Ruby Coverage

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/mycli

The 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 walked

mycli 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: true

mycli 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 mycli

Read what that output proves:

  • db offers migrate alongside its own --verbose, so a group with both a command and children keeps both.
  • 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 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 minor

File 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 name

Program 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-autocomplete

Or 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]
end

That 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/mycli

Or 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 loaded

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

About

Autocomplete for any Ruby CLI tool that's based on the dry-cli. Adds "completion" command for BASH and ZSH.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages