slk 0.6.0 → 0.8.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: ae44e63120d4264c9adeb4adb2ea9aba96c4fe9e6087c1134f9bd9d8480a843e
4
+ data.tar.gz: '087ea385b7cf62868737d1e583e60ec9fac5aee8bb3d49f711f5c979413db4e7'
5
5
  SHA512:
6
- metadata.gz: f79c404a79de6d32c77e85226b92b65322ff159ae610201d3514abc66fc9f8497a30c29da802b81e0ed2f36cdb3bc805835eee0e85f744f0f323d2620391c483
7
- data.tar.gz: 6fff7a3b9b11762d179ccd2d22e5a0e4a6e395f52bd4fe89810cb045fed144c989026c07928179efdcd35e8ac3c3c991dab8f90344042cd0475adc8fc1007d7e
6
+ metadata.gz: 71be8f27208e35f5dfe81c3504f38f40af7a7630d93802ba56316e07778aa631f7a42ffe1172b9b1c280735871c0667b5b6568a3bb0224642cdabf59523ac8c5
7
+ data.tar.gz: 9e4b991d52004df5100fe901e87c1b61f8c50a8fa501665f2cb0a5f58f31a37a6ffae6cbe5c287aac7ae2e99f679cea0889c80866f7980b7f495c7311a507c73
data/CHANGELOG.md CHANGED
@@ -5,7 +5,53 @@ 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.8.0] - 2026-08-30
9
+
10
+ ### Added
11
+
12
+ - **`slk status` now answers the questions the status raises** — who can reach you, and whether the status is about to change on its own
13
+ - Presence and DND appear as suffixes on the status line: `:computer: Working [away] [dnd until 3:00pm]`. Only exceptional states are labelled, so the workspace that differs stands out rather than being buried under "active" on every line
14
+ - `[dnd until …]` covers a manual snooze and the configured DND hours alike — from the outside both mean messages do not notify — and reports the later end when both apply. `slk dnd` still breaks out which is which
15
+ - Anything scheduled to turn on later is listed under the status, soonest first and without IDs; `slk status scheduled` remains the view to paste an ID from
16
+ - Times carry a day when the end is not today, so an overnight window does not read as a time that has already passed
17
+ - **`slk status --json`** (and `slk status scheduled --json`) for scripts and statuslines
18
+ - Always an array, one entry per workspace, even for a single workspace: a document whose shape changes with `-w` cannot be parsed by a script that did not pass it
19
+ - `null` means "not checked" — skipped by a flag, or a lookup that failed — as distinct from checked-and-empty. A statusline that read a failed DND lookup as "DND off" would report the opposite of the truth
20
+ - Every timestamp appears twice: Slack's own epoch under Slack's own field name, and an ISO 8601 string beside it
21
+ - **`--brief` and `--no-scheduled`** for the get view. It now costs up to four calls per workspace; `--no-scheduled` drops the internal `users.customStatus.list` lookup and `--brief` drops all three extras. `--quiet` skips them too, since their output would be discarded (`--json` still gathers them: it prints regardless)
22
+
23
+ ### Changed
24
+
25
+ - A failed presence, DND or schedule lookup during `slk status` warns and omits that part instead of failing the command — the status itself is what was asked for. A rate limit stops the remaining extra lookups altogether rather than spending calls the status reads need
26
+
27
+ ## [0.7.0] - 2026-08-03
28
+
29
+ ### Added
30
+
31
+ - **`slk status schedule|scheduled|unschedule`** — queue statuses to turn on later
32
+ - `slk status schedule "<text>" [:emoji:] <start-end>` schedules a status; emoji defaults to `:speech_balloon:`
33
+ - 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`)
34
+ - Bare times resolve forward: a window at or before now rolls to tomorrow, and an end before the start crosses midnight (`11p-1a`)
35
+ - 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
36
+ - 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`
37
+ - 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`
38
+ - `--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
39
+ - `--with-dnd` also pauses notifications while the status is active
40
+ - `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)
41
+ - `slk status scheduled` marks the one Slack reports as currently applied with `[active]`
42
+ - 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
43
+
44
+ ### Changed
45
+
46
+ - 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
47
+ - 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
48
+
49
+ ### Fixed
50
+
51
+ - `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
52
+ - `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"
53
+ - `Slk::UsageError` no longer files a backtrace in `~/.cache/slk/error.log`; a mistyped flag is not a fault to investigate later
54
+ - 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
55
 
10
56
  ## [0.6.0] - 2026-04-27
11
57
 
@@ -183,6 +229,9 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
183
229
  - Pure Ruby stdlib - no gem dependencies
184
230
  - Ruby 3.2+ with modern features (Data.define, pattern matching)
185
231
 
232
+ [0.8.0]: https://github.com/ericboehs/slk/releases/tag/v0.8.0
233
+ [0.7.0]: https://github.com/ericboehs/slk/releases/tag/v0.7.0
234
+ [0.6.0]: https://github.com/ericboehs/slk/releases/tag/v0.6.0
186
235
  [0.5.0]: https://github.com/ericboehs/slk/releases/tag/v0.5.0
187
236
  [0.4.2]: https://github.com/ericboehs/slk/releases/tag/v0.4.2
188
237
  [0.4.0]: https://github.com/ericboehs/slk/releases/tag/v0.4.0
data/README.md CHANGED
@@ -46,12 +46,83 @@ You'll need a Slack token. Get one from:
46
46
  ### Status
47
47
 
48
48
  ```bash
