slk 0.11.2 → 0.12.1

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: 663f35d4a747ac42d0fb9596caa7dd7b4809d7f3451b811b7fb7e8389c7a991c
4
- data.tar.gz: 42e1ba0fda08b83c644b77212155ab2c68ac318d95f4966060624f5f0eb94827
3
+ metadata.gz: 709675ee0926af3aee4e201318382d1558d6a46e4ebd8158a6353ee0da49f670
4
+ data.tar.gz: 8b82f996a687811516b17c6fe164a0dcedffc0e199de0eacadab61f37918955d
5
5
  SHA512:
6
- metadata.gz: bd98ff12ddd5d0dfcf0ebc33e72852bfffed272c4136b74ed4b63b41ef38c0aab01b28826a02069738556781bec325a63b73eab66407f72c099de9186a6b56c2
7
- data.tar.gz: 92a34fac64238598233edf4b8273d4597bb80deb07b316504649e21da575b4f56e75969a5178f2035c5fbaf5f0fa2ac70fe1eeb2e89d5baa3d6054285991a59a
6
+ metadata.gz: 2ab37ace8886dd600f194faf26f96597e422f2d1252aaba09fafacf83fff77e549bd9bc69be059cd567c787a3b2b2e69e0fc84360ac42f186916c1ea8930fe5d
7
+ data.tar.gz: 68f5dcd8ca42484c6c9783de5c35d02e95755046a69b1b0184b62ed4a84aaedfe0a2067821d18572e5b1f821ad98fe45a2af8b438f44930626df433a5806c5e3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.1] - 2026-10-06
11
+
12
+ ### Fixed
13
+
14
+ - `slk unread --json` now prints exactly one valid JSON document, with no workspace name lines mixed in. With one workspace (or `-w`), it prints `{"channels": [...], "dms": [...]}` alone. With several, it prints an object keyed by workspace name: `{"oddball": {...}, "dsva": {...}}`. This changes the output shape for multi-workspace callers.
15
+ - `slk unread -w WS` and `slk catchup -w WS` now limit themselves to that workspace. Before, they defaulted to all workspaces and ignored `-w`.
16
+ - `slk unread` text output shows workspace name headers only when it covers more than one workspace.
17
+
18
+ ## [0.12.0] - 2026-09-30
19
+
20
+ ### Added
21
+
22
+ - `slk thread subscribe <url>` follows a thread without posting in it, and `slk thread unsubscribe <url>` stops following it. The URL can point at the parent or any reply. Existing replies are marked read, so only new replies surface under Threads in `slk unread` and Slack's Threads view. `--json` reports the channel, thread, and last-read timestamps. Requires a session (`xoxc`) token; other token types get a hint instead of a bare API error.
23
+
10
24
  ## [0.11.2] - 2026-09-24
11
25
 
12
26
  ### Changed
data/README.md CHANGED
@@ -149,6 +149,16 @@ slk messages general -n 50 # Show 50 messages
149
149
  slk messages general --json # Output as JSON
150
150
  ```
151
151
 
152
+ ### Threads
153
+
154
+ ```bash
155
+ slk thread <url> # View a thread
156
+ slk thread subscribe <url> # Follow a thread without replying
157
+ slk thread unsubscribe <url> # Stop following it
158
+ ```
159
+
160
+ Subscribing treats existing replies as read, so only new replies show up under Threads in `slk unread` (and in Slack's own Threads view). Requires a session (`xoxc`) token.
161
+
152
162
  ### Activity
153
163
 
154
164
  ```bash
@@ -344,10 +354,30 @@ rake test
344
354
  ### Releasing
345
355
 
346
356
  1. Update version in `lib/slk/version.rb`
