dry-cli-ui 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d64ee9f8fedbc7de0fcd1875acf59d07876ef6acaffbfcc8574b197de0e8d6fd
4
- data.tar.gz: 2f7e11eca1a0dff40b3df7cba685bca7f97d4884515db1107805036e746e20bc
3
+ metadata.gz: ef89bfadc9ccc7f7d737abdf585a9dfa57780b085dc12635be3149c623a965ea
4
+ data.tar.gz: e91a1f2ad8f9ebc4f174f7df2627fba76db005fc3d278b3f50d63d760c39c53f
5
5
  SHA512:
6
- metadata.gz: d9105f75325a1a7f8282e989d4c847320fb481880a7a529041d98d4bff9457dc5235d08a5c3b311c0b3d442a6dc4c3923b6c72844bd2fbb55ec04f2824b906e3
7
- data.tar.gz: d095b784083ba57db9592e51ef2d56274dd9cb187fcac350d35aaea51b8e64c519a800e3f7f899b2b248acfe8035733c4fa63930b64086cdbebea17c8af4636b
6
+ metadata.gz: c3b9332b0cc87c0fa50a8bae398492c5994edf410168be89ae41c6ecf41104cc0d3f664257a890e7a066faad0ffa19790716b1fa74d35872c89bc3ad3c8e0107
7
+ data.tar.gz: a8985df9aa1c143c538557c9195dec1d060d3f54263b5041246810c2203007ddd30b2e246abdffea467cd21fae37f6db3081d21d317fc843401869ede4518b29
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## [Unreleased]
2
+
3
+ - `ui` reads the command's public `stdout`, `stderr` and `stdin`, the streams dry-cli was called with, when dry-cli has them. Prompts then read from the command's `stdin`.
4
+ - Under released dry-cli (1.4 and earlier), `ui` writes to the command's protected `out` and `err`, as before, and reads prompts from `$stdin`. dry-cli with public command streams is no longer required.
5
+ - `ui` builds its console again when those streams change, so a command registered as an instance, or run in-process by `Dry::CLI::Launcher`, writes each call to that call's streams.
6
+ - `ui_options` configures the console `ui` builds, in place of overriding `ui`.
7
+ - CI runs the suite against both kigster/dry-cli and the latest dry-cli release, and against the latest release every week.
8
+ - Reserved flags: `extend Dry::CLI::UI::Flags` and `flags :dry_run, :yes, :output, :log` give a command `-n/--dry-run`, `-y/--yes`, `-o/--output [FILE]`, `-l/--log [FILE]`, `-L/--log-level LEVEL` and `--log-format FORMAT`, the same in every CLI. Loading `Flags` prepends a hook onto `Dry::CLI` that lets `-o` and `-l` go without a file, for those commands only; `Flags.arguments(argv)` is the rewrite on its own.
9
+ - `ui.output(output) { |io| ... }` writes the command's report (what goes to `out`) to `log/<executable>-<action>.<YYYY-MM-DD>.<HHMMSS>.log` at the repository root for a bare `-o`, or to the file given, in colour, ending with the time it was closed. Spinners, bars and prompts stay on `err`.
10
+ - `ui.logging(log, level:, format:) { ... }` logs through SemanticLogger to `log/<executable>-<action>.log`, a file, or `out` while the block runs. `ui.logger` is the command's logger, and `ui.log_exception(e)` logs an exception, with each frame's local variables when it was raised at `-L debug`. `ui.with_flags(**options) { |io| ... }` does both.
11
+ - `ui.confirm` takes `yes:`, which answers it without asking, and on a terminal offers YES and NO as a list.
12
+ - A stream answering `color?` with true gets colour although it is no TTY. `Terminal#inspect` leaves out the environment.
13
+ - New dependencies: `semantic_logger`, `logger` (which semantic_logger needs on Ruby 4.0 and does not declare) and `binding_of_caller`. They load the first time a command logs.
14
+ - A progress handle counts each unit as an outcome: `bar.advance(step = 1, as: :ok)` takes `:ok`, `:aux` (relevant but auxiliary) or `:failed`, and `bar.counts` returns `{ ok:, aux:, failed: }`. Plain `advance(n)` counts as `:ok`, as before. Works in `ui.progress` and in every row of `ui.multi_progress`.
15
+ - A bar draws its failed units first, in red, then its auxiliary ones, in yellow, then the rest in its own colour, each part as wide as its share. A bar with neither draws as before. `Dry::CLI::UI.configure` takes `bar_failed_color` and `bar_aux_color`.
16
+ - A bar ends `✓` even when every unit failed, and its outcome line adds the breakdown after the time: `✓ Placing forms 100/100 (2.1s) 20 failed, 5 auxiliary`. The `multi_progress` headline, when it counts units, adds the sum of its rows'.
17
+ - A bar whose total is known and 0 when its job ends now ends `𝘅 label 0/0: nothing to process`, unless the job called `bar.fail` with a reason of its own. Before, it ended `✓`. In `multi_progress` the headline then ends `𝘅` too.
18
+ - `ui.progress` turns a spinner before its label while it runs, as each `multi_progress` row does, and draws the bar itself rather than through `TTY::ProgressBar`. Piped output is unchanged.
19
+ - `ui.legend(failed:, aux:, ok:)` prints one line to `err` saying what each colour of a bar means: `Color Mapping: [ red: errors and invalid files | yellow: relevant but auxiliary | green: forms ]`, or each label on its colour's background on a terminal.
20
+
1
21
  ## [0.6.1]
