dry-cli-ui 0.3.1 → 0.5.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 +4 -4
- data/CHANGELOG.md +19 -2
- data/README.md +595 -35
- data/examples/.envrc +1 -0
- data/examples/.gitignore +1 -0
- data/examples/Gemfile +1 -1
- data/examples/Gemfile.lock +5 -5
- data/examples/README.md +24 -10
- data/examples/bin/mycli +270 -94
- data/lib/dry/cli/ui/configuration.rb +160 -0
- data/lib/dry/cli/ui/console.rb +83 -8
- data/lib/dry/cli/ui/status_bar.rb +314 -0
- data/lib/dry/cli/ui/terminal.rb +38 -2
- data/lib/dry/cli/ui/theme.rb +21 -6
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/multi.rb +300 -0
- data/lib/dry/cli/ui/widgets/multi_progress.rb +123 -0
- data/lib/dry/cli/ui/widgets/multi_spinner.rb +69 -0
- data/lib/dry/cli/ui/widgets/outcome.rb +1 -1
- data/lib/dry/cli/ui/widgets/pool.rb +82 -0
- data/lib/dry/cli/ui/widgets/progress.rb +71 -12
- data/lib/dry/cli/ui/widgets/spinner.rb +11 -3
- data/lib/dry/cli/ui/widgets/tasks.rb +17 -61
- data/lib/dry/cli/ui/widgets.rb +4 -0
- data/lib/dry/cli/ui.rb +32 -0
- data/sig/dry/cli/ui.rbs +26 -1
- metadata +9 -2
- data/SPECIFICATION.md +0 -351
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Runtime terminal UI for [dry-cli](https://github.com/dry-rb/dry-cli) commands: spinners, progress bars, boxes, status lines, task trees, tables and prompts.
|
|
6
6
|
|
|
7
7
|
> [!NOTE]
|
|
8
|
-
> The design, and the reasons behind it, are in [SPECIFICATION](SPECIFICATION.md).
|
|
8
|
+
> The design, and the reasons behind it, are in [SPECIFICATION](docs/SPECIFICATION.md).
|
|
9
9
|
|
|
10
10
|
A long-running command has more to say than `puts` can show well: what it is doing now, how far along it is, what went wrong. Include one module and the command gets a `ui` that says it, in colour and in place on a terminal, and as plain lines when the output is piped to a file or a CI log.
|
|
11
11
|
|
|
@@ -15,7 +15,14 @@ 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.
|
|
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.
|
|
19
|
+
|
|
20
|
+
Either require works, and both load the same file:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
require "dry-cli-ui" # matches the gem name
|
|
24
|
+
require "dry/cli/ui" # matches the constant Dry::CLI::UI
|
|
25
|
+
```
|
|
19
26
|
|
|
20
27
|
## Usage
|
|
21
28
|
|
|
@@ -51,21 +58,48 @@ When the output is piped, and the import fails part way:
|
|
|
51
58
|
Loading tax rules...
|
|
52
59
|
✓ Loading tax rules (0.3s)
|
|
53
60
|
Importing rules...
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
│
|
|
58
|
-
│
|
|
59
|
-
│
|
|
60
|
-
│ dependency
|
|
61
|
-
│
|
|
62
|
-
|
|
61
|
+
𝘅 Importing rules 1482/1900 (4.1s)
|
|
62
|
+
|
|
63
|
+
┌─ Error ────────────────────────────────────────────────────────────────────┐
|
|
64
|
+
│ │
|
|
65
|
+
│ Import failed │
|
|
66
|
+
│ │
|
|
67
|
+
│ Could not validate rule US.2026.IRC.199A: missing dependency │
|
|
68
|
+
│ taxable_income │
|
|
69
|
+
│ │
|
|
70
|
+
└────────────────────────────────────────────────────────────────────────────┘
|
|
63
71
|
```
|
|
64
72
|
|
|
65
|
-
|
|
73
|
+
A stream that is not a terminal is taken to be 80 columns wide, so a piped box is 78.
|
|
74
|
+
|
|
75
|
+
On a terminal the spinner turns and the bar fills in place, with percent, count and ETA, and each is replaced by the same `✓` or `𝘅` line when its block ends.
|
|
66
76
|
|
|
67
77
|
Include the module once in a base class and every command has `ui`. Including it loads nothing: the TTY gems load the first time `ui` is used.
|
|
68
78
|
|
|
79
|
+
```ruby
|
|
80
|
+
class ApplicationCommand < Dry::CLI::Command
|
|
81
|
+
include Dry::CLI::UI
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class Import < ApplicationCommand
|
|
85
|
+
def call(**) = ui.success("Nothing to import")
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`ui` writes to the command's `out` and `err` when dry-cli has set them, and to `$stdout` and `$stderr` otherwise.
|
|
90
|
+
|
|
91
|
+
### Without dry-cli
|
|
92
|
+
|
|
93
|
+
`Dry::CLI::UI::Console` needs nothing from dry-cli, so a Rake task or a plain script can use it directly:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
require "dry-cli-ui"
|
|
97
|
+
|
|
98
|
+
ui = Dry::CLI::UI::Console.new
|
|
99
|
+
ui.spinner("Compacting the database") { compact! }
|
|
100
|
+
ui.success "Done"
|
|
101
|
+
```
|
|
102
|
+
|
|
69
103
|
## API
|
|
70
104
|
|
|
71
105
|
### Messages
|
|
@@ -79,35 +113,102 @@ ui.error "Import failed", e.message
|
|
|
79
113
|
ui.fatal "Database unreachable"
|
|
80
114
|
```
|
|
81
115
|
|
|
82
|
-
Each
|
|
116
|
+
Each prints a blank line, then a box with a single white border and the level's name as a coloured title, and returns `nil`. Every argument is a paragraph, wrapped to fit, with a blank line between paragraphs. The box fills the terminal less a two-column margin, or takes a fixed width:
|
|
83
117
|
|
|
84
118
|
```ruby
|
|
85
119
|
ui.info "Short and narrow", width: 40
|
|
86
|
-
ui.box "Name: Alan Turing", "Role: Cryptanalyst", title: "Profile" # untitled without title:
|
|
87
120
|
```
|
|
88
121
|
|
|
122
|
+
```text
|
|
123
|
+
|
|
124
|
+
┌─ Info ───────────────────────────────┐
|
|
125
|
+
│ │
|
|
126
|
+
│ Short and narrow │
|
|
127
|
+
│ │
|
|
128
|
+
└──────────────────────────────────────┘
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
A `width:` is never wider than the terminal less its margin, and never narrower than 20 columns.
|
|
132
|
+
|
|
133
|
+
| Method | Title | Colour | Stream |
|
|
134
|
+
| --------- | ------- | ------- | ------ |
|
|
135
|
+
| `debug` | Debug | gray | `err` |
|
|
136
|
+
| `info` | Info | cyan | `out` |
|
|
137
|
+
| `success` | Success | green | `out` |
|
|
138
|
+
| `warn` | Warning | yellow | `err` |
|
|
139
|
+
| `error` | Error | red | `err` |
|
|
140
|
+
| `fatal` | Fatal | magenta | `err` |
|
|
141
|
+
|
|
142
|
+
`ui.box` is the general form. Without `level:` it is untitled unless given a `title:`, uncoloured, and goes to `out`:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
ui.box "Name: Alan Turing", "Role: Cryptanalyst", title: "Profile", width: 40
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```text
|
|
149
|
+
|
|
150
|
+
┌─ Profile ────────────────────────────┐
|
|
151
|
+
│ │
|
|
152
|
+
│ Name: Alan Turing │
|
|
153
|
+
│ │
|
|
154
|
+
│ Role: Cryptanalyst │
|
|
155
|
+
│ │
|
|
156
|
+
└──────────────────────────────────────┘
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
With `level:` it takes that level's colour and stream, and its title unless `title:` replaces it:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
ui.box "Disk is 91% full", level: :warn, title: "Disk" # a yellow "Disk" box on err
|
|
163
|
+
ui.box "Plain and untitled" # no title, on out
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
An unknown level raises `ArgumentError`.
|
|
167
|
+
|
|
89
168
|
### Popups
|
|
90
169
|
|
|
91
170
|
```ruby
|
|
92
171
|
ui.popup "h help", "q quit", title: "Keys"
|
|
93
172
|
```
|
|
94
173
|
|
|
95
|
-
On a terminal, a box drawn over whatever is on the screen: only as wide as its text, centred, and leaving the cursor where it was, so a spinner or a redrawn screen carries on underneath. Piped, it is the same box `ui.box` draws, on `err
|
|
174
|
+
On a terminal, a box drawn over whatever is on the screen: only as wide as its text (at least 20 columns, at most `width:` or the box width), centred, and leaving the cursor where it was, so a spinner or a redrawn screen carries on underneath. Piped, it is the same box `ui.box` draws, on `err`:
|
|
175
|
+
|
|
176
|
+
```text
|
|
177
|
+
┌─ Keys ─────────────────────┐
|
|
178
|
+
│ │
|
|
179
|
+
│ h help │
|
|
180
|
+
│ │
|
|
181
|
+
│ q quit │
|
|
182
|
+
│ │
|
|
183
|
+
└────────────────────────────┘
|
|
184
|
+
```
|
|
96
185
|
|
|
97
186
|
### Status lines
|
|
98
187
|
|
|
188
|
+
One line with a coloured glyph, on the level's stream. The level defaults to `:info`, and every argument is joined with a space:
|
|
189
|
+
|
|
99
190
|
```ruby
|
|
100
191
|
ui.status "Connected to the database", level: :success # ✓ Connected to the database
|
|
101
192
|
ui.status "Disk nearly full", level: :warn # ⚠ Disk nearly full
|
|
193
|
+
ui.status "Loaded", rules.size, "rules" # ℹ Loaded 1900 rules
|
|
102
194
|
```
|
|
103
195
|
|
|
196
|
+
| Level | Glyph | Stream |
|
|
197
|
+
| ---------- | ----- | ------ |
|
|
198
|
+
| `:debug` | `·` | `err` |
|
|
199
|
+
| `:info` | `ℹ` | `out` |
|
|
200
|
+
| `:success` | `✓` | `out` |
|
|
201
|
+
| `:warn` | `⚠` | `err` |
|
|
202
|
+
| `:error` | `✗` | `err` |
|
|
203
|
+
| `:fatal` | `✖` | `err` |
|
|
204
|
+
|
|
104
205
|
### Spinners
|
|
105
206
|
|
|
106
207
|
```ruby
|
|
107
208
|
rules = ui.spinner("Loading tax rules") { load_rules }
|
|
108
209
|
```
|
|
109
210
|
|
|
110
|
-
Returns the block's value. Leaves `✓ Loading tax rules (0.3s)` behind, or
|
|
211
|
+
Returns the block's value. Leaves `✓ Loading tax rules (0.3s)` behind, or `𝘅` and the re-raised error when the block fails.
|
|
111
212
|
|
|
112
213
|
The block is given a `Dry::CLI::UI::Line`, for work that has more to say while it runs, or that can fail without raising:
|
|
113
214
|
|
|
@@ -118,7 +219,27 @@ ui.spinner("Importing rules") do |line|
|
|
|
118
219
|
end
|
|
119
220
|
```
|
|
120
221
|
|
|
121
|
-
`line.detail = "..."` shows text after the label, redrawn in place as it changes. Piped, the detail is kept and never printed, since it can change many times a second. `line.fail(reason)` ends the spinner as
|
|
222
|
+
`line.detail = "..."` shows text after the label, redrawn in place as it changes. Piped, the detail is kept and never printed, since it can change many times a second. `line.fail(reason)` ends the spinner as `𝘅 Importing rules: 3 rules skipped (4.1s)` without raising, and the block's value is still returned. `line.failed?`, `line.reason` and `line.detail` read it back. Every `Line` method is safe to call from any thread.
|
|
223
|
+
|
|
224
|
+
Piped, each of these prints its label with `...` when it starts, then its outcome:
|
|
225
|
+
|
|
226
|
+
```text
|
|
227
|
+
Loading tax rules...
|
|
228
|
+
✓ Loading tax rules (0.3s)
|
|
229
|
+
Importing rules...
|
|
230
|
+
𝘅 Importing rules: 3 rules skipped (4.1s)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A block that raises leaves `𝘅 Loading tax rules (0.3s)` and the error propagates, so `rescue` it around the call. A lambda that takes no arguments is called without a `Line`, so a method object or a stored lambda can be passed as it is:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
load = -> { YAML.load_file("rules.yml") }
|
|
237
|
+
rules = ui.spinner("Loading tax rules", &load)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Without a block, `spinner` raises `ArgumentError`. The same is true of `progress`, `multi_spinner`, `multi_progress`, `tasks` and `status_bar`.
|
|
241
|
+
|
|
242
|
+
Elapsed times read `0.4s` under a minute, `1m 02s` under an hour, and `1h 02m` after that.
|
|
122
243
|
|
|
123
244
|
### Progress bars
|
|
124
245
|
|
|
@@ -131,7 +252,189 @@ ui.progress("Importing rules", total: rules.size) do |bar|
|
|
|
131
252
|
end
|
|
132
253
|
```
|
|
133
254
|
|
|
134
|
-
The bar shows percent, `current/total` and ETA, and ends with `✓ Importing rules 1900/1900 (4.2s)`.
|
|
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.
|
|
256
|
+
|
|
257
|
+
The block is given a `Dry::CLI::UI::Widgets::Progress::Handle`, never the underlying `TTY::ProgressBar`:
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
ui.progress("Copying", total: files.sum(&:size)) do |bar|
|
|
261
|
+
files.each do |file|
|
|
262
|
+
copy(file) { |bytes| bar.advance(bytes) }
|
|
263
|
+
ui.status "#{bar.current} of #{bar.total} bytes" if bar.current == bar.total
|
|
264
|
+
end
|
|
265
|
+
:copied # progress returns the block's value
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- `advance(step = 1)` adds to `current` and returns the handle; `current` never passes `total`.
|
|
270
|
+
- `total:` must be a non-negative Integer, or `progress` raises `ArgumentError`.
|
|
271
|
+
- `total: 0` draws no bar, and ends `✓ Copying 0/0`.
|
|
272
|
+
- `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.
|
|
273
|
+
- The outcome is `✓` whenever the block returns, even short of the total (`✓ Copying 12/20`), and `𝘅` when it raises.
|
|
274
|
+
|
|
275
|
+
Piped, it prints `Importing rules...` when it starts and the outcome line when it ends, with no bar in between.
|
|
276
|
+
|
|
277
|
+
### Several spinners at once
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
ui.multi_spinner("Fetching", concurrent: 2) do |m|
|
|
281
|
+
m.spinner("fonts") { fetch(:fonts) }
|
|
282
|
+
m.spinner("images") do |line|
|
|
283
|
+
fetch(:images) { |done, all| line.detail = "#{done} of #{all}" }
|
|
284
|
+
end
|
|
285
|
+
m.spinner("video") { fetch(:video) }
|
|
286
|
+
end
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The block declares the jobs; they run once it returns, all at once by default, or at most `concurrent: 2` at a time. It returns what each job returned, in declaration order. While they run, every job has a row of its own under a headline spinner, and a job still waiting shows `[ ]`:
|
|
290
|
+
|
|
291
|
+
```text
|
|
292
|
+
[⠹] Fetching
|
|
293
|
+
├─ [⠹] fonts
|
|
294
|
+
├─ [⠹] images 12 of 40
|
|
295
|
+
└─ [ ] video
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Once they finish, the headline ends `✓`, or `𝘅` when any job failed:
|
|
299
|
+
|
|
300
|
+
```text
|
|
301
|
+
[𝘅] Fetching (0.5s)
|
|
302
|
+
├─ [✓] fonts (0.3s)
|
|
303
|
+
├─ [𝘅] images: 2 timed out (0.3s)
|
|
304
|
+
└─ [✓] video (0.2s)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Each job is given a `Line`, as a single spinner's block is. When a job raises, jobs already running finish, jobs not yet started are marked skipped (`[—]`), and the first error is re-raised. A job that never ran has `nil` in the returned array, which matters only when you rescue the error.
|
|
308
|
+
|
|
309
|
+
`concurrent:` takes `true` (all at once, the default), `false` (one at a time, in order), or a positive Integer. Anything else, `0` included, raises `ArgumentError` before any job runs.
|
|
310
|
+
|
|
311
|
+
Piped, it prints `Fetching...`, then each job's outcome as it ends, then the headline's:
|
|
312
|
+
|
|
313
|
+
```text
|
|
314
|
+
Fetching...
|
|
315
|
+
[✓] fonts (0.3s)
|
|
316
|
+
[𝘅] images: 2 timed out (0.3s)
|
|
317
|
+
[✓] video (0.2s)
|
|
318
|
+
𝘅 Fetching (0.5s)
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
When a job raises under `concurrent: 1`:
|
|
322
|
+
|
|
323
|
+
```text
|
|
324
|
+
Fetching...
|
|
325
|
+
[𝘅] fonts (0.1s)
|
|
326
|
+
[—] images
|
|
327
|
+
[—] video
|
|
328
|
+
𝘅 Fetching (0.1s)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The rows are also printed one by one on a terminal that has fewer rows than the widget needs, since the cursor cannot move above the top of the screen.
|
|
332
|
+
|
|
333
|
+
### Several progress bars at once
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
ui.multi_progress("Downloading") do |m|
|
|
337
|
+
files.each do |file|
|
|
338
|
+
m.progress(file.name, total: file.size) do |bar|
|
|
339
|
+
download(file) { |bytes| bar.advance(bytes) }
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
The same shape as `multi_spinner`, with a bar per job and a headline bar that counts them all. Bars start and end in the same columns, and counts are right-aligned:
|
|
346
|
+
|
|
347
|
+
```text
|
|
348
|
+
[⠋] Downloading [◼◼◼◼ ] 27% 66/240 ETA 2.1s
|
|
349
|
+
├─ [⠋] fonts.zip [◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ ] 95% 38/40 ETA 0.1s
|
|
350
|
+
├─ [⠋] images.tar.gz [◼◼◼ ] 23% 28/120 ETA 2.4s
|
|
351
|
+
└─ [ ] video.mp4
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
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:
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
ui.multi_progress("Probing #{hosts.size} hosts") do |m|
|
|
358
|
+
m.progress("Answered", total: hosts.size, color: :green) { |bar| ... }
|
|
359
|
+
m.progress("No answer", total: hosts.size, color: :red) { |bar| ... }
|
|
360
|
+
end
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The headline bar keeps the configured colour. A finished job's row reads `[✓] fonts.zip 40/40 (0.1s)`, and the headline's `[✓] Downloading 240/240 (0.3s)`. It returns what each job returned, in declaration order, and takes `concurrent:` as `multi_spinner` does. Every `m.progress` needs a block and a non-negative Integer `total:`, or raises `ArgumentError`.
|
|
364
|
+
|
|
365
|
+
Piped:
|
|
366
|
+
|
|
367
|
+
```text
|
|
368
|
+
Downloading...
|
|
369
|
+
[✓] fonts.zip 40/40 (0.1s)
|
|
370
|
+
[✓] images.tar.gz 120/120 (0.2s)
|
|
371
|
+
[✓] video.mp4 80/80 (0.3s)
|
|
372
|
+
✓ Downloading 240/240 (0.3s)
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Example: fetching many URLs
|
|
376
|
+
|
|
377
|
+
With `multi_spinner`, each URL gets a spinner, and the call returns every page in the same order as `urls`. Nothing writes to a shared file from several threads:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
bodies = ui.multi_spinner("Fetching #{urls.size} URLs", concurrent: 8) do |m|
|
|
381
|
+
urls.each { |url| m.spinner(url) { fetch(url) } }
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
File.write("urls.txt", bodies.join("\n"))
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
With `multi_progress`, each URL gets a bar that fills one byte at a time as the body arrives. A bar's `total` is fixed when it is declared, so a `HEAD` request asks each URL for its size first:
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
found = urls.filter_map do |url|
|
|
391
|
+
[url, content_length(url)]
|
|
392
|
+
rescue StandardError => e
|
|
393
|
+
ui.status "#{url}: #{e.message}", level: :warn
|
|
394
|
+
nil
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
bodies = ui.multi_progress("Fetching #{found.size} URLs", concurrent: 8) do |m|
|
|
398
|
+
found.each do |url, size|
|
|
399
|
+
m.progress(url, total: size || 1) do |bar|
|
|
400
|
+
body = download(url) { |bytes| bytes.times { bar.advance } if size }
|
|
401
|
+
bar.advance unless size # no Content-Length: done in one step
|
|
402
|
+
body
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
File.write("urls.txt", bodies.join("\n"))
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
The two helpers, with `Net::HTTP`:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
require "net/http"
|
|
414
|
+
|
|
415
|
+
def content_length(url)
|
|
416
|
+
uri = URI(url)
|
|
417
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
418
|
+
http.head(uri.request_uri).content_length # nil when the server does not say
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def download(url)
|
|
423
|
+
uri = URI(url)
|
|
424
|
+
body = +""
|
|
425
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
426
|
+
http.request_get(uri.request_uri) do |response|
|
|
427
|
+
response.read_body do |chunk|
|
|
428
|
+
body << chunk
|
|
429
|
+
yield chunk.bytesize
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
body
|
|
434
|
+
end
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
In both, at most eight requests run at once and the rest wait as `[ ]` rows. The headline counts every URL, and in `multi_progress` every byte. A server that sends no `Content-Length` gets a bar of one unit, which sits at 0% and fills when its download ends. These helpers are kept short: a real one follows redirects, and sends `Accept-Encoding: identity` so the bytes counted match `Content-Length`. With more URLs than the screen has rows, each finished URL prints one line instead.
|
|
135
438
|
|
|
136
439
|
### Task trees
|
|
137
440
|
|
|
@@ -156,19 +459,49 @@ On a terminal, once it finishes:
|
|
|
156
459
|
|
|
157
460
|
```text
|
|
158
461
|
Deploy
|
|
159
|
-
├─ ✓ Build assets (0.4s)
|
|
160
|
-
├─ ✓ Migrate (0.3s)
|
|
161
|
-
│ ├─ ✓ users (0.1s)
|
|
162
|
-
│ └─ ✓ orders (0.2s)
|
|
163
|
-
├─ ✓ Warm caches (0.5s)
|
|
164
|
-
│ ├─ ✓ fonts (0.5s)
|
|
165
|
-
│ └─ ✓ images (0.3s)
|
|
166
|
-
└─ ✓ Restart (0.3s)
|
|
462
|
+
├─ [✓] Build assets (0.4s)
|
|
463
|
+
├─ [✓] Migrate (0.3s)
|
|
464
|
+
│ ├─ [✓] users (0.1s)
|
|
465
|
+
│ └─ [✓] orders (0.2s)
|
|
466
|
+
├─ [✓] Warm caches (0.5s)
|
|
467
|
+
│ ├─ [✓] fonts (0.5s)
|
|
468
|
+
│ └─ [✓] images (0.3s)
|
|
469
|
+
└─ [✓] Restart (0.3s)
|
|
167
470
|
```
|
|
168
471
|
|
|
169
|
-
While it runs, the tree redraws in place and every running task has its own spinner. Piped, each line is printed once it is final, and a group's line appears as
|
|
472
|
+
While it runs, the tree redraws in place and every running task has its own spinner. Every row is marked in brackets, in bold yellow while it waits, `[ ]`, and while it runs, a turning `[⠏]`; then a green `[✓]` when it is done, a red `[𝘅]` when it failed, or a yellow `[—]` when it was skipped. Piped, each line is printed once it is final, and a group's line appears as `[▸]` when it starts. `concurrent: true` runs a group's tasks at the same time, on a group or on `ui.tasks` itself, and `concurrent: 3` runs at most three at once. Without it, tasks run one after another. When a task raises, it is marked `[𝘅]`, tasks already running finish, the rest are marked skipped (`[—]`), and the error is re-raised. `ui.tasks` returns `nil`, and its title is optional: `ui.tasks { |t| ... }` draws the tree without a heading.
|
|
473
|
+
|
|
474
|
+
Piped, the same tree as the example above, with one migration failing:
|
|
475
|
+
|
|
476
|
+
```text
|
|
477
|
+
Deploy
|
|
478
|
+
├─ [✓] Build assets (0.4s)
|
|
479
|
+
├─ [▸] Migrate
|
|
480
|
+
│ ├─ [✓] users (0.1s)
|
|
481
|
+
│ └─ [𝘅] orders: table locked (0.2s)
|
|
482
|
+
├─ [▸] Warm caches
|
|
483
|
+
│ ├─ [✓] fonts (0.5s)
|
|
484
|
+
│ └─ [✓] images (0.3s)
|
|
485
|
+
└─ [✓] Restart (0.3s)
|
|
486
|
+
```
|
|
170
487
|
|
|
171
|
-
|
|
488
|
+
And when `Build assets` raises instead:
|
|
489
|
+
|
|
490
|
+
```text
|
|
491
|
+
Deploy
|
|
492
|
+
├─ [𝘅] Build assets (0.4s)
|
|
493
|
+
├─ [—] Migrate
|
|
494
|
+
│ ├─ [—] users
|
|
495
|
+
│ └─ [—] orders
|
|
496
|
+
├─ [—] Warm caches
|
|
497
|
+
│ ├─ [—] fonts
|
|
498
|
+
│ └─ [—] images
|
|
499
|
+
└─ [—] Restart
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
`task` and `group` each need a block, and `concurrent:` takes the same values as on the multi widgets; either mistake raises `ArgumentError` while the tree is being declared, before anything runs.
|
|
503
|
+
|
|
504
|
+
Each task is given a `Line`, as a spinner's block is. Its detail is drawn after the task's name while it runs, and `line.fail(reason)` marks the task `𝘅 name: reason` and its groups `𝘅`, while the rest of the tree runs on:
|
|
172
505
|
|
|
173
506
|
```ruby
|
|
174
507
|
ui.tasks("Fetching", concurrent: 4) do |t|
|
|
@@ -182,6 +515,74 @@ ui.tasks("Fetching", concurrent: 4) do |t|
|
|
|
182
515
|
end
|
|
183
516
|
```
|
|
184
517
|
|
|
518
|
+
### Status bar
|
|
519
|
+
|
|
520
|
+
```ruby
|
|
521
|
+
ui.status_bar("deploy", hints: ["^C cancel"]) do
|
|
522
|
+
ui.spinner("Building assets") { build }
|
|
523
|
+
ui.multi_progress("Uploading", concurrent: 2) { |m| ... }
|
|
524
|
+
ui.tasks("Migrate") { |t| ... }
|
|
525
|
+
end
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
While the block runs, the bottom of the screen shows how the whole command is doing, under a rule: what started last, how many things are running, done and failed, a bar over every progress bar so far, the elapsed time, and your hints at the right edge. Everything the widgets print scrolls above it, and it disappears when the block ends:
|
|
529
|
+
|
|
530
|
+
```text
|
|
531
|
+
✓ Building assets (0.2s)
|
|
532
|
+
[⠙] Uploading [◼◼◼◼◼◼◼◼◼◼◼◼◼◼ ] 37% 49/132 ETA 0.3s
|
|
533
|
+
├─ [✓] app.js 40/40 (0.2s)
|
|
534
|
+
├─ [⠙] app.css [◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ ] 75% 9/12 ETA 0.1s
|
|
535
|
+
└─ [⠙] fonts.zip [ ] 0% 0/80 ETA --
|
|
536
|
+
──────────────────────────────────────────────────────────────────────────────────────────
|
|
537
|
+
⠸ deploy · fonts.zip · 2 running · 2 done · [◼◼◼ ] 37% · 0.4s ^C cancel
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Nothing reports to it by hand: every `spinner`, `progress`, `multi_spinner`, `multi_progress` and task started inside the block does so on its own. Hints that do not fit are left out, and a status that does not fit is cut short with `…`.
|
|
541
|
+
|
|
542
|
+
It sets no scroll region, so the scrollback keeps everything, and an interrupted command leaves nothing behind. Piped, or without animation, it just runs the block, and a `status_bar` inside another one does the same. Either way it returns the block's value. Write through `ui` while it runs: a bare `puts` lands where the bar is, until the next `ui` call draws the bar again.
|
|
543
|
+
|
|
544
|
+
Both arguments are optional, and `hints:` takes one string or several:
|
|
545
|
+
|
|
546
|
+
```ruby
|
|
547
|
+
report = ui.status_bar { build_report } # no title, no hints
|
|
548
|
+
ui.status_bar("sync", hints: "q quit") { sync }
|
|
549
|
+
ui.status_bar("sync", hints: ["^C cancel", "? help"]) { sync }
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Putting it together
|
|
553
|
+
|
|
554
|
+
```ruby
|
|
555
|
+
class Deploy < Dry::CLI::Command
|
|
556
|
+
include Dry::CLI::UI
|
|
557
|
+
|
|
558
|
+
def call(**)
|
|
559
|
+
ui.status_bar("deploy", hints: ["^C cancel"]) do
|
|
560
|
+
assets = ui.spinner("Building assets") { build_assets }
|
|
561
|
+
|
|
562
|
+
ui.multi_progress("Uploading", concurrent: 3) do |m|
|
|
563
|
+
assets.each do |asset|
|
|
564
|
+
m.progress(asset.name, total: asset.bytesize) do |bar|
|
|
565
|
+
upload(asset) { |sent| bar.advance(sent) }
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
ui.multi_spinner("Warming caches") do |m|
|
|
571
|
+
regions.each do |region|
|
|
572
|
+
m.spinner(region) do |line|
|
|
573
|
+
warm(region) { |host| line.detail = host }
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
ui.success "Deployed #{assets.size} assets"
|
|
580
|
+
rescue => e
|
|
581
|
+
ui.error("Deploy failed", e.message)
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
```
|
|
585
|
+
|
|
185
586
|
### Tables
|
|
186
587
|
|
|
187
588
|
```ruby
|
|
@@ -197,7 +598,11 @@ ui.table([["Alan Turing", 41], ["Ada Lovelace", 36]], header: %w[Name Age])
|
|
|
197
598
|
└──────────────┴─────┘
|
|
198
599
|
```
|
|
199
600
|
|
|
200
|
-
Tables are never truncated or rotated to fit the screen.
|
|
601
|
+
Tables go to `out` and return `nil`. Cells are converted with `to_s`, the header is bold on a colour terminal, `header:` is optional, and an empty `rows` prints nothing. Tables are never truncated or rotated to fit the screen: a table wider than the terminal wraps like any long line.
|
|
602
|
+
|
|
603
|
+
```ruby
|
|
604
|
+
ui.table(User.limit(10).pluck(:email, :created_at)) # no header
|
|
605
|
+
```
|
|
201
606
|
|
|
202
607
|
### Prompts
|
|
203
608
|
|
|
@@ -208,19 +613,58 @@ tier = ui.prompt("Tier?", choices: { "Free" => :free, "Pro" => :pro })
|
|
|
208
613
|
ui.confirm("Deploy to #{env}?", default: false)
|
|
209
614
|
```
|
|
210
615
|
|
|
211
|
-
|
|
616
|
+
- `prompt` returns the answer as a String, or the default for an empty answer.
|
|
617
|
+
- 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.
|
|
618
|
+
- `confirm` returns `true` or `false`, and `default:` is `false` unless given.
|
|
619
|
+
|
|
620
|
+
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:
|
|
212
621
|
|
|
213
622
|
```bash
|
|
214
623
|
printf 'production\ny\n' | mycli deploy
|
|
215
624
|
```
|
|
216
625
|
|
|
217
|
-
|
|
626
|
+
In that mode a list of choices is numbered, and an answer may be the number or the name:
|
|
627
|
+
|
|
628
|
+
```text
|
|
629
|
+
Name? [Alan Turing]
|
|
630
|
+
Environment?
|
|
631
|
+
1) staging
|
|
632
|
+
2) production
|
|
633
|
+
Choose 1-2 [staging]: 2
|
|
634
|
+
Tier?
|
|
635
|
+
1) Free
|
|
636
|
+
2) Pro
|
|
637
|
+
Choose 1-2: Pro
|
|
638
|
+
Deploy to production? (y/N) maybe
|
|
639
|
+
Please answer y or n.
|
|
640
|
+
Deploy to production? (y/N) yes
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
An answer that matches no choice asks again, and `confirm` accepts `y`, `yes`, `n` and `no` in any case. When the input runs out, a prompt returns its default, or raises `Dry::CLI::UI::NonInteractiveError` if it has none:
|
|
644
|
+
|
|
645
|
+
```ruby
|
|
646
|
+
token = begin
|
|
647
|
+
ui.prompt("API token?")
|
|
648
|
+
rescue Dry::CLI::UI::NonInteractiveError
|
|
649
|
+
ENV.fetch("API_TOKEN")
|
|
650
|
+
end
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
## Errors
|
|
654
|
+
|
|
655
|
+
| Error | Raised when |
|
|
656
|
+
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
657
|
+
| `Dry::CLI::UI::NonInteractiveError` | a prompt has no answer left to read and no default |
|
|
658
|
+
| `Dry::CLI::UI::Error` | never directly: the base class of the gem's own errors, for `rescue Dry::CLI::UI::Error` |
|
|
659
|
+
| `ArgumentError` | a widget has no block, `total:` is not a non-negative Integer, `concurrent:` is invalid, a level or configuration is unknown |
|
|
660
|
+
|
|
661
|
+
Errors raised inside a block are never swallowed: the widget marks itself failed and re-raises them.
|
|
218
662
|
|
|
219
663
|
## Where output goes
|
|
220
664
|
|
|
221
|
-
| To `out` (results) | To `err` (everything else)
|
|
222
|
-
| ----------------------------------------------------------- |
|
|
223
|
-
| `info`, `success`, `box`, `table`, `status` at those levels | `debug`, `warn`, `error`, `fatal`, `popup`, spinners, progress bars, task trees, prompts |
|
|
665
|
+
| To `out` (results) | To `err` (everything else) |
|
|
666
|
+
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
667
|
+
| `info`, `success`, `box`, `table`, `status` at those levels | `debug`, `warn`, `error`, `fatal`, `popup`, spinners, progress bars, their multi forms, task trees, the status bar, prompts |
|
|
224
668
|
|
|
225
669
|
`mycli export > rules.csv` therefore writes only the command's results to the file, while its progress stays on the screen. `ui` writes to the streams dry-cli was called with, so `Dry::CLI.new(registry).call(out: io, err: io)` captures everything.
|
|
226
670
|
|
|
@@ -228,6 +672,55 @@ A stream that is not a terminal, or runs under `TERM=dumb`, gets no animation, n
|
|
|
228
672
|
|
|
229
673
|
## Configuration
|
|
230
674
|
|
|
675
|
+
Spinners and bars look the same everywhere, and are set once for the whole process:
|
|
676
|
+
|
|
677
|
+
```ruby
|
|
678
|
+
Dry::CLI::UI.configure do
|
|
679
|
+
spinner_format :dots # any TTY::Spinner format name
|
|
680
|
+
bar_format(complete: "◼", incomplete: " ") # or any TTY::ProgressBar bar format name, such as :box
|
|
681
|
+
bar_color :green # the finished part: any Pastel style, or nil
|
|
682
|
+
bar_background nil # the whole bar: any Pastel style, or nil
|
|
683
|
+
end
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
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:
|
|
687
|
+
|
|
688
|
+
```ruby
|
|
689
|
+
Dry::CLI::UI.configure do |config|
|
|
690
|
+
config.spinner_format = { interval: 8, frames: %w[◐ ◓ ◑ ◒] } # frames per second, and the frames
|
|
691
|
+
config.bar_format = { complete: "#", incomplete: "." }
|
|
692
|
+
end
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
An unknown name or a malformed definition raises `ArgumentError` when it is set, as does a colour Pastel does not know:
|
|
696
|
+
|
|
697
|
+
```ruby
|
|
698
|
+
Dry::CLI::UI.configure { bar_color :nope }
|
|
699
|
+
# => ArgumentError: bar_color must be a Pastel style or nil, got :nope
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
Called without a value, each setting reads it back, and `Dry::CLI::UI.config` returns the configuration itself. `Dry::CLI::UI.reset!` restores every default, which is useful between specs:
|
|
703
|
+
|
|
704
|
+
```ruby
|
|
705
|
+
Dry::CLI::UI.config.bar_color # => :green
|
|
706
|
+
Dry::CLI::UI.config.spinner_frames # => ["⠋", "⠙", "⠹", ...]
|
|
707
|
+
|
|
708
|
+
RSpec.configure { |c| c.after { Dry::CLI::UI.reset! } }
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
Some more looks, all from names the TTY gems already know:
|
|
712
|
+
|
|
713
|
+
```ruby
|
|
714
|
+
Dry::CLI::UI.configure do
|
|
715
|
+
spinner_format :classic # | / - \
|
|
716
|
+
bar_format :block # █ and ░
|
|
717
|
+
bar_color :cyan
|
|
718
|
+
bar_background :on_blue # a blue track the whole bar's width
|
|
719
|
+
end
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
### Console options
|
|
723
|
+
|
|
231
724
|
Override `ui` to configure the console:
|
|
232
725
|
|
|
233
726
|
```ruby
|
|
@@ -246,6 +739,47 @@ class ApplicationCommand < Dry::CLI::Command
|
|
|
246
739
|
end
|
|
247
740
|
```
|
|
248
741
|
|
|
742
|
+
Every option `Console.new` takes:
|
|
743
|
+
|
|
744
|
+
| Option | Default | What it does |
|
|
745
|
+
| ------------ | --------------------- | -------------------------------------------------------------------- |
|
|
746
|
+
| `out:` | `$stdout` | where results go |
|
|
747
|
+
| `err:` | `$stderr` | where diagnostics, progress and prompts go |
|
|
748
|
+
| `input:` | `$stdin` | where prompt answers are read from |
|
|
749
|
+
| `env:` | `ENV` | read for `NO_COLOR` and `TERM` |
|
|
750
|
+
| `color:` | `nil` | `true` or `false` forces colour on both streams; `nil` detects it |
|
|
751
|
+
| `animate:` | `nil` | `true` or `false` forces animation on both streams; `nil` detects it |
|
|
752
|
+
| `width:` | `nil` | forces the terminal width in columns; `nil` asks the terminal, or 80 |
|
|
753
|
+
| `box_width:` | `nil` | the width of every box; `nil` fills the terminal less two columns |
|
|
754
|
+
| `clock:` | monotonic clock | any object whose `call` returns seconds, for elapsed times |
|
|
755
|
+
| `config:` | `Dry::CLI::UI.config` | a `Dry::CLI::UI::Configuration` for this console alone |
|
|
756
|
+
|
|
757
|
+
`config:` gives one console a look of its own without changing the process-wide one:
|
|
758
|
+
|
|
759
|
+
```ruby
|
|
760
|
+
classic = Dry::CLI::UI::Configuration.new.tap { |c| c.spinner_format = :classic }
|
|
761
|
+
ui = Dry::CLI::UI::Console.new(config: classic)
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
### Testing a command
|
|
765
|
+
|
|
766
|
+
Pass `StringIO`s and assert on what was written. A `StringIO` is not a terminal, so the output is the plain form shown throughout this README, with no escape codes:
|
|
767
|
+
|
|
768
|
+
```ruby
|
|
769
|
+
out = StringIO.new
|
|
770
|
+
err = StringIO.new
|
|
771
|
+
ui = Dry::CLI::UI::Console.new(out: out, err: err, input: StringIO.new("y\n"))
|
|
772
|
+
|
|
773
|
+
ui.spinner("Loading") { :ok }
|
|
774
|
+
ui.success "Imported"
|
|
775
|
+
ui.confirm("Continue?") # => true
|
|
776
|
+
|
|
777
|
+
err.string # => "Loading...\n✓ Loading (0.0s)\nContinue? (y/N) "
|
|
778
|
+
out.string # => the Success box
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
Through dry-cli, `Dry::CLI.new(registry).call(arguments: %w[import], out: out, err: err)` gives every command's `ui` those streams.
|
|
782
|
+
|
|
249
783
|
## Relationship to dry-cli-help
|
|
250
784
|
|
|
251
785
|
`dry-cli-help` is static presentation: what does this command do? `dry-cli-ui` is runtime presentation: what is this command doing? Use either, or both.
|
|
@@ -256,6 +790,19 @@ gem "dry-cli-help"
|
|
|
256
790
|
gem "dry-cli-ui"
|
|
257
791
|
```
|
|
258
792
|
|
|
793
|
+
## Examples
|
|
794
|
+
|
|
795
|
+
[`examples/`](examples/README.md) holds a small dry-cli application that uses the gem:
|
|
796
|
+
|
|
797
|
+
```bash
|
|
798
|
+
cd examples
|
|
799
|
+
bundle install
|
|
800
|
+
bundle exec bin/mycli primes --max 200000
|
|
801
|
+
bundle exec bin/mycli urls_spinner https://www.ruby-lang.org https://dry-rb.org
|
|
802
|
+
bundle exec bin/mycli urls_progress https://www.ruby-lang.org https://dry-rb.org
|
|
803
|
+
bundle exec bin/mycli urls_progress https://www.ruby-lang.org | cat # the plain form
|
|
804
|
+
```
|
|
805
|
+
|
|
259
806
|
## Development
|
|
260
807
|
|
|
261
808
|
```bash
|
|
@@ -264,10 +811,11 @@ just test # the suite, with 100% line and branch coverage enforced
|
|
|
264
811
|
just lint # rubocop
|
|
265
812
|
just ci # both
|
|
266
813
|
just format # rubocop -a, then mdformat
|
|
814
|
+
just doc # YARD documentation
|
|
267
815
|
bin/console # IRB with the gem loaded
|
|
268
816
|
```
|
|
269
817
|
|
|
270
|
-
Specs render into a `StringIO`. The animated code paths run against `FakeTTY`, a `StringIO` that answers `tty?` with true, and elapsed times come from a fake clock.
|
|
818
|
+
Specs render into a `StringIO`. The animated code paths run against `FakeTTY`, a `StringIO` that answers `tty?` with true, and elapsed times come from a fake clock. RBS signatures for the public API are in [`sig/dry/cli/ui.rbs`](sig/dry/cli/ui.rbs).
|
|
271
819
|
|
|
272
820
|
## Contributing
|
|
273
821
|
|
|
@@ -276,6 +824,18 @@ Bug reports and pull requests are welcome at <https://github.com/kigster/dry-cli
|
|
|
276
824
|
> [!WARNING]
|
|
277
825
|
> The `dry-` prefix and the `Dry::CLI::UI` namespace do not imply endorsement by `dry-rb`. This is an independent gem that extends theirs.
|
|
278
826
|
|
|
827
|
+
## Note to Dry-Rb Maintainers
|
|
828
|
+
|
|
829
|
+
First — hats off to all of you who tirelessly built out one of the most valuable collections of libraries in the Ruby ecosystem.
|
|
830
|
+
|
|
831
|
+
While I admire and would be willing to contribute any or all of the extension gem's code to the original gem, I feel that creating plugins and extensions allows the author to fully express their needs and wants, and then, if the authors of `dry-cli` become interested in any of them, I would be honored to submit a PR to `dry-cli` itself.
|
|
832
|
+
|
|
833
|
+
This method offered a very open road to extensibility and experimentation. If the code quality or design is not up to the level required for direct contributions to `dry-rb`, then let it be known that:
|
|
834
|
+
|
|
835
|
+
1. We would be very happy to receive any feedback and improve, refactor, and update the gem assuming it improves it
|
|
836
|
+
1. Roll any part of the codebase as a PR to the `dry-cli` core.
|
|
837
|
+
1. We hold the authors of `dry-rb` in high regard, and generally would love to collaborate, as long as the feedback loop/cycle is not so long that the context of the changes gets lost in time, as with so many contributions made to other gems in the past.
|
|
838
|
+
|
|
279
839
|
## License
|
|
280
840
|
|
|
281
841
|
MIT. See [LICENSE.txt](LICENSE.txt).
|