Greetings, folks — first-time caller, long-time listener ![]()
Quick intro before I crack that proposal nut. I’ve been writing Ruby for about twenty years, and I’ve been a fan of dry-rb for about ten years, but I’m a complete noob on these forums. So if some of this comes out incoherent, please forgive me and tell me. Also, I’m Ukrainian by birth, and I tend to drop “the” and “a”, sometimes where they kind of matter. If a sentence below reads like it was written through Eastern European goggles, please ask me, and I’ll say it again properly.
What’s This About
As the AI has come to be oh-so-helpful that we are all supposed to work at 1M x productivity compared to like yesterday, I’ve been working on a very challenging project, where I built a language (grammar/lex/parser) for about 10,000 US Tax forms, with formulas, cross-form references, four levels of data type sensitivity, ambiguity algebra (what’s a minimum of ($2500 and DoesNotApply)) and so on…
It’s written in Rust for speed, and it takes about 2.3ms to compute pretty much anyone’s tax return, scanning some 2K+ forms for this year, identifying relevance, and doing all of this deterministically. As part of this effort, I needed numerous CLI utilities to confirm, verify, analyze, and report errors in a large corpus of Tax forms converted into the new language I call TaxSpec®. That is as much as I can say, since this is a proprietary project.
I’ve written a whole bunch of dry-cli-based utilities in the past, even wrote a blog post about it, and over the months of working with these forms, I found myself building more and more of these CLI tools, and the same things kept coming up over and over again.
And I know that some of these features have just been merged (yay!), such as support for color, for waiting with an animated spinner, etc. All of that is great. But in my opinion, it’s enough as a baseline, but not enough for a real CLI application you might expect your client to run, for instance.
Therefore, as I’ve been sharing in various PRs and forums over the last few months, I ended up writing three small extension gems on top of dry-cli, which are well described on this site: https://dry-cli.tools/
-
dry-cli-help re-renders help screens: wrapping to terminal width, colors, command groups, configurable sections.
-
dry-cli-ui gives commands spinners, progress bars, boxes, tables, and prompts through an
uiobject. -
dry-cli-autocomplete generates bash and zsh completion scripts from a registry.
All three work well and are now integrated into at least four of my CLI tools, but these features have been achieved only by reaching into dry-cli’s private code:
Specifically:
-
dry-cli-help prepends a module to
Dry::CLIand overrides the private#helpand#spell_checker. It also walksCommandRegistry::Nodeto find aliases and hidden commands. -
dry-cli-ui found the command’s streams through
respond_to?(:out, true). -
dry-cli-autocomplete walks
Registry#getand readsLookupResult#childrenandNode#hidden.
Every one of those is marked@api private, and fair enough. The result is what you’d expect. When main got long_desc (#help now takes long:) and the streams got renamed to stdout/stderr, dry-cli-help broke outright, and dry-cli-ui quietly started writing to $stdout instead of the stream the command was given. Nothing failed loudly. That second one is the scary kind.
I don’t think the answer is “make everything public”.
But I would like to propose somewhat surgical extension points, so that gems like mine stop poking at the internals and dry-cli stays free to refactor them.
What I’m proposing
Four separate pieces. Each one stands on its own, so you can vote in or out any one or all of them without sinking the rest. None of them adds a runtime dependency, since dry-cli has none today and I’d hate to be the person who changed that.
1. Exit through an injectable kernel, plus a Launcher
This one accomplishes two things that are pretty important to me:
- It allows complete flexibility with the OS exit code, and
- Perhaps more importantly, it allows complete integration testing of the gem using Aruba in “in_process” mode, instead of the “forking” mode, which is about 10x slower.
For my CLI tools, I really like to test the entire stack, from OS invocation to ARGV parsing, etc. The only way I found to do that is using Aruba (that was the point of my blog post). But in order to do that, the main “launcher” class in your CLI must conform to Aruba’s API, which differs from the existing API by a single parameter: Kernel.
Dry::CLI#call already takes the three streams. If we add a fourth keyword, the thing it exits through:
cli.call(
arguments: ARGV,
stdout:,
stderr:,
stdin:,
kernel: Kernel)
Help, errors and typos currently call exit directly. They’d raise a private Halt instead, and #call turns that into kernel.exit(status). It has to be an exception and not a plain swap, because a stubbed Kernel’s exit actually returns, and then execution walks straight past the help screen into code that expects a parsed command. Commands get kernel through the same auto_initialize mechanism the streams use now, and exit inside a command goes through it too.
On top of that, a small Launcher whose constructor matches what Aruba’s in-process mode calls:
# lib/my_app/launcher.rb
MyApp::Launcher = Dry::CLI::Launcher[MyApp::Commands]
# exe/my_app
MyApp::Launcher.new(ARGV).execute!
# spec/spec_helper.rb
Aruba.configure do |config|
config.command_launcher = :in_process
config.main_class = MyApp::Launcher
end
That gives in-process CLI blazing-fast integration tests with a StringIO per stream and recorded exit codes, with no global $stdout swapping. As I said, I wrote about this pattern in 2020 for a different CLI, and it’s been the most useful testing habit I have for CLIs, and one of the most popular posts on my blog.
With the default Kernel, nothing changes for anybody.
2. A read-only view of the commands
Help renderers, completion generators, and doc generators all need to walk the command tree, and today there’s no public way to do it. I propose to add one:
tree = MyApp::Commands.tree # also Dry::CLI#tree, Dry::CLI::Tree.for(command)
tree.walk(hidden: false) { |node| puts node.path.join(" ") }
tree.dig("db", "migrate").options # name, desc, values, default, aliases, switches...
node, rest = tree.resolve(%w[db migrate --force])
A few details I care about:
-
It’s a live view, not a snapshot. A command registered after you took the tree still shows up. That matters for completion in CLIs that register commands from plugins or config.
-
resolveshares its lookup with dispatch. I pulled the body ofCommandRegistry#getinto a method both use, so “what would the CLI run for these words” can’t drift from what it actually runs. -
Each option and argument carries every key it was declared with. An extension can read its own keys, like
argument :path, file: true, without dry-cli knowing what they mean. -
The internals stay private.
CommandRegistry,LookupResult,OptionandArgumentremain@api private. The tree becomes the one public surface.
I called it Tree because Schema means dry-schema around here, and Help is taken by my gem. That said, I’m not attached to it.
3. A help pipeline
This is the one dry-cli-help actually needs. Help screens get built as a small frozen value and passed through a renderer and then a list of filters before printing:
Dry::CLI.configure do |config|
config.help.renderer = MyGem::Renderer # fills in screen.text
config.help.filters << ->(screen) { screen.with(text: rewrap(screen.text)) }
config.help.filters << ->(screen) { screen.reason == :help ? screen.with(status: 0) : screen }
end
Dry.CLI(MyApp::Commands, config: my_config) # or for one CLI only
The screen knows its kind (one command, or a listing of a level), the reason it’s shown (-h, no command, unknown command), the tree node, the spell checker’s suggestion, the text, the exit status, and both streams. The status picks the stream: 0 goes to stdout, anything else to stderr. So a filter that sets status: 0 for mycli -h also moves it to stdout, which is exactly the behavior I had to hack in by overriding private methods.
Renderers and filters are anything with #call(screen) → screen, so they compose with >>. That covers plain procs, and it also lets dry-transformer functions slot straight in, without dry-cli depending on it.
The default renderer wraps the existing Banner and Usage. With nothing configured, output doesn’t change by a single byte. The existing suite passes untouched, and that is the regression test.
4. Shared objects per command: nothing new, just docs
My commands all receive a ui object through the BaseCommand superclass. This object reaches various primitives in the TTY::Toolkit set, including colors, progress bars, multi-spinners, and a lot more.
In another gem, they all get another GitHub CLI client object, because each command wraps the GitHub API.
Those are my use cases. Now, the proposed solution.
My first instinct was something like a
Command.provides(:ui) { ... }
DSL. Then I actually tried dry-auto_inject against the main branch, and it already works through Command.new:
Deps = Dry::AutoInject(MyApp::Container)
class BaseCommand < Dry::CLI::Command
include Deps["logger", "config"]
end
The one catch is the usual dry-auto_inject rule: a subclass defining its own #initialize has to call super(**), or you get ArgumentError: unknown keyword. So I’d propose no code here at all, just a documentation section on Command plus specs that pin the behavior, including that failure.
A container can’t give you anything built from the command’s own streams, since it has no idea which streams those are. For those I’d document one rule: memoize against the stream, not just against nil.
def logger
@logger = nil unless @logger_stream.equal?(stdout)
@logger_stream = stdout
@logger ||= Logger.new(stdout)
end
A command registered as an instance gets reused on every call, and with an in-process test suite that means across tests. @logger ||= ... would happily keep writing into the previous test’s StringIO. I found that out the hard way.
Is it real?
I built all four on my fork, as four stacked PRs, so there’s something concrete to poke at. I’m not asking anyone to merge them from here; this thread is for discussion only. But given only 4 links per post, let me see if I can put them in a comment.
Kindest regards,
Konstantin