2
22
 
3
23
  - A progress handle takes `fail(reason = nil)`, as a `Line` does: the bar ends `𝘅 label 3/3: reason` without raising, in `ui.progress` and in a row of `ui.multi_progress`, where the jobs after it still run and the headline ends `𝘅`. For work that carries on after some of its units failed.
@@ -6,13 +26,6 @@
6
26
 
7
27
  - `m.spinner(label)` inside `ui.multi_progress` declares a row with a spinner in place of a bar, for a phase whose size is never known, so one widget can show phases of known and unknown size together. The row shows its `Line`'s detail while it runs and ends `[✓] label`; it counts as a job and as no units on the headline.
8
28
 
9
- ## [0.6.0]
10
-
11
- - `ui` reads the command's public `stdout`, `stderr` and `stdin`, the streams dry-cli was called with. Before, it looked for `out` and `err`, which dry-cli no longer has, and wrote to `$stdout` and `$stderr` instead. Prompts now read from the command's `stdin`.
12
- - `ui` builds its console again when those streams change, so a command registered as an instance, or run in-process by `Dry::CLI::Launcher`, writes each call to that call's streams.
13
- - `ui_options` configures the console `ui` builds, in place of overriding `ui`.
14
- - Needs dry-cli with public command streams and `Dry::CLI::Launcher`; the Gemfile takes it from kigster/dry-cli until it is released.
15
-
16
29
  ## [0.5.1]
17
30
 
18
31
  - `ui.stoppable { |stop| ... }` makes Ctrl-C ask for a stop instead of interrupting, and `ui.multi_spinner` and `ui.multi_progress` take `stop:`. Once it is set, the jobs running finish, the rest are skipped, and the headline says `stopping`, then ends skipped. A second Ctrl-C interrupts. `Dry::CLI::UI::Stop` is the object behind it.
data/README.md CHANGED
@@ -15,7 +15,7 @@ A long-running command has more to say than `puts` can show well: what it is doi
15
15
  gem "dry-cli-ui"
16
16
  ```
17
17
 
18
- Requires Ruby 4.0 or later and `dry-cli` 1.0 or later. The rendering comes from the [TTY toolkit](https://ttytoolkit.org) (`tty-box`, `tty-spinner`, `tty-progressbar`, `tty-table`, `tty-prompt`, `tty-cursor`, `tty-screen`), `pastel`, `strings` and `concurrent-ruby`, which Bundler installs with the gem.
18
+ Requires Ruby 4.0 or later and `dry-cli` 1.0 or later. The rendering comes from the [TTY toolkit](https://ttytoolkit.org) (`tty-box`, `tty-spinner`, `tty-progressbar`, `tty-table`, `tty-prompt`, `tty-cursor`, `tty-screen`), `pastel`, `strings` and `concurrent-ruby`, and the [reserved flags](#reserved-flags) log through `semantic_logger` (with `logger`) and `binding_of_caller`. Bundler installs them all with the gem.
19
19
 
20
20
  Either require works, and both load the same file:
21
21
 
@@ -88,6 +88,8 @@ end
88
88
 
89
89
  `ui` writes to the command's `stdout` and `stderr`, and reads prompt answers from its `stdin`: the streams dry-cli was called with. Outside a dry-cli command it uses `$stdout`, `$stderr` and `$stdin`. The console is built again when those streams change, so a command registered as an instance writes each call to that call's streams.
90
90
 
91
+ Released dry-cli, up to 1.4, gives a command only `out` and `err`. There `ui` writes to those and reads prompt answers from `$stdin`; `Dry::CLI.new(registry).call(out: io, err: io)` still captures everything. `stdin:`, `stdout:`, `stderr:` and `Dry::CLI::Launcher` need dry-cli with public command streams ([dry-rb/dry-cli#175](https://github.com/dry-rb/dry-cli/pull/175) to [#176](https://github.com/dry-rb/dry-cli/pull/176)).
92
+
91
93
  ### Without dry-cli
92
94
 
93
95
  `Dry::CLI::UI::Console` needs nothing from dry-cli, so a Rake task or a plain script can use it directly:
@@ -252,9 +254,9 @@ ui.progress("Importing rules", total: rules.size) do |bar|
252
254
  end
253
255
  ```
254
256
 
