slk 0.6.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: 139ba582e24a6b92cd79493c0000a1479fbeb4ce59c90d883d0bbb603e8ff4c7
4
- data.tar.gz: 81bdd74fc8a1d9b44480272098f78744297b3bd2b42b39dbc25c7f1fdb891c2f
3
+ metadata.gz: 895e4fdd405a7b4f560424a93d4906ea7330088763a5559b2d581788908794b3
4
+ data.tar.gz: 6f8c66d9cd6555153db454d99d7cafdf4e82bbaac2f6349edee1427047e31104
5
5
  SHA512:
6
- metadata.gz: f79c404a79de6d32c77e85226b92b65322ff159ae610201d3514abc66fc9f8497a30c29da802b81e0ed2f36cdb3bc805835eee0e85f744f0f323d2620391c483
7
- data.tar.gz: 6fff7a3b9b11762d179ccd2d22e5a0e4a6e395f52bd4fe89810cb045fed144c989026c07928179efdcd35e8ac3c3c991dab8f90344042cd0475adc8fc1007d7e
6
+ metadata.gz: 7394318fac53168225d5ff30a249612a0f756fe60eaca67141ca937d43f8c70aaa7a1066681bb35a97fc49f242254d87438f369b02700193cc86456ded97ca9c
7
+ data.tar.gz: ea890ffc51e6540c6a19db3481d92b8f0263c042594f74c4ec2ac3f975c7f988d345f69e0a6b37345993b499535180b87456cd189dffa9506b2c60148f2b9481
data/CHANGELOG.md CHANGED
@@ -5,7 +5,34 @@ 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
9
36
 
10
37
  ## [0.6.0] - 2026-04-27
11
38
 
@@ -183,6 +210,8 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
183
210
  - Pure Ruby stdlib - no gem dependencies
184
211
  - Ruby 3.2+ with modern features (Data.define, pattern matching)
185
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
186
215
  [0.5.0]: https://github.com/ericboehs/slk/releases/tag/v0.5.0
187
216
  [0.4.2]: https://github.com/ericboehs/slk/releases/tag/v0.4.2
188
217
  [0.4.0]: https://github.com/ericboehs/slk/releases/tag/v0.4.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
data/lib/slk/cli.rb CHANGED
@@ -72,7 +72,7 @@ module Slk
72
72
  else
73
73
  show_unknown_command(command_name)
74
74
  end
75
- rescue ConfigError, EncryptionError, ApiError => e
75
+ rescue ConfigError, EncryptionError, ApiError, UsageError => e
76
76
  handle_known_error(e)
77
77
  end
78
78
 
@@ -86,7 +86,10 @@ module Slk
86
86
  def handle_known_error(error)
87
87
  label = error_label(error)
88
88
  @output.error(label ? "#{label}: #{error.message}" : error.message)
89
- 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)
90
93
  1
91
94
  end
92
95
 
@@ -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
@@ -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
@@ -10,6 +10,10 @@ module Slk
10
10
  class Status < Base
11
11
  include Support::InlineImages
12
12
 
13
+ SCHEDULE_USAGE = 'Usage: slk status schedule "<text>" [:emoji:] <start-end> | --start WHEN [--end WHEN]'
14
+ MISSING_RANGE = 'Missing time range. Example: slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p ' \
15
+ '(or --start/--end for a multi-day window).'
16
+
13
17
  def execute
14
18
  result = validate_options
15
19
  return result if result
@@ -22,6 +26,9 @@ module Slk
22
26
 
23
27
  def dispatch_action
24
28
  case positional_args
29
+ in ['schedule', *rest] then schedule_status(rest)
30
+ in ['scheduled', *] then list_scheduled
31
+ in ['unschedule', *rest] then unschedule_status(rest)
25
32
  in ['clear', *] then clear_status
26
33
  in [text, *rest] then set_status(text, rest)
27
34
  in [] then get_status
@@ -31,17 +38,17 @@ module Slk
31
38
  protected
32
39
 
33
40
  def default_options
34
- super.merge(presence: nil, dnd: nil)
41
+ super.merge(presence: nil, dnd: nil, with_dnd: false, start_at: nil, end_at: nil)
35
42
  end
36
43
 
37
44
  def handle_option(arg, args, remaining)
38
45
  case arg
39
- when '-p', '--presence'
40
- @options[:presence] = args.shift
41
- when '-d', '--dnd'
42
- @options[:dnd] = args.shift
43
- else
44
- super
46
+ when '-p', '--presence' then @options[:presence] = option_value(arg, args)
47
+ when '-d', '--dnd' then @options[:dnd] = option_value(arg, args)
48
+ when '--with-dnd' then @options[:with_dnd] = true
49
+ when '--start' then @options[:start_at] = option_value(arg, args)
50
+ when '--end' then @options[:end_at] = option_value(arg, args)
51
+ else super
45
52
  end