347
- 2. Update `CHANGELOG.md` (move Unreleased to new version, add date)
348
- 3. Commit: `git commit -am "Release vX.Y.Z"`
349
- 4. Release to RubyGems: `rake release`
350
- 5. Create GitHub Release: `gh release create vX.Y.Z --generate-notes`
357
+ 2. Update `CHANGELOG.md`: add a `## [X.Y.Z] - YYYY-MM-DD` heading under `## [Unreleased]` so the pending entries move into the release
358
+ 3. Run `bundle install` so `Gemfile.lock` picks up the new version
359
+ 4. Verify: `bundle exec rake test && bundle exec rubocop`
360
+ 5. Commit version, changelog, and lockfile together, then push and wait for CI:
361
+ ```bash
362
+ git commit -am "chore(release): prepare vX.Y.Z"
363
+ git push origin master
364
+ gh run watch --exit-status
365
+ ```
366
+ 6. Build, tag, and push the tag:
367
+ ```bash
368
+ bundle exec rake build
369
+ git tag -a vX.Y.Z -m "Version X.Y.Z"
370
+ git push origin vX.Y.Z
371
+ ```
372
+ 7. Publish to RubyGems. `fnox exec` supplies `GEM_HOST_API_KEY` from `fnox.toml` (a stale key in `~/.gem/credentials` gets "Access Denied"); MFA is required, and `GEM_HOST_OTP_CODE` skips the prompt:
373
+ ```bash
374
+ fnox exec -- sh -c 'GEM_HOST_OTP_CODE="$(op item get Rubygems --otp)" gem push pkg/slk-X.Y.Z.gem'
375
+ ```
376
+ 8. Create the GitHub Release with the changelog entry as notes:
377
+ ```bash
378
+ awk '/^## \[X.Y.Z\]/{f=1;next} /^## \[/{f=0} f' CHANGELOG.md | sed '/./,$!d' > /tmp/notes.md
379
+ gh release create vX.Y.Z --title vX.Y.Z --notes-file /tmp/notes.md
380
+ ```
351
381
 
352
382
  ## License
353
383
 
@@ -28,6 +28,30 @@ module Slk
28
28
  })
29
29
  end
30
30
 
31
+ # Subscribe to (follow) a thread so new replies land in the Threads view
32
+ # Requires a session (xoxc) token; this is an undocumented client endpoint.
33
+ # @param channel [String] Channel ID
34
+ # @param thread_ts [String] Thread parent timestamp
35
+ # @param last_read [String] Latest reply timestamp to treat as already read
36
+ def subscribe(channel:, thread_ts:, last_read:)
37
+ @api.post_form(@workspace, 'subscriptions.thread.add', subscription_params(channel, thread_ts, last_read))
38
+ end
39
+
40
+ # Unsubscribe from (unfollow) a thread
41
+ # @param channel [String] Channel ID
42
+ # @param thread_ts [String] Thread parent timestamp
43
+ # @param last_read [String] Latest reply timestamp to treat as already read
44
+ def unsubscribe(channel:, thread_ts:, last_read:)
45
+ @api.post_form(@workspace, 'subscriptions.thread.remove', subscription_params(channel, thread_ts, last_read))
46
+ end
47
+
48
+ # Check whether you are subscribed to a thread
49
+ # @param channel [String] Channel ID
50
+ # @param thread_ts [String] Thread parent timestamp
51
+ def get(channel:, thread_ts:)
52
+ @api.post_form(@workspace, 'subscriptions.thread.get', { channel: channel, thread_ts: thread_ts })
53
+ end
54
+
31
55
  # Get unread thread count
32
56
  # @return [Integer] Number of unread thread replies
33
57
  def unread_count
@@ -40,6 +64,12 @@ module Slk
40
64
  def unreads?
41
65
  unread_count.positive?
42
66
  end
67
+
68
+ private
69
+
70
+ def subscription_params(channel, thread_ts, last_read)
71
+ { channel: channel, thread_ts: thread_ts, last_read: last_read }
72
+ end
43
73
  end
44
74
  end
45
75
  end
@@ -90,6 +90,14 @@ module Slk
90
90
 
91
91
  private
92
92
 
93
+ # Catchup defaults to every workspace, but -w names one: let it win, or
94
+ # `slk catchup -w oddball` quietly walks (or batch-clears) all of them.
95
+ def target_workspaces
96
+ return [runner.workspace(@options[:workspace])] if @options[:workspace]
97
+
98
+ super
99
+ end
100
+
93
101
  def batch_catchup
