slk 0.4.0 → 0.4.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: fd09692101d4658e57208d39e81d770e6dc36d2218088194ef55ad5fad0adbd0
4
- data.tar.gz: 88e03a0be601536ffb3fd8f1b986900f8f57144b03a52ec5fa24b0b5dbdfbc57
3
+ metadata.gz: ef8284e736f39c42a65ef336e2d8db1a36f998393070433809db242cf6de0861
4
+ data.tar.gz: 6af8a0cdb8cbc5f35aea41e89ba0221cd0eb7ab5e4fbbf360cc60b2eb750c4d7
5
5
  SHA512:
6
- metadata.gz: 399ca26d5d030704ad553e579fab75376db1578e0814f1ad8a9c2e4115c44e32c8173263fb39c8839eb7a0014ca1758d4e1aaa33b8d65353ba64e6fe83fa8efe
7
- data.tar.gz: 64bfbe31c44af1961f43cd7f027bd352847af05434b957bbceb03e26c8bdf820ee805c1fb67d2adac80e18e7ddc286fd643572a17695209db1e3e7826c358baa
6
+ metadata.gz: 2c384f28530877909f82a596d4b306ca342989b5720f7cee47ea8157b91f59bd21202101c85e8878a83cb909a3ff954dec6dcabea8d3de2ae72c5d49e6f55882
7
+ data.tar.gz: 1da065c6704edfd5beaf45b2989a9fec1a183061109d5d167467943d7eecaf3b1f7c707e46ae4b517b2e28c3d1edcb23cf37d0f90fda1882408d362be59fc992
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - **Ghostty/Kitty terminal support** - Inline emoji images now work in Ghostty and Kitty terminals
13
+ - Uses Kitty graphics protocol with Unicode placeholders for proper tmux support
14
+ - Images clear correctly with `clear` command (no floating artifacts)
15
+ - Converts GIF/JPEG to PNG automatically (macOS only via `sips`)
16
+
17
+ - **`later` command** - View Slack's "Save for Later" items
18
+ - Lists saved messages with content preview
19
+ - Filter by state: `--completed`, `--in-progress`
20
+ - `--counts` for summary statistics (total, overdue, with due dates)
21
+ - `--no-content` to skip fetching message text
22
+ - `--workspace-emoji` for inline custom emoji images
23
+ - `--width N` to wrap text at N columns
24
+ - `--no-wrap` to truncate messages to single line
25
+ - `--json` output includes message content
26
+
27
+ ### Changed
28
+
29
+ - New `TextProcessor` service centralizes text processing (HTML decode, mentions, emoji)
30
+ - New `MessageResolver` service extracted from activity command for reuse
31
+ - Refactored formatters to use shared TextProcessor
32
+
10
33
  ## [0.4.0] - 2026-01-30
11
34
 
12
35
  ### Added
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Api
5
+ # Thin wrapper for the Slack saved.list API endpoint
6
+ # Used to fetch "Save for Later" items
7
+ class Saved
8
+ def initialize(api_client, workspace)
9
+ @api = api_client
10
+ @workspace = workspace
11
+ end
12
+
13
+ # List saved items
14
+ # @param filter [String] Filter type: 'saved', 'in_progress', 'completed', 'archived'
15
+ # @param limit [Integer] Number of items to return (default: 15)
16
+ # @param cursor [String, nil] Pagination cursor
17
+ # @return [Hash] API response with 'ok' and 'saved_items' keys
18
+ # @raise [ApiError] if the API call fails (network error, auth error, etc.)
19
+ def list(filter: 'saved', limit: 15, cursor: nil)
20
+ params = { filter: filter, limit: limit.to_s }
21
+ params[:cursor] = cursor if cursor
22
+ @api.post_form(@workspace, 'saved.list', params)
23
+ end
24
+ end
25
+ end
26
+ end
data/lib/slk/cli.rb CHANGED
@@ -13,6 +13,7 @@ module Slk
13
13
  'unread' => Commands::Unread,
14
14
  'catchup' => Commands::Catchup,
15
15
  'activity' => Commands::Activity,
16
+ 'later' => Commands::Later,
16
17
  'search' => Commands::Search,
