philiprehberger-cli_kit 0.2.1 → 0.3.1

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: 42b4dfe8a2da9a5c1836a7c3454ba89f602a03ecbf3f498d2c59b78346211698
4
- data.tar.gz: c17082808b7bbcbec95514bf206e2cd56e4401d72ef053c0913fe2074ddb67aa
3
+ metadata.gz: 62b7b5d92fab1ecd92a23be447b3fbc30954178eee755845f9a7f37e6de6e079
4
+ data.tar.gz: 9ea01598fc9e677cb480d92b66c00594df89dced14179144bd630052a97ad73c
5
5
  SHA512:
6
- metadata.gz: 2108606cbd61a7d7edab01f14f29d2961f580a01231bd03990fb520fb670055f18c53a16317fcee78712034cfd0ae4335c56caf8bdba5b3d0048e1bcc8ed562e
7
- data.tar.gz: ff65e678ecf0b55b1bdcab3a035bf499dd4d8bd154f33695987ae003dd068d1b0b3543c47a5dbf0b95a265bd24c370dc00b831287d5c7645dcb16a6f528e015a
6
+ metadata.gz: 3fd67e90ab104d28e6a759c360fc7609f4d0fee691bf0a0f21d828c54ca38b56974abd10e965293c96b7e98a9152e8c73fc90f6e1d5afcbe3a0d648386bbdc2a
7
+ data.tar.gz: '0459248772c329302ed8fdd2c798cb153e29e131f8f93656250a75fd7537edfef6f37c10c48b829533dafe33f272ed5e824d7a0958135cc8cb7adfebbbbe8360'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-04-15
11
+
12
+ ### Changed
13
+ - Compliance audit against Ruby package, gemspec, and README guides — no blocking issues found; reaffirmed gemspec metadata (5 URIs, `rubygems_mfa_required`, `required_ruby_version >= 3.1.0`, `Dir['lib/**/*.rb', 'LICENSE', 'README.md', 'CHANGELOG.md']`), README structure (10 sections with 3 badges and emoji Support list), Keep a Changelog format, full `.github/` scaffolding (issue templates, dependabot, PR template, CI matrix with publish job), and config files (`.rubocop.yml`, `.gitignore`, `Gemfile`, `Rakefile`)
14
+
15
+ ## [0.3.0] - 2026-04-15
16
+
17
+ ### Added
18
+ - Repeatable options via `option :name, multi: true` — collects each occurrence into an array
19
+ - `CliKit.password(message)` — prompt that reads input without echoing when stdin is a TTY (falls back to plain `gets` otherwise)
20
+ - `CliKit.ask(message)` — prompt that re-asks until a validator block returns truthy (defaults to any non-empty answer)
21
+ - `CliKit.multi_select(message, choices, defaults:)` — numbered menu supporting comma- or space-separated multi-selection
22
+ - Help text now shows `VALUE (repeatable)` for `multi: true` options
23
+
24
+ ### Changed
25
+ - VERSION spec no longer hardcodes the version string; asserts semver format instead
26
+
10
27
  ## [0.2.1] - 2026-03-31
11
28
 
12
29
  ### Changed
@@ -45,3 +62,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
45
62
  - Yes/no confirmation prompt
46
63
  - Animated spinner for long-running operations
47
64
  - Positional argument collection
65
+
66
+ [Unreleased]: https://github.com/philiprehberger/rb-cli-kit/compare/v0.3.1...HEAD
67
+ [0.3.1]: https://github.com/philiprehberger/rb-cli-kit/compare/v0.3.0...v0.3.1
68
+ [0.3.0]: https://github.com/philiprehberger/rb-cli-kit/compare/v0.2.1...v0.3.0
69
+ [0.2.1]: https://github.com/philiprehberger/rb-cli-kit/compare/v0.2.0...v0.2.1
70
+ [0.2.0]: https://github.com/philiprehberger/rb-cli-kit/compare/v0.1.2...v0.2.0
71
+ [0.1.2]: https://github.com/philiprehberger/rb-cli-kit/compare/v0.1.1...v0.1.2
72
+ [0.1.1]: https://github.com/philiprehberger/rb-cli-kit/compare/v0.1.0...v0.1.1
73
+ [0.1.0]: https://github.com/philiprehberger/rb-cli-kit/releases/tag/v0.1.0
data/README.md CHANGED
@@ -85,6 +85,37 @@ confirmed = Philiprehberger::CliKit.confirm('Continue?')
85
85
  # Continue? [y/n] _