94
102
  target_workspaces.each { |ws| batch_mark_workspace(ws) }
95
103
  0
@@ -7,6 +7,7 @@ module Slk
7
7
  # and field shapes before building higher-level features.
8
8
  #
9
9
  # Not registered in `slk help`.
10
+ # rubocop:disable Metrics/ClassLength
10
11
  class Debug < Base
11
12
  def execute
12
13
  result = validate_options
@@ -24,6 +25,7 @@ module Slk
24
25
  in ['profile'] then dump_profile(nil)
25
26
  in ['team'] then dump_team
26
27
  in ['schema'] then dump_schema
28
+ in ['thread', url] then dump_thread(url)
27
29
  else unknown_action
28
30
  end
29
31
  end
@@ -55,6 +57,40 @@ module Slk
55
57
  0
56
58
  end
57
59
 
60
+ # Raw shapes behind thread subscriptions: the parent message (does it
61
+ # carry `subscribed`/`last_read`?) and subscriptions.thread.get. Each call
62
+ # is captured separately so one failing does not hide the other.
63
+ def dump_thread(url)
64
+ resolved = resolve_thread(url)
65
+ ts = resolved.thread_ts || resolved.msg_ts
66
+ raise UsageError, 'URL must point to a specific message' unless ts
67
+
68
+ output.puts(JSON.pretty_generate(thread_calls(resolved.workspace.name, resolved.channel_id, ts)))
69
+ 0
70
+ end
71
+
72
+ def resolve_thread(url)
73
+ Services::TargetResolver.new(runner: runner, cache_store: cache_store)
74
+ .resolve(url, default_workspace: runner.workspace(@options[:workspace]))
75
+ end
76
+
77
+ def thread_calls(workspace_name, channel, thread_ts)
78
+ {
79
+ 'conversations.replies' => capture do
80
+ runner.conversations_api(workspace_name).replies(channel: channel, timestamp: thread_ts, limit: 1)
81
+ end,
82
+ 'subscriptions.thread.get' => capture do
83
+ runner.threads_api(workspace_name).get(channel: channel, thread_ts: thread_ts)
84
+ end
85
+ }
86
+ end
87
+
88
+ def capture
89
+ yield
90
+ rescue ApiError => e
91
+ { 'ok' => false, 'error' => e.message }
92
+ end
93
+
58
94
  def resolve_user_id(workspace, user_input)
59
95
  return self_user_id(workspace) if user_input.nil? || user_input == 'me'
60
96
  return user_input if user_input.match?(/\A[UW][A-Z0-9]+\z/)
@@ -82,7 +118,7 @@ module Slk
82
118
 
83
119
  def unknown_action
84
120
  error("Unknown debug action: #{positional_args.first.inspect}")
85
- error('Valid actions: profile [user], team, schema')
121
+ error('Valid actions: profile [user], team, schema, thread <url>')
86
122
  1
87
123
  end
88
124
 
@@ -98,6 +134,7 @@ module Slk
98
134
  profile [user] Dump users.profile.get + users.info + team.profile.get
99
135
  team Dump team.info
100
136
  schema Dump team.profile.get
137
+ thread <url> Dump thread parent + subscriptions.thread.get
101
138
 
102
139
  USER
103
140
  (none) | me Self
@@ -106,5 +143,6 @@ module Slk
106
143
  HELP
107
144
  end
108
145
  end
146
+ # rubocop:enable Metrics/ClassLength
109
147
  end
110
148
  end
@@ -11,6 +11,7 @@ module Slk
11
11
  ['presence', 'Get or set your presence (away/active)'],
12
12
  ['dnd', 'Manage Do Not Disturb'],
13
13
  ['messages', 'Read channel or DM messages'],
14
+ ['thread', 'View a thread, or subscribe to it without replying'],
14
15
  ['search', 'Search messages across channels'],
15
16
  ['sent', 'Show your sent messages across workspaces'],
16
17
  ['unread', 'View and clear unread messages'],
@@ -4,13 +4,19 @@ require_relative 'messages'
4
4
 
5
5
  module Slk
6
6
  module Commands