49
- slk status # Show current status
49
+ slk status # Status, presence, DND and what's queued next
50
+ slk status --brief # Status text only (one call per workspace)
51
+ slk status --json # Machine-readable, for scripts and statuslines
50
52
  slk status "Working from home" :house: # Set status with emoji
51
53
  slk status "In a meeting" :calendar: 1h # Set status for 1 hour
52
54
  slk status clear # Clear status
53
55
  ```
54
56
 
57
+ Getting the status also answers the two questions it usually raises — can anyone
58
+ reach me, and is this about to change on its own:
59
+
60
+ ```
61
+ $ slk status
62
+ work
63
+ :computer: Working [away] [dnd until 3:00pm]
64
+ Scheduled:
65
+ :paw_prints: Vet Appt (Mon Aug 3 1:30pm -> 3:30pm)
66
+ side-project
67
+ (no status set)
68
+ ```
69
+
70
+ Only the exceptional states are labelled: an active, notifiable workspace says
71
+ nothing, so the one that differs stands out. `[dnd until ...]` covers both a
72
+ manual snooze and the configured DND hours — from the outside they are the same
73
+ thing — and `slk dnd` breaks out which.
74
+
75
+ Each of those is a separate call, so `slk status` costs up to four per
76
+ workspace. `--no-scheduled` drops the schedule lookup (the one internal
77
+ endpoint), `--brief` drops all three, and a lookup that fails warns and leaves
78
+ that part out rather than failing the command.
79
+
80
+ #### JSON output
81
+
82
+ `slk status --json` (and `slk status scheduled --json`) prints one entry per
83
+ workspace — always an array, even for a single workspace, so a script that did
84
+ not pass `-w` can still parse it. `null` means *not checked* (skipped by a flag,
85
+ or the lookup failed), which is not the same as checked-and-empty: a statusline
86
+ that read a failed DND lookup as "DND off" would say the opposite of the truth.
87
+ Every timestamp appears twice, as Slack's epoch and as ISO 8601:
88
+
89
+ ```bash
90
+ # "away, quiet until 3:00pm"
91
+ slk status -w work --json --no-scheduled |
92
+ jq -r '.[0] | "\(.presence.presence)\(if .dnd.active then ", quiet until " + (.dnd.until | strflocaltime("%-I:%M%p")) else "" end)"'
93
+ ```
94
+
95
+ Statuses can also be scheduled to turn on later (Slack allows up to 5 at a time):
96
+
97
+ ```bash
98
+ slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p # Bare times; rolls to tomorrow if past
99
+ slk status schedule "OOO" :palm_tree: 2026-08-04 9:00-17:00 # Explicit date
100
+ slk status schedule "Heads down" :no_bell: 11p-1a --with-dnd # Overnight, pausing notifications
101
+ slk status scheduled # List pending (all workspaces)
102
+ slk status unschedule CS0BMQDDGWTU # Cancel one (finds the owning workspace)
103
+ ```
104
+
105
+ A single am/pm carries across the range, so `1-3p` is 1pm to 3pm — unless that
106
+ would invert it, leaving `9-5p` as 9am to 5pm. Anything the am/pm cannot settle
107
+ is read as a 24-hour time, which is usually right but occasionally means the
108
+ opposite of what you typed: `9-5` and `9a-5` both come out as 9am until 5am the
109
+ next morning. Windows that land that way — guessed, crossing midnight, and over
110
+ 12 hours — are rejected rather than scheduled, so add the second am/pm (`9a-5p`)
111
+ or use 24-hour times (`9:00-17:00`).
112
+
113
+ Overnight windows are fine when they say so. A meridiem on both sides (`11p-1a`,
114
+ `8p-9a`) or a 24-hour time on either side (`20:00-09:00`, `20:00-6`) settles the
115
+ range, and a pm start is enough on its own: `9p-5` is 9pm to 5am.
116
+
117
+ A `start-end` range writes the date once and the end can only reach the next
118
+ day, so multi-day windows use `--start` / `--end` instead. Each takes
119
+ `[YYYY-MM-DD ]TIME`, and omitting `--end` schedules a status with no expiry:
120
+
121
+ ```bash
122
+ slk status schedule "OOO" :palm_tree: --start "2026-08-12 8a" --end "2026-08-14 5p"
123
+ slk status schedule "Heads down" :no_bell: --start 2p # Stays until cleared
124
+ ```
125
+
55
126
  ### Presence
56
127
 
57
128
  ```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