17
18
  'preset' => Commands::Preset,
18
19
  'workspaces' => Commands::Workspaces,
@@ -25,6 +26,7 @@ module Slk
25
26
  def initialize(argv, output: nil)
26
27
  @argv = argv.dup
27
28
  @output = output || Formatters::Output.new
29
+ @output_injected = !output.nil?
28
30
  end
29
31
 
30
32
  def run
@@ -118,13 +120,22 @@ module Slk
118
120
  def build_runner(args)
119
121
  verbose = verbose_mode?(args)
120
122
  very_verbose = args.include?('-vv') || args.include?('--very-verbose')
121
- output = @output || Formatters::Output.new(verbose: verbose)
123
+ markdown = args.include?('--markdown')
124
+ output = @output_injected ? @output : build_output(verbose: verbose, markdown: markdown)
122
125
  runner = Runner.new(output: output)
123
126
  setup_verbose_logging(runner, output) if verbose
124
127
  setup_very_verbose_logging(runner, output) if very_verbose
125
128
  runner
126
129
  end
127
130
 
131
+ def build_output(verbose:, markdown:)
132
+ if markdown
133
+ Formatters::MarkdownOutput.new(verbose: verbose)
134
+ else
135
+ Formatters::Output.new(verbose: verbose)
136
+ end
137
+ end
138
+
128
139
  def setup_verbose_logging(runner, output)
129
140
  runner.api_client.on_request = lambda { |method, count|
130
141
  output.debug("[API ##{count}] #{method}")
@@ -118,7 +118,7 @@ module Slk
118
118
  output: output,
119
119
  enricher: enricher(workspace),
120
120
  emoji_replacer: runner.emoji_replacer,
121
- mention_replacer: runner.mention_replacer,
121
+ text_processor: runner.text_processor,
122
122
  on_debug: ->(msg) { debug(msg) }
123
123
  )
124
124
  end
@@ -131,20 +131,14 @@ module Slk
131
131
  end
132
132
 
133
133
  def fetch_message(workspace, channel_id, message_ts)
134
- response = fetch_message_history(workspace, channel_id, message_ts)
135
- return nil unless response['ok'] && response['messages']&.any?
136
-
137
- response['messages'].find { |msg| msg['ts'] == message_ts }
138
- rescue ApiError => e
139
- debug("Could not fetch message #{message_ts} from #{channel_id}: #{e.message}")
140
- nil
134
+ message_resolver(workspace).fetch_by_ts(channel_id, message_ts)
141
135
  end
142
136
 
143
- def fetch_message_history(workspace, channel_id, message_ts)
144
- api = runner.conversations_api(workspace.name)
145
- oldest_ts = (message_ts.to_f - 1).to_s
146
- latest_ts = (message_ts.to_f + 1).to_s
147
- api.history(channel: channel_id, limit: 10, oldest: oldest_ts, latest: latest_ts)
137
+ def message_resolver(workspace)
138
+ Services::MessageResolver.new(
139
+ conversations_api: runner.conversations_api(workspace.name),
140
+ on_debug: ->(msg) { debug(msg) }
141
+ )
148
142
  end
149
143
  end
150
144
  # rubocop:enable Metrics/ClassLength
@@ -32,7 +32,7 @@ module Slk
32
32
  end
33
33
 
34
34
  def base_options
35
- { workspace: nil, all: false, verbose: false, quiet: false, json: false, width: default_width }
35
+ { workspace: nil, all: false, verbose: false, quiet: false, json: false, markdown: false, width: default_width }
36
36
  end
37
37
 
38
38
  def formatting_options
@@ -72,6 +72,7 @@ module Slk
72
72
  when '-vv', '--very-verbose' then @options[:verbose] = @options[:very_verbose] = true
73
73
  when '-q', '--quiet' then @options[:quiet] = true
74
74
  when '--json' then @options[:json] = true
75
+ when '--markdown' then @options[:markdown] = true
75
76
  when '-h', '--help' then @options[:help] = true
76
77
  when '--no-emoji' then @options[:no_emoji] = true
77
78
  when '--no-reactions' then @options[:no_reactions] = true
@@ -45,6 +45,8 @@ module Slk
45
45
  #{output.cyan('messages')} Read channel or DM messages
