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,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/help_formatter'
4
+ require_relative '../support/inline_images'
5
+
6
+ module Slk
7
+ module Commands
8
+ # Reads messages from channels, DMs, or threads
9
+ # rubocop:disable Metrics/ClassLength
10
+ class Messages < Base
11
+ include Support::InlineImages
12
+
13
+ def execute
14
+ result = validate_options
15
+ return result if result
16
+
17
+ target = positional_args.first
18
+ return missing_target_error unless target
19
+
20
+ resolved = target_resolver.resolve(target, default_workspace: target_workspaces.first)
21
+ fetch_and_display_messages(resolved)
22
+ rescue ApiError => e
23
+ error("Failed to fetch messages: #{e.message}")
24
+ 1
25
+ end
26
+
27
+ def missing_target_error
28
+ error('Usage: slk messages <channel|@user|url>')
29
+ 1
30
+ end
31
+
32
+ def fetch_and_display_messages(resolved)
33
+ apply_default_limit(resolved.msg_ts)
34
+ messages = fetch_messages(resolved.workspace, resolved.channel_id, resolved.thread_ts, oldest: resolved.msg_ts)
35
+ messages = enrich_reactions(messages, resolved.workspace, resolved.channel_id) if @options[:reaction_timestamps]
36
+
37
+ output_messages(messages, resolved.workspace, resolved.channel_id)
38
+ 0
39
+ end
40
+
41
+ def output_messages(messages, workspace, channel_id)
42
+ if @options[:json]
43
+ output_json_messages(messages, workspace, channel_id)
44
+ else
45
+ display_messages(messages, workspace, channel_id)
46
+ end
47
+ end
48
+
49
+ protected
50
+
51
+ def default_options
52
+ super.merge(
53
+ limit: 500,
54
+ limit_set: false,
55
+ threads: false,
56
+ workspace_emoji: false
57
+ )
58
+ end
59
+
60
+ def handle_option(arg, args, remaining)
61
+ case arg
62
+ when '-n', '--limit' then handle_limit_option(args)
63
+ when '--threads' then @options[:threads] = true
64
+ when '--workspace-emoji' then @options[:workspace_emoji] = true
65
+ else super
66
+ end
67
+ end
68
+
69
+ def handle_limit_option(args)
70
+ @options[:limit] = args.shift.to_i
71
+ @options[:limit_set] = true
72
+ end
73
+
74
+ def help_text
75
+ help = Support::HelpFormatter.new('slk messages <target> [options]')
76
+ help.description('Read messages from a channel, DM, or thread.')
77
+ add_target_section(help)
78
+ add_options_section(help)
79
+ help.render
80
+ end
81
+
82
+ def add_target_section(help)
83
+ help.section('TARGET') do |s|
84
+ s.item('#channel', 'Channel by name')
85
+ s.item('channel', 'Channel by name (without #)')
86
+ s.item('@user', 'Direct message with user')
87
+ s.item('C123ABC', 'Channel by ID')
88
+ s.item('<slack_url>', 'Slack message URL (returns message + subsequent)')
89
+ end
90
+ end
91
+
92
+ def add_options_section(help)
93
+ help.section('OPTIONS') do |s|
94
+ add_message_options(s)
95
+ add_formatting_options(s)
96
+ add_common_options(s)
97
+ end
98
+ end
99
+
100
+ def add_message_options(section)
101
+ section.option('-n, --limit N', 'Number of messages (default: 500, or 50 for message URLs)')
102
+ section.option('--threads', 'Show thread replies inline')
103
+ section.option('--workspace-emoji', 'Show workspace emoji as inline images (experimental)')
104
+ end
105
+
106
+ def add_formatting_options(section)
107
+ section.option('--no-emoji', 'Show :emoji: codes instead of unicode')
108
+ section.option('--no-reactions', 'Hide reactions')
109
+ section.option('--no-names', 'Skip user name lookups (faster)')
110
+ section.option('--reaction-names', 'Show reactions with user names')
111
+ section.option('--reaction-timestamps', 'Show when each person reacted')
112
+ section.option('--width N', 'Wrap text at N columns (default: 72 on TTY, no wrap otherwise)')
113
+ section.option('--no-wrap', 'Disable text wrapping')
114
+ end
115
+
116
+ def add_common_options(section)
117
+ section.option('--json', 'Output as JSON')
118
+ section.option('-w, --workspace', 'Specify workspace')
119
+ section.option('-v, --verbose', 'Show debug information')
120
+ section.option('-q, --quiet', 'Suppress output')
121
+ end
122
+
123
+ private
124
+
125
+ def target_resolver
126
+ @target_resolver ||= Services::TargetResolver.new(runner: runner, cache_store: cache_store)
127
+ end
128
+
129
+ def enrich_reactions(messages, workspace, channel_id)
130
+ enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
131
+ enricher.enrich_messages(messages, channel_id)
132
+ end
133
+
134
+ def output_json_messages(messages, workspace, channel_id)
135
+ format_options = {
136
+ no_names: @options[:no_names],
137
+ reaction_timestamps: @options[:reaction_timestamps],
138
+ channel_id: channel_id
139
+ }
140
+ output_json(messages.map do |m|
141
+ runner.message_formatter.format_json(m, workspace: workspace, options: format_options)
142
+ end)
143
+ end
144
+
145
+ # Apply default limit based on target type (50 for message URLs, 500 otherwise)
146
+ def apply_default_limit(msg_ts)
147
+ return if @options[:limit_set]
148
+
149
+ @options[:limit] = msg_ts ? 50 : 500
150
+ end
151
+
152
+ def fetch_messages(workspace, channel_id, thread_ts = nil, oldest: nil)
153
+ api = runner.conversations_api(workspace.name)
154
+ raw = if thread_ts
155
+ fetch_thread_messages(api, channel_id, thread_ts)
156
+ else
157
+ fetch_channel_history(api, channel_id, oldest)
158
+ end
159
+
160
+ raw.map { |m| Models::Message.from_api(m, channel_id: channel_id) }.reverse
161
+ end
162
+
163
+ def fetch_thread_messages(api, channel_id, thread_ts)
164
+ messages = fetch_all_thread_replies(api, channel_id, thread_ts)
165
+ apply_thread_limit(messages)
166
+ end
167
+
168
+ def apply_thread_limit(messages)
169
+ return messages unless @options[:limit].positive? && messages.length > @options[:limit]
170
+
171
+ [messages.first] + messages.last(@options[:limit] - 1)
172
+ end
173
+
174
+ def fetch_channel_history(api, channel_id, oldest)
175
+ oldest_adjusted = oldest ? adjust_timestamp(oldest, -0.000001) : nil
176
+ response = api.history(channel: channel_id, limit: @options[:limit], oldest: oldest_adjusted)
177
+ response['messages'] || []
178
+ end
179
+
180
+ # Adjust a Slack timestamp by a small amount while preserving precision
181
+ def adjust_timestamp(timestamp, delta)
182
+ require 'bigdecimal'
183
+ (BigDecimal(timestamp) + BigDecimal(delta.to_s)).to_s('F')
184
+ end
185
+
186
+ def fetch_all_thread_replies(api, channel_id, thread_ts)
187
+ all_messages = []
188
+ cursor = nil
189
+
190
+ loop do
191
+ response, cursor = fetch_thread_page(api, channel_id, thread_ts, cursor)
192
+ all_messages.concat(response)
193
+ break if cursor.nil? || cursor.empty?
194
+ end
195
+
196
+ deduplicate_and_sort(all_messages)
197
+ end
198
+
199
+ def fetch_thread_page(api, channel_id, thread_ts, cursor)
200
+ response = api.replies(channel: channel_id, timestamp: thread_ts, limit: 200, cursor: cursor)
201
+ messages = response['messages'] || []
202
+ debug("Fetched #{messages.length} messages")
203
+
204
+ next_cursor = response['has_more'] ? response.dig('response_metadata', 'next_cursor') : nil
205
+ [messages, next_cursor]
206
+ end
207
+
208
+ def deduplicate_and_sort(messages)
209
+ messages.uniq { |m| m['ts'] }.sort_by { |m| m['ts'].to_f }
210
+ end
211
+
212
+ def display_messages(messages, workspace, channel_id)
213
+ formatter = runner.message_formatter
214
+ opts = format_options.merge(channel_id: channel_id)
215
+
216
+ messages.each_with_index do |message, index|
217
+ display_single_message(formatter, message, workspace, opts)
218
+ puts if index < messages.length - 1
219
+
220
+ show_thread_replies(workspace, channel_id, message, opts) if should_show_thread?(message)
221
+ end
222
+ end
223
+
224
+ def should_show_thread?(message)
225
+ @options[:threads] && message.thread? && !message.reply?
226
+ end
227
+
228
+ def display_single_message(formatter, message, workspace, opts)
229
+ formatted = formatter.format(message, workspace: workspace, options: opts)
230
+ print_with_workspace_emoji(formatted, workspace)
231
+ end
232
+
233
+ def show_thread_replies(workspace, channel_id, parent_message, opts)
234
+ api = runner.conversations_api(workspace.name)
235
+ replies = fetch_all_thread_replies(api, channel_id, parent_message.ts)
236
+
237
+ replies[1..].each { |reply_data| display_thread_reply(reply_data, workspace, channel_id, opts) }
238
+ end
239
+
240
+ def display_thread_reply(reply_data, workspace, channel_id, opts)
241
+ reply = Models::Message.from_api(reply_data, channel_id: channel_id)
242
+ formatted = runner.message_formatter.format(reply, workspace: workspace, options: opts)
243
+
244
+ lines = formatted.lines
245
+ print_with_workspace_emoji(" └ #{lines.first}", workspace)
246
+ lines[1..].each { |line| print_with_workspace_emoji(" #{line}", workspace) }
247
+ end
248
+
249
+ # Print text, replacing workspace emoji codes with inline images when enabled
250
+ def print_with_workspace_emoji(text, workspace)
251
+ if @options[:workspace_emoji] && inline_images_supported?
252
+ print_line_with_emoji_images(text, workspace)
253
+ else
254
+ puts text
255
+ end
256
+ end
257
+
258
+ # Print a line, inserting inline images for workspace emoji
259
+ def print_line_with_emoji_images(text, workspace)
260
+ emoji_pattern = /:([a-zA-Z0-9_+-]+):/
261
+ parts = text.split(emoji_pattern)
262
+
263
+ parts.each_with_index { |part, i| print_emoji_part(part, i, workspace) }
264
+ puts
265
+ end
266
+
267
+ def print_emoji_part(part, index, workspace)
268
+ if index.odd?
269
+ print_emoji_or_code(part, workspace)
270
+ else
271
+ print part
272
+ end
273
+ end
274
+
275
+ def print_emoji_or_code(emoji_name, workspace)
276
+ emoji_path = find_workspace_emoji(workspace.name, emoji_name)
277
+ if emoji_path
278
+ print_inline_image(emoji_path, height: 1)
279
+ print ' ' unless in_tmux?
280
+ else
281
+ print ":#{emoji_name}:"
282
+ end
283
+ end
284
+
285
+ def find_workspace_emoji(workspace_name, emoji_name)
286
+ return nil if emoji_name.empty?
287
+
288
+ paths = Support::XdgPaths.new
289
+ emoji_dir = config.emoji_dir || paths.cache_dir
290
+ workspace_dir = File.join(emoji_dir, workspace_name)
291
+ return nil unless Dir.exist?(workspace_dir)
292
+
293
+ # Look for emoji file with any extension
294
+ Dir.glob(File.join(workspace_dir, "#{emoji_name}.*")).first
295
+ end
296
+ end
297
+ # rubocop:enable Metrics/ClassLength
298
+ end
299
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/help_formatter'
4
+
5
+ module Slk
6
+ module Commands
7
+ # Gets or sets user presence (away/active)
8
+ class Presence < Base
9
+ def execute
10
+ result = validate_options
11
+ return result if result
12
+
13
+ dispatch_action
14
+ rescue ApiError => e
15
+ error("Failed: #{e.message}")
16
+ 1
17
+ end
18
+
19
+ private
20
+
21
+ def dispatch_action
22
+ case positional_args
23
+ in ['away'] then set_presence('away')
24
+ in ['auto' | 'active'] then set_presence('auto')
25
+ in [] then get_presence
26
+ else unknown_presence
27
+ end
28
+ end
29
+
30
+ def unknown_presence
31
+ error("Unknown presence: #{positional_args.first}")
32
+ error('Valid options: away, auto, active')
33
+ 1
34
+ end
35
+
36
+ protected
37
+
38
+ def help_text
39
+ help = Support::HelpFormatter.new('slk presence [away|auto|active]')
40
+ help.description('Get or set your presence status.')
41
+ help.note('GET shows all workspaces by default. SET applies to primary only.')
42
+ add_actions_section(help)
43
+ add_options_section(help)
44
+ help.render
45
+ end
46
+
47
+ def add_actions_section(help)
48
+ help.section('ACTIONS') do |s|
49
+ s.action('(none)', 'Show current presence (all workspaces)')
50
+ s.action('away', 'Set presence to away')
51
+ s.action('auto', 'Set presence to auto (active)')
52
+ s.action('active', 'Alias for auto')
53
+ end
54
+ end
55
+
56
+ def add_options_section(help)
57
+ help.section('OPTIONS') do |s|
58
+ s.option('-w, --workspace', 'Limit to specific workspace')
59
+ s.option('--all', 'Set across all workspaces')
60
+ s.option('-q, --quiet', 'Suppress output')
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def get_presence # rubocop:disable Naming/AccessorMethodName
67
+ workspaces = @options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
68
+ workspaces.each { |workspace| display_workspace_presence(workspace, workspaces.size > 1) }
69
+ 0
70
+ end
71
+
72
+ def display_workspace_presence(workspace, show_header)
73
+ data = runner.users_api(workspace.name).get_presence
74
+ puts output.bold(workspace.name) if show_header
75
+ puts " Presence: #{format_presence_status(data[:presence], data[:manual_away])}"
76
+ end
77
+
78
+ def format_presence_status(presence, manual)
79
+ case [presence, manual]
80
+ in ['away', true] then output.yellow('away (manual)')
81
+ in ['away', _] then output.yellow('away')
82
+ in ['active', _] then output.green('active')
83
+ else presence
84
+ end
85
+ end
86
+
87
+ def set_presence(presence) # rubocop:disable Naming/AccessorMethodName
88
+ target_workspaces.each do |workspace|
89
+ runner.users_api(workspace.name).set_presence(presence)
90
+
91
+ status_text = presence == 'away' ? output.yellow('away') : output.green('active')
92
+ success("Presence set to #{status_text} on #{workspace.name}")
93
+ end
94
+
95
+ show_all_workspaces_hint
96
+
97
+ 0
98
+ end
99
+
100
+ def show_all_workspaces_hint
101
+ # Show hint if user has multiple workspaces and didn't use --all or -w
102
+ return if @options[:all] || @options[:workspace]
103
+ return if runner.all_workspaces.size <= 1
104
+
105
+ info('Tip: Use --all to set across all workspaces')
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/inline_images'
4
+ require_relative '../support/help_formatter'
5
+
6
+ module Slk
7
+ module Commands
8
+ # Manages and applies saved status presets
9
+ # rubocop:disable Metrics/ClassLength
10
+ class Preset < Base
11
+ include Support::InlineImages
12
+
13
+ def execute
14
+ result = validate_options
15
+ return result if result
16
+
17
+ dispatch_action
18
+ end
19
+
20
+ def dispatch_action
21
+ case positional_args
22
+ in ['list' | 'ls'] | [] then list_presets
23
+ in ['add'] then add_preset
24
+ in ['edit', name] then edit_preset(name)
25
+ in ['delete' | 'rm', name] then delete_preset(name)
26
+ in [name, *] then apply_preset(name)
27
+ end
28
+ end
29
+
30
+ protected
31
+
32
+ def help_text
33
+ help = Support::HelpFormatter.new('slk preset <action|name> [options]')
34
+ help.description('Manage and apply status presets.')
35
+ add_actions_section(help)
36
+ add_examples_section(help)
37
+ add_options_section(help)
38
+ help.render
39
+ end
40
+
41
+ def add_actions_section(help)
42
+ help.section('ACTIONS') do |s|
43
+ s.action('list', 'List all presets')
44
+ s.action('add', 'Add a new preset (interactive)')
45
+ s.action('edit <name>', 'Edit an existing preset')
46
+ s.action('delete <name>', 'Delete a preset')
47
+ s.action('<name>', 'Apply a preset')
48
+ end
49
+ end
50
+
51
+ def add_examples_section(help)
52
+ help.section('EXAMPLES') do |s|
53
+ s.example('slk preset list')
54
+ s.example('slk preset meeting')
55
+ s.example('slk preset add')
56
+ end
57
+ end
58
+
59
+ def add_options_section(help)
60
+ help.section('OPTIONS') do |s|
61
+ s.option('-w, --workspace', 'Specify workspace')
62
+ s.option('--all', 'Apply to all workspaces')
63
+ s.option('-q, --quiet', 'Suppress output')
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def list_presets
70
+ presets = preset_store.all
71
+ return show_no_presets if presets.empty?
72
+
73
+ puts 'Presets:'
74
+ presets.each { |preset| display_preset(preset) }
75
+ 0
76
+ end
77
+
78
+ def show_no_presets
79
+ puts 'No presets configured.'
80
+ 0
81
+ end
82
+
83
+ def display_preset(preset)
84
+ puts " #{output.bold(preset.name)}"
85
+ display_preset_status(preset) if preset_has_status?(preset)
86
+ display_preset_options(preset)
87
+ end
88
+
89
+ def preset_has_status?(preset)
90
+ !preset.text.empty? || !preset.emoji.empty?
91
+ end
92
+
93
+ def display_preset_options(preset)
94
+ puts " Duration: #{preset.duration}" unless preset.duration == '0'
95
+ puts " Presence: #{preset.presence}" if preset.sets_presence?
96
+ puts " DND: #{preset.dnd}" if preset.sets_dnd?
97
+ end
98
+
99
+ def display_preset_status(preset)
100
+ emoji_name = preset.emoji.delete_prefix(':').delete_suffix(':')
101
+ emoji_path = find_workspace_emoji_any(emoji_name)
102
+
103
+ if emoji_path && inline_images_supported?
104
+ text = " #{preset.text}"
105
+ print_inline_image_with_text(emoji_path, text)
106
+ else
107
+ puts " #{preset.emoji} #{preset.text}"
108
+ end
109
+ end
110
+
111
+ def find_workspace_emoji_any(emoji_name)
112
+ return nil if emoji_name.empty?
113
+
114
+ paths = Support::XdgPaths.new
115
+ emoji_dir = config.emoji_dir || paths.cache_dir
116
+
117
+ # Search across all workspaces
118
+ runner.all_workspaces.each do |workspace|
119
+ workspace_dir = File.join(emoji_dir, workspace.name)
120
+ next unless Dir.exist?(workspace_dir)
121
+
122
+ path = Dir.glob(File.join(workspace_dir, "#{emoji_name}.*")).first
123
+ return path if path
124
+ end
125
+
126
+ nil
127
+ end
128
+
129
+ def add_preset
130
+ print 'Preset name: '
131
+ name = $stdin.gets&.chomp
132
+ return error('Name is required') if name.nil? || name.empty?
133
+
134
+ preset = prompt_for_preset_fields(name)
135
+ preset_store.add(preset)
136
+ success("Preset '#{name}' created")
137
+
138
+ 0
139
+ end
140
+
141
+ def edit_preset(name)
142
+ existing = preset_store.get(name)
143
+ return error("Preset '#{name}' not found") unless existing
144
+
145
+ puts "Editing preset '#{name}' (press Enter to keep current value)"
146
+ preset = prompt_for_preset_fields(name, defaults: existing)
147
+ preset_store.add(preset)
148
+ success("Preset '#{name}' updated")
149
+
150
+ 0
151
+ end
152
+
153
+ def prompt_for_preset_fields(name, defaults: nil)
154
+ Models::Preset.new(
155
+ name: name,
156
+ text: prompt_field('Status text', defaults&.text),
157
+ emoji: prompt_field('Emoji (e.g., :calendar:)', defaults&.emoji),
158
+ duration: prompt_field('Duration (e.g., 1h, 30m, or 0 for none)', defaults&.duration || '0'),
159
+ presence: prompt_field('Presence (away/auto or blank)', defaults&.presence),
160
+ dnd: prompt_field('DND (e.g., 1h, off, or blank)', defaults&.dnd)
161
+ )
162
+ end
163
+
164
+ def prompt_field(label, default = nil)
165
+ if default
166
+ print "#{label} [#{default}]: "
167
+ input = $stdin.gets&.chomp
168
+ input.empty? ? default : input
169
+ else
170
+ print "#{label}: "
171
+ $stdin.gets&.chomp || ''
172
+ end
173
+ end
174
+
175
+ def delete_preset(name)
176
+ return error("Preset '#{name}' not found") unless preset_store.exists?(name)
177
+
178
+ preset_store.remove(name)
179
+ success("Preset '#{name}' deleted")
180
+
181
+ 0
182
+ end
183
+
184
+ def apply_preset(name)
185
+ preset = preset_store.get(name)
186
+ return error("Preset '#{name}' not found") unless preset
187
+
188
+ target_workspaces.each { |workspace| apply_preset_to_workspace(workspace, preset, name) }
189
+ 0
190
+ rescue ApiError => e
191
+ error("Failed to apply preset: #{e.message}")
192
+ 1
193
+ end
194
+
195
+ def apply_preset_to_workspace(workspace, preset, name)
196
+ apply_status(workspace, preset)
197
+ apply_presence(workspace, preset)
198
+ apply_dnd(workspace, preset)
199
+ success("Applied preset '#{name}' on #{workspace.name}")
200
+ end
201
+
202
+ def apply_status(workspace, preset)
203
+ users_api = runner.users_api(workspace.name)
204
+ if preset.clears_status?
205
+ users_api.clear_status
206
+ else
207
+ users_api.set_status(text: preset.text, emoji: preset.emoji, duration: preset.duration_value)
208
+ end
209
+ end
210
+
211
+ def apply_presence(workspace, preset)
212
+ return unless preset.sets_presence?
213
+
214
+ runner.users_api(workspace.name).set_presence(preset.presence)
215
+ end
216
+
217
+ def apply_dnd(workspace, preset)
218
+ return unless preset.sets_dnd?
219
+
220
+ dnd_api = runner.dnd_api(workspace.name)
221
+ if preset.dnd == 'off'
222
+ dnd_api.end_snooze
223
+ else
224
+ duration = Models::Duration.parse(preset.dnd)
225
+ dnd_api.set_snooze(duration)
226
+ end
227
+ end
228
+ end
229
+ # rubocop:enable Metrics/ClassLength
230
+ end
231
+ end