255
- The bar shows percent, `current/total` and ETA, `Importing rules [◼◼◼◼◼◼ ] 61% 1159/1900 ETA 2.7s`, and ends with `✓ Importing rules 1900/1900 (4.2s)`. On a terminal the `◼`s are green, between brackets, on no background; see [Configuration](#configuration) to change either.
257
+ The bar shows a turning spinner, percent, `current/total` and ETA, `⠋ Importing rules [◼◼◼◼◼◼ ] 61% 1159/1900 ETA 2.7s`, and ends with `✓ Importing rules 1900/1900 (4.2s)`. On a terminal the `◼`s are green, between brackets, on no background; see [Configuration](#configuration) to change either.
256
258
 
257
- The block is given a `Dry::CLI::UI::Widgets::Progress::Handle`, never the underlying `TTY::ProgressBar`:
259
+ The block is given a `Dry::CLI::UI::Widgets::Progress::Handle`, never a TTY toolkit object:
258
260
 
259
261
  ```ruby
260
262
  ui.progress("Copying", total: files.sum(&:size)) do |bar|
@@ -266,15 +268,72 @@ ui.progress("Copying", total: files.sum(&:size)) do |bar|
266
268
  end
267
269
  ```
268
270
 
269
- - `advance(step = 1)` adds to `current` and returns the handle; `current` never passes `total`.
271
+ - `advance(step = 1, as: :ok)` adds to `current` and returns the handle; `current` never passes `total`. `as:` counts the units as `:ok`, `:aux` or `:failed`; see [Failed and auxiliary units](#failed-and-auxiliary-units).
270
272
  - `total = n` sets the total once the work finds out, such as a download learning its size, and lowers `current` to fit. Anything but a non-negative Integer raises `ArgumentError`.
271
273
  - `total:` must be a non-negative Integer, or `progress` raises `ArgumentError`.
272
- - `total: 0` draws no bar, and ends `✓ Copying 0/0`.
274
+ - `total: 0` draws no bar, and ends `𝘅 Copying 0/0: nothing to process`, since there was nothing to do. A block that calls `bar.fail(reason)` first keeps its own reason.
273
275
  - `color:` paints this bar's finished part in any Pastel style, such as `color: :red`, instead of the configured `bar_color`. A style Pastel does not know raises `ArgumentError` before the block runs.
274
- - The outcome is `✓` whenever the block returns, even short of the total (`✓ Copying 12/20`), and `𝘅` when it raises.
276
+ - The outcome is `✓` whenever the block returns, even short of the total (`✓ Copying 12/20`) and even when every unit failed, and `𝘅` when it raises or calls `bar.fail`.
275
277
 
276
278
  Piped, it prints `Importing rules...` when it starts and the outcome line when it ends, with no bar in between.
277
279
 
280
+ ### Failed and auxiliary units
281
+
282
+ Work that goes through many files rarely succeeds on all of them. `bar.advance(as:)` counts each unit as one of three outcomes:
283
+
284
+ - `:ok`, the default: the unit is what the work is for, such as a form placed.
285
+ - `:aux`: relevant but auxiliary, such as a scaffold created or an instruction filed elsewhere.
286
+ - `:failed`: an error, or an invalid file.
287
+
288
+ Anything else raises `ArgumentError`. `bar.counts` returns the units so far by outcome, `{ ok: 7, aux: 1, failed: 2 }`, adding up to `bar.current`.
289
+
290
+ The bar's finished part is drawn left to right in red for the failed units, yellow for the auxiliary ones, and the bar's own colour for the rest, each part as wide as its share of the units done: half way through, with a fifth of the files failed, the first fifth of the filled part is red and the rest green. The bar ends with `✓` and the breakdown after the time, with the numbers in their colours on a terminal. `ui.legend` prints a line saying what each colour means; call it before the first bar. Each of `failed:`, `aux:` and `ok:` is optional, and a label not given is left out. On a terminal each label is drawn on its colour's background.
291
+
292
+ ```ruby
293
+ ui.legend(failed: "errors and invalid files", aux: "relevant but auxiliary", ok: "forms")
294
+
295
+ ui.progress("Placing forms", total: files.size) do |bar|
296
+ files.each do |file|
297
+ case place(file)
298
+ in :placed then bar.advance
299
+ in :scaffold then bar.advance(as: :aux)
300
+ in :invalid then bar.advance(as: :failed)
301
+ end
302
+ end
303
+ end
304
+ ```
305
+
306
+ Piped, with two invalid files and one scaffold among ten:
307
+
308
+ ```text
309
+ Color Mapping: [ red: errors and invalid files | yellow: relevant but auxiliary | green: forms ]
310
+ Placing forms...
311
+ ✓ Placing forms 10/10 (0.0s) 2 failed, 1 auxiliary
312
+ ```
313
+
314
+ `m.progress` inside `ui.multi_progress` takes the same `as:`. Each row ends with its own breakdown, and the headline, when it counts units, with the sum of them all. A row whose total is 0 when its job ends fails with `nothing to process`, and so the headline ends `𝘅`:
315
+
316
+ ```ruby
317
+ ui.multi_progress("Placing forms", concurrent: false) do |m|
318
+ m.progress("us", total: federal.size) { |bar| federal.each { bar.advance(as: place(it) == :invalid ? :failed : :ok) } }
319
+ states.each do |state, forms|
320
+ m.progress(state, total: forms.size) { |bar| forms.each { bar.advance(as: :failed) } }
321
+ end
322
+ end
323
+ ```
324
+
325
+ Piped, where `us-ny` has no forms:
326
+
327
+ ```text
328
+ Placing forms...
329
+ [✓] us 6/6 (0.0s) 2 failed
330
+ [✓] us-ca 2/2 (0.0s) 2 failed
331
+ [𝘅] us-ny 0/0: nothing to process (0.0s)
332
+ 𝘅 Placing forms 8/8 (0.0s) 4 failed
333
+ ```
334
+
335
+ The colours are `bar_failed_color` and `bar_aux_color` in the [Configuration](#configuration).
336
+
278
337
  ### Several spinners at once
279
338
 
280
339
  ```ruby
@@ -358,7 +417,7 @@ The same shape as `multi_spinner`, with a bar per job and a headline bar that co
358
417
  └─ [ ] video.mp4
359
418
  ```
360
419
 
361
- Each job is given the same handle as `ui.progress`, with `advance(step = 1)`, `current` and `total`. `m.progress` takes `color:` as `ui.progress` does, so bars side by side can differ:
420
+ Each job is given the same handle as `ui.progress`, with `advance(step = 1, as: :ok)`, `counts`, `current` and `total`. `m.progress` takes `color:` as `ui.progress` does, so bars side by side can differ:
362
421
 
363
422
  ```ruby
364
423
  ui.multi_progress("Probing #{hosts.size} hosts", total: hosts.size) do |m|
@@ -723,7 +782,7 @@ ui.confirm("Deploy to #{env}?", default: false)
723
782
 
724
783
  - `prompt` returns the answer as a String, or the default for an empty answer.
725
784
  - With an Array of `choices:`, it returns the chosen name; with a Hash, the value the chosen name maps to (`:pro` above). `default:` is the name of a choice.
726
- - `confirm` returns `true` or `false`, and `default:` is `false` unless given.
785
+ - `confirm` returns `true` or `false`, and `default:` is `false` unless given. On a terminal it is a list to pick YES or NO from, starting on the default. `yes: true`, which is what the reserved `-y/--yes` flag gives (see [Reserved flags](#reserved-flags)), returns `true` without asking.
727
786
 
728
787
  When both standard input and `err` are terminals, these use arrow-key menus and line editing (TTY::Prompt). Otherwise they print the question to `err` and read lines from standard input, so answers can be piped:
729
788
 
@@ -778,6 +837,141 @@ Errors raised inside a block are never swallowed: the widget marks itself failed
778
837
 
779
838
  A stream that is not a terminal, or runs under `TERM=dumb`, gets no animation, no cursor movement and no escape codes. [`NO_COLOR`](https://no-color.org) turns colour off and leaves animation on.
780
839
 
840
+ ## Reserved flags
841
+
842
+ A few flags mean the same thing in every CLI built on this gem. A command takes the ones it needs:
843
+
844
+ | Flag | Long | Reaches `call` as | Meaning |
845
+ | ---- | --------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
846
+ | `-n` | `--dry-run` | `dry_run:` | change nothing, print what would happen |
847
+ | `-y` | `--yes` | `yes:` | skip every interactive prompt |
848
+ | `-o` | `--output [FILE]` | `output:` | write the command's report (tables, lists, summaries) to FILE, in colour |
849
+ | `-l` | `--log [FILE]` | `log:` | log to FILE through [SemanticLogger](https://logger.rocketjob.io) |
850
+ | `-L` | `--log-level LEVEL` | `log_level:` | `debug`, `info` (the default), `warn`, `error` or `fatal` |
851
+ | | `--log-format FORMAT` | `log_format:` | `standard` (the default, one line per entry), `json`, or any other SemanticLogger formatter, such as `logfmt` or `color` |
852
+
853
+ ```ruby
854
+ require "dry/cli"
855
+ require "dry-cli-ui"
856
+
857
+ class Export < Dry::CLI::Command
858
+ include Dry::CLI::UI
859
+ extend Dry::CLI::UI::Flags
860
+
861
+ desc "Export the rules"
862
+ flags :dry_run, :yes, :output, :log # :log brings -L and --log-format with it
863
+
864
+ RULES = [["standard_deduction", 14_600], ["salt_cap", 10_000]].freeze
865
+
866
+ def call(dry_run:, yes:, **options)
867
+ ui.with_flags(**options) do |io|
868
+ ui.logger.info("Exporting", rules: RULES.size)
869
+ next ui.status("Would export #{RULES.size} rules", level: :warn) if dry_run
870
+ next unless ui.confirm("Export #{RULES.size} rules?", yes: yes)
871
+
872
+ ui.table(RULES, header: %w[Rule Amount])
873
+ io.puts "#{RULES.size} rules exported"
874
+ end
875
+ end
876
+ end
877
+ ```
878
+
879
+ `flags` declares ordinary dry-cli options, so they show in the command's help, and an unknown name raises `ArgumentError`:
880
+
881
+ ```text
882
+ Options:
883
+ --dry-run, -n # Change nothing; print what would happen, default: false
884
+ --yes, -y # Answer yes to every prompt, default: false
885
+ --output=VALUE, -o VALUE # Write the report to FILE: log/ by default, - for STDOUT
886
+ --log=VALUE, -l VALUE # Log to FILE: log/ by default, - for STDOUT
887
+ --log-level=VALUE, -L VALUE # Log level: (debug, info, warn, error, fatal), default: "info"
888
+ --log-format=VALUE # Log format: standard, json, or another SemanticLogger formatter, default: "standard"
889
+ --help, -h # Print this help
890
+ ```
891
+
892
+ `-o` and `-l` may be given without a file. dry-cli requires a value for every option that takes one, so loading `Dry::CLI::UI::Flags` prepends a small hook onto `Dry::CLI` that, for the commands extending it and nothing else, turns a bare `-o`, `--output`, `-l` or `--log` into an empty value before dry-cli parses the line. A switch is bare when it comes last, or when the next argument starts with `-` and is not exactly `-`. `-o x`, `-o=x`, `--output=x`, `-ox` and `-o -` are left alone, as is everything after `--`. The rewrite is also a function of its own:
893
+
894
+ ```ruby
895
+ Dry::CLI::UI::Flags.arguments(%w[export -o -l run.log]) # => ["export", "-o", "", "-l", "run.log"]
896
+ ```
897
+
898
+ The hook also records the names a command was called by, which name its default files: `<executable>-<action>`, where the action is the command's names joined with `-` (`law-cli generate text` gives `law-cli-generate-text`).
899
+
900
+ ### Where the report goes
901
+
902
+ `ui.output(output) { |io| ... }` sends what the block writes to `out` (tables, boxes and statuses at `info` and `success`) to the file `-o` named, and gives the block that file as `io` for writing to it directly. Spinners, bars, prompts and warnings stay on `err`, and so on the screen.
903
+
904
+ | `-o` given | `output:` | The report goes to |
905
+ | ------------------ | ----------------- | ------------------------------------------------------------------------------------------------ |
906
+ | not at all | `nil` | the command's `out`, as usual |
907
+ | `-o -` | `"-"` | the command's `out` |
908
+ | `-o` | `""` | `log/<executable>-<action>.<YYYY-MM-DD>.<HHMMSS>.log`, stamped with the time the process started |
909
+ | `-o reports/x.txt` | `"reports/x.txt"` | that file, relative to the current directory |
910
+
911
+ `log/` is at the repository root: the nearest directory up from the current one holding `.git`, else the current directory. It is created when missing; add `/log/*` to the repository's `.gitignore` once. A file is written in colour although it is no terminal (unless `NO_COLOR` is set), its last line says when it was closed, and a line on `err` says where it is:
912
+
913
+ ```text
914
+ $ mycli export -y -o | cat
915
+ ℹ Report written to log/mycli-export.2026-10-08.031215.log
916
+ ```
917
+
918
+ ```text
919
+ $ cat log/mycli-export.2026-10-08.031215.log
920
+ ┌────────────────────┬────────┐
921
+ │ Rule │ Amount │
922
+ ├────────────────────┼────────┤
923
+ │ standard_deduction │ 14600 │
924
+ │ salt_cap │ 10000 │
925
+ └────────────────────┴────────┘
926
+ 2 rules exported
927
+ Closed at 2026-10-08 03:12:15 -0700
928
+ ```
929
+
930
+ ### Logging
931
+
932
+ `ui.logging(log, level: log_level, format: log_format) { ... }` adds a SemanticLogger appender while the block runs, then flushes and removes it. `ui.logger` is a SemanticLogger logger named after the command's class, and writes nothing until an appender is added.
933
+
934
+ | `-l` given | `log:` | The log goes to |
935
+ | ------------ | ----------- | -------------------------------------------- |
936
+ | not at all | `nil` | nowhere: no appender is added |
937
+ | `-l -` | `"-"` | the command's `out` |
938
+ | `-l` | `""` | `log/<executable>-<action>.log`, appended to |
939
+ | `-l run.log` | `"run.log"` | that file |
940
+
941
+ ```text
942
+ $ mycli export -n -l - | cat
943
+ ⚠ Would export 2 rules
944
+ 2026-10-08 03:11:21.436756 I [22306:1056] Export -- Exporting -- {rules: 2}
945
+ ```
946
+
947
+ An unknown level or format raises `ArgumentError` naming the ones there are, as in `unknown log format "yaml", expected one of color, ecs, fluentd, json, logfmt, ...`.
948
+
949
+ `ui.log_exception(e, message = nil, level: :error)` logs an exception with its backtrace. At `-L debug` the gem also records, for every exception raised while the block runs, the local variables of each frame it was raised through (with [binding_of_caller](https://github.com/banister/binding_of_caller)), and `log_exception` adds them as the payload's `locals`. Each value is its `inspect`, cut at 200 characters, for at most 25 frames, leaving out the gem's own. Recording costs time on every `raise`, which is why it happens at `debug` only. The values are whatever the program held, secrets included, so treat a debug log as you would a core dump.
950
+
951
+ ```ruby
952
+ def check(amount, limit: 10_000)
953
+ raise ArgumentError, "over the cap" if amount > limit
954
+ end
955
+
956
+ ui.logging("-", level: "debug") do
957
+ check(14_600)
958
+ rescue ArgumentError => e
959
+ ui.log_exception(e, "Check failed")
960
+ end
961
+ ```
962
+
963
+ ```text
964
+ 2026-10-08 03:11:32.024362 E [22511:792 reporting.rb:107] check.rb -- Check failed -- {locals: [{frame: "check.rb:4", locals: {amount: "14600", limit: "10000"}}, {frame: "check.rb:8", locals: {e: "nil"}}]} -- Exception: ArgumentError: over the cap
965
+ check.rb:4:in 'Object#check'
966
+ check.rb:8:in 'block in <main>'
967
+ ```
968
+
969
+ ### All of them at once
970
+
971
+ `ui.with_flags(**options) { |io| ... }` is `ui.logging` around `ui.output`, taking the whole options hash `call` receives and ignoring what it does not use, as in the `Export` command above. `dry_run:` and `yes:` are the command's to act on: return before changing anything, and pass `yes:` to `ui.confirm`.
972
+
973
+ SemanticLogger and binding_of_caller load the first time a command logs, and binding_of_caller only at `debug`, so declaring the flags costs a command nothing at boot.
974
+
781
975
  ## Configuration
782
976
 
783
977
  Spinners and bars look the same everywhere, and are set once for the whole process:
@@ -788,10 +982,12 @@ Dry::CLI::UI.configure do
788
982
  bar_format(complete: "◼", incomplete: " ") # or any TTY::ProgressBar bar format name, such as :box
789
983
  bar_color :green # the finished part: any Pastel style, or nil
790
984
  bar_background nil # the whole bar: any Pastel style, or nil
985
+ bar_failed_color :red # units counted as: :failed: any Pastel style, or nil
986
+ bar_aux_color :yellow # units counted as: :aux: any Pastel style, or nil
791
987
  end
792
988
  ```
793
989
 
794
- Those are the defaults: spinners turn through `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏` ten times a second, and bars draw a green `◼` for each finished part, with nothing behind them, and brackets show where the bar begins and ends. Every spinner reads the same format, including `multi_spinner`, task trees and the status bar, and every bar reads the same characters and colours, except a bar given its own `color:`. The formats also take a definition of your own:
990
+ Those are the defaults: spinners turn through `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏` ten times a second, and bars draw a green `◼` for each finished part, with nothing behind them, and brackets show where the bar begins and ends. Units counted as failed or auxiliary are drawn red and yellow, and `ui.legend` names the same colours. Every spinner reads the same format, including `multi_spinner`, task trees and the status bar, and every bar reads the same characters and colours, except a bar given its own `color:`. The formats also take a definition of your own:
795
991
 
796
992
  ```ruby
797
993
  Dry::CLI::UI.configure do |config|
@@ -860,6 +1056,8 @@ Every option `Console.new` takes:
860
1056
  | `clock:` | monotonic clock | any object whose `call` returns seconds, for elapsed times |
861
1057
  | `config:` | `Dry::CLI::UI.config` | a `Dry::CLI::UI::Configuration` for this console alone |
862
1058
 
1059
+ `Console.new` also takes `invocation:`, a `Dry::CLI::UI::Invocation` naming the files `-o` and `-l` write (see [Reserved flags](#reserved-flags)); `ui` builds one from the command, and a console given none names them after the program alone.
1060
+
863
1061
  `config:` gives one console a look of its own without changing the process-wide one:
864
1062
 
865
1063
  ```ruby
@@ -1,10 +1,13 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- dry-cli-ui (0.6.1)
4
+ dry-cli-ui (0.7.0)
5
+ binding_of_caller (~> 2.0)
5
6
  concurrent-ruby (~> 1.3)
6
7
  dry-cli (>= 1.0)
8
+ logger (~> 1.6)
7
9
  pastel (~> 0.8)
10
+ semantic_logger (~> 5.1)
8
11
  strings (~> 0.2)
9
12
  tty-box (~> 0.7)
10
13
  tty-cursor (~> 0.7)
@@ -17,21 +20,27 @@ PATH
17
20
  GEM
18
21
  remote: https://rubygems.org/
19
22
  specs:
23
+ binding_of_caller (2.0.0)
24
+ debug_inspector (>= 1.2.0)
20
25
  concurrent-ruby (1.3.8)
26
+ debug_inspector (1.2.0)
21
27
  dry-cli (1.4.1)
22
- dry-cli-autocomplete (0.1.3)
28
+ dry-cli-autocomplete (0.6.0)
23
29
  dry-cli (>= 1.0)
24
30
  dry-inflector (>= 1.0)
25
- dry-cli-help (0.2.1)
26
- dry-cli (>= 1.1.1, < 2)
31
+ dry-cli-help (0.6.0)
32
+ dry-cli
27
33
  pastel (~> 0.8)
28
34
  dry-inflector (1.3.1)
29
35
  forwardable (1.4.0)
36
+ logger (1.7.0)
30
37
  pastel (0.8.0)
31
38
  tty-color (~> 0.5)
32
39
  prime (0.1.4)
33
40
  forwardable
34
41
  singleton
42
+ semantic_logger (5.1.0)
43
+ concurrent-ruby (~> 1.0)
35
44
  singleton (0.3.0)
36
45
  strings (0.2.1)
37
46
  strings-ansi (~> 0.2)
@@ -79,16 +88,20 @@ DEPENDENCIES
79
88
  prime
80
89
 
81
90
  CHECKSUMS
91
+ binding_of_caller (2.0.0) sha256=e53eebf8428a85587aede7b43a83e7419eb85b8b690938a96aa330d03052d517
82
92
  bundler (4.1.0.beta1) sha256=8a2dac8ca276ff4eff5efc2c6457b0044080fc1e431534ad7c5cc6187232b800
83
93
  concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
94
+ debug_inspector (1.2.0) sha256=9bdfa02eebc3da163833e6a89b154084232f5766087e59573b70521c77ea68a2
84
95
  dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874
85
- dry-cli-autocomplete (0.1.3) sha256=14f76447bda0000c71739b585c827dc0a0a763e30a68a8b4b2dff91f640768e4
86
- dry-cli-help (0.2.1) sha256=eaaaf6edb787ffe810425b4e4bb1a675d89397ccc759102d70b5b26e7bc692da
87
- dry-cli-ui (0.6.1)
96
+ dry-cli-autocomplete (0.6.0) sha256=4a8b904cc802168ad1657a941cdf8dbec1915b4c825a35eb049fb0ac037947ca
97
+ dry-cli-help (0.6.0) sha256=10def4049bdae8c19e7412f3cefc062829048dbfa3a42155788b333c381766c9
98
+ dry-cli-ui (0.7.0)
88
99
  dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc
89
100
  forwardable (1.4.0) sha256=f1cd40cc9812937980e1c76f1aa053660990a7c9b6a98fc37d945468afcce838
101
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
90
102
  pastel (0.8.0) sha256=481da9fb7d2f6e6b1a08faf11fa10363172dc40fd47848f096ae21209f805a75
91
103
  prime (0.1.4) sha256=4d755ebf7c2994a6f3a3fee0d072063be3fff2d4042ebff6cd5eebd4747a225e
104
+ semantic_logger (5.1.0) sha256=9a8170fc0212ad47ef29e0760219cf416cb5b9973061981733234f7148b6d6ea
92
105
  singleton (0.3.0) sha256=83ea1bca5f4aa34d00305ab842a7862ea5a8a11c73d362cb52379d94e9615778
93
106
  strings (0.2.1) sha256=933293b3c95cf85b81eb44b3cf673e3087661ba739bbadfeadf442083158d6fb
94
107
  strings-ansi (0.2.0) sha256=90262d760ea4a94cc2ae8d58205277a343409c288cbe7c29416b1826bd511c88
@@ -14,6 +14,8 @@ module Dry
14
14
  # bar_format :box # any TTY::ProgressBar bar format name
15
15
  # bar_color :cyan # any Pastel style, or nil
16
16
  # bar_background nil # any Pastel style, or nil
17
+ # bar_failed_color :red # any Pastel style, or nil
18
+ # bar_aux_color :yellow # any Pastel style, or nil
17
19
  # end
18
20
  #
19
21
  # Dry::CLI::UI.configure do |config|
@@ -24,12 +26,15 @@ module Dry
24
26
  # Anything not set reads from {DEFAULTS}.
25
27
  class Configuration
26
28
  # What a setting reads before it is set: a green `◼` for each finished
27
- # part of a bar, with no background behind it.
29
+ # part of a bar, with no background behind it, red for the units that
30
+ # failed and yellow for the auxiliary ones.
28
31
  DEFAULTS = {
29
32
  spinner_format: :dots,
30
33
  bar_format: { complete: "◼", incomplete: " " }.freeze,
31
34
  bar_color: :green,
32
- bar_background: nil
35
+ bar_background: nil,
36
+ bar_failed_color: :red,
37
+ bar_aux_color: :yellow
33
38
  }.freeze
34
39
 
35
40
  # Every style name Pastel knows, for checking colour settings.
@@ -61,6 +66,14 @@ module Dry
61
66
  # Reads the background the whole bar is drawn on, or sets it.
62
67
  # @param value [Symbol, nil] a Pastel style, such as :on_blue; nil for none
63
68
  # @return [Symbol, nil]
69
+ # @!method bar_failed_color(value = UNSET)
70
+ # Reads the colour a bar's failed units are drawn in, or sets it.
71
+ # @param value [Symbol, nil] a Pastel style, such as :red; nil for none
72
+ # @return [Symbol, nil]
73
+ # @!method bar_aux_color(value = UNSET)
74
+ # Reads the colour a bar's auxiliary units are drawn in, or sets it.
75
+ # @param value [Symbol, nil] a Pastel style, such as :yellow; nil for none
76
+ # @return [Symbol, nil]
64
77
  DEFAULTS.each_key do |name|
65
78
  define_method(name) do |value = UNSET|
66
79
  return @values.fetch(name) { DEFAULTS.fetch(name) } if UNSET.equal?(value)
@@ -93,6 +106,18 @@ module Dry
93
106
  @values[:bar_background] = style(:bar_background, value)
94
107
  end
95
108
 
109
+ # @param value [Symbol, nil] see {#bar_failed_color}
110
+ # @raise [ArgumentError] for a style Pastel does not know
111
+ def bar_failed_color=(value)
112
+ @values[:bar_failed_color] = style(:bar_failed_color, value)
113
+ end
114
+
115
+ # @param value [Symbol, nil] see {#bar_aux_color}
116
+ # @raise [ArgumentError] for a style Pastel does not know
117
+ def bar_aux_color=(value)
118
+ @values[:bar_aux_color] = style(:bar_aux_color, value)
119
+ end
120
+
96
121
  # @return [Array<String>] the frames a spinner cycles through
97
122
  def spinner_frames
98
123
  frames = spinner_definition(spinner_format).fetch(:frames)
@@ -24,6 +24,8 @@ module Dry
24
24
  # end
25
25
  # ui.success "Imported #{rules.size} rules"
26
26
  class Console
27
+ include Reporting
28
+
27
29
  # @!method debug(*paragraphs, width: nil)
28
30
  # A grey "Debug" box on `err`.
29
31
  # @param paragraphs [Array<#to_s>] each one wrapped on its own, separated by a blank line
@@ -65,10 +67,12 @@ module Dry
65
67
  # @param box_width [Integer, nil] box width in columns; nil fills the terminal
66
68
  # @param clock [#call] returns monotonic seconds
67
69
  # @param config [Configuration] spinner and bar formats; {UI.config} by default
70
+ # @param invocation [Invocation, nil] names the report and log files; nil for the program alone
68
71
  def initialize(out: $stdout, err: $stderr, input: $stdin, env: ENV, color: nil, animate: nil,
69
- width: nil, box_width: nil, clock: Duration::CLOCK, config: UI.config)
72
+ width: nil, box_width: nil, clock: Duration::CLOCK, config: UI.config, invocation: nil)
70
73
  @out = Terminal.new(out, env: env, color: color, animate: animate, width: width)
71
74
  @err = Terminal.new(err, env: env, color: color, animate: animate, width: width)
75
+ @invocation = invocation
72
76
  @input = input
73
77
  @box_width = box_width
74
78
  @clock = clock
@@ -184,6 +188,24 @@ module Dry
184
188
  Widgets::Progress.new(err, clock: clock, config: config).run(label, total: total, color: color, &)
185
189
  end
186
190
 
191
+ # Prints one line to `err` saying what each colour of a progress bar
192
+ # means, for a command to print before its first bar. Each label is
193
+ # optional; a label not given is left out. See {Widgets::Legend}.
194
+ #
195
+ # @example
196
+ # ui.legend(failed: "errors and invalid files", aux: "relevant but auxiliary", ok: "forms")
197
+ # # Color Mapping: [ red: errors and invalid files | yellow: relevant but auxiliary | green: forms ]
198
+ #
199
+ # @param failed [#to_s, nil] what the units counted `as: :failed` are
200
+ # @param aux [#to_s, nil] what the units counted `as: :aux` are
201
+ # @param ok [#to_s, nil] what the units counted `as: :ok` are
202
+ # @return [nil]
203
+ # @raise [ArgumentError] without any label
204
+ def legend(failed: nil, aux: nil, ok: nil) # rubocop:disable Naming/MethodParameterName
205
+ err.puts(Widgets::Legend.line(err, config, { failed: failed, aux: aux, ok: ok }))
206
+ nil
207
+ end
208
+
187
209
  # Runs several jobs at once, each with a progress bar of its own,
188
210
  # beneath a headline bar that counts them all. Each job is given a
189
211
  # {Widgets::Progress::Handle}. See {Widgets::MultiProgress}.
@@ -309,13 +331,18 @@ module Dry
309
331
  prompter.ask(question, default: default, choices: choices)
310
332
  end
311
333
 
312
- # Asks a yes/no question.
334
+ # Asks a yes/no question: on a terminal, a list to pick YES or NO from. `yes: true`, which
335
+ # is what the reserved `-y/--yes` flag gives, answers it without asking.
336
+ #
337
+ # @example
338
+ # exit 1 unless ui.confirm("Drop the table?", yes: yes)
313
339
  #
314
340
  # @param question [String]
315
341
  # @param default [Boolean]
342
+ # @param yes [Boolean] answer yes without asking
316
343
  # @return [Boolean]
317
- def confirm(question, default: false)
318
- prompter.confirm(question, default: default)
344
+ def confirm(question, default: false, yes: false)
345
+ yes || prompter.confirm(question, default: default)
319
346
  end
320
347
 
321
348
  private