46
46
  #{output.cyan('search')} Search messages across channels
47
47
  #{output.cyan('unread')} View and clear unread messages
48
+ #{output.cyan('activity')} Show activity feed (reactions, mentions, threads)
49
+ #{output.cyan('later')} Show saved "Later" items
48
50
  #{output.cyan('preset')} Manage and apply status presets
49
51
  #{output.cyan('workspaces')} Manage Slack workspaces
50
52
  #{output.cyan('cache')} Manage user/channel cache
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require_relative '../support/help_formatter'
5
+
6
+ module Slk
7
+ module Commands
8
+ # Displays saved "Later" items from Slack
9
+ # rubocop:disable Metrics/ClassLength
10
+ class Later < Base
11
+ include Support::InlineImages
12
+
13
+ def execute
14
+ result = validate_options
15
+ return result if result
16
+
17
+ workspace = target_workspaces.first
18
+ fetch_and_display_later_items(workspace)
19
+ rescue ApiError => e
20
+ error("Failed to fetch saved items: #{e.message}")
21
+ 1
22
+ end
23
+
24
+ private
25
+
26
+ def fetch_and_display_later_items(workspace)
27
+ api = runner.saved_api(workspace.name)
28
+ response = api.list(filter: filter_type, limit: @options[:limit])
29
+
30
+ return error_result(response) unless response['ok']
31
+
32
+ items = parse_items(response['saved_items'] || [])
33
+ display_items(workspace, items)
34
+ 0
35
+ end
36
+
37
+ def error_result(response)
38
+ error("Failed to fetch saved items: #{response['error']}")
39
+ 1
40
+ end
41
+
42
+ def parse_items(items_data)
43
+ items_data.map { |data| Models::SavedItem.from_api(data) }
44
+ end
45
+
46
+ def filter_type
47
+ if @options[:completed]
48
+ 'completed'
49
+ elsif @options[:in_progress]
50
+ 'in_progress'
51
+ else
52
+ 'saved'
53
+ end
54
+ end
55
+
56
+ def display_items(workspace, items)
57
+ if @options[:counts]
58
+ display_counts(items)
59
+ elsif @options[:json]
60
+ output_json(build_json_output(workspace, items))
61
+ else
62
+ display_formatted(workspace, items)
63
+ end
64
+ end
65
+
66
+ def display_counts(items)
67
+ puts "Total: #{items.size}"
68
+ puts "Overdue: #{items.count(&:overdue?)}" unless @options[:completed]
69
+ puts "With due dates: #{items.count(&:due_date?)}"
70
+ end
71
+
72
+ # rubocop:disable Metrics/MethodLength
73
+ def build_json_output(workspace, items)
74
+ items.map do |item|
75
+ json_item = {
76
+ channel_id: item.channel_id,
77
+ ts: item.ts,
78
+ state: item.state,
79
+ date_created: item.date_created,
80
+ date_due: item.date_due,
81
+ date_completed: item.date_completed,
82
+ overdue: item.overdue?
83
+ }
84
+
85
+ # Fetch message content unless --no-content
86
+ unless @options[:no_content]
87
+ message = fetch_message_content(workspace, item)
88
+ json_item[:message] = message if message
89
+ end
90
+
91
+ json_item
92
+ end
93
+ end
94
+ # rubocop:enable Metrics/MethodLength
95
+
96
+ # rubocop:disable Metrics/MethodLength
97
+ def display_formatted(workspace, items)
98
+ if items.empty?
99
+ puts 'No saved items found.'
100
+ return
101
+ end
102
+
103
+ @fetch_failures = 0
104
+ # Skip workspace emoji inline images in markdown mode (they're terminal escape sequences)
105
+ if @options[:workspace_emoji] && inline_images_supported? && !@options[:markdown]
106
+ display_with_workspace_emoji(workspace, items)
107
+ else
108
+ display_without_workspace_emoji(workspace, items)
109
+ end
110
+ show_fetch_failure_summary
111
+ end
112
+ # rubocop:enable Metrics/MethodLength
113
+
114
+ def display_without_workspace_emoji(workspace, items)
115
+ formatter = build_formatter(output)
116
+ items.each do |item|
117
+ message = @options[:no_content] ? nil : fetch_message_content(workspace, item)
118
+ formatter.display_item(item, workspace, message: message, width: display_width, truncate: @options[:truncate])
119
+ end
120
+ end
121
+
122
+ def display_with_workspace_emoji(workspace, items)
123
+ # Capture output to StringIO, then reprint with workspace emoji
124
+ buffer = StringIO.new
125
+ buffer_output = create_buffer_output(buffer)
126
+ formatter = build_formatter(buffer_output)
127
+
128
+ items.each do |item|
129
+ message = @options[:no_content] ? nil : fetch_message_content(workspace, item)
130
+ formatter.display_item(item, workspace, message: message, width: display_width, truncate: @options[:truncate])
131
+ end
132
+
133
+ # Reprint each line with workspace emoji replacement
134
+ buffer.string.each_line { |line| print_with_workspace_emoji(line.chomp, workspace) }
135
+ end
136
+
137
+ def show_fetch_failure_summary
138
+ return if @fetch_failures.zero?
139
+
140
+ output.puts output.gray("Note: Could not load content for #{@fetch_failures} item(s). Use -v for details.")
141
+ end
142
+
143
+ def display_width
144
+ @options[:width]
145
+ end
146
+
147
+ def build_formatter(out)
148
+ Formatters::SavedItemFormatter.new(
149
+ output: out,
150
+ mention_replacer: runner.mention_replacer,
151
+ text_processor: runner.text_processor,
152
+ on_debug: ->(msg) { debug(msg) }
153
+ )
154
+ end
155
+
156
+ # Create a buffer output using the same class as the main output
157
+ # This ensures markdown mode works correctly with workspace emoji buffering
158
+ def create_buffer_output(buffer)
159
+ if @options[:markdown]
160
+ Formatters::MarkdownOutput.new(io: buffer, err: $stderr)
161
+ else
162
+ Formatters::Output.new(io: buffer, err: $stderr, color: $stdout.tty?)
163
+ end
164
+ end
165
+
166
+ def fetch_message_content(workspace, item)
167
+ return nil unless item.ts && item.channel_id
168
+
169
+ result = message_resolver(workspace).fetch_by_ts(item.channel_id, item.ts)
170
+ @fetch_failures += 1 if result.nil?
171
+ result
172
+ end
173
+
174
+ def message_resolver(workspace)
175
+ Services::MessageResolver.new(
176
+ conversations_api: runner.conversations_api(workspace.name),
177
+ on_debug: ->(msg) { debug(msg) }
178
+ )
179
+ end
180
+
181
+ protected
182
+
183
+ def default_options
184
+ super.merge(
185
+ limit: 15,
186
+ completed: false,
187
+ in_progress: false,
188
+ counts: false,
189
+ no_content: false,
190
+ truncate: false,
191
+ workspace_emoji: false
192
+ )
193
+ end
194
+
195
+ def handle_option(arg, args, remaining)
196
+ case arg
197
+ when '-n', '--limit' then @options[:limit] = args.shift.to_i
198
+ when '--completed' then @options[:completed] = true
199
+ when '--in-progress' then @options[:in_progress] = true
200
+ when '--counts' then @options[:counts] = true
201
+ when '--no-content' then @options[:no_content] = true
202
+ when '--workspace-emoji' then @options[:workspace_emoji] = true
203
+ else super
204
+ end
205
+ end
206
+
207
+ # Override to intercept --no-wrap before base class handles it
208
+ def parse_single_option(arg, args, remaining)
209
+ if arg == '--no-wrap'
210
+ @options[:truncate] = true
211
+ @options[:width] ||= 140
212
+ else
213
+ super
214
+ end
215
+ end
216
+
217
+ def default_width
218
+ nil
219
+ end
220
+
221
+ def help_text
222
+ help = Support::HelpFormatter.new('slk later [options]')
223
+ help.description('Show saved "Later" items from Slack.')
224
+ add_options_section(help)
225
+ help.render
226
+ end
227
+
228
+ # rubocop:disable Metrics/MethodLength
229
+ def add_options_section(help)
230
+ help.section('OPTIONS') do |s|
231
+ s.option('-n, --limit N', 'Number of items (default: 15)')
232
+ s.option('--completed', 'Show completed items instead')
233
+ s.option('--in-progress', 'Show in-progress items')
234
+ s.option('--counts', 'Show summary counts only')
235
+ s.option('--no-content', 'Skip fetching message text')
236
+ s.option('--workspace-emoji', 'Show workspace emoji as inline images')
237
+ s.option('--no-emoji', 'Show :emoji: codes instead of unicode')
238
+ s.option('--width N', 'Wrap text at N columns')
239
+ s.option('--no-wrap', 'Truncate to single line instead of wrapping')
240
+ add_common_options(s)
241
+ end
242
+ end
243
+
244
+ # rubocop:enable Metrics/MethodLength
245
+
246
+ def add_common_options(section)
247
+ section.option('--json', 'Output as JSON')
248
+ section.option('-w, --workspace', 'Specify workspace')
249
+ section.option('-v, --verbose', 'Show debug information')
250
+ section.option('-q, --quiet', 'Suppress output')
251
+ end
252
+
253
+ # Print text, replacing workspace emoji codes with inline images
254
+ def print_with_workspace_emoji(text, workspace)
255
+ print_line_with_emoji_images(text, workspace)
256
+ end
257
+
258
+ def print_line_with_emoji_images(text, workspace)
259
+ emoji_pattern = /:([a-zA-Z0-9_+-]+):/
260
+ parts = text.split(emoji_pattern)
261
+
262
+ parts.each_with_index { |part, i| print_emoji_part(part, i, workspace) }
263
+ puts
264
+ end
265
+
266
+ def print_emoji_part(part, index, workspace)
267
+ if index.odd?
268
+ print_emoji_or_code(part, workspace)
269
+ else
270
+ print part
271
+ end
272
+ end
273
+
274
+ def print_emoji_or_code(emoji_name, workspace)
275
+ emoji_path = find_workspace_emoji(workspace.name, emoji_name)
276
+ if emoji_path
277
+ print_inline_image(emoji_path, height: 1)
278
+ print ' ' unless in_tmux?
279
+ else
280
+ print ":#{emoji_name}:"
281
+ end
282
+ end
283
+
284
+ def find_workspace_emoji(workspace_name, emoji_name)
285
+ return nil if emoji_name.empty?
286
+
287
+ paths = Support::XdgPaths.new
288
+ emoji_dir = config.emoji_dir || paths.cache_dir
289
+ workspace_dir = File.join(emoji_dir, workspace_name)
290
+ return nil unless Dir.exist?(workspace_dir)
291
+
292
+ Dir.glob(File.join(workspace_dir, "#{emoji_name}.*")).first
293
+ end
294
+ end
295
+ # rubocop:enable Metrics/ClassLength
296
+ end
297
+ end
@@ -192,10 +192,10 @@ module Slk
192
192
  raw = if thread_ts
