Ruby Butler: Thoughts on CLI Design
This post is about Ruby Butler, a tool I have been working on and using daily for months now. For me, it has already fully replaced my previous Ruby environment tooling.
You can think of it as a tool covering some facets of Bundler and RubyGems, while also overlapping with tools like rv, chruby, rbenv, asdf, or mise on the Ruby-selection side.
In this post I want to share some of the CLI ideas behind it: what problem I was trying to solve, how the interface is structured, and how that all relates to CLI completion.
Why CLI Design Is Hard
Without a clear plan applied strictly from the beginning, a CLI usually starts onboarding messy parts over time. And the more successful a tool becomes, the more old habits, scripts, shortcuts, and compatibility rules start to accumulate around it. That is often the price of success: even good cleanup ideas become expensive once a lot of people already depend on the old shape.
You can see that in Rails, where the CLI ended up carrying a mix of Thor-style commands and older Rake-shaped workflows.
You can see it in Bundler too. Two recent changes are good examples:
bundleis no longer meant to default mentally tobundle install--pathis no longer something you should expect to persist between runs
I do follow all those decisions. They make the tool more explicit and more consistent. But at the same time, some of those older habits are already deeply rooted in my muscle memory, and my mental model built around them is now broken.
None of this is criticism. It is just a reminder that once a CLI becomes important, it becomes hard to reshape.
The Goal
That is why I wanted to be much more intentional with Ruby Butler 0.3.
I use Ruby every day, across different machines, different paths, different Ruby versions, and different systems — mainly Windows and Linux. I was not looking for a tool that only exposes knobs. I wanted something clever enough to do the expected thing for me by default:
- pick a suitable Ruby
- detect Bundler when it makes sense
- prepare gem paths
- sync the bundle when needed
At the same time, I wanted the tool to stay good in two very different situations:
- daily terminal use — short, easy, memorable
- scripts — explicit, readable, predictable
I spent days looking at other tools with strong CLIs — things like npm, cargo, uv, and others — trying to understand what makes them feel natural when they work well. An honorable mention goes to pgBackRest for one of the most logically structured CLIs I have come across.
Out of that came one simple formula:
the CLI should read from left to right, from the broadest context to the most specific action
Left to Right
The custom help now makes the shape explicit:
$ rb
Usage: rb [RUNTIME_OPTIONS] COMMAND [COMMAND_OPTIONS]
Three slots, each narrower than the one before it:
-
[RUNTIME_OPTIONS]- the context to prepare: which Ruby, which directory, whether Bundler is involved -
COMMAND- the action to take in that context -
[COMMAND_OPTIONS]- the detail, handed over to the action
Leave the first slot empty and Butler works out the context on its own:
$ rb exec ruby -v
$ rb info runtime
Fill it in and you override parts of that detection. Notice the right side stays exactly the same:
$ rb --ruby 3.4.5 exec ruby -v
$ rb --work-dir ~/work/myapp --ruby 3.4.5 exec ruby -v
Flags and commands both have a short form, and the two forms mix freely. Here are the same two commands written each way:
$ rb --ruby 3.4.5 exec ruby -v
$ rb -r 3.4.5 x ruby -v
$ rb --work-dir ~/work/myapp --ruby 3.4.5 exec rails console
$ rb -C ~/work/myapp -r 3.4.5 x rails c
Same meaning, different ergonomics. I reach for the long form in scripts, where it explains itself to whoever reads it next, and for the short form in the terminal, where I just want it typed.
Runtime First
Under the hood, this is represented quite literally as ButlerRuntime.
That name fits well, because the first part of the command is really a request for the runtime Butler should prepare before doing anything else.
The code moves in the same direction. Everything on the left is discovered and composed into a single runtime first, and only once that is ready does the command on the right receive it and run.
When I say runtime here, I do not mean only the Ruby executable. I mean the whole execution context:
- selected Ruby
- detected or requested Ruby version
- gem home and gem path
- working directory
- Bundler context, when a
Gemfileis present
Ruby Butler tries to detect as much of that as it reasonably can by following normal Ruby conventions. A .ruby-version file can select the Ruby. A Gemfile can declare a Ruby version. The presence of Gemfile can tell Butler that the project is Bundler-based.
But auto-detection is only useful if it is easy to override.
That is one of the problems I had with some other tools: once you are in a project folder, they assume too much and make opting out awkward. Sometimes I still want to stay in the same folder, but use a different Ruby, or skip Bundler entirely for a moment.
That is why -B / --no-bundler matters:
$ rb -B x gem exec ...
$ rb -B x ruby snippet.rb
That second example is especially useful inside a Rails app. Sometimes I do not want the full Bundler ceremony. I just want to run a quick Ruby snippet and move on.
So for me the CLI is really split into two halves:
- where - runtime options defining the context
- what - command and its arguments
Most of the time, Butler should detect enough that I do not need to specify much. But when I need to tweak or override the environment, the same structure still holds.
Commands, Namespaces, and Aliases
The help output also reflects that structure:
$ rb
Usage: rb [RUNTIME_OPTIONS] COMMAND [COMMAND_OPTIONS]
Workflow Commands:
run (alias: r) Execute project scripts defined in rbproject.toml
exec (alias: x) Execute commands within your meticulously prepared Ruby environment
sync (alias: s) Synchronize your bundler environment with distinguished precision
Diagnostic Commands:
info runtime Detected Rubies and selected runtime
info env Effective Ruby/Bundler environment
info project Resolved rbproject.toml and settings
info config Merged configuration with sources
Utility Commands:
new Create a minimal rbproject.toml in the current directory
version Display Ruby Butler version information
help Display help information for Ruby Butler or specific commands
shell-integration Generate shell integration (completions)
rbproject.toml showing up there is the optional project file from the previous Ruby Butler post. It gives a project a name and a set of scripts.
That grouping is intentional.
The workflow commands (run, exec, sync) are the fast inner loop. The info commands form a small diagnostic namespace. The utility commands stay explicit on purpose.
That is also why not every command has an alias.
The alias namespace is intentionally small. I do not want it filled with debug commands or minor commands. That way the short forms stay unique and easy to remember. Personally, I mostly use only r and x. While writing this post, I realized I use sync directly only rarely, and when I do, I usually type the full command anyway, so it may eventually fit better among utility commands.
Bash Completion That Follows the Grammar
The nice thing about this structure is that completion can follow the same grammar.
It is not enabled by default. Add eval "$(rb shell-integration bash)" to your ~/.bashrc and it is ready. Bash is the only shell covered so far.
At the very start, there is not much context yet:
$ rb <TAB>
run r exec x sync s info new version help shell-integration
Only commands and aliases are suggested. No runtime flags yet.
If I want flags, I ask for flags:
$ rb -<TAB>
-v -V -L -c -P -R -r -G -B -C
$ rb --<TAB>
--verbose --very-verbose --log-level --config --project
--rubies-dir --ruby --gem-home --no-bundler --work-dir
So the suggestion space immediately becomes smaller and more relevant.
Then it starts using the partial context already present on the line:
$ rb -r <TAB>
3.3.7 3.4.5
$ rb -R ~/my-rubies -r <TAB>
3.2.9 3.3.7 3.4.5
Once the command itself is on the line, completion can become even more targeted.
For exec / x, Butler can use the resolved runtime to suggest executables from the current environment:
$ rb x r<TAB>
ruby rails rake rspec
And for run / r, it can suggest project scripts from rbproject.toml:
$ rb run <TAB>
test build deploy
That works especially well together with the project scripts from rbproject.toml.
So the important part is not just that completion exists. It is that completion stays smooth because the CLI itself is stable and predictable. The input is systematic, the runtime can be resolved step by step, and the suggestions can stay narrow and useful instead of becoming one giant static list.