46
53
  end
47
54
 
@@ -49,7 +56,10 @@ module Slk
49
56
  help = Support::HelpFormatter.new('slk status [text] [emoji] [duration] [options]')
50
57
  help.description('Get or set your Slack status.')
51
58
  help.note('GET shows all workspaces by default. SET applies to primary only.')
59
+ help.note('scheduled shows all workspaces; schedule applies to primary; unschedule finds the ID owner.')
60
+ help.note('Slack allows at most 5 scheduled statuses at a time.')
52
61
  add_examples_section(help)
62
+ add_scheduling_section(help)
53
63
  add_options_section(help)
54
64
  help.render
55
65
  end
@@ -64,17 +74,42 @@ module Slk
64
74
  end
65
75
  end
66
76
 
77
+ def add_scheduling_section(help)
78
+ help.section('SCHEDULING') do |s|
79
+ s.example('slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p', 'Schedule for later')
80
+ s.example('slk status schedule "OOO" :palm_tree: 2026-08-04 9:00-17:00')
81
+ s.example('slk status schedule "OOO" :palm_tree: --start "2026-08-12 8a" --end "2026-08-14 5p"',
82
+ 'Span multiple days')
83
+ s.example('slk status schedule "Heads down" :no_bell: --start 2p', 'No end; stays until cleared')
84
+ s.example('slk status scheduled', 'List pending scheduled statuses')
85
+ s.example('slk status unschedule CS0BMQDDGWTU', 'Cancel a scheduled status')
86
+ end
87
+ end
88
+
67
89
  def add_options_section(help)
68
90
  help.section('OPTIONS') do |s|
69
- s.option('-p, --presence VALUE', 'Also set presence (away/auto/active)')
70
- s.option('-d, --dnd DURATION', "Also set DND (or 'off')")
71
- s.option('-w, --workspace', 'Limit to specific workspace')
72
- s.option('--all', 'Set across all workspaces')
73
- s.option('-v, --verbose', 'Show debug information')
74
- s.option('-q, --quiet', 'Suppress output')
91
+ add_general_options(s)
92
+ add_scheduling_options(s)
75
93
  end
76
94
  end
77
95
 
96
+ def add_general_options(section)
97
+ section.option('-p, --presence VALUE', 'Also set presence (away/auto/active)')
98
+ section.option('-d, --dnd DURATION', "Also set DND (or 'off')")
99
+ section.option('-w, --workspace', 'Limit to specific workspace')
100
+ section.option('--all', 'Set across all workspaces')
101
+ section.option('-v, --verbose', 'Show debug information')
102
+ section.option('-q, --quiet', 'Suppress output')
103
+ end
104
+
105
+ # These are ignored outside `schedule`, so say so rather than leaving
106
+ # --with-dnd looking like a sibling of -d/--dnd.
107
+ def add_scheduling_options(section)
108
+ section.option('--with-dnd', 'Scheduling only: pause notifications while the status is active')
109
+ section.option('--start WHEN', 'Scheduling only: window start, "[YYYY-MM-DD ]TIME"')
110
+ section.option('--end WHEN', 'Scheduling only: window end; omit for no expiry')
111
+ end
112
+
78
113
  private
79
114
 
80
115
  def get_status # rubocop:disable Naming/AccessorMethodName
@@ -210,6 +245,182 @@ module Slk
210
245
  0
211
246
  end
212
247
 
