slk 0.4.2 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f36dfb6d59a99e348d4bd14d49eea6b3c77c9a7d077a59adc769529eee94a24
4
- data.tar.gz: f91ab312427b50df2b0938c8112d9057ab66b78f82eff31857a70b2e1b056a23
3
+ metadata.gz: 139ba582e24a6b92cd79493c0000a1479fbeb4ce59c90d883d0bbb603e8ff4c7
4
+ data.tar.gz: 81bdd74fc8a1d9b44480272098f78744297b3bd2b42b39dbc25c7f1fdb891c2f
5
5
  SHA512:
6
- metadata.gz: 7e8e1ce4a53ead893a1c2275cac500aea5e3b78b46caeee31d8343e33e3598df27a4518fcb1d1322a4780b3b17b8472b2fdd319d740f0d918282a133a010f9d7
7
- data.tar.gz: 93d461ceacd6924ecf0b2758609f492a8d16e0919dc8288babbe0717416722e230dfbd991c3e119850c528463831636154fe3259197d8af5b4671b2ac3f53bd8
6
+ metadata.gz: f79c404a79de6d32c77e85226b92b65322ff159ae610201d3514abc66fc9f8497a30c29da802b81e0ed2f36cdb3bc805835eee0e85f744f0f323d2620391c483
7
+ data.tar.gz: 6fff7a3b9b11762d179ccd2d22e5a0e4a6e395f52bd4fe89810cb045fed144c989026c07928179efdcd35e8ac3c3c991dab8f90344042cd0475adc8fc1007d7e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-04-27
11
+
12
+ ### Added
13
+
14
+ - **`slk who [target]`** — compact teems-style profile card for self or any user
15
+ - Targets: positional arg accepts `Uxxx`, display name, real name, or email; defaults to self
16
+ - `--full` expands into Contact / People / About me sections matching the Slack web profile
17
+ - `--json` emits the full Profile struct for piping
18
+ - `--refresh` bypasses both the per-run memo and the on-disk meta cache
19
+ - `--all` prints every match in turn; `--pick N` picks the Nth match non-interactively
20
+ - Multi-match disambiguation: prints a numbered list on stderr and prompts on a TTY; non-TTY contexts raise instead of silently picking
21
+ - Renders Slack Connect external users with a stripped layout (`external — <home workspace>`)
22
+ - Marks deactivated accounts with a bold `deactivated account` tag (and `deactivated: true` in JSON)
23
+ - Type-aware custom field rendering: `link` fields use OSC 8 hyperlinks with their `alt` label, `date` fields show "Jun 17, 2024 (1y 10mo ago)", `user` fields resolve one level (name + pronouns + title)
24
+ - **`slk org [target]`** — walks the supervisor chain upward from the target
25
+ - `--depth N` caps traversal (default 5); cycle-safe via seen-set
26
+ - Indented tree with `└─ ├─ │` glyphs; `← you` marker on whichever node is the authenticated user
27
+ - New `Api::Team` wrapper for `team.info` and `team.profile.get`, plus `Api::Users#profile_for(user_id, include_labels:)`
28
+ - `Services::ProfileResolver`, `ProfileBuilder`, `UserMatcher`, `UserPicker`, `WhoTargetResolver`, `MetaCache` services backing the new commands
29
+ - Hidden `slk debug profile <uid>` subcommand for inspecting raw profile/info/schema responses
30
+
31
+ ### Changed
32
+
33
+ - `ApiError` now carries a typed `code` symbol (`:user_not_found`, `:ratelimited`, `:network_error`, `:unauthorized`, `:http_error`, `:invalid_json`, `:missing_scope`); `ApiClient` populates it on every raise
34
+ - `ProfileResolver` only swallows `:user_not_found` from `users.profile.get` (Slack Connect fallback to `users.info`); other API errors propagate so callers can surface them
35
+ - CI matrix now uses `bundler-cache` and runs `bundle exec rake test`; new `coverage` job enforces a 95/95 line/branch SimpleCov threshold
36
+ - Pinned `parallel < 2.0` in dev/test bundle to keep Ruby 3.2 compatible with current rubocop
37
+
38
+ ### Fixed
39
+
40
+ - `slk org` no longer mislabels the wrong node with `← you` when invoked against a teammate — the marker now compares each node against the authenticated user id
41
+
42
+ ## [0.5.0] - 2026-04-13
43
+
44
+ ### Added
45
+
46
+ - **`--fetch-attachments` flag** - Download message files and attachment images to local cache
47
+ - Downloads Slack files (authed) and public attachment images (Giphy, Tenor, etc.)
48
+ - Cached to `~/.cache/slk/files/{workspace}/` with skip-on-rerun
49
+ - Shows copyable local file paths in output: `[File: /path/to/file.png]`
50
+ - Works with `messages`, `thread`, and `--threads` inline replies
51
+ - Summary line when files are present: `9 files not downloaded. Use --fetch-attachments to download.`
52
+ - Follows up to 3 redirect hops with relative URL resolution
53
+
54
+ ### Changed
55
+
56
+ - Added `rubocop` as a dev dependency for linting
57
+
58
+ ### Fixed
59
+
60
+ - **`thread` command** - Extracted `resolve_and_display_thread` to fix rubocop complexity warnings
61
+
10
62
  ## [0.4.2] - 2026-03-01
