slk 0.5.0 → 0.7.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: 54edc7e40c9bcf891e2d2727c4cf50c4f43de73a70051465da12f3a97bc5282b
4
- data.tar.gz: 40bbb5862b982c4e0d87d2a47c18b91a2f0070cac9fe7ad15a68e109114bf977
3
+ metadata.gz: 895e4fdd405a7b4f560424a93d4906ea7330088763a5559b2d581788908794b3
4
+ data.tar.gz: 6f8c66d9cd6555153db454d99d7cafdf4e82bbaac2f6349edee1427047e31104
5
5
  SHA512:
6
- metadata.gz: 96abfcac2a271dccbce0a7b8068d23cf70d62ff68466e2a915fd67474aa9a3b6f648dce14ebaa87718c069993886a309c86490ab865a060586789bf8f8775752
7
- data.tar.gz: ece446478a9dfda1dec3fa5dad6feff79c5f7be24e89f9835d61a0f3684e3d7839a94c673859ac5678a1f0f0c8675d31a9673493e744efcdd67433acda1f8fda
6
+ metadata.gz: 7394318fac53168225d5ff30a249612a0f756fe60eaca67141ca937d43f8c70aaa7a1066681bb35a97fc49f242254d87438f369b02700193cc86456ded97ca9c
7
+ data.tar.gz: ea890ffc51e6540c6a19db3481d92b8f0263c042594f74c4ec2ac3f975c7f988d345f69e0a6b37345993b499535180b87456cd189dffa9506b2c60148f2b9481
data/CHANGELOG.md CHANGED
@@ -5,7 +5,86 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.7.0] - 2026-08-03
9
+
10
+ ### Added
11
+
12
+ - **`slk status schedule|scheduled|unschedule`** — queue statuses to turn on later
13
+ - `slk status schedule "<text>" [:emoji:] <start-end>` schedules a status; emoji defaults to `:speech_balloon:`
14
+ - Time windows accept bare 12- or 24-hour times (`1:30p-3:30p`, `13:30-15:30`) or an explicit `YYYY-MM-DD` date (`2026-08-04 9:00-17:00`)
15
+ - Bare times resolve forward: a window at or before now rolls to tomorrow, and an end before the start crosses midnight (`11p-1a`)
16
+ - A single am/pm carries across the range, so `1-3p` is 1pm to 3pm — unless that would invert it, leaving `9-5p` as 9am to 5pm
17
+ - Ambiguous or impossible windows are rejected rather than guessed: `9-5` and `9a-5` (both read as crossing midnight and spanning 20 hours), `1p-1p`, a time DST skips, and unrecognized date forms such as `8/4` or `tomorrow`
18
+ - The check applies only to a reading that had to be guessed, and only past 12 hours, so windows that say what they mean still work: `11p-1a`, `8p-9a`, `20:00-09:00`, `20:00-6`, and `9p-5`
19
+ - `--start WHEN` / `--end WHEN` take `[YYYY-MM-DD ]TIME` each, for windows the single-date range cannot express: `--start "2026-08-12 8a" --end "2026-08-14 5p"`. Omitting `--end` schedules a status with no expiry
20
+ - `--with-dnd` also pauses notifications while the status is active
21
+ - `slk status scheduled` lists pending statuses with their IDs across every workspace; `slk status unschedule <id>` looks up which workspace owns the ID rather than assuming the primary one (`-w`/`--all` still override)
22
+ - `slk status scheduled` marks the one Slack reports as currently applied with `[active]`
23
+ - Backed by Slack's internal `users.customStatus.*` endpoints, which require form-encoded bodies and only return the scheduled section when `statuses_count_per_section` is sent. Responses are checked rather than trusted: an absent `scheduled_statuses` section, a create that echoes nothing back, a status payload with no id, and a delete the following list still shows all raise instead of reporting success
24
+
25
+ ### Changed
26
+
27
+ - New `Slk::UsageError` (bad invocation) and `Slk::TimeFormatError` (unparseable time) error types. `slk` prints them without an error-type label, and callers can rescue malformed input without also swallowing arity or range errors from their own code
28
+ - Flags that take a value now reject a missing one or a following flag instead of shifting `nil`. This covers `--start`, `--end`, `-p` and `-d` on `status`, and `-w`/`--workspace` everywhere — a trailing `slk status -w` previously applied to *every* workspace and exited 0
29
+
30
+ ### Fixed
31
+
32
+ - `slk status unschedule` no longer reports a successful cancel as a failure when the confirming re-read fails (a network error, a rate limit, or Slack dropping the `scheduled_statuses` section — which is exactly what happens when you cancel your only scheduled status). The cancel is reported with an explicit "could not confirm" caveat and exit 0; only a status still demonstrably present is an error
33
+ - `slk status schedule` again reports an unusable Slack response as "check the status picker to see whether it was created" rather than the less actionable "returned a scheduled status with no id"
34
+ - `Slk::UsageError` no longer files a backtrace in `~/.cache/slk/error.log`; a mistyped flag is not a fault to investigate later
35
+ - SSH key validation no longer hangs on Windows when the private key is passphrase-protected. `ssh-keygen` prompts on the console rather than on stdin, so the prompt could not be answered or dismissed; it is now given an empty passphrase up front and reports the unsupported key instead of waiting
36
+
37
+ ## [0.6.0] - 2026-04-27
38
+
39
+ ### Added
40
+
41
+ - **`slk who [target]`** — compact teems-style profile card for self or any user
42
+ - Targets: positional arg accepts `Uxxx`, display name, real name, or email; defaults to self
43
+ - `--full` expands into Contact / People / About me sections matching the Slack web profile
44
+ - `--json` emits the full Profile struct for piping
45
+ - `--refresh` bypasses both the per-run memo and the on-disk meta cache
46
+ - `--all` prints every match in turn; `--pick N` picks the Nth match non-interactively
47
+ - Multi-match disambiguation: prints a numbered list on stderr and prompts on a TTY; non-TTY contexts raise instead of silently picking
48
+ - Renders Slack Connect external users with a stripped layout (`external — <home workspace>`)
49
+ - Marks deactivated accounts with a bold `deactivated account` tag (and `deactivated: true` in JSON)
50
+ - 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)
51
+ - **`slk org [target]`** — walks the supervisor chain upward from the target
52
+ - `--depth N` caps traversal (default 5); cycle-safe via seen-set
53
+ - Indented tree with `└─ ├─ │` glyphs; `← you` marker on whichever node is the authenticated user
54
+ - New `Api::Team` wrapper for `team.info` and `team.profile.get`, plus `Api::Users#profile_for(user_id, include_labels:)`
55
+ - `Services::ProfileResolver`, `ProfileBuilder`, `UserMatcher`, `UserPicker`, `WhoTargetResolver`, `MetaCache` services backing the new commands
56
+ - Hidden `slk debug profile <uid>` subcommand for inspecting raw profile/info/schema responses
57
+
58
+ ### Changed
59
+
60
+ - `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
61
+ - `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
62
+ - CI matrix now uses `bundler-cache` and runs `bundle exec rake test`; new `coverage` job enforces a 95/95 line/branch SimpleCov threshold
63
+ - Pinned `parallel < 2.0` in dev/test bundle to keep Ruby 3.2 compatible with current rubocop
64
+
65
+ ### Fixed
66
+
67
+ - `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
68
+
69
+ ## [0.5.0] - 2026-04-13
70
+
71
+ ### Added
72
+
73
+ - **`--fetch-attachments` flag** - Download message files and attachment images to local cache
74
+ - Downloads Slack files (authed) and public attachment images (Giphy, Tenor, etc.)
75
+ - Cached to `~/.cache/slk/files/{workspace}/` with skip-on-rerun
76
+ - Shows copyable local file paths in output: `[File: /path/to/file.png]`
77
+ - Works with `messages`, `thread`, and `--threads` inline replies
78
+ - Summary line when files are present: `9 files not downloaded. Use --fetch-attachments to download.`
79
+ - Follows up to 3 redirect hops with relative URL resolution
80
+
81
+ ### Changed
82
+
83
+ - Added `rubocop` as a dev dependency for linting
84
+
85
+ ### Fixed
86
+
87
+ - **`thread` command** - Extracted `resolve_and_display_thread` to fix rubocop complexity warnings
9
88
 
10
89
  ## [0.4.2] - 2026-03-01
11
90
 
@@ -131,6 +210,9 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
131
210
  - Pure Ruby stdlib - no gem dependencies
132
211
  - Ruby 3.2+ with modern features (Data.define, pattern matching)
133
212
 
213
+ [0.7.0]: https://github.com/ericboehs/slk/releases/tag/v0.7.0
214
+ [0.6.0]: https://github.com/ericboehs/slk/releases/tag/v0.6.0
215
+ [0.5.0]: https://github.com/ericboehs/slk/releases/tag/v0.5.0
134
216
  [0.4.2]: https://github.com/ericboehs/slk/releases/tag/v0.4.2
135
217
  [0.4.0]: https://github.com/ericboehs/slk/releases/tag/v0.4.0
136
218
  [0.3.0]: https://github.com/ericboehs/slk/releases/tag/v0.3.0
data/README.md CHANGED
@@ -52,6 +52,37 @@ slk status "In a meeting" :calendar: 1h # Set status for 1 hour
52
52
  slk status clear # Clear status
53
53
  ```