248
+ def schedule_status(args)
249
+ text, *rest = args
250
+ return error(SCHEDULE_USAGE) if text.to_s.strip.empty?
251
+
252
+ window = parse_schedule_window(rest)
253
+ return 1 unless window
254
+
255
+ result = create_scheduled_status(text, extract_emoji(rest), *window)
256
+ show_all_workspaces_hint
257
+ result
258
+ end
259
+
260
+ # Narrow on both axes: only the parsing is covered, so a Slack failure is
261
+ # not reported as a mistyped range, and only TimeFormatError is caught, so
262
+ # an arity or range error from this code does not surface as user error.
263
+ def parse_schedule_window(rest)
264
+ return flag_window(rest) if @options[:start_at] || @options[:end_at]
265
+
266
+ range = extract_time_range(rest)
267
+ return reject_window(MISSING_RANGE) unless range
268
+
269
+ Support::TimeRangeParser.parse(range)
270
+ rescue TimeFormatError => e
271
+ error(e.message)
272
+ nil
273
+ end
274
+
275
+ # --start/--end is the general form. Both times carry their own date, so
276
+ # it reaches multi-day windows the positional range cannot express, and
277
+ # omitting --end schedules a status that never auto-clears.
278
+ def flag_window(rest)
279
+ return reject_window('--end requires --start.') unless @options[:start_at]
280
+ return reject_window('Use either a time range or --start/--end, not both.') if extract_time_range(rest)
281
+
282
+ starts_at = Support::TimeParser.parse(@options[:start_at])
283
+ return [starts_at, nil] unless @options[:end_at]
284
+
285
+ [starts_at, validated_end(starts_at)]
286
+ end
287
+
288
+ def validated_end(starts_at)
289
+ ends_at = Support::TimeParser.parse(@options[:end_at])
290
+ # An explicit end is unambiguous, so there is nothing to roll forward.
291
+ return ends_at if ends_at > starts_at
292
+
293
+ raise TimeFormatError, "--end #{@options[:end_at]} is not after --start #{@options[:start_at]}."
294
+ end
295
+
296
+ # Returns nil so the caller reads it as "no window, already reported".
297
+ def reject_window(message)
298
+ error(message)
299
+ nil
300
+ end
301
+
302
+ # The range may arrive as one token ("1:30p-3:30p") or several
303
+ # ("2026-08-04 13:30-15:30"), so the non-emoji arguments are matched as a
304
+ # whole. Emoji are dropped first because the pattern is \A-anchored and a
305
+ # leading ":palm_tree:" would stop the date form from matching at all.
306
+ #
307
+ # Everything left has to be part of the range. Only YYYY-MM-DD dates
308
+ # parse, so picking the range out and discarding "8/4" or "tomorrow"
309
+ # would silently schedule the status for today.
310
+ def extract_time_range(rest)
311
+ candidates = rest.reject { |arg| arg.start_with?(':') && arg.end_with?(':') }
312
+ return nil if candidates.empty?
313
+
314
+ joined = candidates.join(' ')
315
+ return joined if Support::TimeRangeParser.match?(joined)
316
+
317
+ raise TimeFormatError, "Unrecognized argument: #{joined}. Use #{Support::TimeRangeParser::EXAMPLE}"
318
+ end
319
+
320
+ # Each workspace is an independent call, so one failure must not strand
321
+ # the workspaces after it: the user would re-run to fix the tail and
322
+ # double-schedule everything that already succeeded.
323
+ def each_workspace_reporting(workspaces)
324
+ failed = false
325
+
326
+ workspaces.each do |workspace|
327
+ yield workspace
328
+ rescue ApiError => e
329
+ failed = true
330
+ error("#{workspace.name}: #{e.message}")
331
+ end
332
+
333
+ failed ? 1 : 0
334
+ end
335
+
336
+ def create_scheduled_status(text, emoji, starts_at, ends_at)
337
+ each_workspace_reporting(target_workspaces) do |workspace|
338
+ scheduled = runner.custom_status_api(workspace.name).schedule(
339
+ text: text, emoji: emoji,
340
+ date_scheduled: starts_at, date_expire: ends_at,
341
+ dnd: @options[:with_dnd]
342
+ )
343
+ success("Scheduled on #{workspace.name}: #{scheduled}")
344
+ debug(" ID: #{scheduled.id}")
345
+ end
346
+ end
347
+
348
+ def list_scheduled
349
+ workspaces = target_workspaces_for_get
350
+
351
+ each_workspace_reporting(workspaces) do |workspace|
352
+ puts output.bold(workspace.name) if workspaces.size > 1
353
+ print_scheduled(runner.custom_status_api(workspace.name).scheduled)
354
+ end
355
+ end
356
+
357
+ def print_scheduled(scheduled)
358
+ return puts ' (none scheduled)' if scheduled.empty?
359
+
360
+ scheduled.each { |status| puts " #{status.id} #{status}" }
361
+ end
362
+
363
+ def unschedule_status(args)
364
+ id = args.first
365
+ return error('Usage: slk status unschedule <id>') if id.to_s.strip.empty?
366
+
367
+ @unchecked = []
368
+ targets = unschedule_targets(id)
369
+ return report_id_not_found(id) if targets.empty?
370
+
371
+ each_workspace_reporting(targets) do |workspace|
372
+ outcome, reason = runner.custom_status_api(workspace.name).delete_scheduled(id)
373
+ report_cancelled(workspace, outcome, reason)
374
+ end
375
+ end
376
+
377
+ # The delete succeeded either way, so both are successes — but only one
378
+ # of them has been checked, and saying so is the difference between the
379
+ # user moving on and the user re-cancelling something already gone.
380
+ def report_cancelled(workspace, outcome, reason)
381
+ return success("Cancelled scheduled status on #{workspace.name}") if outcome == :cancelled
382
+
383
+ warn("Cancelled scheduled status on #{workspace.name}, but could not confirm it: #{reason}")
384
+ end
385
+
386
+ # `slk status scheduled` lists every workspace, so the obvious next step
387
+ # is to paste an ID straight back in. Defaulting to the primary workspace
388
+ # made that fail with a bare Slack error whenever the ID came from
389
+ # another one, so look up the owner instead. An explicit -w/--all still
390
+ # wins, and a lone workspace needs no lookup.
391
+ def unschedule_targets(id)
392
+ return target_workspaces if @options[:all] || @options[:workspace]
393
+
394
+ workspaces = runner.all_workspaces
395
+ return workspaces if workspaces.size <= 1
396
+
397
+ [workspaces.find { |workspace| owns_scheduled?(workspace, id) }].compact
398
+ end
399
+
400
+ # "Not found" is only true of the workspaces we actually reached. Saying
401
+ # it flatly after warning that one could not be checked contradicts the
402
+ # warning, and pointing at `slk status scheduled` would just fail the
403
+ # same way — it calls the same endpoint.
404
+ def report_id_not_found(id)
405
+ return error("No scheduled status #{id} found. Run 'slk status scheduled' to list IDs.") if @unchecked.empty?
406
+
407
+ error("#{id} was not found, but #{@unchecked.join(', ')} could not be checked. " \
408
+ 'Retry, or name the workspace with -w to cancel it directly.')
409
+ end
410
+
411
+ def owns_scheduled?(workspace, id)
412
+ runner.custom_status_api(workspace.name).scheduled.any? { |status| status.id == id }
413
+ rescue RateLimitError
414
+ # Every remaining check spends another call against the same limit, so
415
+ # continuing turns one rate limit into several and still cannot answer.
416
+ raise
417
+ rescue ApiError => e
418
+ # Skipping it silently would report "not found" for an ID that exists.
419
+ warn("Could not check #{workspace.name}: #{e.message}")
420
+ @unchecked << workspace.name
421
+ false
422
+ end
423
+
213
424
  def show_all_workspaces_hint