11
63
 
12
64
  ### Added
@@ -131,6 +183,7 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
131
183
  - Pure Ruby stdlib - no gem dependencies
132
184
  - Ruby 3.2+ with modern features (Data.define, pattern matching)
133
185
 
186
+ [0.5.0]: https://github.com/ericboehs/slk/releases/tag/v0.5.0
134
187
  [0.4.2]: https://github.com/ericboehs/slk/releases/tag/v0.4.2
135
188
  [0.4.0]: https://github.com/ericboehs/slk/releases/tag/v0.4.0
136
189
  [0.3.0]: https://github.com/ericboehs/slk/releases/tag/v0.3.0
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Api
5
+ # Wrapper for Slack team.* API endpoints
6
+ class Team
7
+ def initialize(api_client, workspace)
8
+ @api = api_client
9
+ @workspace = workspace
10
+ end
11
+
12
+ def info(team_id = nil)
13
+ params = team_id ? { team: team_id } : {}
14
+ @api.post_form(@workspace, 'team.info', params)
15
+ end
16
+
17
+ def profile_schema(visibility: nil)
18
+ params = {}
19
+ params[:visibility] = visibility if visibility
20
+ @api.post_form(@workspace, 'team.profile.get', params)
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/slk/api/users.rb CHANGED
@@ -49,6 +49,10 @@ module Slk
49
49
  }
50
50
  end
51
51
 
52
+ def get_presence_for(user_id)
53
+ @api.post_form(@workspace, 'users.getPresence', { user: user_id })
54
+ end
55
+
52
56
  def set_presence(presence) # rubocop:disable Naming/AccessorMethodName
53
57
  @api.post(@workspace, 'users.setPresence', { presence: presence })
54
58
  end
@@ -63,6 +67,12 @@ module Slk
63
67
  @api.post_form(@workspace, 'users.info', { user: user_id })
64
68
  end
65
69
 
70
+ def profile_for(user_id, include_labels: true)
71
+ params = { user: user_id }
72
+ params[:include_labels] = true if include_labels
73
+ @api.post_form(@workspace, 'users.profile.get', params)
74
+ end
75
+
66
76
  def get_prefs # rubocop:disable Naming/AccessorMethodName
67
77
  @api.post(@workspace, 'users.prefs.get')
68
78
  end
data/lib/slk/cli.rb CHANGED
@@ -20,7 +20,10 @@ module Slk
20
20
  'cache' => Commands::Cache,
21
21
  'emoji' => Commands::Emoji,
