slk 0.1.0 → 0.2.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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/README.md +5 -5
  4. data/bin/slk +3 -3
  5. data/lib/{slack_cli → slk}/api/activity.rb +10 -11
  6. data/lib/{slack_cli → slk}/api/bots.rb +5 -4
  7. data/lib/slk/api/client.rb +51 -0
  8. data/lib/{slack_cli → slk}/api/conversations.rb +14 -13
  9. data/lib/slk/api/dnd.rb +41 -0
  10. data/lib/{slack_cli → slk}/api/emoji.rb +4 -3
  11. data/lib/{slack_cli → slk}/api/threads.rb +13 -12
  12. data/lib/{slack_cli → slk}/api/usergroups.rb +2 -1
  13. data/lib/slk/api/users.rb +105 -0
  14. data/lib/slk/cli.rb +157 -0
  15. data/lib/slk/commands/activity.rb +152 -0
  16. data/lib/{slack_cli → slk}/commands/base.rb +67 -41
  17. data/lib/slk/commands/cache.rb +141 -0
  18. data/lib/slk/commands/catchup.rb +411 -0
  19. data/lib/slk/commands/config.rb +114 -0
  20. data/lib/slk/commands/dnd.rb +172 -0
  21. data/lib/slk/commands/emoji.rb +352 -0
  22. data/lib/slk/commands/help.rb +97 -0
  23. data/lib/slk/commands/messages.rb +299 -0
  24. data/lib/slk/commands/presence.rb +109 -0
  25. data/lib/slk/commands/preset.rb +231 -0
  26. data/lib/slk/commands/status.rb +223 -0
  27. data/lib/slk/commands/thread.rb +72 -0
  28. data/lib/slk/commands/unread.rb +305 -0
  29. data/lib/slk/commands/workspaces.rb +168 -0
  30. data/lib/slk/formatters/activity_formatter.rb +148 -0
  31. data/lib/slk/formatters/attachment_formatter.rb +65 -0
  32. data/lib/slk/formatters/block_formatter.rb +57 -0
  33. data/lib/{slack_cli → slk}/formatters/duration_formatter.rb +6 -5
  34. data/lib/slk/formatters/emoji_replacer.rb +141 -0
  35. data/lib/slk/formatters/json_message_formatter.rb +95 -0
  36. data/lib/slk/formatters/mention_replacer.rb +158 -0
  37. data/lib/slk/formatters/message_formatter.rb +174 -0
  38. data/lib/{slack_cli → slk}/formatters/output.rb +7 -6
  39. data/lib/slk/formatters/reaction_formatter.rb +87 -0
  40. data/lib/{slack_cli → slk}/models/channel.rb +12 -10
  41. data/lib/slk/models/duration.rb +94 -0
  42. data/lib/slk/models/message.rb +242 -0
  43. data/lib/slk/models/preset.rb +78 -0
  44. data/lib/{slack_cli → slk}/models/reaction.rb +6 -6
  45. data/lib/{slack_cli → slk}/models/status.rb +6 -6
  46. data/lib/slk/models/user.rb +55 -0
  47. data/lib/slk/models/workspace.rb +54 -0
  48. data/lib/{slack_cli → slk}/runner.rb +22 -19
  49. data/lib/slk/services/activity_enricher.rb +124 -0
  50. data/lib/slk/services/api_client.rb +145 -0
  51. data/lib/{slack_cli → slk}/services/cache_store.rb +20 -17
  52. data/lib/{slack_cli → slk}/services/configuration.rb +9 -8
  53. data/lib/slk/services/emoji_downloader.rb +103 -0
  54. data/lib/slk/services/emoji_searcher.rb +72 -0
  55. data/lib/{slack_cli → slk}/services/encryption.rb +11 -14
  56. data/lib/slk/services/gemoji_sync.rb +97 -0
  57. data/lib/{slack_cli → slk}/services/preset_store.rb +34 -33
  58. data/lib/slk/services/reaction_enricher.rb +82 -0
  59. data/lib/slk/services/setup_wizard.rb +131 -0
  60. data/lib/slk/services/target_resolver.rb +108 -0
  61. data/lib/{slack_cli → slk}/services/token_store.rb +11 -10
  62. data/lib/slk/services/unread_marker.rb +101 -0
  63. data/lib/{slack_cli → slk}/support/error_logger.rb +2 -1
  64. data/lib/{slack_cli → slk}/support/help_formatter.rb +36 -44
  65. data/lib/{slack_cli → slk}/support/inline_images.rb +28 -19
  66. data/lib/slk/support/interactive_prompt.rb +29 -0
  67. data/lib/{slack_cli → slk}/support/slack_url_parser.rb +15 -17
  68. data/lib/slk/support/text_wrapper.rb +57 -0
  69. data/lib/slk/support/user_resolver.rb +141 -0
  70. data/lib/{slack_cli → slk}/support/xdg_paths.rb +6 -5
  71. data/lib/slk/version.rb +5 -0
  72. data/lib/slk.rb +112 -0
  73. metadata +80 -59
  74. data/lib/slack_cli/api/client.rb +0 -49
  75. data/lib/slack_cli/api/dnd.rb +0 -40
  76. data/lib/slack_cli/api/users.rb +0 -101
  77. data/lib/slack_cli/cli.rb +0 -118
  78. data/lib/slack_cli/commands/activity.rb +0 -292
  79. data/lib/slack_cli/commands/cache.rb +0 -116
  80. data/lib/slack_cli/commands/catchup.rb +0 -484
  81. data/lib/slack_cli/commands/config.rb +0 -159
  82. data/lib/slack_cli/commands/dnd.rb +0 -143
  83. data/lib/slack_cli/commands/emoji.rb +0 -412
  84. data/lib/slack_cli/commands/help.rb +0 -76
  85. data/lib/slack_cli/commands/messages.rb +0 -317
  86. data/lib/slack_cli/commands/presence.rb +0 -107
  87. data/lib/slack_cli/commands/preset.rb +0 -239
  88. data/lib/slack_cli/commands/status.rb +0 -194
  89. data/lib/slack_cli/commands/thread.rb +0 -62
  90. data/lib/slack_cli/commands/unread.rb +0 -312
  91. data/lib/slack_cli/commands/workspaces.rb +0 -151
  92. data/lib/slack_cli/formatters/emoji_replacer.rb +0 -143
  93. data/lib/slack_cli/formatters/mention_replacer.rb +0 -154
  94. data/lib/slack_cli/formatters/message_formatter.rb +0 -429
  95. data/lib/slack_cli/models/duration.rb +0 -85
  96. data/lib/slack_cli/models/message.rb +0 -217
  97. data/lib/slack_cli/models/preset.rb +0 -73
  98. data/lib/slack_cli/models/user.rb +0 -56
  99. data/lib/slack_cli/models/workspace.rb +0 -52
  100. data/lib/slack_cli/services/api_client.rb +0 -149
  101. data/lib/slack_cli/services/reaction_enricher.rb +0 -87
  102. data/lib/slack_cli/support/user_resolver.rb +0 -114
  103. data/lib/slack_cli/version.rb +0 -5
  104. data/lib/slack_cli.rb +0 -91
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/help_formatter'
4
+
5
+ module Slk
6
+ module Commands
7
+ # Interactive review and dismissal of unread messages
8
+ # rubocop:disable Metrics/ClassLength
9
+ class Catchup < Base
10
+ include Support::UserResolver
11
+ include Support::InteractivePrompt
12
+
13
+ def execute
14
+ result = validate_options
15
+ return result if result
16
+
17
+ if @options[:batch]
18
+ batch_catchup
19
+ else
20
+ interactive_catchup
21
+ end
22
+ rescue ApiError => e
23
+ error("Failed: #{e.message}")
24
+ 1
25
+ end
26
+
27
+ protected
28
+
29
+ def default_options
30
+ super.merge(
31
+ all: true, # Default to all workspaces
32
+ batch: false,
33
+ muted: false,
34
+ limit: 5
35
+ )
36
+ end
37
+
38
+ def handle_option(arg, args, remaining)
39
+ case arg
40
+ when '--batch'
41
+ @options[:batch] = true
42
+ when '--muted'
43
+ @options[:muted] = true
44
+ when '-n', '--limit'
45
+ @options[:limit] = args.shift.to_i
46
+ else
47
+ super
48
+ end
49
+ end
50
+
51
+ def help_text
52
+ help = Support::HelpFormatter.new('slk catchup [options]')
53
+ help.description('Interactively review and dismiss unread messages (all workspaces by default).')
54
+ add_options_section(help)
55
+ add_keys_section(help)
56
+ help.render
57
+ end
58
+
59
+ def add_options_section(help)
60
+ help.section('OPTIONS') do |s|
61
+ add_primary_options(s)
62
+ add_formatting_options(s)
63
+ end
64
+ end
65
+
66
+ def add_primary_options(section)
67
+ section.option('--batch', 'Non-interactive mode (mark all as read)')
68
+ section.option('--muted', 'Include muted channels')
69
+ section.option('-n, --limit N', 'Messages per channel (default: 5)')
70
+ end
71
+
72
+ def add_formatting_options(section)
73
+ section.option('--no-emoji', 'Show :emoji: codes instead of unicode')
74
+ section.option('--no-reactions', 'Hide reactions')
75
+ section.option('--reaction-names', 'Show reactions with user names')
76
+ section.option('--reaction-timestamps', 'Show when each person reacted')
77
+ section.option('-w, --workspace', 'Limit to specific workspace')
78
+ section.option('-q, --quiet', 'Suppress output')
79
+ end
80
+
81
+ def add_keys_section(help)
82
+ help.section('INTERACTIVE KEYS') do |s|
83
+ s.item('s / Enter', 'Skip channel')
84
+ s.item('r', 'Mark as read and continue')
85
+ s.item('o', 'Open in Slack')
86
+ s.item('q', 'Quit')
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def batch_catchup
93
+ target_workspaces.each { |ws| batch_mark_workspace(ws) }
94
+ 0
95
+ end
96
+
97
+ def batch_mark_workspace(workspace)
98
+ marker = build_unread_marker(workspace)
99
+ counts = marker.mark_all(options: { muted: @options[:muted] })
100
+ success("Marked #{counts[:dms]} DMs, #{counts[:channels]} channels, " \
101
+ "and #{counts[:threads]} threads as read on #{workspace.name}")
102
+ end
103
+
104
+ def build_unread_marker(workspace)
105
+ Services::UnreadMarker.new(
106
+ conversations_api: runner.conversations_api(workspace.name),
107
+ threads_api: runner.threads_api(workspace.name),
108
+ client_api: runner.client_api(workspace.name),
109
+ users_api: runner.users_api(workspace.name),
110
+ on_debug: ->(msg) { debug(msg) }
111
+ )
112
+ end
113
+
114
+ def interactive_catchup
115
+ target_workspaces.each do |workspace|
116
+ result = process_workspace(workspace)
117
+ return 0 if result == :quit
118
+ end
119
+
120
+ puts
121
+ success('Catchup complete!')
122
+ 0
123
+ end
124
+
125
+ def process_workspace(workspace)
126
+ items = gather_unread_items(workspace)
127
+
128
+ if items[:empty]
129
+ puts "No unread messages in #{workspace.name}"
130
+ return :continue
131
+ end
132
+
133
+ puts output.bold("\n#{workspace.name}: #{items[:total]} items with unreads\n")
134
+ process_all_items(workspace, items)
135
+ end
136
+
137
+ def gather_unread_items(workspace)
138
+ counts = runner.client_api(workspace.name).counts
139
+ ims = filter_unread_ims(counts['ims'] || [])
140
+ channels = filter_unread_channels(workspace, counts['channels'] || [])
141
+ threads_response = fetch_unread_threads(workspace)
142
+
143
+ build_items_result(ims, channels, threads_response)
144
+ end
145
+
146
+ def build_items_result(ims, channels, threads_response)
147
+ {
148
+ ims: ims, channels: channels, threads_response: threads_response,
149
+ total: ims.size + channels.size + (threads_response ? 1 : 0),
150
+ empty: ims.empty? && channels.empty? && !threads_response
151
+ }
152
+ end
153
+
154
+ def filter_unread_ims(ims)
155
+ ims.select { |i| i['has_unreads'] }
156
+ end
157
+
158
+ def filter_unread_channels(workspace, channels)
159
+ muted_ids = @options[:muted] ? [] : runner.users_api(workspace.name).muted_channels
160
+ channels
161
+ .select { |c| c['has_unreads'] || (c['mention_count'] || 0).positive? }
162
+ .reject { |c| muted_ids.include?(c['id']) }
163
+ end
164
+
165
+ def fetch_unread_threads(workspace)
166
+ response = runner.threads_api(workspace.name).get_view(limit: 20)
167
+ response if response['ok'] && (response['total_unread_replies'] || 0).positive?
168
+ end
169
+
170
+ def process_all_items(workspace, items)
171
+ index = { current: 0, total: items[:total] }
172
+
173
+ return :quit if process_dms(workspace, items[:ims], index)
174
+ return :quit if items[:threads_response] && process_threads_item(workspace, items[:threads_response], index)
175
+ return :quit if process_channels(workspace, items[:channels], index)
176
+
177
+ :continue
178
+ end
179
+
180
+ # rubocop:disable Naming/PredicateMethod
181
+ def process_dms(workspace, ims, index)
182
+ ims.each do |im|
183
+ return true if process_dm(workspace, im, index[:current], index[:total]) == :quit
184
+
185
+ index[:current] += 1
186
+ end
187
+ false
188
+ end
189
+
190
+ def process_channels(workspace, channels, index)
191
+ channels.each do |channel|
192
+ return true if process_channel(workspace, channel, index[:current], index[:total]) == :quit
193
+
194
+ index[:current] += 1
195
+ end
196
+ false
197
+ end
198
+
199
+ def process_threads_item(workspace, threads_response, index)
200
+ result = process_threads(workspace, threads_response, index[:current], index[:total])
201
+ index[:current] += 1
202
+ result == :quit
203
+ end
204
+ # rubocop:enable Naming/PredicateMethod
205
+
206
+ def process_channel(workspace, channel, index, total)
207
+ channel_id = channel['id']
208
+ channel_name = cache_store.get_channel_name(workspace.name, channel_id) || channel_id
209
+ label = "##{channel_name}"
210
+
211
+ process_conversation(workspace, channel, index, total, label)
212
+ end
213
+
214
+ def process_dm(workspace, dm_item, index, total)
215
+ channel_id = dm_item['id']
216
+ conversations = runner.conversations_api(workspace.name)
217
+ user_name = resolve_dm_user_name(workspace, channel_id, conversations)
218
+ label = "@#{user_name}"
219
+
220
+ process_conversation(workspace, dm_item, index, total, label)
221
+ end
222
+
223
+ def process_conversation(workspace, item, index, total, label)
224
+ channel_id = item['id']
225
+ last_read = item['last_read']
226
+ latest_ts = item['latest']
227
+ mentions = item['mention_count'] || 0
228
+
229
+ messages = fetch_unread_messages(workspace, channel_id, last_read)
230
+ display_conversation_header(index, total, label, mentions)
231
+ display_messages(workspace, messages, channel_id)
232
+ prompt_conversation_action(workspace, channel_id, latest_ts)
233
+ end
234
+
235
+ def fetch_unread_messages(workspace, channel_id, last_read)
236
+ conversations = runner.conversations_api(workspace.name)
237
+ history_opts = { channel: channel_id, limit: @options[:limit] }
238
+ history_opts[:oldest] = last_read if last_read
239
+ history = conversations.history(**history_opts)
240
+ (history['messages'] || []).reverse
241
+ end
242
+
243
+ def display_conversation_header(index, total, label, mentions)
244
+ puts
245
+ puts output.bold("[#{index + 1}/#{total}] #{label}")
246
+ puts output.yellow("#{mentions} mentions") if mentions.positive?
247
+ end
248
+
249
+ def display_messages(workspace, raw_messages, channel_id)
250
+ messages = raw_messages.map { |msg| Models::Message.from_api(msg, channel_id: channel_id) }
251
+ messages = enrich_messages(workspace, messages, channel_id) if @options[:reaction_timestamps]
252
+
253
+ messages.each do |message|
254
+ formatted = runner.message_formatter.format_simple(message, workspace: workspace, options: format_options)
255
+ puts " #{formatted}"
256
+ end
257
+ end
258
+
259
+ def enrich_messages(workspace, messages, channel_id)
260
+ enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
261
+ enricher.enrich_messages(messages, channel_id)
262
+ end
263
+
264
+ def prompt_conversation_action(workspace, channel_id, latest_ts)
265
+ conversations = runner.conversations_api(workspace.name)
266
+ prompt = output.cyan('[s]kip [r]ead [o]pen [q]uit')
267
+ loop do
268
+ input = prompt_for_action(prompt)
269
+ result = handle_channel_action(input, workspace, channel_id, latest_ts, conversations)
270
+ return result if result
271
+ end
272
+ end
273
+
274
+ def handle_channel_action(input, workspace, channel_id, latest_ts, conversations)
275
+ case input&.downcase
276
+ when 's', "\r", "\n", nil then :next
277
+ when 'q', "\u0003", "\u0004" then :quit
278
+ when 'r' then mark_channel_read(conversations, channel_id, latest_ts)
279
+ when 'o' then open_channel_in_slack(workspace, channel_id)
280
+ else
281
+ print_invalid_key
282
+ nil
283
+ end
284
+ end
285
+
286
+ def mark_channel_read(conversations, channel_id, latest_ts)
287
+ if latest_ts
288
+ conversations.mark(channel: channel_id, timestamp: latest_ts)
289
+ success('Marked as read')
290
+ end
291
+ :next
292
+ end
293
+
294
+ def open_channel_in_slack(workspace, channel_id)
295
+ team_id = runner.client_api(workspace.name).team_id
296
+ system('open', "slack://channel?team=#{team_id}&id=#{channel_id}")
297
+ success('Opened in Slack')
298
+ :next
299
+ end
300
+
301
+ def print_invalid_key
302
+ print "\r#{output.red('Invalid key')} - #{output.cyan('[s]kip [r]ead [o]pen [q]uit')}"
303
+ end
304
+
305
+ def process_threads(workspace, threads_response, index, total)
306
+ total_unreads = threads_response['total_unread_replies'] || 0
307
+ threads = threads_response['threads'] || []
308
+
309
+ puts
310
+ puts output.bold("[#{index + 1}/#{total}] 🧵 Threads (#{total_unreads} unread replies)")
311
+
312
+ thread_mark_data = threads.filter_map { |thread| display_thread(workspace, thread) }
313
+
314
+ prompt_threads_action(workspace, thread_mark_data)
315
+ end
316
+
317
+ def display_thread(workspace, thread)
318
+ unread_replies = thread['unread_replies'] || []
319
+ return nil if unread_replies.empty?
320
+
321
+ root_msg = thread['root_msg'] || {}
322
+ print_thread_header(workspace, root_msg)
323
+ display_thread_replies(workspace, unread_replies, root_msg['channel'])
324
+ puts
325
+
326
+ build_thread_mark_data(root_msg, unread_replies)
327
+ end
328
+
329
+ def print_thread_header(workspace, root_msg)
330
+ label = resolve_conversation_label(workspace, root_msg['channel'])
331
+ user = extract_user_from_message(root_msg, workspace)
332
+ puts "#{output.blue(" #{label}")} - thread by #{output.bold(user)}"
333
+ end
334
+
335
+ def build_thread_mark_data(root_msg, unread_replies)
336
+ {
337
+ channel: root_msg['channel'],
338
+ thread_ts: root_msg['thread_ts'],
339
+ ts: unread_replies.map { |r| r['ts'] }.max
340
+ }
341
+ end
342
+
343
+ def display_thread_replies(workspace, replies, channel_id)
344
+ messages = replies.map { |reply| Models::Message.from_api(reply, channel_id: channel_id) }
345
+ messages = enrich_messages(workspace, messages, channel_id) if @options[:reaction_timestamps]
346
+
347
+ messages.each do |message|
348
+ formatted = runner.message_formatter.format_simple(message, workspace: workspace, options: format_options)
349
+ puts " #{formatted}"
350
+ end
351
+ end
352
+
353
+ def prompt_threads_action(workspace, thread_mark_data)
354
+ prompt = output.cyan('[s]kip [r]ead [o]pen [q]uit')
355
+ loop do
356
+ input = prompt_for_action(prompt)
357
+ result = handle_threads_action(input, workspace, thread_mark_data)
358
+ return result if result
359
+ end
360
+ end
361
+
362
+ def handle_threads_action(input, workspace, thread_mark_data)
363
+ case input&.downcase
364
+ when 's', "\r", "\n", nil then :next
365
+ when 'q', "\u0003", "\u0004" then :quit
366
+ when 'r' then handle_mark_threads(workspace, thread_mark_data)
367
+ when 'o' then handle_open_threads(workspace, thread_mark_data)
368
+ else handle_invalid_key
369
+ end
370
+ end
371
+
372
+ def handle_mark_threads(workspace, thread_mark_data)
373
+ mark_threads_as_read(workspace, thread_mark_data)
374
+ :next
375
+ end
376
+
377
+ def handle_open_threads(workspace, thread_mark_data)
378
+ open_first_thread(workspace, thread_mark_data)
379
+ :next
380
+ end
381
+
382
+ def handle_invalid_key
383
+ print_invalid_key
384
+ nil
385
+ end
386
+
387
+ def mark_threads_as_read(workspace, thread_mark_data)
388
+ threads_api = runner.threads_api(workspace.name)
389
+ marked = 0
390
+ thread_mark_data.each do |data|
391
+ threads_api.mark(channel: data[:channel], thread_ts: data[:thread_ts], timestamp: data[:ts])
392
+ marked += 1
393
+ rescue ApiError => e
394
+ debug("Could not mark thread #{data[:thread_ts]} in #{data[:channel]}: #{e.message}")
395
+ end
396
+ success("Marked #{marked} thread(s) as read")
397
+ end
398
+
399
+ def open_first_thread(workspace, thread_mark_data)
400
+ return unless thread_mark_data.any?
401
+
402
+ first = thread_mark_data.first
403
+ team_id = runner.client_api(workspace.name).team_id
404
+ url = "slack://channel?team=#{team_id}&id=#{first[:channel]}&thread_ts=#{first[:thread_ts]}"
405
+ system('open', url)
406
+ success('Opened in Slack')
407
+ end
408
+ end
409
+ # rubocop:enable Metrics/ClassLength
410
+ end
411
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/help_formatter'
4
+
5
+ module Slk
6
+ module Commands
7
+ # Manages CLI configuration settings
8
+ class Config < Base
9
+ def execute
10
+ result = validate_options
11
+ return result if result
12
+
13
+ dispatch_action
14
+ end
15
+
16
+ private
17
+
18
+ def dispatch_action
19
+ case positional_args
20
+ in ['show'] | [] then show_config
21
+ in ['setup'] | [_] then run_setup
22
+ in ['get', key] then get_value(key)
23
+ in ['set', key, value] then set_value(key, value)
24
+ end
25
+ end
26
+
27
+ protected
28
+
29
+ def help_text
30
+ help = Support::HelpFormatter.new('slk config [action]')
31
+ help.description('Manage configuration.')
32
+ add_actions_section(help)
33
+ add_keys_section(help)
34
+ add_options_section(help)
35
+ help.render
36
+ end
37
+
38
+ def add_actions_section(help)
39
+ help.section('ACTIONS') do |s|
40
+ s.action('show', 'Show current configuration')
41
+ s.action('setup', 'Run setup wizard')
42
+ s.action('get <key>', 'Get a config value')
43
+ s.action('set <key> <val>', 'Set a config value')
44
+ end
45
+ end
46
+
47
+ def add_keys_section(help)
48
+ help.section('CONFIG KEYS') do |s|
49
+ s.item('primary_workspace', 'Default workspace name')
50
+ s.item('ssh_key', 'Path to SSH key for encryption')
51
+ s.item('emoji_dir', 'Custom emoji directory')
52
+ end
53
+ end
54
+
55
+ def add_options_section(help)
56
+ help.section('OPTIONS') do |s|
57
+ s.option('-q, --quiet', 'Suppress output')
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def show_config
64
+ display_config_values
65
+ display_workspace_info
66
+ display_paths
67
+ 0
68
+ end
69
+
70
+ def display_config_values
71
+ puts 'Configuration:'
72
+ puts " Primary workspace: #{config.primary_workspace || '(not set)'}"
73
+ puts " SSH key: #{config.ssh_key || '(not set)'}"
74
+ puts " Emoji dir: #{config.emoji_dir || '(default)'}"
75
+ end
76
+
77
+ def display_workspace_info
78
+ puts
79
+ puts "Workspaces: #{runner.workspace_names.join(', ')}"
80
+ end
81
+
82
+ def display_paths
83
+ puts
84
+ paths = Support::XdgPaths.new
85
+ puts "Config dir: #{paths.config_dir}"
86
+ puts "Cache dir: #{paths.cache_dir}"
87
+ end
88
+
89
+ def run_setup
90
+ wizard = Services::SetupWizard.new(
91
+ runner: runner,
92
+ config: config,
93
+ token_store: token_store,
94
+ output: output
95
+ )
96
+ wizard.run
97
+ end
98
+
99
+ def get_value(key)
100
+ value = config[key]
101
+ puts value || '(not set)'
102
+
103
+ 0
104
+ end
105
+
106
+ def set_value(key, value)
107
+ config[key] = value
108
+ success("Set #{key} = #{value}")
109
+
110
+ 0
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/help_formatter'
4
+
5
+ module Slk
6
+ module Commands
7
+ # Manages Do Not Disturb (snooze) settings
8
+ # rubocop:disable Metrics/ClassLength
9
+ class Dnd < Base
10
+ def execute
11
+ result = validate_options
12
+ return result if result
13
+
14
+ dispatch_action
15
+ rescue ArgumentError => e
16
+ error("Invalid duration: #{e.message}")
17
+ 1
18
+ rescue ApiError => e
19
+ error("Failed: #{e.message}")
20
+ 1
21
+ end
22
+
23
+ def dispatch_action
24
+ case positional_args
25
+ in ['status' | 'info'] | [] then get_status
26
+ in ['on' | 'snooze', *rest] then enable_snooze(rest.first)
27
+ in ['off' | 'end'] then end_snooze
28
+ in [duration_str] if duration_str.match?(/^\d+[hms]?$/) then enable_snooze(duration_str)
29
+ else unknown_action
30
+ end
31
+ end
32
+
33
+ def enable_snooze(duration_str)
34
+ duration = Models::Duration.parse(duration_str || '1h')
35
+ set_snooze(duration)
36
+ end
37
+
38
+ def unknown_action
39
+ error("Unknown action: #{positional_args.first}")
40
+ error('Valid actions: status, on, off, or a duration (e.g., 1h)')
41
+ 1
42
+ end
43
+
44
+ protected
45
+
46
+ def help_text
47
+ help = Support::HelpFormatter.new('slk dnd [action] [duration]')
48
+ help.description('Manage Do Not Disturb (snooze) settings.')
49
+ help.note('GET shows all workspaces by default. SET applies to primary only.')
50
+ add_actions_section(help)
51
+ add_duration_section(help)
52
+ add_options_section(help)
53
+ help.render
54
+ end
55
+
56
+ def add_actions_section(help)
57
+ help.section('ACTIONS') do |s|
58
+ s.action('(none)', 'Show current DND status (all workspaces)')
59
+ s.action('status', 'Show current DND status')
60
+ s.action('on [duration]', 'Enable snooze (default: 1h)')
61
+ s.action('off', 'Disable snooze')
62
+ s.action('<duration>', 'Enable snooze for specified duration')
63
+ end
64
+ end
65
+
66
+ def add_duration_section(help)
67
+ help.section('DURATION FORMAT') do |s|
68
+ s.item('1h', '1 hour')
69
+ s.item('30m', '30 minutes')
70
+ s.item('1h30m', '1 hour 30 minutes')
71
+ end
72
+ end
73
+
74
+ def add_options_section(help)
75
+ help.section('OPTIONS') do |s|
76
+ s.option('-w, --workspace', 'Limit to specific workspace')
77
+ s.option('--all', 'Set across all workspaces')
78
+ s.option('-q, --quiet', 'Suppress output')
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def get_status # rubocop:disable Naming/AccessorMethodName
85
+ workspaces = target_workspaces_for_get
86
+
87
+ workspaces.each do |workspace|
88
+ print_workspace_dnd_status(workspaces, workspace)
89
+ end
90
+
91
+ 0
92
+ end
93
+
94
+ def target_workspaces_for_get
95
+ @options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
96
+ end
97
+
98
+ def print_workspace_dnd_status(workspaces, workspace)
99
+ api = runner.dnd_api(workspace.name)
100
+ data = api.info
101
+
102
+ puts output.bold(workspace.name) if workspaces.size > 1
103
+ print_snooze_status(api, data)
104
+ print_scheduled_dnd(data)
105
+ end
106
+
107
+ def print_snooze_status(api, data)
108
+ if data['snooze_enabled']
109
+ print_snoozing_status(api)
110
+ else
111
+ puts " DND: #{output.green('off')}"
112
+ end
113
+ end
114
+
115
+ def print_snoozing_status(api)
116
+ remaining = api.snooze_remaining
117
+ if remaining
118
+ puts " DND: #{output.yellow('snoozing')} (#{remaining} remaining)"
119
+ else
120
+ puts " DND: #{output.yellow('snoozing')} (expired)"
121
+ end
122
+ end
123
+
124
+ def print_scheduled_dnd(data)
125
+ return unless data['dnd_enabled']
126
+
127
+ start_time = data['next_dnd_start_ts']
128
+ end_time = data['next_dnd_end_ts']
129
+ return unless start_time && end_time
130
+
131
+ start_str = Time.at(start_time).strftime('%H:%M')
132
+ end_str = Time.at(end_time).strftime('%H:%M')
133
+ puts " Schedule: #{start_str} - #{end_str}"
134
+ end
135
+
136
+ def set_snooze(duration) # rubocop:disable Naming/AccessorMethodName
137
+ target_workspaces.each do |workspace|
138
+ api = runner.dnd_api(workspace.name)
139
+ api.set_snooze(duration)
140
+
141
+ success("DND enabled for #{duration} on #{workspace.name}")
142
+ end
143
+
144
+ show_all_workspaces_hint
145
+
146
+ 0
147
+ end
148
+
149
+ def end_snooze
150
+ target_workspaces.each do |workspace|
151
+ api = runner.dnd_api(workspace.name)
152
+ api.end_snooze
153
+
154
+ success("DND disabled on #{workspace.name}")
155
+ end
156
+
157
+ show_all_workspaces_hint
158
+
159
+ 0
160
+ end
161
+
162
+ def show_all_workspaces_hint
163
+ # Show hint if user has multiple workspaces and didn't use --all or -w
164
+ return if @options[:all] || @options[:workspace]
165
+ return if runner.all_workspaces.size <= 1
166
+
167
+ info('Tip: Use --all to set across all workspaces')
168
+ end
169
+ end
170
+ # rubocop:enable Metrics/ClassLength
171
+ end
172
+ end