193
193
  fetch_thread_messages(api, channel_id, thread_ts)
194
194
  else
195
- fetch_channel_history(api, channel_id, oldest)
195
+ fetch_channel_history(api, channel_id, oldest).reverse
196
196
  end
197
197
 
198
- raw.map { |m| Models::Message.from_api(m, channel_id: channel_id) }.reverse
198
+ raw.map { |m| Models::Message.from_api(m, channel_id: channel_id) }
199
199
  end
200
200
 
201
201
  def fetch_thread_messages(api, channel_id, thread_ts)
@@ -295,8 +295,9 @@ module Slk
295
295
  end
296
296
 
297
297
  # Print text, replacing workspace emoji codes with inline images when enabled
298
+ # Skip inline images in markdown mode (they're terminal escape sequences)
298
299
  def print_with_workspace_emoji(text, workspace)
299
- if @options[:workspace_emoji] && inline_images_supported?
300
+ if @options[:workspace_emoji] && inline_images_supported? && !@options[:markdown]
300
301
  print_line_with_emoji_images(text, workspace)
301
302
  else
302
303
  puts text
@@ -5,11 +5,11 @@ module Slk
5
5
  # Formats activity feed items for terminal display
6
6
  # rubocop:disable Metrics/ClassLength
7
7
  class ActivityFormatter