22
22
  'config' => Commands::Config,
23
- 'help' => Commands::Help
23
+ 'help' => Commands::Help,
24
+ 'debug' => Commands::Debug,
25
+ 'who' => Commands::Who,
26
+ 'org' => Commands::Org
24
27
  }.freeze
25
28
 
26
29
  def initialize(argv, output: nil)
@@ -140,6 +143,11 @@ module Slk
140
143
  runner.api_client.on_request = lambda { |method, count|
141
144
  output.debug("[API ##{count}] #{method}")
142
145
  }
146
+ runner.api_client.on_response = lambda { |method, code, headers|
147
+ next unless code == 'rate-wait'
148
+
149
+ output.warn("Rate limited on #{method}; sleeping #{headers['sleep_seconds']}s and retrying...")
150
+ }
143
151
  end
144
152
 
145
153
  def setup_very_verbose_logging(runner, output)
@@ -32,7 +32,8 @@ module Slk
32
32
  end
33
33
 
34
34
  def base_options
35
- { workspace: nil, all: false, verbose: false, quiet: false, json: false, markdown: false, width: default_width }
35
+ { workspace: nil, all: false, verbose: false, quiet: false, json: false, markdown: false,
36
+ width: default_width, fetch_attachments: false }
36
37
  end
37
38
 
38
39
  def formatting_options
@@ -79,6 +80,7 @@ module Slk
79
80
  when '--no-names' then @options[:no_names] = true
80
81
  when '--reaction-names' then @options[:reaction_names] = true
81
82
  when '--reaction-timestamps' then @options[:reaction_timestamps] = true
83
+ when '--fetch-attachments' then @options[:fetch_attachments] = true
82
84
  else handle_option(arg, args, remaining)
83
85
  end
84
86
  end
@@ -194,7 +196,8 @@ module Slk
194
196
  no_names: @options[:no_names],
195
197
  reaction_names: @options[:reaction_names],
196
198
  reaction_timestamps: @options[:reaction_timestamps],
197
- width: @options[:width]
199
+ width: @options[:width],
200
+ fetch_attachments: @options[:fetch_attachments]
198
201
  }
199
202
  end