214
425
  # Show hint if user has multiple workspaces and didn't use --all or -w
215
426
  return if @options[:all] || @options[:workspace]
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # A status queued to turn on later, from users.customStatus.list.
6
+ #
7
+ # Slack derives `duration` server-side from the window, so it is not
8
+ # modelled here — the window itself is the source of truth.
9
+ ScheduledStatus = Data.define(:id, :text, :emoji, :date_scheduled, :date_expire, :dnd, :active) do
10
+ def self.from_api(data)
11
+ validate!(data)
12
+
13
+ new(
14
+ id: data['id'].to_s,
15
+ text: data['text'].to_s,
16
+ emoji: data['emoji'].to_s,
17
+ date_scheduled: data['date_scheduled'].to_i,
18
+ date_expire: data['date_expire'].to_i,
19
+ dnd: truthy?(data['is_dnd']),
20
+ active: truthy?(data['is_active'])
21
+ )
22
+ end
23
+
24
+ # Every field is coerced, so without this guard any payload at all —
25
+ # a bare string, nil, a hash of unexpected keys — becomes a valid-looking
26
+ # record with an empty id that prints as a blank line and never matches
27
+ # the id the user asked about. An unusable record is a protocol change,
28
+ # not a status.
29
+ def self.validate!(data)
30
+ return if data.is_a?(Hash) && !data['id'].to_s.empty?
31
+
32
+ raise ApiError.new("Slack returned a scheduled status with no id: #{data.inspect[0, 120]}",
33
+ code: :malformed_scheduled_status)
34
+ end
35
+ private_class_method :validate!
36
+
37
+ # These endpoints are string-typed on the way in — Api::CustomStatus sends
38
+ # is_dnd as 'true' — so a strict `== true` would quietly read a scheduled
39
+ # DND back as off. Accept the shapes Slack actually uses.
40
+ def self.truthy?(value) = [true, 'true', 1, '1'].include?(value)
41
+ private_class_method :truthy?
42
+
43
+ def starts_at = date_scheduled.positive? ? Time.at(date_scheduled) : nil
44
+ def ends_at = date_expire.positive? ? Time.at(date_expire) : nil
45
+
46
+ def to_s
47
+ span = window
48
+ [
49
+ emoji,
50
+ text,
51
+ span.empty? ? '' : "(#{span})",
52
+ ('[dnd]' if dnd),
53
+ # Slack marks the one that has already turned on. Worth showing: it
54
+ # is the difference between "will happen" and "is happening".
55
+ ('[active]' if active)
56
+ ].reject { |part| part.to_s.empty? }.join(' ')
57
+ end
58
+
59
+ # Human-readable window; the end date is only repeated when it differs
60
+ # from the start date, which makes multi-day windows obvious.
61
+ def window
62
+ dated = '%a %b %-d %-l:%M%P'
63
+ start_time = starts_at or return ''
64
+ formatted = start_time.strftime(dated)
65
+ finish = ends_at or return formatted
66
+
67
+ "#{formatted} -> #{finish.strftime(same_day?(start_time, finish) ? '%-l:%M%P' : dated)}"
68
+ end
69
+
70
+ private
71
+
72
+ def same_day?(start_time, finish)
73
+ start_time.to_date == finish.to_date
74
+ end
75
+ end
76
+ end
77
+ end
data/lib/slk/runner.rb CHANGED
@@ -88,6 +88,10 @@ module Slk
88
88
  Api::Saved.new(@api_client, workspace(workspace_name))