86
86
  ```
87
87
 
88
+ ### Password Prompt
89
+
90
+ ```ruby
91
+ secret = Philiprehberger::CliKit.password('Enter password:')
92
+ # Enter password: _
93
+ # Input is read without echoing to the terminal when stdin is a TTY.
94
+ ```
95
+
96
+ ### Validated Ask
97
+
98
+ ```ruby
99
+ # Without a block, any non-empty answer is accepted.
100
+ name = Philiprehberger::CliKit.ask('Name:')
101
+
102
+ # With a block, the prompt repeats until the block returns a truthy value.
103
+ port = Philiprehberger::CliKit.ask('Port:', error: 'Must be a number') do |answer|
104
+ answer.match?(/\A\d+\z/)
105
+ end
106
+ ```
107
+
108
+ ### Repeatable Options
109
+
110
+ ```ruby
111
+ result = Philiprehberger::CliKit.parse(ARGV) do
112
+ option :tag, short: :t, multi: true, desc: 'Add a tag (repeatable)'
113
+ end
114
+
115
+ # Invoked as: mycli --tag ruby --tag cli -t kit
116
+ result.options[:tag] # => ["ruby", "cli", "kit"]
117
+ ```
118
+
88
119
  ### Menu Selection
89
120
 
90
121
  ```ruby