200
203
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Commands
5
+ # Hidden command for development spikes.
6
+ # Dumps raw JSON from Slack endpoints to validate xoxc compatibility
7
+ # and field shapes before building higher-level features.
8
+ #
9
+ # Not registered in `slk help`.
10
+ class Debug < Base
11
+ def execute
12
+ result = validate_options
13
+ return result if result
14
+
15
+ dispatch_action
16
+ rescue ApiError => e
17
+ error("API error: #{e.message}")
18
+ 1
19
+ end
20
+
21
+ def dispatch_action
22
+ case positional_args
23
+ in ['profile', user] then dump_profile(user)
24
+ in ['profile'] then dump_profile(nil)
25
+ in ['team'] then dump_team
26
+ in ['schema'] then dump_schema
27
+ else unknown_action
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def dump_profile(user_input)
34
+ workspace = runner.workspace(@options[:workspace])
35
+ user_id = resolve_user_id(workspace, user_input)
36
+ users_api = runner.users_api(workspace.name)
37
+ out = {
38
+ 'users.profile.get' => users_api.profile_for(user_id),
39
+ 'users.info' => users_api.info(user_id),
40
+ 'team.profile.get' => runner.team_api(workspace.name).profile_schema
41
+ }
42
+ output.puts(JSON.pretty_generate(out))
43
+ 0
44
+ end
45
+
46
+ def dump_team
47
+ workspace = runner.workspace(@options[:workspace])
48
+ output.puts(JSON.pretty_generate(runner.team_api(workspace.name).info))
49
+ 0
50
+ end
51
+
52
+ def dump_schema
53
+ workspace = runner.workspace(@options[:workspace])
54
+ output.puts(JSON.pretty_generate(runner.team_api(workspace.name).profile_schema))
55
+ 0
56
+ end
57
+
58
+ def resolve_user_id(workspace, user_input)
59
+ return self_user_id(workspace) if user_input.nil? || user_input == 'me'
60
+ return user_input if user_input.match?(/\A[UW][A-Z0-9]+\z/)
61
+
62
+ id = lookup_for(workspace).find_id_by_name(user_input.delete_prefix('@'))
63
+ raise ApiError, "Could not resolve user: #{user_input}" unless id
64
+
65
+ id
66
+ end
67
+
68
+ def lookup_for(workspace)
69
+ Services::UserLookup.new(
70
+ cache_store: cache_store,
71
+ workspace: workspace,
72
+ api_client: api_client,
73
+ on_debug: ->(msg) { output.debug(msg) }
74
+ )
75
+ end
76
+
77
+ def self_user_id(workspace)
78
+ client = Api::Client.new(api_client, workspace)
79
+ response = client.auth_test
80
+ response['user_id']
81
+ end
82
+
83
+ def unknown_action
84
+ error("Unknown debug action: #{positional_args.first.inspect}")
85
+ error('Valid actions: profile [user], team, schema')
86
+ 1
87
+ end
88
+
89
+ protected
90
+
91
+ def help_text
92
+ <<~HELP
93
+ slk debug <action> [args]
94
+
95
+ Hidden development command — dumps raw API responses for spike validation.
96
+
97
+ ACTIONS
98
+ profile [user] Dump users.profile.get + users.info + team.profile.get
99
+ team Dump team.info
100
+ schema Dump team.profile.get
101
+
102
+ USER
103
+ (none) | me Self
104
+ @handle | name Resolved via user cache
105
+ Uxxx Raw user ID
106
+ HELP
107
+ end
108
+ end
109
+ end
110
+ end
@@ -124,6 +124,7 @@ module Slk
124
124
  section.option('--no-names', 'Skip user name lookups (faster)')
125
125
  section.option('--reaction-names', 'Show reactions with user names')
126
126
  section.option('--reaction-timestamps', 'Show when each person reacted')
127
+ section.option('--fetch-attachments', 'Download files/images to local cache (~/.cache/slk/files/)')
127
128
  section.option('--width N', 'Wrap text at N columns (default: 72 on TTY, no wrap otherwise)')
128
129
  section.option('--no-wrap', 'Disable text wrapping')
129
130
  end
@@ -258,15 +259,22 @@ module Slk
258
259
  end
259
260
 
260
261
  def display_messages(messages, workspace, channel_id)
261
- formatter = runner.message_formatter
262
- opts = format_options.merge(channel_id: channel_id)
262
+ opts = build_display_options(messages, workspace, channel_id)
263
263
 
264
264
  messages.each_with_index do |message, index|
265
- display_single_message(formatter, message, workspace, opts)
265
+ display_single_message(runner.message_formatter, message, workspace, opts)
266
266
  puts if index < messages.length - 1
267
267
 
268
268
  show_thread_replies(workspace, channel_id, message, opts) if should_show_thread?(message)
269
269
  end
270
+
271
+ print_file_summary(messages) unless opts[:fetch_attachments]
272
+ end
273
+
274
+ def build_display_options(messages, workspace, channel_id)
275
+ opts = format_options.merge(channel_id: channel_id)
276
+ opts[:file_paths] = fetch_attachment_files(messages, workspace) if opts[:fetch_attachments]
277
+ opts
270
278
  end
271
279
 
272
280
  def should_show_thread?(message)
@@ -281,12 +289,20 @@ module Slk
281
289
  def show_thread_replies(workspace, channel_id, parent_message, opts)
282
290
  api = runner.conversations_api(workspace.name)
283
291
  replies = fetch_all_thread_replies(api, channel_id, parent_message.ts)
292
+ reply_messages = replies[1..].map { |r| Models::Message.from_api(r, channel_id: channel_id) }
284
293
 
