slk 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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE +21 -0
  4. data/README.md +190 -0
  5. data/bin/slk +7 -0
  6. data/lib/slack_cli/api/activity.rb +28 -0
  7. data/lib/slack_cli/api/bots.rb +32 -0
  8. data/lib/slack_cli/api/client.rb +49 -0
  9. data/lib/slack_cli/api/conversations.rb +52 -0
  10. data/lib/slack_cli/api/dnd.rb +40 -0
  11. data/lib/slack_cli/api/emoji.rb +21 -0
  12. data/lib/slack_cli/api/threads.rb +44 -0
  13. data/lib/slack_cli/api/usergroups.rb +25 -0
  14. data/lib/slack_cli/api/users.rb +101 -0
  15. data/lib/slack_cli/cli.rb +118 -0
  16. data/lib/slack_cli/commands/activity.rb +292 -0
  17. data/lib/slack_cli/commands/base.rb +175 -0
  18. data/lib/slack_cli/commands/cache.rb +116 -0
  19. data/lib/slack_cli/commands/catchup.rb +484 -0
  20. data/lib/slack_cli/commands/config.rb +159 -0
  21. data/lib/slack_cli/commands/dnd.rb +143 -0
  22. data/lib/slack_cli/commands/emoji.rb +412 -0
  23. data/lib/slack_cli/commands/help.rb +76 -0
  24. data/lib/slack_cli/commands/messages.rb +317 -0
  25. data/lib/slack_cli/commands/presence.rb +107 -0
  26. data/lib/slack_cli/commands/preset.rb +239 -0
  27. data/lib/slack_cli/commands/status.rb +194 -0
  28. data/lib/slack_cli/commands/thread.rb +62 -0
  29. data/lib/slack_cli/commands/unread.rb +312 -0
  30. data/lib/slack_cli/commands/workspaces.rb +151 -0
  31. data/lib/slack_cli/formatters/duration_formatter.rb +28 -0
  32. data/lib/slack_cli/formatters/emoji_replacer.rb +143 -0
  33. data/lib/slack_cli/formatters/mention_replacer.rb +154 -0
  34. data/lib/slack_cli/formatters/message_formatter.rb +429 -0
  35. data/lib/slack_cli/formatters/output.rb +89 -0
  36. data/lib/slack_cli/models/channel.rb +52 -0
  37. data/lib/slack_cli/models/duration.rb +85 -0
  38. data/lib/slack_cli/models/message.rb +217 -0
  39. data/lib/slack_cli/models/preset.rb +73 -0
  40. data/lib/slack_cli/models/reaction.rb +54 -0
  41. data/lib/slack_cli/models/status.rb +57 -0
  42. data/lib/slack_cli/models/user.rb +56 -0
  43. data/lib/slack_cli/models/workspace.rb +52 -0
  44. data/lib/slack_cli/runner.rb +123 -0
  45. data/lib/slack_cli/services/api_client.rb +149 -0
  46. data/lib/slack_cli/services/cache_store.rb +198 -0
  47. data/lib/slack_cli/services/configuration.rb +74 -0
  48. data/lib/slack_cli/services/encryption.rb +51 -0
  49. data/lib/slack_cli/services/preset_store.rb +112 -0
  50. data/lib/slack_cli/services/reaction_enricher.rb +87 -0
  51. data/lib/slack_cli/services/token_store.rb +117 -0
  52. data/lib/slack_cli/support/error_logger.rb +28 -0
  53. data/lib/slack_cli/support/help_formatter.rb +139 -0
  54. data/lib/slack_cli/support/inline_images.rb +62 -0
  55. data/lib/slack_cli/support/slack_url_parser.rb +78 -0
  56. data/lib/slack_cli/support/user_resolver.rb +114 -0
  57. data/lib/slack_cli/support/xdg_paths.rb +37 -0
  58. data/lib/slack_cli/version.rb +5 -0
  59. data/lib/slack_cli.rb +91 -0
  60. metadata +103 -0
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/help_formatter"
4
+
5
+ module SlackCli
6
+ module Commands
7
+ class Dnd < Base
8
+ def execute
9
+ result = validate_options
10
+ return result if result
11
+
12
+ case positional_args
13
+ in ["status" | "info"]
14
+ get_status
15
+ in ["on" | "snooze", *rest]
16
+ duration = rest.first ? Models::Duration.parse(rest.first) : Models::Duration.parse("1h")
17
+ set_snooze(duration)
18
+ in ["off" | "end"]
19
+ end_snooze
20
+ in [duration_str] if duration_str.match?(/^\d+[hms]?$/)
21
+ duration = Models::Duration.parse(duration_str)
22
+ set_snooze(duration)
23
+ in []
24
+ get_status
25
+ else
26
+ error("Unknown action: #{positional_args.first}")
27
+ error("Valid actions: status, on, off, or a duration (e.g., 1h)")
28
+ 1
29
+ end
30
+ rescue ArgumentError => e
31
+ error("Invalid duration: #{e.message}")
32
+ 1
33
+ rescue ApiError => e
34
+ error("Failed: #{e.message}")
35
+ 1
36
+ end
37
+
38
+ protected
39
+
40
+ def help_text
41
+ help = Support::HelpFormatter.new("slk dnd [action] [duration]")
42
+ help.description("Manage Do Not Disturb (snooze) settings.")
43
+ help.note("GET shows all workspaces by default. SET applies to primary only.")
44
+
45
+ help.section("ACTIONS") do |s|
46
+ s.action("(none)", "Show current DND status (all workspaces)")
47
+ s.action("status", "Show current DND status")
48
+ s.action("on [duration]", "Enable snooze (default: 1h)")
49
+ s.action("off", "Disable snooze")
50
+ s.action("<duration>", "Enable snooze for specified duration")
51
+ end
52
+
53
+ help.section("DURATION FORMAT") do |s|
54
+ s.item("1h", "1 hour")
55
+ s.item("30m", "30 minutes")
56
+ s.item("1h30m", "1 hour 30 minutes")
57
+ end
58
+
59
+ help.section("OPTIONS") do |s|
60
+ s.option("-w, --workspace", "Limit to specific workspace")
61
+ s.option("--all", "Set across all workspaces")
62
+ s.option("-q, --quiet", "Suppress output")
63
+ end
64
+
65
+ help.render
66
+ end
67
+
68
+ private
69
+
70
+ def get_status
71
+ # GET defaults to all workspaces unless -w specified
72
+ workspaces = @options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
73
+
74
+ workspaces.each do |workspace|
75
+ api = runner.dnd_api(workspace.name)
76
+ data = api.info
77
+
78
+ if workspaces.size > 1
79
+ puts output.bold(workspace.name)
80
+ end
81
+
82
+ if data["snooze_enabled"]
83
+ remaining = api.snooze_remaining
84
+ if remaining
85
+ puts " DND: #{output.yellow("snoozing")} (#{remaining} remaining)"
86
+ else
87
+ puts " DND: #{output.yellow("snoozing")} (expired)"
88
+ end
89
+ else
90
+ puts " DND: #{output.green("off")}"
91
+ end
92
+
93
+ # Show scheduled DND if present
94
+ if data["dnd_enabled"]
95
+ start_time = data["next_dnd_start_ts"]
96
+ end_time = data["next_dnd_end_ts"]
97
+ if start_time && end_time
98
+ start_str = Time.at(start_time).strftime("%H:%M")
99
+ end_str = Time.at(end_time).strftime("%H:%M")
100
+ puts " Schedule: #{start_str} - #{end_str}"
101
+ end
102
+ end
103
+ end
104
+
105
+ 0
106
+ end
107
+
108
+ def set_snooze(duration)
109
+ target_workspaces.each do |workspace|
110
+ api = runner.dnd_api(workspace.name)
111
+ api.set_snooze(duration)
112
+
113
+ success("DND enabled for #{duration} on #{workspace.name}")
114
+ end
115
+
116
+ show_all_workspaces_hint
117
+
118
+ 0
119
+ end
120
+
121
+ def end_snooze
122
+ target_workspaces.each do |workspace|
123
+ api = runner.dnd_api(workspace.name)
124
+ api.end_snooze
125
+
126
+ success("DND disabled on #{workspace.name}")
127
+ end
128
+
129
+ show_all_workspaces_hint
130
+
131
+ 0
132
+ end
133
+
134
+ def show_all_workspaces_hint
135
+ # Show hint if user has multiple workspaces and didn't use --all or -w
136
+ return if @options[:all] || @options[:workspace]
137
+ return if runner.all_workspaces.size <= 1
138
+
139
+ info("Tip: Use --all to set across all workspaces")
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/inline_images"
4
+ require_relative "../support/help_formatter"
5
+
6
+ module SlackCli
7
+ module Commands
8
+ class Emoji < Base
9
+ include Support::InlineImages
10
+
11
+ NETWORK_ERRORS = [
12
+ SocketError,
13
+ Errno::ECONNREFUSED,
14
+ Errno::ETIMEDOUT,
15
+ Net::OpenTimeout,
16
+ Net::ReadTimeout,
17
+ URI::InvalidURIError,
18
+ OpenSSL::SSL::SSLError
19
+ ].freeze
20
+
21
+ def execute
22
+ result = validate_options
23
+ return result if result
24
+
25
+ case positional_args
26
+ in ["status" | "list"] | []
27
+ show_status
28
+ in ["sync-standard"]
29
+ sync_standard
30
+ in ["download", *rest]
31
+ download_emoji(rest.first)
32
+ in ["clear", *rest]
33
+ clear_emoji(rest.first)
34
+ in ["search", query, *_]
35
+ search_emoji(query)
36
+ in ["search"]
37
+ error("Usage: slk emoji search <query>")
38
+ 1
39
+ else
40
+ error("Unknown action: #{positional_args.first}")
41
+ 1
42
+ end
43
+ rescue ApiError => e
44
+ error("Failed: #{e.message}")
45
+ 1
46
+ end
47
+
48
+ protected
49
+
50
+ def default_options
51
+ super.merge(force: false)
52
+ end
53
+
54
+ def handle_option(arg, args, remaining)
55
+ case arg
56
+ when "-f", "--force"
57
+ @options[:force] = true
58
+ else
59
+ super
60
+ end
61
+ end
62
+
63
+ def help_text
64
+ help = Support::HelpFormatter.new("slk emoji <action> [workspace]")
65
+ help.description("Manage emoji cache.")
66
+
67
+ help.section("ACTIONS") do |s|
68
+ s.action("status", "Show emoji cache status")
69
+ s.action("search <query>", "Search emoji by name (all workspaces by default)")
70
+ s.action("sync-standard", "Download standard emoji database (gemoji)")
71
+ s.action("download [ws]", "Download workspace custom emoji")
72
+ s.action("clear [ws]", "Clear emoji cache")
73
+ end
74
+
75
+ help.section("OPTIONS") do |s|
76
+ s.option("-w, --workspace", "Limit to specific workspace")
77
+ s.option("-f, --force", "Skip confirmation for clear")
78
+ s.option("-q, --quiet", "Suppress output")
79
+ end
80
+
81
+ help.render
82
+ end
83
+
84
+ private
85
+
86
+ def show_status
87
+ paths = Support::XdgPaths.new
88
+ emoji_dir = config.emoji_dir || paths.cache_dir
89
+ gemoji_path = File.join(paths.cache_dir, "gemoji.json")
90
+
91
+ # Show standard emoji status
92
+ if File.exist?(gemoji_path)
93
+ begin
94
+ gemoji = JSON.parse(File.read(gemoji_path))
95
+ puts "Standard emoji database: #{gemoji.size} emojis"
96
+ rescue JSON::ParserError
97
+ puts "Standard emoji database: #{output.yellow("corrupted")}"
98
+ puts " Run 'slk emoji sync-standard' to re-download"
99
+ end
100
+ else
101
+ puts "Standard emoji database: #{output.yellow("not downloaded")}"
102
+ puts " Run 'slk emoji sync-standard' to download"
103
+ end
104
+
105
+ puts
106
+ puts "Workspace emojis: (#{emoji_dir})"
107
+
108
+ target_workspaces.each do |workspace|
109
+ workspace_dir = File.join(emoji_dir, workspace.name)
110
+
111
+ if Dir.exist?(workspace_dir)
112
+ files = Dir.glob(File.join(workspace_dir, "*"))
113
+ count = files.count
114
+ size = files.sum { |f| safe_file_size(f) }
115
+ size_str = format_size(size)
116
+ puts " #{workspace.name}: #{count} emojis (#{size_str})"
117
+ else
118
+ puts " #{workspace.name}: #{output.yellow("not downloaded")}"
119
+ end
120
+ end
121
+
122
+ 0
123
+ end
124
+
125
+ def search_emoji(query)
126
+ paths = Support::XdgPaths.new
127
+ emoji_dir = config.emoji_dir || paths.cache_dir
128
+ gemoji_path = File.join(paths.cache_dir, "gemoji.json")
129
+ pattern = Regexp.new(Regexp.escape(query), Regexp::IGNORECASE)
130
+
131
+ results = []
132
+
133
+ # Search standard emoji
134
+ if File.exist?(gemoji_path)
135
+ begin
136
+ gemoji = JSON.parse(File.read(gemoji_path))
137
+ gemoji.each do |name, char|
138
+ results << { name: name, char: char, source: "standard" } if name.match?(pattern)
139
+ end
140
+ rescue JSON::ParserError
141
+ # Skip standard emoji search if cache is corrupted
142
+ debug("Standard emoji cache corrupted, skipping")
143
+ end
144
+ end
145
+
146
+ # Search workspace custom emoji (all workspaces by default, or -w to limit)
147
+ workspaces = @options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
148
+ workspaces.each do |workspace|
149
+ workspace_dir = File.join(emoji_dir, workspace.name)
150
+ next unless Dir.exist?(workspace_dir)
151
+
152
+ Dir.glob(File.join(workspace_dir, "*")).each do |filepath|
153
+ name = File.basename(filepath, ".*")
154
+ results << { name: name, path: filepath, source: workspace.name } if name.match?(pattern)
155
+ end
156
+ end
157
+
158
+ if results.empty?
159
+ puts "No emoji matching '#{query}'"
160
+ return 0
161
+ end
162
+
163
+ # Group by source
164
+ by_source = results.group_by { |r| r[:source] }
165
+
166
+ by_source.each do |source, items|
167
+ puts output.bold(source == "standard" ? "Standard emoji:" : "#{source}:")
168
+ items.sort_by { |r| r[:name] }.each do |item|
169
+ if item[:char]
170
+ puts "#{item[:char]} :#{item[:name]}:"
171
+ elsif item[:path]
172
+ unless print_inline_image_with_text(item[:path], ":#{item[:name]}:")
173
+ puts ":#{item[:name]}:"
174
+ end
175
+ else
176
+ puts ":#{item[:name]}:"
177
+ end
178
+ end
179
+ puts
180
+ end
181
+
182
+ puts "Found #{results.size} emoji matching '#{query}'"
183
+ 0
184
+ end
185
+
186
+ def print_progress(current, total, downloaded, skipped)
187
+ # Only update every 1% or when downloading (to show new count)
188
+ pct = ((current.to_f / total) * 100).round
189
+ @last_pct ||= -1
190
+ return if pct == @last_pct && downloaded == (@last_downloaded || 0)
191
+
192
+ @last_pct = pct
193
+ @last_downloaded = downloaded
194
+
195
+ bar_width = 20
196
+ filled = (pct * bar_width / 100).round
197
+ bar = "=" * filled + "-" * (bar_width - filled)
198
+ print "\r [#{bar}] #{pct}% (#{current}/#{total}) +#{downloaded} new"
199
+ $stdout.flush
200
+ end
201
+
202
+ def format_size(bytes)
203
+ if bytes >= 1024 * 1024
204
+ "#{(bytes / (1024.0 * 1024)).round}M"
205
+ elsif bytes >= 1024
206
+ "#{(bytes / 1024.0).round}K"
207
+ else
208
+ "#{bytes}B"
209
+ end
210
+ end
211
+
212
+ # Get file size, returning 0 if file doesn't exist or is inaccessible
213
+ def safe_file_size(path)
214
+ File.size(path)
215
+ rescue Errno::ENOENT, Errno::EACCES
216
+ 0
217
+ end
218
+
219
+ def sync_standard
220
+ paths = Support::XdgPaths.new
221
+ cache_dir = paths.cache_dir
222
+ emoji_json_path = File.join(cache_dir, "gemoji.json")
223
+
224
+ puts "Downloading standard emoji database..."
225
+
226
+ # Download gemoji JSON from GitHub
227
+ gemoji_url = "https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json"
228
+
229
+ begin
230
+ uri = URI.parse(gemoji_url)
231
+ http = Net::HTTP.new(uri.host, uri.port)
232
+ http.use_ssl = true
233
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
234
+ http.cert_store = OpenSSL::X509::Store.new
235
+ http.cert_store.set_default_paths
236
+
237
+ request = Net::HTTP::Get.new(uri)
238
+ response = http.request(request)
239
+
240
+ unless response.is_a?(Net::HTTPSuccess)
241
+ error("Failed to download: HTTP #{response.code}")
242
+ return 1
243
+ end
244
+
245
+ # Parse and transform to shortcode -> emoji mapping
246
+ emoji_data = JSON.parse(response.body)
247
+ emoji_map = {}
248
+
249
+ emoji_data.each do |emoji|
250
+ char = emoji["emoji"]
251
+ next unless char
252
+
253
+ # Add all aliases
254
+ (emoji["aliases"] || []).each do |name|
255
+ emoji_map[name] = char
256
+ end
257
+ end
258
+
259
+ # Save to cache
260
+ FileUtils.mkdir_p(cache_dir)
261
+ File.write(emoji_json_path, JSON.pretty_generate(emoji_map))
262
+
263
+ success("Downloaded #{emoji_map.size} standard emoji mappings")
264
+ puts " Location: #{emoji_json_path}"
265
+
266
+ 0
267
+ rescue JSON::ParserError => e
268
+ error("Failed to parse emoji data: #{e.message}")
269
+ 1
270
+ rescue *NETWORK_ERRORS => e
271
+ error("Network error: #{e.message}")
272
+ 1
273
+ rescue SystemCallError => e
274
+ error("File system error: #{e.message}")
275
+ 1
276
+ end
277
+ end
278
+
279
+ def download_emoji(workspace_name)
280
+ workspaces = workspace_name ? [runner.workspace(workspace_name)] : target_workspaces
281
+ paths = Support::XdgPaths.new
282
+ emoji_dir = config.emoji_dir || paths.cache_dir
283
+
284
+ workspaces.each do |workspace|
285
+ puts "Fetching emoji list for #{workspace.name}..."
286
+
287
+ api = runner.emoji_api(workspace.name)
288
+ emoji_map = api.custom_emoji
289
+
290
+ workspace_dir = File.join(emoji_dir, workspace.name)
291
+ FileUtils.mkdir_p(workspace_dir)
292
+
293
+ downloaded = 0
294
+ skipped = 0
295
+ failed = 0
296
+ total = emoji_map.size
297
+ processed = 0
298
+
299
+ # Filter to only downloadable (non-alias) emoji
300
+ to_download = emoji_map.reject { |_, url| url.start_with?("alias:") }
301
+ aliases = total - to_download.size
302
+
303
+ to_download.each do |name, url|
304
+ processed += 1
305
+ ext = File.extname(URI.parse(url).path)
306
+ ext = ".png" if ext.empty?
307
+ filepath = File.join(workspace_dir, "#{name}#{ext}")
308
+
309
+ # Skip if already exists
310
+ if File.exist?(filepath)
311
+ skipped += 1
312
+ print_progress(processed, to_download.size, downloaded, skipped)
313
+ next
314
+ end
315
+
316
+ # Download
317
+ begin
318
+ uri = URI.parse(url)
319
+ http = Net::HTTP.new(uri.host, uri.port)
320
+ http.use_ssl = true
321
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
322
+ http.cert_store = OpenSSL::X509::Store.new
323
+ http.cert_store.set_default_paths
324
+ http.open_timeout = 10
325
+ http.read_timeout = 30
326
+
327
+ request = Net::HTTP::Get.new(uri)
328
+ response = http.request(request)
329
+
330
+ if response.is_a?(Net::HTTPSuccess)
331
+ File.binwrite(filepath, response.body)
332
+ downloaded += 1
333
+ else
334
+ failed += 1
335
+ end
336
+ rescue *NETWORK_ERRORS, SystemCallError => e
337
+ debug("Failed to download emoji #{name}: #{e.message}")
338
+ failed += 1
339
+ end
340
+
341
+ print_progress(processed, to_download.size, downloaded, skipped)
342
+ end
343
+
344
+ puts "\r#{" " * 60}\r" # Clear progress line
345
+ success("Downloaded #{downloaded} new emoji for #{workspace.name}")
346
+ puts " Skipped: #{skipped} (already exist), #{aliases} aliases, #{failed} failed" if skipped > 0 || failed > 0
347
+ end
348
+
349
+ 0
350
+ end
351
+
352
+ def clear_emoji(workspace_name)
353
+ paths = Support::XdgPaths.new
354
+ emoji_dir = config.emoji_dir || paths.cache_dir
355
+
356
+ # Gather info about what will be deleted
357
+ to_clear = []
358
+ if workspace_name
359
+ workspace_dir = File.join(emoji_dir, workspace_name)
360
+ if Dir.exist?(workspace_dir)
361
+ to_clear << { name: workspace_name, dir: workspace_dir }
362
+ else
363
+ puts "No emoji cache for #{workspace_name}"
364
+ return 0
365
+ end
366
+ else
367
+ target_workspaces.each do |workspace|
368
+ workspace_dir = File.join(emoji_dir, workspace.name)
369
+ to_clear << { name: workspace.name, dir: workspace_dir } if Dir.exist?(workspace_dir)
370
+ end
371
+
372
+ if to_clear.empty?
373
+ puts "No emoji caches to clear"
374
+ return 0
375
+ end
376
+ end
377
+
378
+ # Show what will be deleted
379
+ puts "Will delete:"
380
+ total_count = 0
381
+ total_size = 0
382
+ to_clear.each do |entry|
383
+ files = Dir.glob(File.join(entry[:dir], "*"))
384
+ count = files.count
385
+ size = files.sum { |f| safe_file_size(f) }
386
+ total_count += count
387
+ total_size += size
388
+ puts " #{entry[:name]}: #{count} files (#{format_size(size)})"
389
+ end
390
+ puts " Total: #{total_count} files (#{format_size(total_size)})"
391
+
392
+ # Confirm unless --force
393
+ unless @options[:force]
394
+ print "\nAre you sure? [y/N] "
395
+ response = $stdin.gets&.chomp&.downcase
396
+ unless response == "y" || response == "yes"
397
+ puts "Cancelled"
398
+ return 0
399
+ end
400
+ end
401
+
402
+ # Delete
403
+ to_clear.each do |entry|
404
+ FileUtils.rm_rf(entry[:dir])
405
+ end
406
+
407
+ success("Cleared #{total_count} emoji files")
408
+ 0
409
+ end
410
+ end
411
+ end
412
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Commands
5
+ class Help < Base
6
+ def execute
7
+ topic = positional_args.first
8
+
9
+ if topic
10
+ show_command_help(topic)
11
+ else
12
+ show_general_help
13
+ end
14
+
15
+ 0
16
+ end
17
+
18
+ private
19
+
20
+ def show_general_help
21
+ puts <<~HELP
22
+ #{output.bold("slk")} - Slack CLI v#{VERSION}
23
+
24
+ #{output.bold("USAGE:")}
25
+ slk <command> [options]
26
+
27
+ #{output.bold("COMMANDS:")}
28
+ #{output.cyan("status")} Get or set your status
29
+ #{output.cyan("presence")} Get or set your presence (away/active)
30
+ #{output.cyan("dnd")} Manage Do Not Disturb
31
+ #{output.cyan("messages")} Read channel or DM messages
32
+ #{output.cyan("unread")} View and clear unread messages
33
+ #{output.cyan("preset")} Manage and apply status presets
34
+ #{output.cyan("workspaces")} Manage Slack workspaces
35
+ #{output.cyan("cache")} Manage user/channel cache
36
+ #{output.cyan("emoji")} Download workspace custom emoji
37
+ #{output.cyan("config")} Configuration and setup
38
+
39
+ #{output.bold("GLOBAL OPTIONS:")}
40
+ -w, --workspace NAME Use specific workspace
41
+ --all Apply to all workspaces
42
+ -v, --verbose Show debug output
43
+ -q, --quiet Suppress output
44
+ --json Output as JSON (where supported)
45
+ -h, --help Show help
46
+
47
+ #{output.bold("EXAMPLES:")}
48
+ slk status Show current status
49
+ slk status "Working" :laptop: Set status
50
+ slk status clear Clear status
51
+ slk dnd 1h Enable DND for 1 hour
52
+ slk messages #general Read channel messages
53
+ slk preset meeting Apply preset
54
+
55
+ Run #{output.cyan("slk <command> --help")} for command-specific help.
56
+ HELP
57
+ end
58
+
59
+ def show_command_help(topic)
60
+ command_class = CLI::COMMANDS[topic]
61
+
62
+ if command_class
63
+ # Create instance just to get help text
64
+ # Call --help directly since help_text is protected
65
+ runner_stub = Runner.new(output: output)
66
+ cmd = command_class.new(["--help"], runner: runner_stub)
67
+ cmd.execute
68
+ else
69
+ error("Unknown command: #{topic}")
70
+ puts
71
+ puts "Available commands: #{CLI::COMMANDS.keys.join(", ")}"
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end