clack 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 82be7312cfdade424ca896c9ddb5c70e2bfadadc5f61c411a073d01d1daa73c5
4
+ data.tar.gz: 2322b602de289654d8ba79925b804027b49a2027eb1e76fe8708d315527706a6
5
+ SHA512:
6
+ metadata.gz: 2412c28b3f625313df7249154a2a7a72cd039e336f3ee2764130e7dc52a5f932358a1576bc793d8fa33b5862d60cf5dff319a5b6495546bd59cef24714c93b1f
7
+ data.tar.gz: dc26d51d23d264c510c913383e448b3b8552312d1a1a78db7cba71e69ace120cc8638f69adc615fb844652065520947286a46cf75657a6a8ecdbcb499ee58bfc
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+ - Full prompt library: text, password, confirm, select, multiselect, autocomplete, path, select_key, spinner, progress, tasks, group_multiselect
7
+ - `Clack.group` for chaining prompts with shared results
8
+ - `Clack.stream` for streaming output from commands and iterables
9
+ - Vim-style navigation (`hjkl`) alongside arrow keys
10
+ - Zero runtime dependencies
11
+
12
+ ### Changed
13
+ - Ruby 3.2+ support
14
+
15
+ ## [0.1.0]
16
+
17
+ Initial release.
data/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Steve Whittaker
4
+
5
+ This project is a Ruby port of Clack (https://github.com/bombshell-dev/clack)
6
+ Originally created by Nate Moore and contributors, licensed under MIT.
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,424 @@
1
+ # Clack
2
+
3
+ **Effortlessly beautiful CLI prompts for Ruby.**
4
+
5
+ A faithful Ruby port of [@clack/prompts](https://github.com/bombshell-dev/clack).
6
+
7
+ <p align="center">
8
+ <img src="examples/demo.gif" width="640" alt="Clack demo">
9
+ </p>
10
+
11
+ ## Why Clack?
12
+
13
+ - **Zero dependencies** - Pure Ruby, stdlib only
14
+ - **Beautiful by default** - Thoughtfully designed prompts that just look right
15
+ - **Vim-friendly** - Navigate with `hjkl` or arrow keys
16
+ - **Accessible** - Graceful ASCII fallbacks for limited terminals
17
+ - **Composable** - Group prompts together with `Clack.group`
18
+
19
+ ## Installation
20
+
21
+ ```ruby
22
+ # Gemfile
23
+ gem "clack"
24
+
25
+ # Or from GitHub
26
+ gem "clack", github: "swhitt/clackrb"
27
+ ```
28
+
29
+ ```bash
30
+ # Or install directly
31
+ gem install clack
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```ruby
37
+ require "clack"
38
+
39
+ Clack.intro "project-setup"
40
+
41
+ result = Clack.group do |g|
42
+ g.prompt(:name) { Clack.text(message: "Project name?", placeholder: "my-app") }
43
+ g.prompt(:framework) do
44
+ Clack.select(
45
+ message: "Pick a framework",
46
+ options: [
47
+ { value: "rails", label: "Ruby on Rails", hint: "recommended" },
48
+ { value: "sinatra", label: "Sinatra" },
49
+ { value: "roda", label: "Roda" }
50
+ ]
51
+ )
52
+ end
53
+ g.prompt(:features) do
54
+ Clack.multiselect(
55
+ message: "Select features",
56
+ options: %w[api auth admin websockets]
57
+ )
58
+ end
59
+ end
60
+
61
+ if Clack.cancel?(result)
62
+ Clack.cancel("Setup cancelled")
63
+ exit 1
64
+ end
65
+
66
+ Clack.outro "You're all set!"
67
+ ```
68
+
69
+ ## Demo
70
+
71
+ Try it yourself:
72
+
73
+ ```bash
74
+ ruby examples/full_demo.rb
75
+ ```
76
+
77
+ <details>
78
+ <summary>Recording the demo GIF</summary>
79
+
80
+ Install [VHS](https://github.com/charmbracelet/vhs) and run:
81
+
82
+ ```bash
83
+ vhs examples/demo.tape
84
+ ```
85
+ </details>
86
+
87
+ ## Prompts
88
+
89
+ All prompts return the user's input, or `Clack::CANCEL` if they pressed Escape/Ctrl+C.
90
+
91
+ ```ruby
92
+ # Always check for cancellation
93
+ result = Clack.text(message: "Name?")
94
+ exit 1 if Clack.cancel?(result)
95
+ ```
96
+
97
+ ### Text
98
+
99
+ ```ruby
100
+ name = Clack.text(
101
+ message: "What is your project named?",
102
+ placeholder: "my-project", # Shown when empty (dim)
103
+ default_value: "untitled", # Used if submitted empty
104
+ initial_value: "hello-world", # Pre-filled, editable
105
+ validate: ->(v) { "Required!" if v.empty? }
106
+ )
107
+ ```
108
+
109
+ ### Password
110
+
111
+ ```ruby
112
+ secret = Clack.password(
113
+ message: "Enter your API key",
114
+ mask: "*" # Default: "▪"
115
+ )
116
+ ```
117
+
118
+ ### Confirm
119
+
120
+ ```ruby
121
+ proceed = Clack.confirm(
122
+ message: "Deploy to production?",
123
+ active: "Yes, ship it!",
124
+ inactive: "No, abort",
125
+ initial_value: false
126
+ )
127
+ ```
128
+
129
+ ### Select
130
+
131
+ Single selection with keyboard navigation.
132
+
133
+ ```ruby
134
+ db = Clack.select(
135
+ message: "Choose a database",
136
+ options: [
137
+ { value: "pg", label: "PostgreSQL", hint: "recommended" },
138
+ { value: "mysql", label: "MySQL" },
139
+ { value: "sqlite", label: "SQLite", disabled: true }
140
+ ],
141
+ initial_value: "pg",
142
+ max_items: 5 # Enable scrolling
143
+ )
144
+ ```
145
+
146
+ ### Multiselect
147
+
148
+ Multiple selections with toggle controls.
149
+
150
+ ```ruby
151
+ features = Clack.multiselect(
152
+ message: "Select features to install",
153
+ options: [
154
+ { value: "api", label: "API Mode" },
155
+ { value: "auth", label: "Authentication" },
156
+ { value: "jobs", label: "Background Jobs" }
157
+ ],
158
+ initial_values: ["api"],
159
+ required: true, # Must select at least one
160
+ max_items: 5 # Enable scrolling
161
+ )
162
+ ```
163
+
164
+ **Shortcuts:** `Space` toggle | `a` all | `i` invert
165
+
166
+ ### Autocomplete
167
+
168
+ Type to filter from a list of options.
169
+
170
+ ```ruby
171
+ color = Clack.autocomplete(
172
+ message: "Pick a color",
173
+ options: %w[red orange yellow green blue indigo violet],
174
+ placeholder: "Type to search..."
175
+ )
176
+ ```
177
+
178
+ ### Autocomplete Multiselect
179
+
180
+ Type to filter with multi-selection support.
181
+
182
+ ```ruby
183
+ colors = Clack.autocomplete_multiselect(
184
+ message: "Pick colors",
185
+ options: %w[red orange yellow green blue indigo violet],
186
+ placeholder: "Type to filter...",
187
+ required: true, # At least one selection required
188
+ initial_values: ["red"] # Pre-selected values
189
+ )
190
+ ```
191
+
192
+ **Shortcuts:** `Space` toggle | `a` toggle all | `i` invert | `Enter` confirm
193
+
194
+ ### Path
195
+
196
+ File/directory path selector with filesystem navigation.
197
+
198
+ ```ruby
199
+ project_dir = Clack.path(
200
+ message: "Where should we create your project?",
201
+ only_directories: true, # Only show directories
202
+ root: "." # Starting directory
203
+ )
204
+ ```
205
+
206
+ **Navigation:** Type to filter | `Tab` to autocomplete | `↑↓` to select
207
+
208
+ ### Select Key
209
+
210
+ Quick selection using keyboard shortcuts.
211
+
212
+ ```ruby
213
+ action = Clack.select_key(
214
+ message: "What would you like to do?",
215
+ options: [
216
+ { value: "create", label: "Create new project", key: "c" },
217
+ { value: "open", label: "Open existing", key: "o" },
218
+ { value: "quit", label: "Quit", key: "q" }
219
+ ]
220
+ )
221
+ ```
222
+
223
+ ### Spinner
224
+
225
+ Non-blocking animated indicator for async work.
226
+
227
+ ```ruby
228
+ spinner = Clack.spinner
229
+ spinner.start("Installing dependencies...")
230
+
231
+ # Do your work...
232
+ sleep 2
233
+
234
+ spinner.stop("Dependencies installed!")
235
+ # Or: spinner.error("Installation failed")
236
+ # Or: spinner.cancel("Cancelled")
237
+ ```
238
+
239
+ ### Progress
240
+
241
+ Visual progress bar for measurable operations.
242
+
243
+ ```ruby
244
+ progress = Clack.progress(total: 100, message: "Downloading...")
245
+ progress.start
246
+
247
+ files.each_with_index do |file, i|
248
+ download(file)
249
+ progress.update(i + 1)
250
+ end
251
+
252
+ progress.stop("Download complete!")
253
+ ```
254
+
255
+ ### Tasks
256
+
257
+ Run multiple tasks with status indicators.
258
+
259
+ ```ruby
260
+ results = Clack.tasks(tasks: [
261
+ { title: "Checking dependencies", task: -> { check_deps } },
262
+ { title: "Building project", task: -> { build } },
263
+ { title: "Running tests", task: -> { run_tests } }
264
+ ])
265
+ ```
266
+
267
+ ### Group Multiselect
268
+
269
+ Multiselect with options organized into groups.
270
+
271
+ ```ruby
272
+ features = Clack.group_multiselect(
273
+ message: "Select features",
274
+ options: [
275
+ {
276
+ label: "Frontend",
277
+ options: [
278
+ { value: "hotwire", label: "Hotwire" },
279
+ { value: "stimulus", label: "Stimulus" }
280
+ ]
281
+ },
282
+ {
283
+ label: "Background",
284
+ options: [
285
+ { value: "sidekiq", label: "Sidekiq" },
286
+ { value: "solid_queue", label: "Solid Queue" }
287
+ ]
288
+ }
289
+ ]
290
+ )
291
+ ```
292
+
293
+ ## Prompt Groups
294
+
295
+ Chain multiple prompts and collect results in a hash. Cancellation is handled automatically.
296
+
297
+ ```ruby
298
+ result = Clack.group do |g|
299
+ g.prompt(:name) { Clack.text(message: "Your name?") }
300
+ g.prompt(:email) { Clack.text(message: "Your email?") }
301
+ g.prompt(:confirm) { |r| Clack.confirm(message: "Create account for #{r[:email]}?") }
302
+ end
303
+
304
+ return if Clack.cancel?(result)
305
+
306
+ puts "Welcome, #{result[:name]}!"
307
+ ```
308
+
309
+ Handle cancellation with a callback:
310
+
311
+ ```ruby
312
+ Clack.group(on_cancel: ->(r) { cleanup(r) }) do |g|
313
+ # prompts...
314
+ end
315
+ ```
316
+
317
+ ## Logging
318
+
319
+ Beautiful, consistent log messages.
320
+
321
+ ```ruby
322
+ Clack.log.info("Starting build...")
323
+ Clack.log.success("Build completed!")
324
+ Clack.log.warn("Cache is stale")
325
+ Clack.log.error("Build failed")
326
+ Clack.log.step("Running migrations")
327
+ Clack.log.message("Custom message")
328
+ ```
329
+
330
+ ### Stream
331
+
332
+ Stream output from iterables, enumerables, or shell commands:
333
+
334
+ ```ruby
335
+ # Stream from an array or enumerable
336
+ Clack.stream.info(["Line 1", "Line 2", "Line 3"])
337
+ Clack.stream.step(["Step 1", "Step 2", "Step 3"])
338
+
339
+ # Stream from a shell command (returns true/false for success)
340
+ success = Clack.stream.command("npm install", type: :info)
341
+
342
+ # Stream from any IO or StringIO
343
+ Clack.stream.success(io_stream)
344
+ ```
345
+
346
+ ## Note
347
+
348
+ Display important information in a box.
349
+
350
+ ```ruby
351
+ Clack.note(<<~MSG, title: "Next Steps")
352
+ cd my-project
353
+ bundle install
354
+ bin/rails server
355
+ MSG
356
+ ```
357
+
358
+ ### Box
359
+
360
+ Render a customizable bordered box.
361
+
362
+ ```ruby
363
+ Clack.box("Hello, World!", title: "Greeting")
364
+
365
+ # With options
366
+ Clack.box(
367
+ "Centered content",
368
+ title: "My Box",
369
+ content_align: :center, # :left, :center, :right
370
+ title_align: :center,
371
+ width: 40, # or :auto to fit content
372
+ rounded: true # rounded or square corners
373
+ )
374
+ ```
375
+
376
+ ### Task Log
377
+
378
+ Streaming log that clears on success and shows full output on failure. Useful for build output.
379
+
380
+ ```ruby
381
+ tl = Clack.task_log(title: "Building...", limit: 10)
382
+
383
+ tl.message("Compiling file 1...")
384
+ tl.message("Compiling file 2...")
385
+
386
+ # On success: clears the log
387
+ tl.success("Build complete!")
388
+
389
+ # On error: keeps the log visible
390
+ # tl.error("Build failed!")
391
+ ```
392
+
393
+ ## Session Markers
394
+
395
+ ```ruby
396
+ Clack.intro("my-cli v1.0") # ┌ my-cli v1.0
397
+ # ... your prompts ...
398
+ Clack.outro("Done!") # └ Done!
399
+
400
+ # Or on error:
401
+ Clack.cancel("Aborted") # └ Aborted (red)
402
+ ```
403
+
404
+ ## Requirements
405
+
406
+ - Ruby 3.2+
407
+ - No runtime dependencies
408
+
409
+ ## Development
410
+
411
+ ```bash
412
+ bundle install
413
+ bundle exec rake # Lint + tests
414
+ bundle exec rake spec # Tests only
415
+ COVERAGE=true bundle exec rake spec # With coverage
416
+ ```
417
+
418
+ ## Credits
419
+
420
+ This is a Ruby port of [Clack](https://github.com/bombshell-dev/clack), created by [Nate Moore](https://github.com/natemoo-re) and the [Astro](https://astro.build) team.
421
+
422
+ ## License
423
+
424
+ MIT - See [LICENSE](LICENSE)
data/exe/clack-demo ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Support running from development checkout
5
+ lib = File.expand_path("../lib", __dir__)
6
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
7
+
8
+ require "clack"
9
+ Clack.demo
data/lib/clack/box.rb ADDED
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ # Renders a box with optional title around content
5
+ # Supports alignment, padding, and rounded/square corners
6
+ module Box
7
+ class << self
8
+ # @param message [String] Content to display in the box
9
+ # @param title [String] Optional title for the box
10
+ # @param content_align [:left, :center, :right] Content alignment
11
+ # @param title_align [:left, :center, :right] Title alignment
12
+ # @param width [Integer, :auto] Box width (auto fits to content)
13
+ # @param title_padding [Integer] Padding around title
14
+ # @param content_padding [Integer] Padding around content
15
+ # @param rounded [Boolean] Use rounded corners (default: true)
16
+ # @param format_border [Proc] Optional proc to format border characters
17
+ # @param output [IO] Output stream
18
+ def render(
19
+ message = "",
20
+ title: "",
21
+ content_align: :left,
22
+ title_align: :left,
23
+ width: :auto,
24
+ title_padding: 1,
25
+ content_padding: 2,
26
+ rounded: true,
27
+ format_border: nil,
28
+ output: $stdout
29
+ )
30
+ ctx = build_context(message, title, title_padding, content_padding, width, rounded, format_border)
31
+ output.puts build_top_border(ctx[:display_title], ctx[:inner_width], title_padding, title_align, ctx[:symbols], ctx[:h_symbol])
32
+ render_content_lines(output, ctx, content_align, content_padding)
33
+ output.puts "#{ctx[:symbols][2]}#{ctx[:h_symbol] * ctx[:inner_width]}#{ctx[:symbols][3]}"
34
+ end
35
+
36
+ private
37
+
38
+ def build_context(message, title, title_padding, content_padding, width, rounded, format_border)
39
+ format_border ||= ->(text) { Colors.gray(text) }
40
+ symbols = corner_symbols(rounded).map(&format_border)
41
+ lines = message.to_s.lines.map(&:chomp)
42
+ box_width = calculate_width(lines, title.length, title_padding, content_padding, width)
43
+ inner_width = box_width - 2
44
+ max_title_len = inner_width - (title_padding * 2)
45
+ display_title = (title.length > max_title_len) ? "#{title[0, max_title_len - 3]}..." : title
46
+
47
+ {
48
+ symbols: symbols,
49
+ h_symbol: format_border.call(Symbols::S_BAR_H),
50
+ v_symbol: format_border.call(Symbols::S_BAR),
51
+ lines: lines,
52
+ inner_width: inner_width,
53
+ display_title: display_title
54
+ }
55
+ end
56
+
57
+ def render_content_lines(output, ctx, content_align, content_padding)
58
+ ctx[:lines].each do |line|
59
+ left_pad, right_pad = padding_for_line(line.length, ctx[:inner_width], content_padding, content_align)
60
+ output.puts "#{ctx[:v_symbol]}#{" " * left_pad}#{line}#{" " * right_pad}#{ctx[:v_symbol]}"
61
+ end
62
+ end
63
+
64
+ def corner_symbols(rounded)
65
+ if rounded
66
+ [
67
+ Symbols::S_CORNER_TOP_LEFT,
68
+ Symbols::S_CORNER_TOP_RIGHT,
69
+ Symbols::S_CORNER_BOTTOM_LEFT,
70
+ Symbols::S_CORNER_BOTTOM_RIGHT
71
+ ]
72
+ else
73
+ [
74
+ Symbols::S_BAR_START,
75
+ Symbols::S_BAR_START_RIGHT,
76
+ Symbols::S_BAR_END,
77
+ Symbols::S_BAR_END_RIGHT
78
+ ]
79
+ end
80
+ end
81
+
82
+ def calculate_width(lines, title_len, title_padding, content_padding, width)
83
+ return width + 2 if width.is_a?(Integer) # Add 2 for borders
84
+
85
+ # Auto width: fit to content
86
+ max_line = lines.map(&:length).max || 0
87
+ title_with_padding = title_len + (title_padding * 2)
88
+ content_with_padding = max_line + (content_padding * 2)
89
+
90
+ [title_with_padding, content_with_padding].max + 2
91
+ end
92
+
93
+ def build_top_border(title, inner_width, title_padding, title_align, symbols, h_symbol)
94
+ if title.empty?
95
+ "#{symbols[0]}#{h_symbol * inner_width}#{symbols[1]}"
96
+ else
97
+ left_pad, right_pad = padding_for_line(title.length, inner_width, title_padding, title_align)
98
+ "#{symbols[0]}#{h_symbol * left_pad}#{title}#{h_symbol * right_pad}#{symbols[1]}"
99
+ end
100
+ end
101
+
102
+ def padding_for_line(line_length, inner_width, padding, align)
103
+ case align
104
+ when :center
105
+ left = (inner_width - line_length) / 2
106
+ right = inner_width - left - line_length
107
+ [left, right]
108
+ when :right
109
+ left = inner_width - line_length - padding
110
+ right = padding
111
+ [[left, 0].max, right]
112
+ else # :left
113
+ left = padding
114
+ right = inner_width - left - line_length
115
+ [left, [right, 0].max]
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ # ANSI color codes for terminal output styling.
5
+ # Colors are automatically disabled when:
6
+ # - Output is not a TTY (piped/redirected)
7
+ # - NO_COLOR environment variable is set
8
+ # - FORCE_COLOR environment variable forces colors on
9
+ module Colors
10
+ class << self
11
+ def enabled?
12
+ return true if ENV["FORCE_COLOR"] && ENV["FORCE_COLOR"] != "0"
13
+ return false if ENV["NO_COLOR"]
14
+
15
+ $stdout.tty?
16
+ end
17
+
18
+ # Foreground colors (standard)
19
+ def gray(text) = wrap(text, "90")
20
+ def cyan(text) = wrap(text, "36")
21
+ def green(text) = wrap(text, "32")
22
+ def yellow(text) = wrap(text, "33")
23
+ def red(text) = wrap(text, "31")
24
+ def blue(text) = wrap(text, "34")
25
+ def magenta(text) = wrap(text, "35")
26
+ def white(text) = wrap(text, "37")
27
+
28
+ # Text styles
29
+ def dim(text) = wrap(text, "2")
30
+ def bold(text) = wrap(text, "1")
31
+ def italic(text) = wrap(text, "3")
32
+ def underline(text) = wrap(text, "4")
33
+ def inverse(text) = wrap(text, "7")
34
+ def strikethrough(text) = wrap(text, "9")
35
+ def hidden(text) = wrap(text, "8")
36
+
37
+ # Bright/vivid foreground colors (higher contrast)
38
+ def bright_cyan(text) = wrap(text, "96")
39
+ def bright_green(text) = wrap(text, "92")
40
+ def bright_yellow(text) = wrap(text, "93")
41
+ def bright_red(text) = wrap(text, "91")
42
+ def bright_blue(text) = wrap(text, "94")
43
+ def bright_magenta(text) = wrap(text, "95")
44
+ def bright_white(text) = wrap(text, "97")
45
+
46
+ private
47
+
48
+ def wrap(text, code)
49
+ return text.to_s unless enabled?
50
+
51
+ "\e[#{code}m#{text}\e[0m"
52
+ end
53
+ end
54
+ end
55
+ end