54
54
 
55
+ Statuses can also be scheduled to turn on later (Slack allows up to 5 at a time):
56
+
57
+ ```bash
58
+ slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p # Bare times; rolls to tomorrow if past
59
+ slk status schedule "OOO" :palm_tree: 2026-08-04 9:00-17:00 # Explicit date
60
+ slk status schedule "Heads down" :no_bell: 11p-1a --with-dnd # Overnight, pausing notifications
61
+ slk status scheduled # List pending (all workspaces)
62
+ slk status unschedule CS0BMQDDGWTU # Cancel one (finds the owning workspace)
63
+ ```
64
+
65
+ A single am/pm carries across the range, so `1-3p` is 1pm to 3pm — unless that
66
+ would invert it, leaving `9-5p` as 9am to 5pm. Anything the am/pm cannot settle
67
+ is read as a 24-hour time, which is usually right but occasionally means the
68
+ opposite of what you typed: `9-5` and `9a-5` both come out as 9am until 5am the
69
+ next morning. Windows that land that way — guessed, crossing midnight, and over
70
+ 12 hours — are rejected rather than scheduled, so add the second am/pm (`9a-5p`)
71
+ or use 24-hour times (`9:00-17:00`).
72
+
73
+ Overnight windows are fine when they say so. A meridiem on both sides (`11p-1a`,
74
+ `8p-9a`) or a 24-hour time on either side (`20:00-09:00`, `20:00-6`) settles the
75
+ range, and a pm start is enough on its own: `9p-5` is 9pm to 5am.
76
+
77
+ A `start-end` range writes the date once and the end can only reach the next
78
+ day, so multi-day windows use `--start` / `--end` instead. Each takes
79
+ `[YYYY-MM-DD ]TIME`, and omitting `--end` schedules a status with no expiry:
80
+
81
+ ```bash
82
+ slk status schedule "OOO" :palm_tree: --start "2026-08-12 8a" --end "2026-08-14 5p"
83
+ slk status schedule "Heads down" :no_bell: --start 2p # Stays until cleared
84
+ ```
85
+
55
86
  ### Presence