89
89
  end
90
90
 
91
+ def custom_status_api(workspace_name = nil)
92
+ Api::CustomStatus.new(@api_client, workspace(workspace_name))
93
+ end
94
+
91
95
  def team_api(workspace_name = nil)
92
96
  Api::Team.new(@api_client, workspace(workspace_name))
93
97
  end
@@ -104,8 +104,15 @@ module Slk
104
104
  'Please provide the correct public key for this private key.'
105
105
  end
106
106
 
107
+ # `-P ''` supplies the passphrase up front so ssh-keygen cannot ask for
108
+ # one. Without it, an encrypted key makes ssh-keygen prompt — and it
109
+ # prompts on the console directly, not on the stdin capture3 hands it, so
110
+ # closing stdin does not help. On Windows that read blocks forever; this
111
+ # hung the CI job until its timeout with no output. An empty passphrase
112
+ # fails an encrypted key with the same message it already reported, which
113
+ # is the right answer regardless: slk cannot use such a key anyway.
107
114
  def derive_public_key(private_key_path)
108
- output, error, status = Open3.capture3('ssh-keygen', '-y', '-f', private_key_path)
115
+ output, error, status = Open3.capture3('ssh-keygen', '-y', '-P', '', '-f', private_key_path)
109
116
  return output.strip if status.success?
110
117
 