285
- replies[1..].each { |reply_data| display_thread_reply(reply_data, workspace, channel_id, opts) }
294
+ download_reply_files(reply_messages, workspace, opts)
295
+ reply_messages.each { |reply| display_thread_reply_message(reply, workspace, opts) }
286
296
  end
287
297
 
288
- def display_thread_reply(reply_data, workspace, channel_id, opts)
289
- reply = Models::Message.from_api(reply_data, channel_id: channel_id)
298
+ def download_reply_files(replies, workspace, opts)
299
+ return unless opts[:fetch_attachments]
300
+
301
+ new_paths = fetch_attachment_files(replies, workspace)
302
+ opts[:file_paths].merge!(new_paths)
303
+ end
304
+
305
+ def display_thread_reply_message(reply, workspace, opts)
290
306
  formatted = runner.message_formatter.format(reply, workspace: workspace, options: opts)
291
307
 
292
308
  lines = formatted.lines
@@ -331,6 +347,28 @@ module Slk
331
347
  end
332
348
  end
333
349
 
350
+ def print_file_summary(messages)
351
+ file_count = messages.sum { |m| m.files.size + downloadable_attachment_count(m) }
352
+ return if file_count.zero?
353
+
354
+ label = file_count == 1 ? '1 file' : "#{file_count} files"
355
+ puts
356
+ info("#{label} not downloaded. Use --fetch-attachments to download.")
357
+ end
358
+
359
+ def downloadable_attachment_count(message)
360
+ message.attachments.count { |a| a['image_url'] || a['thumb_url'] }
361
+ end
362
+
363
+ def fetch_attachment_files(messages, workspace)
364
+ paths = Support::XdgPaths.new
365
+ downloader = Services::FileDownloader.new(
366
+ cache_dir: paths.cache_dir,
367
+ on_debug: ->(msg) { debug(msg) }
368
+ )
369
+ downloader.download_message_files(messages, workspace)
370
+ end
371
+
334
372
  def find_workspace_emoji(workspace_name, emoji_name)
335
373
  return nil if emoji_name.empty?
