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,484 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/help_formatter"
4
+
5
+ module SlackCli
6
+ module Commands
7
+ class Catchup < Base
8
+ include Support::UserResolver
9
+
10
+ def execute
11
+ result = validate_options
12
+ return result if result
13
+
14
+ if @options[:batch]
15
+ batch_catchup
16
+ else
17
+ interactive_catchup
18
+ end
19
+ rescue ApiError => e
20
+ error("Failed: #{e.message}")
21
+ 1
22
+ end
23
+
24
+ protected
25
+
26
+ def default_options
27
+ super.merge(
28
+ all: true, # Default to all workspaces
29
+ batch: false,
30
+ muted: false,
31
+ limit: 5,
32
+ no_emoji: false,
33
+ no_reactions: false,
34
+ workspace_emoji: true, # Default to showing workspace emoji as images
35
+ reaction_names: false,
36
+ reaction_timestamps: false
37
+ )
38
+ end
39
+
40
+ def handle_option(arg, args, remaining)
41
+ case arg
42
+ when "--batch"
43
+ @options[:batch] = true
44
+ when "--muted"
45
+ @options[:muted] = true
46
+ when "-n", "--limit"
47
+ @options[:limit] = args.shift.to_i
48
+ when "--no-emoji"
49
+ @options[:no_emoji] = true
50
+ when "--no-reactions"
51
+ @options[:no_reactions] = true
52
+ when "--no-workspace-emoji"
53
+ @options[:workspace_emoji] = false
54
+ when "--reaction-names"
55
+ @options[:reaction_names] = true
56
+ when "--reaction-timestamps"
57
+ @options[:reaction_timestamps] = true
58
+ else
59
+ super
60
+ end
61
+ end
62
+
63
+ def help_text
64
+ help = Support::HelpFormatter.new("slk catchup [options]")
65
+ help.description("Interactively review and dismiss unread messages (all workspaces by default).")
66
+
67
+ help.section("OPTIONS") do |s|
68
+ s.option("--batch", "Non-interactive mode (mark all as read)")
69
+ s.option("--muted", "Include muted channels")
70
+ s.option("-n, --limit N", "Messages per channel (default: 5)")
71
+ s.option("--no-emoji", "Show :emoji: codes instead of unicode")
72
+ s.option("--no-reactions", "Hide reactions")
73
+ s.option("--no-workspace-emoji", "Disable workspace emoji images")
74
+ s.option("--reaction-names", "Show reactions with user names")
75
+ s.option("--reaction-timestamps", "Show when each person reacted")
76
+ s.option("-w, --workspace", "Limit to specific workspace")
77
+ s.option("-q, --quiet", "Suppress output")
78
+ end
79
+
80
+ help.section("INTERACTIVE KEYS") do |s|
81
+ s.item("s / Enter", "Skip channel")
82
+ s.item("r", "Mark as read and continue")
83
+ s.item("o", "Open in Slack")
84
+ s.item("q", "Quit")
85
+ end
86
+
87
+ help.render
88
+ end
89
+
90
+ private
91
+
92
+ def batch_catchup
93
+ target_workspaces.each do |workspace|
94
+ client = runner.client_api(workspace.name)
95
+ counts = client.counts
96
+ conversations = runner.conversations_api(workspace.name)
97
+
98
+ # Mark DMs as read
99
+ ims = counts["ims"] || []
100
+ dms_marked = 0
101
+
102
+ ims.each do |im|
103
+ next unless im["has_unreads"]
104
+
105
+ begin
106
+ history = conversations.history(channel: im["id"], limit: 1)
107
+ if (messages = history["messages"]) && messages.any?
108
+ conversations.mark(channel: im["id"], ts: messages.first["ts"])
109
+ dms_marked += 1
110
+ end
111
+ rescue ApiError => e
112
+ debug("Could not mark DM #{im["id"]}: #{e.message}")
113
+ end
114
+ end
115
+
116
+ # Mark channels as read
117
+ channels = counts["channels"] || []
118
+ channels_marked = 0
119
+
120
+ channels.each do |channel|
121
+ next unless channel["has_unreads"]
122
+ next if !@options[:muted] && channel["is_muted"]
123
+
124
+ begin
125
+ history = conversations.history(channel: channel["id"], limit: 1)
126
+ if (messages = history["messages"]) && messages.any?
127
+ conversations.mark(channel: channel["id"], ts: messages.first["ts"])
128
+ channels_marked += 1
129
+ end
130
+ rescue ApiError => e
131
+ debug("Could not mark channel #{channel["id"]}: #{e.message}")
132
+ end
133
+ end
134
+
135
+ # Mark threads as read
136
+ threads_api = runner.threads_api(workspace.name)
137
+ threads_response = threads_api.get_view(limit: 50)
138
+ threads_marked = 0
139
+
140
+ if threads_response["ok"]
141
+ (threads_response["threads"] || []).each do |thread|
142
+ unread_replies = thread["unread_replies"] || []
143
+ next if unread_replies.empty?
144
+
145
+ root_msg = thread["root_msg"] || {}
146
+ channel_id = root_msg["channel"]
147
+ thread_ts = root_msg["thread_ts"]
148
+ latest_ts = unread_replies.map { |r| r["ts"] }.max
149
+
150
+ begin
151
+ threads_api.mark(channel: channel_id, thread_ts: thread_ts, ts: latest_ts)
152
+ threads_marked += 1
153
+ rescue ApiError => e
154
+ debug("Could not mark thread #{thread_ts} in #{channel_id}: #{e.message}")
155
+ end
156
+ end
157
+ end
158
+
159
+ success("Marked #{dms_marked} DMs, #{channels_marked} channels, and #{threads_marked} threads as read on #{workspace.name}")
160
+ end
161
+
162
+ 0
163
+ end
164
+
165
+ def interactive_catchup
166
+ target_workspaces.each do |workspace|
167
+ result = process_workspace(workspace)
168
+ return 0 if result == :quit
169
+ end
170
+
171
+ puts
172
+ success("Catchup complete!")
173
+ 0
174
+ end
175
+
176
+ def process_workspace(workspace)
177
+ client = runner.client_api(workspace.name)
178
+ counts = client.counts
179
+
180
+ # Get muted channels from user prefs unless --muted flag is set
181
+ muted_ids = @options[:muted] ? [] : runner.users_api(workspace.name).muted_channels
182
+
183
+ # Get unread DMs
184
+ ims = (counts["ims"] || [])
185
+ .select { |i| i["has_unreads"] }
186
+
187
+ # Get unread channels
188
+ channels = (counts["channels"] || [])
189
+ .select { |c| c["has_unreads"] || (c["mention_count"] || 0) > 0 }
190
+ .reject { |c| muted_ids.include?(c["id"]) }
191
+
192
+ # Check for unread threads
193
+ threads_api = runner.threads_api(workspace.name)
194
+ threads_response = threads_api.get_view(limit: 20)
195
+ has_threads = threads_response["ok"] && (threads_response["total_unread_replies"] || 0) > 0
196
+
197
+ total_items = ims.size + channels.size + (has_threads ? 1 : 0)
198
+
199
+ if ims.empty? && channels.empty? && !has_threads
200
+ puts "No unread messages in #{workspace.name}"
201
+ return :continue
202
+ end
203
+
204
+ puts output.bold("\n#{workspace.name}: #{total_items} items with unreads\n")
205
+
206
+ current_index = 0
207
+
208
+ # Process DMs first
209
+ ims.each do |im|
210
+ result = process_dm(workspace, im, current_index, total_items)
211
+ return :quit if result == :quit
212
+ current_index += 1
213
+ end
214
+
215
+ # Process threads
216
+ if has_threads
217
+ result = process_threads(workspace, threads_response, current_index, total_items)
218
+ return :quit if result == :quit
219
+ current_index += 1
220
+ end
221
+
222
+ # Process channels
223
+ channels.each do |channel|
224
+ result = process_channel(workspace, channel, current_index, total_items)
225
+ return :quit if result == :quit
226
+ current_index += 1
227
+ end
228
+
229
+ :continue
230
+ end
231
+
232
+ def process_channel(workspace, channel, index, total)
233
+ channel_id = channel["id"]
234
+ channel_name = cache_store.get_channel_name(workspace.name, channel_id) || channel_id
235
+ mentions = channel["mention_count"] || 0
236
+ last_read = channel["last_read"]
237
+ latest_ts = channel["latest"] # Latest message timestamp for marking as read
238
+
239
+ # Fetch only unread messages (after last_read timestamp)
240
+ conversations = runner.conversations_api(workspace.name)
241
+ history_opts = { channel: channel_id, limit: @options[:limit] }
242
+ history_opts[:oldest] = last_read if last_read
243
+ history = conversations.history(**history_opts)
244
+ messages = (history["messages"] || []).reverse
245
+
246
+ # Display header
247
+ puts
248
+ puts output.bold("[#{index + 1}/#{total}] ##{channel_name}")
249
+ puts output.yellow("#{mentions} mentions") if mentions > 0
250
+
251
+ # Convert to model objects
252
+ message_objects = messages.map { |msg| Models::Message.from_api(msg, channel_id: channel_id) }
253
+
254
+ # Enrich with reaction timestamps if requested
255
+ if @options[:reaction_timestamps]
256
+ enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
257
+ message_objects = enricher.enrich_messages(message_objects, channel_id)
258
+ end
259
+
260
+ # Display messages
261
+ format_options = {
262
+ no_emoji: @options[:no_emoji],
263
+ no_reactions: @options[:no_reactions],
264
+ workspace_emoji: @options[:workspace_emoji],
265
+ reaction_names: @options[:reaction_names],
266
+ reaction_timestamps: @options[:reaction_timestamps]
267
+ }
268
+
269
+ message_objects.each do |message|
270
+ formatted = runner.message_formatter.format_simple(message, workspace: workspace, options: format_options)
271
+ puts " #{formatted}"
272
+ end
273
+
274
+ # Prompt for action (loop until valid key)
275
+ prompt = output.cyan("[s]kip [r]ead [o]pen [q]uit")
276
+ loop do
277
+ input = prompt_for_action(prompt)
278
+ result = handle_channel_action(input, workspace, channel_id, latest_ts, conversations)
279
+ return result if result
280
+ end
281
+ end
282
+
283
+ def process_dm(workspace, im, index, total)
284
+ channel_id = im["id"]
285
+ last_read = im["last_read"]
286
+ latest_ts = im["latest"] # Latest message timestamp for marking as read
287
+ mention_count = im["mention_count"] || 0
288
+
289
+ # Get user info from conversation
290
+ conversations = runner.conversations_api(workspace.name)
291
+ user_name = resolve_dm_user_name(workspace, channel_id, conversations)
292
+
293
+ # Fetch only unread messages (after last_read timestamp)
294
+ history_opts = { channel: channel_id, limit: @options[:limit] }
295
+ history_opts[:oldest] = last_read if last_read
296
+ history = conversations.history(**history_opts)
297
+ messages = (history["messages"] || []).reverse
298
+
299
+ # Display header
300
+ puts
301
+ puts output.bold("[#{index + 1}/#{total}] @#{user_name}")
302
+ puts output.yellow("#{mention_count} mentions") if mention_count > 0
303
+
304
+ # Convert to model objects
305
+ message_objects = messages.map { |msg| Models::Message.from_api(msg, channel_id: channel_id) }
306
+
307
+ # Enrich with reaction timestamps if requested
308
+ if @options[:reaction_timestamps]
309
+ enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
310
+ message_objects = enricher.enrich_messages(message_objects, channel_id)
311
+ end
312
+
313
+ # Display messages
314
+ format_options = {
315
+ no_emoji: @options[:no_emoji],
316
+ no_reactions: @options[:no_reactions],
317
+ workspace_emoji: @options[:workspace_emoji],
318
+ reaction_names: @options[:reaction_names],
319
+ reaction_timestamps: @options[:reaction_timestamps]
320
+ }
321
+
322
+ message_objects.each do |message|
323
+ formatted = runner.message_formatter.format_simple(message, workspace: workspace, options: format_options)
324
+ puts " #{formatted}"
325
+ end
326
+
327
+ # Prompt for action (loop until valid key)
328
+ prompt = output.cyan("[s]kip [r]ead [o]pen [q]uit")
329
+ loop do
330
+ input = prompt_for_action(prompt)
331
+ result = handle_channel_action(input, workspace, channel_id, latest_ts, conversations)
332
+ return result if result
333
+ end
334
+ end
335
+
336
+ def prompt_for_action(prompt)
337
+ print "\n#{prompt} > "
338
+ input = read_single_char
339
+ puts
340
+ input
341
+ end
342
+
343
+ def handle_channel_action(input, workspace, channel_id, latest_ts, conversations)
344
+ case input&.downcase
345
+ when "s", "\r", "\n", nil
346
+ :next
347
+ when "\u0003", "\u0004" # Ctrl-C, Ctrl-D
348
+ :quit
349
+ when "r"
350
+ # Mark as read using the latest message timestamp
351
+ if latest_ts
352
+ conversations.mark(channel: channel_id, ts: latest_ts)
353
+ success("Marked as read")
354
+ end
355
+ :next
356
+ when "o"
357
+ # Open in Slack (macOS)
358
+ team_id = runner.client_api(workspace.name).team_id
359
+ url = "slack://channel?team=#{team_id}&id=#{channel_id}"
360
+ system("open", url)
361
+ success("Opened in Slack")
362
+ :next
363
+ when "q"
364
+ :quit
365
+ else
366
+ print "\r#{output.red("Invalid key")} - #{output.cyan("[s]kip [r]ead [o]pen [q]uit")}"
367
+ nil # Return nil to continue loop
368
+ end
369
+ end
370
+
371
+ def process_threads(workspace, threads_response, index, total)
372
+ total_unreads = threads_response["total_unread_replies"] || 0
373
+ threads = threads_response["threads"] || []
374
+
375
+ format_options = {
376
+ no_emoji: @options[:no_emoji],
377
+ no_reactions: @options[:no_reactions],
378
+ workspace_emoji: @options[:workspace_emoji],
379
+ reaction_names: @options[:reaction_names],
380
+ reaction_timestamps: @options[:reaction_timestamps]
381
+ }
382
+
383
+ # Display header
384
+ puts
385
+ puts output.bold("[#{index + 1}/#{total}] 🧵 Threads (#{total_unreads} unread replies)")
386
+
387
+ # Display threads and track for marking
388
+ thread_mark_data = []
389
+
390
+ threads.each do |thread|
391
+ unread_replies = thread["unread_replies"] || []
392
+ next if unread_replies.empty?
393
+
394
+ root_msg = thread["root_msg"] || {}
395
+ channel_id = root_msg["channel"]
396
+ thread_ts = root_msg["thread_ts"]
397
+ conversation_label = resolve_conversation_label(workspace, channel_id)
398
+
399
+ # Get root user name
400
+ root_user = extract_user_from_message(root_msg, workspace)
401
+
402
+ puts output.blue(" #{conversation_label}") + " - thread by " + output.bold(root_user)
403
+
404
+ # Convert to model objects
405
+ message_objects = unread_replies.map { |reply| Models::Message.from_api(reply, channel_id: channel_id) }
406
+
407
+ # Enrich with reaction timestamps if requested
408
+ if @options[:reaction_timestamps]
409
+ enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
410
+ message_objects = enricher.enrich_messages(message_objects, channel_id)
411
+ end
412
+
413
+ # Display unread replies
414
+ message_objects.each do |message|
415
+ formatted = runner.message_formatter.format_simple(message, workspace: workspace, options: format_options)
416
+ puts " #{formatted}"
417
+ end
418
+
419
+ # Track latest reply ts for marking
420
+ latest_ts = unread_replies.map { |r| r["ts"] }.max
421
+ thread_mark_data << { channel: channel_id, thread_ts: thread_ts, ts: latest_ts }
422
+
423
+ puts
424
+ end
425
+
426
+ # Prompt for action (loop until valid key)
427
+ prompt = output.cyan("[s]kip [r]ead [o]pen [q]uit")
428
+ loop do
429
+ input = prompt_for_action(prompt)
430
+ result = handle_threads_action(input, workspace, thread_mark_data)
431
+ return result if result
432
+ end
433
+ end
434
+
435
+ def handle_threads_action(input, workspace, thread_mark_data)
436
+ case input&.downcase
437
+ when "s", "\r", "\n", nil
438
+ :next
439
+ when "\u0003", "\u0004" # Ctrl-C, Ctrl-D
440
+ :quit
441
+ when "r"
442
+ # Mark all threads as read
443
+ threads_api = runner.threads_api(workspace.name)
444
+ marked = 0
445
+ thread_mark_data.each do |data|
446
+ begin
447
+ threads_api.mark(channel: data[:channel], thread_ts: data[:thread_ts], ts: data[:ts])
448
+ marked += 1
449
+ rescue ApiError => e
450
+ debug("Could not mark thread #{data[:thread_ts]} in #{data[:channel]}: #{e.message}")
451
+ end
452
+ end
453
+ success("Marked #{marked} thread(s) as read")
454
+ :next
455
+ when "o"
456
+ # Open first thread in Slack
457
+ if thread_mark_data.any?
458
+ first = thread_mark_data.first
459
+ team_id = runner.client_api(workspace.name).team_id
460
+ url = "slack://channel?team=#{team_id}&id=#{first[:channel]}&thread_ts=#{first[:thread_ts]}"
461
+ system("open", url)
462
+ success("Opened in Slack")
463
+ end
464
+ :next
465
+ when "q"
466
+ :quit
467
+ else
468
+ print "\r#{output.red("Invalid key")} - #{output.cyan("[s]kip [r]ead [o]pen [q]uit")}"
469
+ nil # Return nil to continue loop
470
+ end
471
+ end
472
+
473
+ def read_single_char
474
+ if $stdin.tty?
475
+ $stdin.raw { |io| io.readchar }
476
+ else
477
+ $stdin.gets&.chomp
478
+ end
479
+ rescue Interrupt
480
+ "q"
481
+ end
482
+ end
483
+ end
484
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/help_formatter"
4
+
5
+ module SlackCli
6
+ module Commands
7
+ class Config < Base
8
+ def execute
9
+ result = validate_options
10
+ return result if result
11
+
12
+ case positional_args
13
+ in ["show"] | []
14
+ show_config
15
+ in ["setup"]
16
+ run_setup
17
+ in ["get", key]
18
+ get_value(key)
19
+ in ["set", key, value]
20
+ set_value(key, value)
21
+ else
22
+ run_setup
23
+ end
24
+ end
25
+
26
+ protected
27
+
28
+ def help_text
29
+ help = Support::HelpFormatter.new("slk config [action]")
30
+ help.description("Manage configuration.")
31
+
32
+ help.section("ACTIONS") do |s|
33
+ s.action("show", "Show current configuration")
34
+ s.action("setup", "Run setup wizard")
35
+ s.action("get <key>", "Get a config value")
36
+ s.action("set <key> <val>", "Set a config value")
37
+ end
38
+
39
+ help.section("CONFIG KEYS") do |s|
40
+ s.item("primary_workspace", "Default workspace name")
41
+ s.item("ssh_key", "Path to SSH key for encryption")
42
+ s.item("emoji_dir", "Custom emoji directory")
43
+ end
44
+
45
+ help.section("OPTIONS") do |s|
46
+ s.option("-q, --quiet", "Suppress output")
47
+ end
48
+
49
+ help.render
50
+ end
51
+
52
+ private
53
+
54
+ def show_config
55
+ puts "Configuration:"
56
+ puts " Primary workspace: #{config.primary_workspace || "(not set)"}"
57
+ puts " SSH key: #{config.ssh_key || "(not set)"}"
58
+ puts " Emoji dir: #{config.emoji_dir || "(default)"}"
59
+ puts
60
+ puts "Workspaces: #{runner.workspace_names.join(", ")}"
61
+ puts
62
+ paths = Support::XdgPaths.new
63
+ puts "Config dir: #{paths.config_dir}"
64
+ puts "Cache dir: #{paths.cache_dir}"
65
+
66
+ 0
67
+ end
68
+
69
+ def run_setup
70
+ puts "Slack CLI Setup"
71
+ puts "==============="
72
+ puts
73
+
74
+ # Check for existing config
75
+ if runner.has_workspaces?
76
+ puts "You already have workspaces configured."
77
+ print "Add another workspace? (y/n): "
78
+ answer = $stdin.gets&.chomp&.downcase
79
+ return 0 unless answer == "y"
80
+ end
81
+
82
+ # Setup encryption
83
+ if config.ssh_key.nil?
84
+ puts
85
+ puts "Encryption Setup (optional)"
86
+ puts "----------------------------"
87
+ puts "You can encrypt your tokens with age using an SSH key."
88
+ print "SSH key path (or press Enter to skip): "
89
+ ssh_key = $stdin.gets&.chomp
90
+
91
+ unless ssh_key.nil? || ssh_key.empty?
92
+ if File.exist?(ssh_key)
93
+ config.ssh_key = ssh_key
94
+ success("SSH key configured")
95
+ else
96
+ warn("File not found: #{ssh_key}")
97
+ end
98
+ end
99
+ end
100
+
101
+ # Add workspace
102
+ puts
103
+ puts "Workspace Setup"
104
+ puts "---------------"
105
+
106
+ print "Workspace name: "
107
+ name = $stdin.gets&.chomp
108
+ return error("Name is required") if name.nil? || name.empty?
109
+
110
+ print "Token (xoxb-... or xoxc-...): "
111
+ token = $stdin.gets&.chomp
112
+ return error("Token is required") if token.nil? || token.empty?
113
+
114
+ cookie = nil
115
+ if token.start_with?("xoxc-")
116
+ puts
117
+ puts "xoxc tokens require a cookie for authentication."
118
+ print "Cookie (d=...): "
119
+ cookie = $stdin.gets&.chomp
120
+ end
121
+
122
+ token_store.add(name, token, cookie)
123
+
124
+ # Set as primary if first
125
+ if config.primary_workspace.nil?
126
+ config.primary_workspace = name
127
+ end
128
+
129
+ puts
130
+ success("Setup complete!")
131
+ puts
132
+ puts "Try these commands:"
133
+ puts " slack status - View your status"
134
+ puts " slack messages #general - Read channel messages"
135
+ puts " slack help - See all commands"
136
+
137
+ 0
138
+ end
139
+
140
+ def get_value(key)
141
+ value = config[key]
142
+ if value
143
+ puts value
144
+ else
145
+ puts "(not set)"
146
+ end
147
+
148
+ 0
149
+ end
150
+
151
+ def set_value(key, value)
152
+ config[key] = value
153
+ success("Set #{key} = #{value}")
154
+
155
+ 0
156
+ end
157
+ end
158
+ end
159
+ end