111
118
  # Check if ssh-keygen is missing vs other failures
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'date'
5
+
6
+ module Slk
7
+ module Support
8
+ # Resolves a single clock time ("1:30p", "2026-08-12 8a") to a Unix
9
+ # timestamp. A bare time at or before now rolls to tomorrow; an explicit
10
+ # YYYY-MM-DD date is honoured as given.
11
+ #
12
+ # TimeRangeParser builds the two-sided form on the clock arithmetic here.
13
+ class TimeParser
14
+ TIME = /(\d{1,2})(?::(\d{2}))?\s*([ap]m?)?/i
15
+ PATTERN = /\A(?:(\d{4}-\d{2}-\d{2})\s+)?#{TIME}\z/i
16
+
17
+ EXAMPLE = '1:30p or 2026-08-12 8:00'
18
+
19
+ MINUTES_PER_DAY = 24 * 60
20
+
21
+ # A bare hour of 0 or 13-23 can only be 24-hour notation, so no am/pm was
22
+ # omitted. Anything in 1..12 is genuinely ambiguous without one.
23
+ CLOCK_HOURS = (1..12)
24
+
25
+ # @param now [Time] reference point for rolling bare times forward
26
+ # @return [Integer] Unix timestamp
27
+ def self.parse(input, now: Time.now) = new(now: now).parse(input)
28
+
29
+ def initialize(now: Time.now)
30
+ @now = now
31
+ end
32
+
33
+ def parse(input)
34
+ match = PATTERN.match(input.to_s.strip)
35
+ raise TimeFormatError, "Invalid time: #{input}. Use #{EXAMPLE}" unless match
36
+
37
+ parts = match.captures[1..3]
38
+ date = match[1] ? parse_date(match[1]) : roll_forward(parts)
39
+ at(date, *parts).to_i
40
+ end
41
+
42
+ # The rest of this class is clock arithmetic shared with TimeRangeParser,
43
+ # which needs to place the same parts on dates it works out itself.
44
+
45
+ # @param example [String] format hint, so a caller with its own syntax
46
+ # (TimeRangeParser) does not advertise this class's example
47
+ def parse_date(text, example: EXAMPLE)
48
+ Date.parse(text)
49
+ rescue Date::Error
50
+ raise TimeFormatError, "Invalid date: #{text}. Use #{example}"
51
+ end
52
+
53
+ def at(date, *parts)
54
+ hours, minutes = to_24_hour(*parts)
55
+ time = build(date, hours, minutes)
56
+ # Ruby silently shifts a local time that DST skips (2:30a on a
57
+ # spring-forward date becomes 3:30a), which would move the window
58
+ # rather than fail. Reject it instead.
59
+ return time if time.hour == hours && time.min == minutes
60
+
61
+ raise TimeFormatError,
62
+ format('%<clock>s does not exist on %<date>s (clocks skip forward for DST).',
63
+ clock: format('%<h>02d:%<m>02d', h: hours, m: minutes), date: date)
64
+ end
65
+
66
+ # Same placement without the existence check, for callers asking a
67
+ # question a nonexistent time still answers. `at` would raise, which for
68
+ # the roll-forward probe would reject "2:30a" outright on a spring-forward
69
+ # date instead of rolling it to the next day, where it does exist.
70
+ def place(date, *parts) = build(date, *to_24_hour(*parts))
71
+
72
+ # Minutes since midnight, for comparing two times before either has a date.
73
+ def minutes(parts)
74
+ hours, mins = to_24_hour(*parts)
75
+ (hours * 60) + mins
76
+ end
77
+
78
+ # True when these parts could have meant either am or pm — no meridiem
79
+ # given, and an hour small enough that both readings are plausible.
80
+ def ambiguous?(parts) = parts[2].nil? && CLOCK_HOURS.cover?(parts[0].to_i)
81
+
82
+ # The opposite: a bare hour too large to be a 12-hour clock reading, so
83
+ # the writer was plainly using 24-hour notation. TimeRangeParser treats
84
+ # one of these as settling how to read the *other* side of a range.
85
+ def twenty_four_hour?(parts) = parts[2].nil? && !CLOCK_HOURS.cover?(parts[0].to_i)
86
+
87
+ private
88
+
89
+ def build(date, hours, minutes) = Time.new(date.year, date.month, date.day, hours, minutes, 0)
90
+
91
+ def roll_forward(parts)
92
+ today = @now.to_date
93
+ place(today, *parts) <= @now ? today + 1 : today
94
+ end
95
+
96
+ def to_24_hour(hour, minute, meridiem)
97
+ hours = hour.to_i
98
+ minutes = minute.to_i
99
+ raise TimeFormatError, "Invalid minute: #{minute}" if minutes > 59
100
+ return [validate_24_hour(hours), minutes] unless meridiem
101
+
102
+ [apply_meridiem(hours, meridiem), minutes]
103
+ end
104
+
105
+ def validate_24_hour(hours)
106
+ raise TimeFormatError, "Invalid hour: #{hours}" if hours > 23
107
+
108
+ hours
109
+ end
110
+
111
+ def apply_meridiem(hours, meridiem)
112
+ raise TimeFormatError, "Invalid hour for 12-hour time: #{hours}" if hours.zero? || hours > 12
113
+
114
+ # 12am is hour 0 and 12pm is hour 12, so fold 12 down before shifting.
115
+ base = hours % 12
116
+ meridiem[0].casecmp?('p') ? base + 12 : base
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'date'
5
+
6
+ module Slk
7
+ module Support
8
+ # Parses a scheduling window ("1:30p-3:30p", "2026-08-04 13:30-15:30")
9
+ # into a [start, end] pair of Unix timestamps.
10
+ #
11
+ # Unlike DateParser, which resolves an input to a point in the past, this
12
+ # always resolves forward: a bare time at or before now rolls to tomorrow,
13
+ # and an end before the start rolls to the next day so overnight windows
14
+ # ("11p-1a") work.
15
+ #
16
+ # The start date is written once and the end can only reach the following
17
+ # day, so this cannot express a multi-day window — `slk status schedule
18
+ # --start/--end` is the general form for that.
19
+ class TimeRangeParser
20
+ RANGE_PATTERN = /\A(?:(\d{4}-\d{2}-\d{2})\s+)?#{TimeParser::TIME}\s*-\s*#{TimeParser::TIME}\z/i
21
+
22
+ EXAMPLE = '1:30p-3:30p or 2026-08-04 13:30-15:30'
23
+
24
+ # Longest overnight window accepted when a reading had to be guessed.
25
+ # Twelve hours is not a policy about window length — an explicit
26
+ # "8p-9a" is longer and fine — it is the point past which a guessed
27
+ # crossing can no longer be what the user meant.
28
+ MAX_GUESSED_OVERNIGHT_MINUTES = 12 * 60
29
+
30
+ # @param input [String] the range to parse
31
+ # @param now [Time] reference point for rolling bare times forward
32
+ # @return [Array(Integer, Integer)] start and end Unix timestamps
33
+ def self.parse(input, now: Time.now) = new(now: now).parse(input)
34
+
35
+ # True when input looks like a time range, used to pick it out of argv.
36
+ def self.match?(input) = RANGE_PATTERN.match?(input.to_s.strip)
37
+
38
+ def initialize(now: Time.now)
39
+ @now = now
40
+ @clock = TimeParser.new(now: now)
41
+ end
42
+
43
+ def parse(input)
44
+ match = RANGE_PATTERN.match(input.to_s.strip)
45
+ raise TimeFormatError, "Invalid time range: #{input}. Use #{EXAMPLE}" unless match
46
+
47
+ start_parts, end_parts = infer_meridiems(match)
48
+ validate_range(input, start_parts, end_parts)
49
+
50
+ date = start_date(match, start_parts)
51
+ start_at = @clock.at(date, *start_parts)
52
+
53
+ [start_at.to_i, end_time(date, start_at, end_parts).to_i]
54
+ end
55
+
56
+ private
57
+
58
+ # `match.captures` layout: 0 is the optional date, 1..3 the start time,
59
+ # 4..6 the end. Note the date is `match[1]` in the 1-indexed MatchData form.
60
+ #
61
+ # "1-3p" means 1pm, not 1am: when only one side names a meridiem the
62
+ # other borrows it. The borrow is skipped when it would invert the range
63
+ # ("9-5p" is 9am to 5pm, not 9pm to 5pm).
64
+ def infer_meridiems(match)
65
+ start_parts = match.captures[1..3]
66
+ end_parts = match.captures[4..6]
67
+
68
+ if borrowable?(start_parts, end_parts)
69
+ borrowed = with_meridiem(start_parts, end_parts[2])
70
+ return [borrowed, end_parts] if @clock.minutes(borrowed) < @clock.minutes(end_parts)
71
+ elsif borrowable?(end_parts, start_parts)
72
+ borrowed = with_meridiem(end_parts, start_parts[2])
73
+ return [start_parts, borrowed] if @clock.minutes(start_parts) < @clock.minutes(borrowed)
74
+ end
75
+
76
+ [start_parts, end_parts]
77
+ end
78
+
79
+ def borrowable?(parts, source) = !source[2].nil? && @clock.ambiguous?(parts)
80
+
81
+ def with_meridiem(parts, meridiem) = [parts[0], parts[1], meridiem]
82
+
83
+ # An explicit date is honoured as given; a bare time at or before now
84
+ # refers to tomorrow.
85
+ def start_date(match, start_parts)
86
+ return @clock.parse_date(match[1], example: EXAMPLE) if match[1]
87
+
88
+ today = @now.to_date
89
+ # `place`, not `at`: this only asks "is that time already past today?",
90
+ # and on a spring-forward date `at` would reject a skipped time outright
91
+ # instead of letting it roll to tomorrow, where it exists.
92
+ @clock.place(today, *start_parts) <= @now ? today + 1 : today
93
+ end
94
+
95
+ def end_time(date, start_at, end_parts)
96
+ end_at = @clock.at(date, *end_parts)
97
+ # An end before the start means the window crosses midnight.
98
+ end_at < start_at ? @clock.at(date + 1, *end_parts) : end_at
99
+ end
100
+
101
+ def validate_range(input, start_parts, end_parts)
102
+ if @clock.minutes(start_parts) == @clock.minutes(end_parts)
103
+ raise TimeFormatError, "Time range #{input} starts and ends at the same time."
104
+ end
105
+
106
+ validate_overnight(input, start_parts, end_parts)
107
+ end
108
+
109
+ # "9-5" almost always means 9am-5pm, but reads literally as 09:00 to
110
+ # 05:00 the next morning — a 20-hour window nobody asked for. So is
111
+ # "9a-5", where the am/pm is there but on the wrong side to help.
112
+ #
113
+ # Only guessed readings are checked, and only past 12 hours, which is
114
+ # what separates the mistake from the intent. An overnight shift written
115
+ # with a pm start ("9p-5", "10p-2") can only come out 12 hours or less;
116
+ # a dropped meridiem ("9-5", "9a-5", "10-5a") can only come out longer.
117
+ def validate_overnight(input, start_parts, end_parts)
118
+ return unless guessed?(start_parts, end_parts)
119
+
120
+ span = overnight_minutes(start_parts, end_parts)
121
+ return if span.nil? || span <= MAX_GUESSED_OVERNIGHT_MINUTES
122
+
123
+ raise TimeFormatError,
124
+ "Time range #{input} reads as crossing midnight and spans #{format_span(span)}. " \
125
+ 'Add am/pm to both sides (9a-5p), use 24-hour times (9:00-17:00), ' \
126
+ 'or --start/--end for a multi-day window.'
127
+ end
128
+
129
+ # True when a side's reading had to be guessed.
130
+ #
131
+ # A leftover ambiguous side is not enough on its own: in "20:00-6" the
132
+ # 24-hour start settles the whole range, so "6" is 6am and was not
133
+ # guessed. Without such an anchor, any side still bare after
134
+ # `infer_meridiems` was read as 24-hour for want of anything better —
135
+ # either because no meridiem was given at all ("9-5"), or because
136
+ # borrowing the one that was given would have inverted the range
137
+ # ("9a-5", where 5am precedes 9am).
138
+ def guessed?(start_parts, end_parts)
139
+ return false if @clock.twenty_four_hour?(start_parts) || @clock.twenty_four_hour?(end_parts)
140
+
141
+ @clock.ambiguous?(start_parts) || @clock.ambiguous?(end_parts)
142
+ end
143
+
144
+ # Wall-clock minutes from start to end across midnight, or nil when the
145
+ # window stays on one date.
146
+ #
147
+ # Deliberately not `end_at - start_at`: elapsed seconds stretch or shrink
148
+ # by an hour across a DST boundary, so the same text would report a
149
+ # different span depending on the date it happened to land on.
150
+ def overnight_minutes(start_parts, end_parts)
151
+ start_minutes = @clock.minutes(start_parts)
152
+ end_minutes = @clock.minutes(end_parts)
153
+ return nil if end_minutes > start_minutes
154
+
155
+ TimeParser::MINUTES_PER_DAY - start_minutes + end_minutes
156
+ end
157
+
158
+ # Whole hours read better, but rounding them would report a 12h01m window
159
+ # as "12 hours" against a 12-hour limit.
160
+ def format_span(minutes)
161
+ hours, mins = minutes.divmod(60)
162
+ mins.zero? ? "#{hours} hours" : format('%<h>dh%<m>02dm', h: hours, m: mins)
163
+ end
164
+ end
165
+ end
166
+ end
data/lib/slk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.6.0'
4
+ VERSION = '0.7.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -12,6 +12,17 @@ require 'io/console'
12
12
  module Slk