7
- # Views a message thread from a Slack URL
7
+ # Views a message thread from a Slack URL, or subscribes to / unsubscribes
8
+ # from it so new replies surface without posting in the thread
9
+ # rubocop:disable Metrics/ClassLength
8
10
  class Thread < Messages
11
+ SUBSCRIPTION_ACTIONS = %w[subscribe unsubscribe].freeze
12
+ # Session-only endpoint: bot/user OAuth tokens get one of these back.
13
+ TOKEN_ERRORS = %i[not_allowed_token_type unknown_method missing_scope].freeze
14
+
9
15
  def execute
10
16
  result = validate_options
11
17
  return result if result
12
18
 
13
- resolve_and_display_thread
19
+ dispatch
14
20
  rescue ApiError => e
15
21
  error("Failed to fetch messages: #{e.message}")
16
22
  1
@@ -19,6 +25,13 @@ module Slk
19
25
  1
20
26
  end
21
27
 
28
+ def dispatch
29
+ action = positional_args.first
30
+ return change_subscription(action, positional_args[1]) if SUBSCRIPTION_ACTIONS.include?(action)
31
+
32
+ resolve_and_display_thread
33
+ end
34
+
22
35
  def resolve_and_display_thread
23
36
  target = positional_args.first
24
37
  return usage_error unless target
@@ -30,6 +43,16 @@ module Slk
30
43
  fetch_and_display_messages(resolved)
31
44
  end
32
45
 
46
+ def change_subscription(action, target)
47
+ return subscription_usage_error(action) unless target
48
+ return url_required_error unless Support::SlackUrlParser.new.parse(target)&.message?
49
+
50
+ resolved = target_resolver.resolve(target, default_workspace: target_workspaces.first)
51
+ report_subscription(action, resolved.workspace, apply_subscription(action, resolved))
52
+ rescue ApiError => e
53
+ subscription_error(action, e)
54
+ end
55
+
33
56
  def fetch_and_display_messages(resolved)
34
57
  ts = resolved.thread_ts || resolved.msg_ts
35
58
  return message_url_required_error unless ts
@@ -46,6 +69,12 @@ module Slk
46
69
 
47
70
  def usage_error
48
71
  error('Usage: slk thread <url>')
72
+ error(' slk thread subscribe|unsubscribe <url>')
73
+ 1
74
+ end
75
+
76
+ def subscription_usage_error(action)
77
+ error("Usage: slk thread #{action} <url>")
49
78
  1
50
79
  end
51
80
 
@@ -60,9 +89,11 @@ module Slk
60
89
  end
61
90
 
62
91
  def help_text
63
- help = Support::HelpFormatter.new('slk thread <url> [options]')
64
- help.description('View a message thread from a Slack URL.')
92
+ help = Support::HelpFormatter.new('slk thread [subscribe|unsubscribe] <url> [options]')
93
+ help.description('View a message thread from a Slack URL, or subscribe to it without replying.')
94
+ help.note('Subscribing marks existing replies read; new replies show up in `slk unread`.')
65
95
  add_usage_section(help)
96
+ add_actions_section(help)
66
97
  add_options_section(help)
67
98
  add_examples_section(help)
68
99
  help.render
@@ -70,10 +101,56 @@ module Slk
70
101
 
71
102
  private
72
103
 
