Proposal: public extension points for dry-cli

Greetings, folks — first-time caller, long-time listener :slight_smile:

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 ui object.

  • 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::CLI and overrides the private #help and #spell_checker. It also walks CommandRegistry::Node to find aliases and hidden commands.

  • dry-cli-ui found the command’s streams through respond_to?(:out, true).

  • dry-cli-autocomplete walks Registry#get and reads LookupResult#children and Node#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:

  1. It allows complete flexibility with the OS exit code, and
  2. 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.

  • resolve shares its lookup with dispatch. I pulled the body of CommandRegistry#get into 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, Option and Argument remain @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

PRs against dry-cli that accomplish these tasks

The suite goes from 600 to 687 examples, all passing, rubocop clean, and there’s one Aruba in-process spec to prove the Launcher actually works in-process.

Next comment — PRs against my own gems that use these above.

PRs against my extension gems that use proposed features

To check that the hooks are sufficient and not just nice-looking, I moved all three gems onto them:

After that, none of them touch anything marked @api private. dry-cli-help’s contract spec, which used to list the private methods it overrode, now lists only the public API. The autocomplete gem also picked up an optional dynamic mode on top of Tree#resolve along the way. It asks the program on each TAB, so commands registered at runtime complete too.

Open questions

These are the things I’d most like opinions on:

  1. Is any of this wanted at all? If the answer is “dry-cli should stay small and extensions should live with the risk”, I’d rather hear it now.

  2. Four PRs or fewer? They’re split so each can be judged alone, but 3 needs 1 and 2. I can reshape them however reviewers prefer.

  3. The name Tree. Other candidates were Manifest, Catalog, and Introspection. Happy to rename.

  4. Per-CLI config. Dry.CLI(registry, config:) keeps tests isolated and lets two CLIs in one process render help differently. Is that extra surface worth it, or is process-wide Dry::CLI.configure enough?

  5. Should parse errors go through the help pipeline too? Right now ERROR: "x" was called with ... bypasses it on purpose. A kind:: error could be added later without breaking anything, but maybe it belongs from the start.

  6. Should the Launcher live in dry-cli at all, or in a tiny separate gem? The kernel keyword has to be in core; the Launcher itself is about 30 lines.

  7. Colors. dry-cli’s Stream strips escape codes when the stream isn’t a terminal, which is right for normal output. A help renderer that decides color itself (with its own --color=always) has to print to stream.raw. Is that the intended way, or should Screen carry a flag for “this text is already rendered”?

  8. Completion default. Not a dry-cli question, strictly, but I’m curious: for a Ruby CLI with a 150 to 500ms boot, would you rather have static completion scripts (near instant, but stale if you add commands dynamically, or until regenerated) or a hybrid that falls back to asking the program only for commands the script doesn’t know? How often do folks have dynamic commands/subcommands or arguments/options in general? I suspect it’s a rare use case, but I might be wrong.

Thanks for reading this far, and for dry-rb in general. It’s shaped how I write Ruby more than any other library has. If this is in the wrong category or needs to be an issue first, point me in the right direction, and I’ll move it.

Keep up the great work!

Konstantin

Hi @kigster — thanks for this proposal (I deleted your duplicate post, btw, and just re-categorised this one to “proposals”), I’m excited that you’re excited by Dry CLI!

I have to admit, I find your proposal overwhelming. I appreciate you’re trying to do what you can to make things reviewable, but it is a lot of text accompanied by an out-of-nowhere fully-baked prototype split across many PRs.

Right now our team has spent some months on preparing for version 2.0 of Dry CLI, which includes a range of significant enhancements, and our work is close to being done.

I don’t see us including extensions as part of this release, and anyway, I’d want to wait longer before seriously considering it. I think things like extension systems are best done when we have multiple different people and projects that would be served by them. Right now we have one instance (you), which is a start, but we need more than that. Otherwise we risk building the wrong-shaped extension system and end up having to carry the cost of maintaining it as public API.


I’ll also ask you to revisit our AI contributions policy, which I’ve directed you to before. This asks that contributors take authorial responsibility, disclose AI usage, and use their own words for communication. I know you’ve been thinking a lot about Dry CLI, but it’s still important for us that all our contributors satisfy this policy.

1 Like

Tìm,

Haha, I totally understand what you mean, and I can’t tell you how many times in my career i did a similar overwhelming blunder.

To address the policy: i wrote every word of the posts above. These are all real architectural suggestions thst came entirely from my brain seeking to solve the untidy problem about the current implementation or my gems hooking deep into the private apis of dry-cli and that once you release the next version I’ll need to update all the gems.

Consider it a food for thought for some distant future.

I wrote two more CLI gems this week, all internal, and it’s saving me so much time.

I will try to be less over-enthusiastic about things in the future and use less words. Thats my commitment.

Thanks!