56
87
 
57
88
  ```bash
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Api
5
+ # Wrapper for Slack's internal users.customStatus.* endpoints, which back
6
+ # the "Scheduled" section of the status picker.
7
+ #
8
+ # Two undocumented quirks these methods paper over:
9
+ # - Only form-encoded bodies are accepted. A JSON body is ignored and the
10
+ # call fails with invalid_arguments naming every field as missing.
11
+ # - list omits scheduled_statuses entirely unless
12
+ # statuses_count_per_section is passed.
13
+ class CustomStatus
14
+ DEFAULT_SECTION_COUNT = 20
15
+
16
+ def initialize(api_client, workspace)
17
+ @api = api_client
18
+ @workspace = workspace
19
+ end
20
+
21
+ # @return [Hash] raw response; 'statuses' (recent) and 'scheduled_statuses'
22
+ # (pending) are each absent when Slack omits the section
23
+ def list(count_per_section: DEFAULT_SECTION_COUNT)
24
+ @api.post_form(@workspace, 'users.customStatus.list',
25
+ { statuses_count_per_section: count_per_section.to_s })
26
+ end
27
+
28
+ # @return [Array<Models::ScheduledStatus>] pending statuses only
29
+ def scheduled(count_per_section: DEFAULT_SECTION_COUNT)
30
+ response = list(count_per_section: count_per_section)
31
+ section = response['scheduled_statuses']
32
+ # An absent section is a protocol change, not an empty list. Reporting
33
+ # it as "none scheduled" would invite the user to re-create statuses
34
+ # that still exist.
35
+ unless section.is_a?(Array)
36
+ raise ApiError.new('Slack returned no scheduled_statuses section; this internal endpoint may have changed. ' \
37
+ 'Check the Slack status picker before re-scheduling.',
38
+ code: :missing_scheduled_section)
39
+ end
40
+
41
+ section.map { |item| Models::ScheduledStatus.from_api(item) }
42
+ end
43
+
44
+ # @param date_scheduled [Integer] Unix timestamp the status turns on
45
+ # @param date_expire [Integer, nil] Unix timestamp it clears
46
+ # @param dnd [Boolean] also pause notifications while active
47
+ # @return [Models::ScheduledStatus]
48
+ def schedule(text:, emoji:, date_scheduled:, date_expire: nil, dnd: false)
49
+ params = { text: text, emoji: emoji, date_scheduled: date_scheduled.to_i.to_s }
50
+ params[:date_expire] = date_expire.to_i.to_s if date_expire
51
+ params[:is_dnd] = 'true' if dnd
52
+
53
+ response = @api.post_form(@workspace, 'users.customStatus.schedule', params)
54
+ Models::ScheduledStatus.from_api(confirmed_schedule(response))
55
+ end
56
+
57
+ # Slack answers a delete with ok: true whether or not anything changed —
58
+ # including for an id that never existed — so the response alone is not
59
+ # evidence. Re-reading the list at least catches an accepted delete that
60
+ # did not apply. It cannot distinguish "cancelled" from "was never
61
+ # there": both leave the id absent. `unschedule` covers that case ahead
62
+ # of time by finding the workspace that owns the id.
63
+ #
64
+ # @return [Array(Symbol, String, nil)] `[:cancelled, nil]`, or
65
+ # `[:unconfirmed, reason]` when the delete was accepted but the
66
+ # following read failed. Those are different things: only the second
67
+ # read failed, and reporting it as a failed *cancel* would send the
68
+ # user back to re-cancel something already gone.
69
+ # @raise [ApiError] only when the status is demonstrably still there
70
+ def delete_scheduled(custom_status_id)
71
+ @api.post_form(@workspace, 'users.customStatus.deleteScheduled',
72
+ { custom_status_id: custom_status_id })
73
+ confirm_deleted(custom_status_id)
74
+ end
75
+
76
+ private
77
+
78
+ # The delete has already been accepted by the time this runs, so this
79
+ # only decides how much can honestly be said about it. A read that
80
+ # fails — including a rate-limited one, since the confirming read hits
81
+ # the same `list` method a multi-workspace sweep just used — is a
82
+ # failure to look, not a failure to delete.
83
+ def confirm_deleted(custom_status_id)
84
+ return [:cancelled, nil] unless scheduled.any? { |status| status.id == custom_status_id }
85
+
86
+ raise ApiError.new("Slack reported success but #{custom_status_id} is still scheduled.",
87
+ code: :delete_not_applied)
88
+ rescue ApiError => e
89
+ raise if e.code == :delete_not_applied
90
+
91
+ [:unconfirmed, e.message]
92
+ end
93
+
94
+ # A create that reports ok without echoing back the status it made has
95
+ # not demonstrably created anything.
96
+ #
97
+ # The id check duplicates ScheduledStatus.validate!, deliberately:
98
+ # letting an id-less payload through to that guard trades this message
99
+ # for "Slack returned a scheduled status with no id: {}", which tells
100
+ # the user nothing they can act on. Here the answer is the same either
101
+ # way — go look at the picker.
102
+ def confirmed_schedule(response)
103
+ payload = response['scheduled_status']
104
+ return payload if payload.is_a?(Hash) && !payload['id'].to_s.empty?
105
+
106
+ raise ApiError.new('Slack accepted the request but returned no scheduled status, so nothing was confirmed. ' \
107
+ 'Check the Slack status picker to see whether it was created.',
108
+ code: :malformed_schedule_response)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -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)
@@ -69,7 +72,7 @@ module Slk
69
72
  else
70
73
  show_unknown_command(command_name)
71
74
  end
72
- rescue ConfigError, EncryptionError, ApiError => e
75
+ rescue ConfigError, EncryptionError, ApiError, UsageError => e
73
76
  handle_known_error(e)
74
77
  end
75
78
 
@@ -83,7 +86,10 @@ module Slk
83
86
  def handle_known_error(error)
84
87
  label = error_label(error)
85
88
  @output.error(label ? "#{label}: #{error.message}" : error.message)
86
- log_error(error)
89
+ # error.log exists to investigate faults after the fact. A mistyped flag
90
+ # or an unparseable time is not one, and filing a backtrace for it both
91
+ # buries the real entries and contradicts what UsageError promises.
92
+ log_error(error) unless error.is_a?(UsageError)
87
93
  1
88
94
  end
89
95
 
@@ -140,6 +146,11 @@ module Slk
140
146
  runner.api_client.on_request = lambda { |method, count|
141
147
  output.debug("[API ##{count}] #{method}")
142
148
  }
149
+ runner.api_client.on_response = lambda { |method, code, headers|
150
+ next unless code == 'rate-wait'
151
+
152
+ output.warn("Rate limited on #{method}; sleeping #{headers['sleep_seconds']}s and retrying...")
153
+ }
143
154
  end
144
155
 
145
156
  def setup_very_verbose_logging(runner, output)
@@ -65,7 +65,7 @@ module Slk
65
65
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
66
66
  def parse_single_option(arg, args, remaining)
67
67
  case arg
68
- when '-w', '--workspace' then @options[:workspace] = args.shift
68
+ when '-w', '--workspace' then @options[:workspace] = option_value(arg, args)
69
69
  when '--width' then parse_width_option(args)
70
70
  when '--no-wrap' then @options[:width] = nil
71
71
  when '--all' then @options[:all] = true
@@ -93,6 +93,18 @@ module Slk
93
93
 
94
94
  protected
95
95
 
96
+ # A bare `args.shift` returns nil for a trailing flag, which then reads
97
+ # as an option nobody set: `slk status -w` selected *every* workspace
98
+ # and exited 0, the opposite of what it says. A following flag is the
99
+ # same mistake one token later — `--end --with-dnd` would take
100
+ # "--with-dnd" as the time.
101
+ def option_value(flag, args)
102
+ value = args.first
103
+ raise UsageError, "#{flag} requires a value." if value.nil? || value.start_with?('-')
104
+
105
+ args.shift
106
+ end
107
+
96
108
  # Override in subclass to handle command-specific options
97
109
  # Return true if option was handled, false to raise unknown option error
98
110
  def handle_option(arg, _args, _remaining) # rubocop:disable Naming/PredicateMethod
@@ -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
@@ -74,6 +74,7 @@ module Slk
74
74
  slk status Show current status
75
75
  slk status "Working" :laptop: Set status
76
76
  slk status clear Clear status
77
+ slk status schedule "Vet" 1p-3p Schedule a status (am/pm or 24h)
77
78
  slk dnd 1h Enable DND for 1 hour
78
79
  slk messages #general Read channel messages
79
80
  slk preset meeting Apply preset
@@ -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