104
+ def apply_subscription(action, resolved)
105
+ thread_subscription(resolved.workspace).public_send(
106
+ action, channel_id: resolved.channel_id, timestamp: resolved.thread_ts || resolved.msg_ts
107
+ )
108
+ end
109
+
110
+ def thread_subscription(workspace)
111
+ Services::ThreadSubscription.new(
112
+ conversations_api: runner.conversations_api(workspace.name),
113
+ threads_api: runner.threads_api(workspace.name)
114
+ )
115
+ end
116
+
117
+ def report_subscription(action, workspace, result)
118
+ if @options[:json]
119
+ output_json(subscription_json(action, workspace, result))
120
+ else
121
+ verb = action == 'subscribe' ? 'Subscribed to' : 'Unsubscribed from'
122
+ success("#{verb} thread #{result.thread_ts} in #{channel_label(workspace, result.channel_id)}")
123
+ end
124
+ 0
125
+ end
126
+
127
+ def subscription_json(action, workspace, result)
128
+ { subscribed: action == 'subscribe', workspace: workspace.name, channel_id: result.channel_id,
129
+ thread_ts: result.thread_ts, last_read: result.last_read }
130
+ end
131
+
132
+ def channel_label(workspace, channel_id)
133
+ name = cache_store.get_channel_name(workspace.name, channel_id)
134
+ name ? "##{name}" : channel_id
135
+ end
136
+
137
+ def subscription_error(action, err)
138
+ error("Failed to #{action} #{action == 'subscribe' ? 'to' : 'from'} thread: #{err.message}")
139
+ error('Thread subscriptions require a session (xoxc) token.') if TOKEN_ERRORS.include?(err.code)
140
+ 1
141
+ end
142
+
73
143
  def add_usage_section(help)
74
144
  help.section('USAGE') { |s| s.item('<slack_url>', 'Slack message URL') }
75
145
  end
76
146
 
147
+ def add_actions_section(help)
148
+ help.section('ACTIONS') do |s|
149
+ s.action('subscribe <url>', 'Follow the thread (get notified of new replies)')
150
+ s.action('unsubscribe <url>', 'Stop following the thread')
151
+ end
152
+ end
153
+
77
154
  def add_options_section(help)
78
155
  help.section('OPTIONS') do |s|
79
156
  s.option('--no-emoji', 'Show :emoji: codes instead of unicode')
@@ -88,8 +165,11 @@ module Slk
88
165
  def add_examples_section(help)
89
166
  help.section('EXAMPLES') do |s|
90
167
  s.item('slk thread https://work.slack.com/archives/C123/p1234567890', 'View thread')
168
+ s.item('slk thread subscribe https://work.slack.com/archives/C123/p1234567890', 'Follow thread')
169
+ s.item('slk thread unsubscribe https://work.slack.com/archives/C123/p1234567890', 'Unfollow thread')
91
170
  end
92
171
  end
93
172
  end
173
+ # rubocop:enable Metrics/ClassLength
94
174
  end
95
175
  end
@@ -95,22 +95,38 @@ module Slk
95
95
 
96
96
  private
97
97
 
98
- def show_unread
99
- target_workspaces.each do |workspace|
100
- puts output.bold(workspace.name) if @options[:all] || target_workspaces.size > 1
98
+ # Unread defaults to every workspace, but -w names one: let it win, or
99
+ # `slk unread -w oddball` quietly reports on all of them.
100
+ def target_workspaces
101
+ return [runner.workspace(@options[:workspace])] if @options[:workspace]
101
102
 
102
- unread_data = fetch_unread_data(workspace)
103
+ super
104
+ end
103
105
 
104
- if @options[:json]
105
- output_unread_json(workspace, unread_data)
106
- else
107
- display_unread(workspace, unread_data)
108
- end
106
+ def show_unread
107
+ return show_unread_json if @options[:json]
108
+
109
+ workspaces = target_workspaces
110
+ workspaces.each do |workspace|
111
+ puts output.bold(workspace.name) if workspaces.size > 1
112
+ display_unread(workspace, fetch_unread_data(workspace))
109
113
  end
110
114
 
111
115
  0
112
116
  end
113
117
 
118
+ # Exactly one JSON document: one workspace prints its {channels, dms}
119
+ # alone; several are keyed by workspace name. No name headers, so the
120
+ # output pipes straight into jq.
121
+ def show_unread_json
122
+ workspaces = target_workspaces
123
+ documents = workspaces.to_h do |workspace|
124
+ [workspace.name, unread_json(workspace, fetch_unread_data(workspace))]
125
+ end
126
+ output_json(workspaces.size == 1 ? documents.values.first : documents)
127
+ 0
128
+ end
129
+
114
130
  def fetch_unread_data(workspace)
115
131
  counts = runner.client_api(workspace.name).counts
116
132
  muted_ids = fetch_muted_ids(workspace)
@@ -135,11 +151,11 @@ module Slk
135
151
  .reject { |c| muted_ids.include?(c['id']) }