13
13
  class Error < StandardError; end
14
14
 
15
+ # The invocation itself was wrong (a flag with no value, an unparseable
16
+ # time). The message is written for the user and is printed verbatim, with
17
+ # no "Error type:" label, because there is no internal fault to report.
18
+ class UsageError < Error; end
19
+
20
+ # A time or range the user typed that cannot be understood. Separate from
21
+ # UsageError so callers can rescue *only* malformed input: a blanket
22
+ # `rescue ArgumentError` around a parse call also swallows arity and range
23
+ # errors from the code itself and presents them as if the user mistyped.
24
+ class TimeFormatError < UsageError; end
25
+
15
26
  # Errors from any Slack-API-shaped failure: HTTP errors, network errors,
16
27
  # logical Slack errors (user_not_found, missing_scope, etc.), JSON parse
17
28
  # failures. The optional `code` symbol lets callers match specific cases
@@ -55,6 +66,7 @@ module Slk
55
66
  autoload :Duration, 'slk/models/duration'
56
67
  autoload :Workspace, 'slk/models/workspace'
57
68
  autoload :Status, 'slk/models/status'
69
+ autoload :ScheduledStatus, 'slk/models/scheduled_status'
58
70
  autoload :Message, 'slk/models/message'
59
71
  autoload :Reaction, 'slk/models/reaction'