@@ -103,6 +134,26 @@ env = Philiprehberger::CliKit.select('Choose env:', %w[dev staging prod], defaul
103
134
  # Choose [2]: _
104
135
  ```
105
136
 
137
+ ### Multi-Select Menu
138
+
139
+ ```ruby
140
+ tags = Philiprehberger::CliKit.multi_select('Pick tags:', %w[ruby cli dsl testing])
141
+ # Pick tags:
142
+ # 1) ruby
143
+ # 2) cli
144
+ # 3) dsl
145
+ # 4) testing
146
+ # Choose (comma-separated): 1,3
147
+ # => ["ruby", "dsl"]
148
+
149
+ tags = Philiprehberger::CliKit.multi_select('Pick tags:', %w[ruby cli dsl], defaults: %w[ruby dsl])
150
+ # Pick tags:
151
+ # * 1) ruby
152
+ # 2) cli
153
+ # * 3) dsl
154
+ # Choose (comma-separated): _ # empty answer => defaults
155
+ ```
156
+
106
157
  ### Spinners
107
158
 
108
159
  ```ruby
@@ -119,10 +170,14 @@ end
119
170
  | `.parse(args) { ... }` | Parse arguments with flag/option/command DSL |
120
171
  | `.prompt(message)` | Display prompt and read input |
121
172
  | `.confirm(message)` | Display yes/no confirmation |
122
- | `.select(message, choices)` | Present numbered menu and return selection |
173
+ | `.password(message)` | Read input without echoing to the terminal |
174
+ | `.ask(message) { \|answer\| ... }` | Prompt until block returns truthy (defaults to non-empty) |
175
+ | `.select(message, choices)` | Present numbered menu and return one selection |
176
+ | `.multi_select(message, choices, defaults:)` | Present numbered menu and return multiple selections |
123
177
  | `.spinner(message) { ... }` | Show spinner during block execution |
178
+ | `Parser#option(name, multi: true)` | Collect repeated option values into an array |
124
179
  | `Parser#flags` | Hash of boolean flag values |
125
- | `Parser#options` | Hash of option values |
180
+ | `Parser#options` | Hash of option values (arrays when `multi: true`) |
126
181
  | `Parser#arguments` | Array of positional arguments |
127
182
  | `Parser#command` | Matched subcommand name or nil |
128
183
  | `Parser#help_text` | Formatted help string |
@@ -43,6 +43,47 @@ module Philiprehberger
43
43
  choices.first
44
44
  end
45
45
  end
46
+
47
+ # Present a numbered menu and allow multiple selections.
48
+ #
49
+ # The user enters a comma- or space-separated list of numbers (e.g.
50
+ # "1,3" or "1 3"). Unknown or out-of-range entries are ignored, and
51
+ # duplicates are collapsed. An empty answer returns +defaults+ or an
52
+ # empty array if no defaults are given.
53
+ #
54
+ # @param message [String] the prompt message
55
+ # @param choices [Array<String>] the list of choices
56
+ # @param defaults [Array<String>] choices pre-selected by default
57
+ # @param input [IO] input stream (default: $stdin)
58
+ # @param output [IO] output stream (default: $stdout)
59
+ # @return [Array<String>] the selected values in choice order
60
+ # @raise [ArgumentError] if choices is empty
61
+ def self.multi_select(message, choices, defaults: [], input: $stdin, output: $stdout)
62
+ raise ArgumentError, 'choices must not be empty' if choices.empty?
63
+
64
+ default_indexes = defaults.map { |d| choices.index(d) }.compact
65
+
66
+ output.puts message
67
+ choices.each_with_index do |choice, idx|
68
+ marker = default_indexes.include?(idx) ? '*' : ' '
69
+ output.puts " #{marker} #{idx + 1}) #{choice}"
70
+ end
71
+
72
+ output.print 'Choose (comma-separated): '
73
+ output.flush
74
+
75
+ answer = input.gets&.strip || ''
76
+ return defaults.dup if answer.empty?
77
+
78
+ picks = answer.split(/[,\s]+/).filter_map do |token|
79
+ idx = Integer(token, 10) - 1
80
+ idx if idx >= 0 && idx < choices.length
81
+ rescue ArgumentError
82
+ nil
83
+ end
84
+
85
+ picks.uniq.sort.map { |i| choices[i] }
86
+ end
46
87
  end
47
88
  end
48
89
  end
@@ -34,10 +34,11 @@ module Philiprehberger
34
34
  # @param short [Symbol, nil] short alias (single character)
35
35
  # @param default [Object, nil] default value
36
36
  # @param desc [String, nil] description for help text
37
+ # @param multi [Boolean] when true, collect repeated values into an array
37
38
  # @return [void]
38
- def option(name, short: nil, default: nil, desc: nil)
39
- @option_definitions[name] = { short: short, default: default, desc: desc }
40
- @options[name] = default
39
+ def option(name, short: nil, default: nil, desc: nil, multi: false)
40
+ @option_definitions[name] = { short: short, default: default, desc: desc, multi: multi }
41
+ @options[name] = multi ? [] : default
41
42
  end
42
43
 
43
44
  # Define a subcommand or return the matched command name.
@@ -147,7 +148,7 @@ module Philiprehberger
147
148
  if @flag_definitions.key?(name)
148
149
  @flags[name] = true
149
150
  elsif @option_definitions.key?(name)
150
- @options[name] = args.shift
151
+ assign_option(name, args.shift)
151
152
  end
152
153
  end
153
154
 
@@ -163,12 +164,22 @@ module Philiprehberger
163
164
 
164
165
  @option_definitions.each do |name, defn|
165
166
  if defn[:short] == char
166
- @options[name] = args.shift
167
+ assign_option(name, args.shift)
167
168
  return
168
169
  end
169
170
  end
170
171
  end
171
172
 
173
+ def assign_option(name, value)
174
+ defn = @option_definitions[name]
175
+ if defn && defn[:multi]
176
+ @options[name] = [] unless @options[name].is_a?(Array)
177
+ @options[name] << value unless value.nil?
178
+ else
179
+ @options[name] = value
180
+ end
181
+ end
182
+
172
183
  def format_flag_help(name, defn)
173
184
  long = "--#{name.to_s.tr('_', '-')}"
174
185
  if defn[:short]
@@ -185,7 +196,8 @@ module Philiprehberger
185
196
  end
186
197
 
187
198
  def format_option_help(name, defn)
188
- long = "--#{name.to_s.tr('_', '-')} VALUE"
199
+ placeholder = defn[:multi] ? 'VALUE (repeatable)' : 'VALUE'
200
+ long = "--#{name.to_s.tr('_', '-')} #{placeholder}"
189
201
  if defn[:short]
190
202
  short = "-#{defn[:short]}"
191
203
  label = " #{short}, #{long}"
@@ -28,6 +28,66 @@ module Philiprehberger
28
28
  answer = input.gets&.strip&.downcase || ''
29
29
  %w[y yes].include?(answer)
30
30
  end
31
+
32
+ # Display a prompt and read input without echoing characters to the terminal.
33
+ #
34
+ # When the input stream responds to +noecho+ (a real TTY), echo is disabled
35
+ # for the duration of the read. When it does not (e.g. a +StringIO+ during
36
+ # tests), input is read normally. A trailing newline is printed to the
37
+ # output after the read so the next prompt starts on its own line.
38
+ #
39
+ # @param message [String] the prompt message
40
+ # @param input [IO] input stream (default: $stdin)
41
+ # @param output [IO] output stream (default: $stdout)
42
+ # @return [String] the user's input, stripped of whitespace
43
+ def self.password(message, input: $stdin, output: $stdout)
44
+ output.print "#{message} "
45
+ output.flush
46
+
47
+ raw = if input.respond_to?(:noecho)
48
+ begin
49
+ input.noecho(&:gets)
50
+ rescue IOError, Errno::ENOTTY
51
+ input.gets
52
+ end
53
+ else
54
+ input.gets
55
+ end
56
+
57
+ output.print "\n"
58
+ output.flush
59
+ raw&.strip || ''
60
+ end
61
+
62
+ # Display a prompt and read input, repeating until it passes validation.
63
+ #
64
+ # The block is called with the stripped input; when it returns a truthy
65
+ # value the input is accepted and returned. When it returns a falsy value
66
+ # an optional +error+ message is printed and the user is prompted again.
67
+ # When no block is given, any non-empty input is accepted.
68
+ #
69
+ # @param message [String] the prompt message
70
+ # @param error [String] the message shown on invalid input
71
+ # @param input [IO] input stream (default: $stdin)
72
+ # @param output [IO] output stream (default: $stdout)
73
+ # @yieldparam answer [String] the stripped user input
74
+ # @yieldreturn [Boolean] whether the answer is acceptable
75
+ # @return [String] the accepted user input
76
+ def self.ask(message, error: 'Invalid input, please try again.', input: $stdin, output: $stdout, &block)
77
+ validator = block || ->(answer) { !answer.empty? }
78
+
79
+ loop do
80
+ output.print "#{message} "
81
+ output.flush
82
+ raw = input.gets
83
+ return '' if raw.nil?
84
+
85
+ answer = raw.strip
86
+ return answer if validator.call(answer)
87
+
88
+ output.puts error
89
+ end
90
+ end
31
91
  end
32
92
  end
33
93
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module CliKit
5
- VERSION = '0.2.1'
5
+ VERSION = '0.3.1'
6
6
  end
7
7
  end
@@ -49,6 +49,29 @@ module Philiprehberger
49
49
  Prompt.confirm(message, input: input, output: output)
50
50
  end
51
51
 
52
+ # Display a prompt and read input without echoing to the terminal.
53
+ #
54
+ # @param message [String] the prompt message
55
+ # @param input [IO] input stream
56
+ # @param output [IO] output stream
57
+ # @return [String] the user's input
58
+ def self.password(message, input: $stdin, output: $stdout)
59
+ Prompt.password(message, input: input, output: output)
60
+ end
61
+
62
+ # Display a prompt, re-asking until the answer satisfies the given block.
63
+ #
64
+ # @param message [String] the prompt message
65
+ # @param error [String] error message shown on invalid input
66
+ # @param input [IO] input stream
67
+ # @param output [IO] output stream
68
+ # @yieldparam answer [String] the stripped user input
69
+ # @yieldreturn [Boolean] whether the answer is acceptable
70
+ # @return [String] the accepted user input
71
+ def self.ask(message, error: 'Invalid input, please try again.', input: $stdin, output: $stdout, &block)
72
+ Prompt.ask(message, error: error, input: input, output: output, &block)
73
+ end
74
+
52
75
  # Display a spinner while executing a block.
53
76
  #
54
77
  # @param message [String] the spinner message
@@ -70,5 +93,17 @@ module Philiprehberger
70
93
  def self.select(message, choices, default: nil, input: $stdin, output: $stdout)
71
94
  Menu.select(message, choices, default: default, input: input, output: output)
72
95
  end
96
+
97
+ # Present a numbered menu allowing multiple selections and return the selected values.
98
+ #
99
+ # @param message [String] the prompt message
100
+ # @param choices [Array<String>] the list of choices
101
+ # @param defaults [Array<String>] pre-selected default choices
102
+ # @param input [IO] input stream
103
+ # @param output [IO] output stream
104
+ # @return [Array<String>] the selected values
105
+ def self.multi_select(message, choices, defaults: [], input: $stdin, output: $stdout)
106
+ Menu.multi_select(message, choices, defaults: defaults, input: input, output: output)
107
+ end
73
108
  end
74
109
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-cli_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-31 00:00:00.000000000 Z
11
+ date: 2026-04-15 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Lightweight CLI toolkit combining argument parsing with flags and options,
14
14
  interactive prompts with confirmation, and animated spinners for long-running operations.
@@ -27,11 +27,11 @@ files:
27
27
  - lib/philiprehberger/cli_kit/prompt.rb
28
28
  - lib/philiprehberger/cli_kit/spinner.rb
29
29
  - lib/philiprehberger/cli_kit/version.rb
30
- homepage: https://github.com/philiprehberger/rb-cli-kit
30
+ homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-cli_kit
31
31
  licenses:
32
32
  - MIT
33
33
  metadata:
34
- homepage_uri: https://github.com/philiprehberger/rb-cli-kit
34
+ homepage_uri: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-cli_kit
35
35
  source_code_uri: https://github.com/philiprehberger/rb-cli-kit
36
36
  changelog_uri: https://github.com/philiprehberger/rb-cli-kit/blob/main/CHANGELOG.md
37
37
  bug_tracker_uri: https://github.com/philiprehberger/rb-cli-kit/issues