8
- def initialize(output:, enricher:, emoji_replacer:, mention_replacer:, on_debug: nil)
8
+ def initialize(output:, enricher:, emoji_replacer:, text_processor:, on_debug: nil)
9
9
  @output = output
10
10
  @enricher = enricher
11
11
  @emoji = emoji_replacer
12
- @mentions = mention_replacer
12
+ @text_processor = text_processor
13
13
  @on_debug = on_debug
14
14
  end
15
15
 
@@ -119,10 +119,7 @@ module Slk
119
119
  end
120
120
 
121
121
  def prepare_message_text(message, workspace)
122
- text = message['text'] || ''
123
- return '[No text]' if text.empty?
124
-
125
- @mentions.replace(text, workspace)
122
+ @text_processor.process(message['text'], workspace)
126
123
  end
127
124
 
128
125
  def display_additional_lines(lines)
@@ -136,7 +133,7 @@ module Slk
136
133
  end
137
134
 
138
135
  def format_time(slack_timestamp)
139
- Time.at(slack_timestamp.to_f).strftime('%b %d %-I:%M %p')
136
+ Time.at(slack_timestamp.to_f).strftime('%b %d %-I:%M:%S %p')
140
137
  end
141
138
 
142
139
  def debug_missing(item_type)
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Markdown output adapter with same interface as Output
6
+ # Produces markdown formatting instead of ANSI colors
7
+ class MarkdownOutput
8
+ attr_reader :verbose, :quiet
9
+
10
+ # Accept color: parameter for interface compatibility with Output (ignored for markdown)
11
+ def initialize(io: $stdout, err: $stderr, color: nil, verbose: false, quiet: false) # rubocop:disable Lint/UnusedMethodArgument
12
+ @io = io
13
+ @err = err
14
+ @verbose = verbose
15
+ @quiet = quiet
16
+ end
17
+
18
+ def puts(message = '')
19
+ @io.puts(message) unless @quiet
20
+ end
21
+
22
+ def print(message)
23
+ @io.print(message) unless @quiet
24
+ end
25
+
26
+ def error(message)
27
+ @err.puts("**Error:** #{message}")
28
+ end
29
+
30
+ def warn(message)
31
+ @err.puts("*Warning:* #{message}") unless @quiet
32
+ end
33
+
34
+ def success(message)
35
+ puts("✓ #{message}")
36
+ end
37
+
38
+ def info(message)
39
+ puts(message)
40
+ end
41
+
42
+ def debug(message)
43
+ return unless @verbose
44
+
45
+ @err.puts("*[debug]* #{message}")
46
+ end
47
+
48
+ # Markdown formatting helpers - string interpolation handles nil conversion
49
+ # bold -> **text**
50
+ def bold(text)
51
+ "**#{text}**"
52
+ end
53
+
54
+ # red -> **text** (emphasis for errors)
55
+ def red(text)
56
+ "**#{text}**"
57
+ end
58
+
59
+ # green -> plain text (success doesn't need markup)
60
+ def green(text)
61
+ text.to_s
62
+ end
63
+
64
+ # yellow -> *text* (italics for warnings)
65
+ def yellow(text)
66
+ "*#{text}*"
67
+ end
68
+
69
+ # blue -> `text` (code for timestamps)
70
+ def blue(text)
71
+ "`#{text}`"
72
+ end
73
+
74
+ # magenta -> *text* (italics for secondary)
75
+ def magenta(text)
76
+ "*#{text}*"
77
+ end
78
+
79
+ # cyan -> `text` (code for metadata like timestamps)
80
+ def cyan(text)
81
+ "`#{text}`"
82
+ end
83
+
84
+ # gray -> *text* (italics for secondary info)
85
+ def gray(text)
86
+ "*#{text}*"
87
+ end
88
+
89
+ def with_verbose(value)
90
+ self.class.new(io: @io, err: @err, verbose: value, quiet: @quiet)
91
+ end
92
+
93
+ def with_quiet(value)
94
+ self.class.new(io: @io, err: @err, verbose: @verbose, quiet: value)
95
+ end
96
+ end
97
+ end
98
+ end