60
72
  autoload :User, 'slk/models/user'
@@ -153,6 +165,7 @@ module Slk
153
165
  autoload :Activity, 'slk/api/activity'
154
166
  autoload :Search, 'slk/api/search'
155
167
  autoload :Saved, 'slk/api/saved'
168
+ autoload :CustomStatus, 'slk/api/custom_status'
156
169
  autoload :Team, 'slk/api/team'
157
170
  end
158
171
 
@@ -167,5 +180,7 @@ module Slk
167
180
  autoload :TextWrapper, 'slk/support/text_wrapper'
168
181
  autoload :InteractivePrompt, 'slk/support/interactive_prompt'
169
182
  autoload :DateParser, 'slk/support/date_parser'
183
+ autoload :TimeParser, 'slk/support/time_parser'
184
+ autoload :TimeRangeParser, 'slk/support/time_range_parser'
170
185
  end
171
186
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -30,6 +30,7 @@ files:
30
30
  - lib/slk/api/bots.rb
31
31
  - lib/slk/api/client.rb
32
32
  - lib/slk/api/conversations.rb
33
+ - lib/slk/api/custom_status.rb
33
34
  - lib/slk/api/dnd.rb
34
35
  - lib/slk/api/emoji.rb
35
36
  - lib/slk/api/saved.rb
@@ -85,6 +86,7 @@ files:
85
86
  - lib/slk/models/profile_field.rb
86
87
  - lib/slk/models/reaction.rb
87
88
  - lib/slk/models/saved_item.rb
89
+ - lib/slk/models/scheduled_status.rb
88
90
  - lib/slk/models/search_result.rb
89
91
  - lib/slk/models/status.rb
90
92
  - lib/slk/models/user.rb
@@ -123,6 +125,8 @@ files:
123
125
  - lib/slk/support/platform.rb
124
126
  - lib/slk/support/slack_url_parser.rb
125
127
  - lib/slk/support/text_wrapper.rb
128
+ - lib/slk/support/time_parser.rb
129
+ - lib/slk/support/time_range_parser.rb
126
130
  - lib/slk/support/user_resolver.rb
127
131
  - lib/slk/support/xdg_paths.rb
128
132
  - lib/slk/version.rb
@@ -148,7 +152,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
148
152
  - !ruby/object:Gem::Version
149
153
  version: '0'
150
154
  requirements: []
151
- rubygems_version: 4.0.10
155
+ rubygems_version: 4.0.13
152
156
  specification_version: 4
153
157
  summary: A command-line interface for Slack
154
158
  test_files: []