136
152
  end
137
153
 
138
- def output_unread_json(workspace, data)
139
- output_json({
140
- channels: data[:unread_channels].map { |c| format_channel_json(workspace, c) },
141
- dms: data[:unread_ims].map { |i| format_dm_json(workspace, i) }
142
- })
154
+ def unread_json(workspace, data)
155
+ {
156
+ channels: data[:unread_channels].map { |c| format_channel_json(workspace, c) },
157
+ dms: data[:unread_ims].map { |i| format_dm_json(workspace, i) }
158
+ }
143
159
  end
144
160
 
145
161
  def format_channel_json(workspace, channel)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Subscribes to or unsubscribes from a thread without posting in it.
6
+ #
7
+ # Slack wants the thread's parent timestamp plus a last-read marker. A
8
+ # permalink can point at any reply, so the parent is looked up first and
9
+ # its latest reply becomes the marker: what is already there counts as
10
+ # read, and only replies posted after subscribing show up as unread.
11
+ class ThreadSubscription
12
+ Result = Data.define(:channel_id, :thread_ts, :last_read)
13
+
14
+ def initialize(conversations_api:, threads_api:)
15
+ @conversations = conversations_api
16
+ @threads = threads_api
17
+ end
18
+
19
+ # @param channel_id [String] Channel ID
20
+ # @param timestamp [String] Timestamp of the parent or any reply
21
+ # @return [Result]
22
+ def subscribe(channel_id:, timestamp:)
23
+ change(:subscribe, channel_id, timestamp)
24
+ end
25
+
26
+ # @param channel_id [String] Channel ID
27
+ # @param timestamp [String] Timestamp of the parent or any reply
28
+ # @return [Result]
29
+ def unsubscribe(channel_id:, timestamp:)
30
+ change(:unsubscribe, channel_id, timestamp)
31
+ end
32
+
33
+ private
34
+
35
+ def change(action, channel_id, timestamp)
36
+ result = locate(channel_id, timestamp)
37
+ @threads.public_send(action, channel: channel_id, thread_ts: result.thread_ts, last_read: result.last_read)
38
+ result
39
+ end
40
+
41
+ def locate(channel_id, timestamp)
42
+ message = first_message(channel_id, timestamp)
43
+ thread_ts = message['thread_ts'] || message['ts'] || timestamp
44
+ # Pointed at a reply: only the parent carries latest_reply.
45
+ message = first_message(channel_id, thread_ts) if message['ts'] && message['ts'] != thread_ts
46
+
47
+ Result.new(channel_id: channel_id, thread_ts: thread_ts, last_read: message['latest_reply'] || thread_ts)
48
+ end
49
+
50
+ def first_message(channel_id, timestamp)
51
+ response = @conversations.replies(channel: channel_id, timestamp: timestamp, limit: 1)
52
+ message = (response['messages'] || []).first
53
+ raise ApiError.new('thread_not_found', code: :thread_not_found) unless message
54
+
55
+ message
56
+ end
57
+ end
58
+ end
59
+ 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.11.2'
4
+ VERSION = '0.12.1'
5
5
  end
data/lib/slk.rb CHANGED
@@ -117,6 +117,7 @@ module Slk
117
117
  autoload :SentConversations, 'slk/services/sent_conversations'
118
118
  autoload :SentChanges, 'slk/services/sent_changes'
119
119
  autoload :SentChannelLabel, 'slk/services/sent_channel_label'
120
+ autoload :ThreadSubscription, 'slk/services/thread_subscription'
120
121
  end
121
122
 
122
123
  # Output formatters for messages, durations, and emoji
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.11.2
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -127,6 +127,7 @@ files:
127
127
  - lib/slk/services/start_date_field.rb
128
128
  - lib/slk/services/start_date_lookup.rb
129
129
  - lib/slk/services/target_resolver.rb
130
+ - lib/slk/services/thread_subscription.rb
130
131
  - lib/slk/services/token_loader.rb
131
132
  - lib/slk/services/token_saver.rb
132
133
  - lib/slk/services/token_store.rb