336
374
 
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Commands
5
+ # Walk the Slack org chart by following Supervisor custom profile fields.
6
+ # Examples:
7
+ # slk org # self, supervisors up to depth 3
8
+ # slk org @alex # alex's chain
9
+ # slk org Uxxx --depth 5
10
+ # slk org --down # reports (best-effort, requires reindex for completeness)
11
+ class Org < Base
12
+ DEFAULT_DEPTH = 5
13
+
14
+ def execute
15
+ result = validate_options
16
+ return result if result
17
+
18
+ run
19
+ rescue ApiError => e
20
+ error("API error: #{e.message}")
21
+ 1
22
+ end
23
+
24
+ protected
25
+
26
+ def handle_option(arg, args, _remaining)
27
+ case arg
28
+ when '--up' then @options[:direction] = :up
29
+ when '--down' then @options[:direction] = :down
30
+ when '--depth' then @options[:depth] = args.shift.to_i
31
+ else return super
32
+ end
33
+ true
34
+ end
35
+
36
+ def help_text
37
+ <<~HELP
38
+ slk org [target]
39
+
40
+ Walk the Slack org chart by Supervisor field.
41
+
42
+ OPTIONS
43
+ --up Walk supervisors upward (default)
44
+ --down Show direct reports (best-effort, see note)
45
+ --depth N Levels to walk (default #{DEFAULT_DEPTH})
46
+
47
+ NOTES
48
+ --down currently scans cached profiles only. Run `slk who` on a
49
+ user to seed their profile in cache; full crawl support is TODO.
50
+ HELP
51
+ end
52
+
53
+ private
54
+
55
+ def run
56
+ workspace = runner.workspace(@options[:workspace])
57
+ resolver = runner.profile_resolver(workspace.name, refresh: @options[:refresh])
58
+ user_id = resolve_user_id(workspace)
59
+ target = resolver.resolve(user_id)
60
+ @self_user_id = self_user_id(workspace)
61
+
62
+ case @options[:direction] || :up
63
+ when :up then render_up(resolver, target)
64
+ when :down then render_down(target)
65
+ end
66
+ 0
67
+ end
68
+
69
+ def render_up(resolver, target)
70
+ chain = resolver.resolve_chain_up(target.user_id, depth: @options[:depth] || DEFAULT_DEPTH)
71
+ if chain.empty?
72
+ info("No supervisor on #{target.best_name}'s profile.")
73
+ return
74
+ end
75
+
76
+ chain.reverse_each.with_index do |profile, depth|
77
+ render_node(profile, depth, you: profile.user_id == @self_user_id)
78
+ end
79
+ render_node(target, chain.size, you: target.user_id == @self_user_id)
80
+ end
81
+
82
+ def render_down(target)
83
+ warn('slk org --down is best-effort against cached profiles only.')
84
+ warn('Run `slk who <user>` to seed their profile, or wait for `slk org reindex` (TODO).')
85
+ info("Direct reports for #{target.best_name}: lookup not yet wired (Phase 4).")
86
+ end
87
+
88
+ def render_node(profile, depth, you: false)
89
+ prefix = depth.zero? ? '' : "#{' ' * depth}└─ "
90
+ marker = you ? output.bold(' ← you') : ''
91
+ title = profile.title.to_s.empty? ? '' : " — #{output.gray(profile.title)}"
92
+ output.puts("#{prefix}#{profile.best_name}#{title}#{marker}")
93
+ end
94
+
95
+ def resolve_user_id(workspace)
96
+ target = positional_args.first
97
+ return self_user_id(workspace) if target.nil? || target == 'me'
98
+ return target if target.match?(/\A[UW][A-Z0-9]+\z/)
99
+
100
+ Services::UserLookup.new(
101
+ cache_store: cache_store,
102
+ workspace: workspace,
103
+ api_client: api_client,
104
+ on_debug: ->(msg) { output.debug(msg) }
105
+ ).find_id_by_name(target.delete_prefix('@')) ||
106
+ (raise ApiError, "Could not resolve user: #{target}")
107
+ end
108
+
109
+ def self_user_id(workspace)
110
+ cached = cache_store.get_meta(workspace.name, 'self_user_id')
111
+ return cached if cached
112
+
113
+ user_id = Api::Client.new(api_client, workspace).auth_test['user_id']
114
+ cache_store.set_meta(workspace.name, 'self_user_id', user_id) if user_id
115
+ user_id
116
+ end
117
+ end
118
+ end
119
+ end
@@ -10,6 +10,16 @@ module Slk
10
10
  result = validate_options
11
11
  return result if result
12
12
 
13
+ resolve_and_display_thread
14
+ rescue ApiError => e
15
+ error("Failed to fetch messages: #{e.message}")
16
+ 1
17
+ rescue ArgumentError => e
18
+ error(e.message)
19
+ 1
20
+ end
21
+
22
+ def resolve_and_display_thread
13
23
  target = positional_args.first
14
24
  return usage_error unless target
15
25
 
@@ -18,12 +28,6 @@ module Slk
18
28
 
19
29
  resolved = target_resolver.resolve(target, default_workspace: target_workspaces.first)
20
30
  fetch_and_display_messages(resolved)
21
- rescue ApiError => e
22
- error("Failed to fetch messages: #{e.message}")
23
- 1
24
- rescue ArgumentError => e
25
- error(e.message)
26
- 1
27
31
  end
28
32
 
29
33
  def fetch_and_display_messages(resolved)
@@ -75,6 +79,7 @@ module Slk
75
79
  s.option('--no-emoji', 'Show :emoji: codes instead of unicode')
76
80
  s.option('--no-reactions', 'Hide reactions')
77
81
  s.option('--no-names', 'Skip user name lookups (faster)')
82
+ s.option('--fetch-attachments', 'Download files/images to local cache (~/.cache/slk/files/)')
78
83
  s.option('--json', 'Output as JSON')
79
84
  s.option('-v, --verbose', 'Show debug information')
80
85
  end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Commands
5
+ # Display a Slack user profile (teems-style compact card by default).
6
+ # Examples:
7
+ # slk who # self
8
+ # slk who @alex
9
+ # slk who alice
10
+ # slk who Uxxx --full
11
+ # slk who --json
12
+ class Who < Base
13
+ def execute
14
+ result = validate_options
15
+ return result if result
16
+
17
+ run
18
+ rescue ApiError => e
19
+ error("API error: #{e.message}")
20
+ 1
21
+ end
22
+
23
+ protected
24
+
25
+ def handle_option(arg, args, _remaining)
26
+ case arg
27
+ when '--full' then @options[:full] = true
28
+ when '--no-cache', '--refresh' then @options[:refresh] = true
29
+ when '--all' then @options[:all] = true
30
+ when '--pick' then @options[:pick] = parse_pick(args.shift)
31
+ else return super
32
+ end
33
+ true
34
+ end
35
+
36
+ def help_text
37
+ <<~HELP
38
+ slk who [target]
39
+
40
+ Show a Slack user profile.
41
+
42
+ TARGET
43
+ (none) Self
44
+ @handle | name Resolved via user cache
45
+ Uxxx Raw user ID
46
+
47
+ OPTIONS
48
+ --full Section-grouped layout (Contact, People, About me)
49
+ --json Raw JSON output
50
+ --refresh Bypass cache
51
+ --all Render every match (skips the disambiguation prompt)
52
+ --pick N Auto-select match N when a name resolves to multiple
53
+ HELP
54
+ end
55
+
56
+ private
57
+
58
+ def parse_pick(value)
59
+ Integer(value)
60
+ rescue ArgumentError, TypeError
61
+ raise ApiError, "--pick expects an integer (got #{value.inspect})"
62
+ end
63
+
64
+ def run
65
+ workspace = runner.workspace(@options[:workspace])
66
+ profiles = resolve_profiles(workspace)
67
+ return output_json_profiles(profiles) if @options[:json]
68
+
69
+ render_profiles(profiles)
70
+ 0
71
+ end
72
+
73
+ def resolve_profiles(workspace)
74
+ resolver = Services::WhoTargetResolver.new(
75
+ workspace: workspace, cache_store: cache_store,
76
+ api_client: api_client, output: output, options: @options
77
+ )
78
+ resolver.resolve(positional_args.first).map { |id| load_profile(workspace, id) }
79
+ end
80
+
81
+ def load_profile(workspace, user_id)
82
+ runner.profile_resolver(workspace.name, refresh: @options[:refresh])
83
+ .resolve_with_people(user_id)
84
+ end
85
+
86
+ def render_profiles(profiles)
87
+ profiles.each_with_index do |profile, idx|
88
+ output.puts(output.gray('—' * 40)) if idx.positive?
89
+ render(profile)
90
+ end
91
+ end
92
+
93
+ def render(profile)
94
+ formatter = Formatters::ProfileFormatter.new(
95
+ output: output, emoji_replacer: runner.emoji_replacer
96
+ )
97
+ @options[:full] ? formatter.full(profile) : formatter.compact(profile)
98
+ end
99
+
100
+ def output_json_profiles(profiles)
101
+ payload = profiles.map { |p| profile_payload(p) }
102
+ output_json(profiles.size == 1 ? payload.first : payload)
103
+ 0
104
+ end
105
+
106
+ def profile_payload(profile)
107
+ profile.to_h.merge(
108
+ custom_fields: profile.visible_fields.map(&:to_h),
109
+ sections: profile.sections,
110
+ resolved_users: profile.resolved_users.transform_values(&:to_h)
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end