# Proposal: public extension points for dry-cli

**URL:** <https://discourse.hanakai.org/t/proposal-public-extension-points-for-dry-cli/1553>\
**Category:** proposals\
**Created:** [September 27, 2026, 10:50am UTC](https://discourse.hanakai.org/t/proposal-public-extension-points-for-dry-cli/1553 "2026-09-27T10:50:22Z")\
**Posts on this page:** 5\
**Page:** 1

<div class="post-metadata">

**Author:** ![kigster](https://yyz2.discourse-cdn.com/flex030/user_avatar/discourse.hanakai.org/kigster/32/1192_2.png) [@kigster](https://discourse.hanakai.org/u/kigster)\
**Post date:** [September 27, 2026, 10:50am UTC](https://discourse.hanakai.org/t/proposal-public-extension-points-for-dry-cli/1553/1 "2026-09-27T10:50:22Z")

</div>

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](https://kig.re/2020/09/07/writing-cli-tools-ruby-migrating-github-issues-to-pivotal-tracker.html), 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/](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:

```auto
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:

```auto
# 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](https://kig.re/2020/09/07/writing-cli-tools-ruby-migrating-github-issues-to-pivotal-tracker.html), 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:

```auto
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:

```auto
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

```ruby
Command.provides(:ui) { ... }

```

DSL. Then I actually tried `dry-auto_inject` against the `main` branch, and it already works through `Command.new`:

```auto
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`.

```auto
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

---

<div class="post-metadata">

**Author:** ![kigster](https://yyz2.discourse-cdn.com/flex030/user_avatar/discourse.hanakai.org/kigster/32/1192_2.png) [@kigster](https://discourse.hanakai.org/u/kigster)\
**Post date:** [September 27, 2026, 10:51am UTC](https://discourse.hanakai.org/t/proposal-public-extension-points-for-dry-cli/1553/2 "2026-09-27T10:51:38Z")

</div>

## PRs against `dry-cli` that accomplish these tasks

- [Exit through an injectable kernel; add Launcher by kigster · Pull Request #1 · kigster/dry-cli · GitHub](https://github.com/kigster/dry-cli/pull/1) (kernel and `Launcher`)

- [Add Tree, a read-only view of a CLI's commands by kigster · Pull Request #2 · kigster/dry-cli · GitHub](https://github.com/kigster/dry-cli/pull/2) (`Tree`)

- [Render help through a configurable pipeline by kigster · Pull Request #3 · kigster/dry-cli · GitHub](https://github.com/kigster/dry-cli/pull/3) (help pipeline)

- [Document dependencies injected with dry-auto\_inject by kigster · Pull Request #4 · kigster/dry-cli · GitHub](https://github.com/kigster/dry-cli/pull/4) (dry-auto\_inject docs and specs)

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.

---

<div class="post-metadata">

**Author:** ![kigster](https://yyz2.discourse-cdn.com/flex030/user_avatar/discourse.hanakai.org/kigster/32/1192_2.png) [@kigster](https://discourse.hanakai.org/u/kigster)\
**Post date:** [September 27, 2026, 10:52am UTC](https://discourse.hanakai.org/t/proposal-public-extension-points-for-dry-cli/1553/3 "2026-09-27T10:52:23Z")

</div>

## 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:

- dry-cli-help: [Render help through dry-cli's public help hooks by kigster · Pull Request #4 · kigster/dry-cli-help · GitHub](https://github.com/kigster/dry-cli-help/pull/4)

- dry-cli-ui: [Present through the command's public streams by kigster · Pull Request #8 · kigster/dry-cli-ui · GitHub](https://github.com/kigster/dry-cli-ui/pull/8)

- dry-cli-autocomplete: [Walk Dry::CLI::Tree; add dynamic and hybrid modes by kigster · Pull Request #3 · kigster/dry-cli-autocomplete · GitHub](https://github.com/kigster/dry-cli-autocomplete/pull/3)

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

---

<div class="post-metadata">

**Author:** ![timriley](https://yyz2.discourse-cdn.com/flex030/user_avatar/discourse.hanakai.org/timriley/32/621_2.png) [@timriley](https://discourse.hanakai.org/u/timriley)\
**Post date:** [September 28, 2026, 11:41pm UTC](https://discourse.hanakai.org/t/proposal-public-extension-points-for-dry-cli/1553/4 "2026-09-28T23:41:02Z")

</div>

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](https://hanakai.org/ai-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.

---

<div class="post-metadata">

**Author:** ![kigster](https://yyz2.discourse-cdn.com/flex030/user_avatar/discourse.hanakai.org/kigster/32/1192_2.png) [@kigster](https://discourse.hanakai.org/u/kigster)\
**Post date:** [September 30, 2026, 12:58am UTC](https://discourse.hanakai.org/t/proposal-public-extension-points-for-dry-cli/1553/5 "2026-09-30T00:58:24Z")

</div>

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!
