slk 0.2.0 → 0.4.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: f633ce48448a37653aed188faa126b5f5d009a3c393128602ee5cb3ac01ff469
4
- data.tar.gz: a9a342b35df6960696c800e6007c3cdab422edd0d3132e82e3bd7f91d4a8aeb4
3
+ metadata.gz: fd09692101d4658e57208d39e81d770e6dc36d2218088194ef55ad5fad0adbd0
4
+ data.tar.gz: 88e03a0be601536ffb3fd8f1b986900f8f57144b03a52ec5fa24b0b5dbdfbc57
5
5
  SHA512:
6
- metadata.gz: 3422fcd166300be8a976c6996107c9e95623a1ee24d6850c365c8da8f14e57e81d089902405b027fc5552006ad38468a326b79a8fdfc49916abef875f36c5b6a
7
- data.tar.gz: 23def7efe41b31edbe1791e8baf603676d4250ff04d92d0ef90932ae8682bcf3a6babdc9c514f40f64e980703afc9d1057bb70ab9a46380b8a038520ba9de160
6
+ metadata.gz: 399ca26d5d030704ad553e579fab75376db1578e0814f1ad8a9c2e4115c44e32c8173263fb39c8839eb7a0014ca1758d4e1aaa33b8d65353ba64e6fe83fa8efe
7
+ data.tar.gz: 64bfbe31c44af1961f43cd7f027bd352847af05434b957bbceb03e26c8bdf820ee805c1fb67d2adac80e18e7ddc286fd643572a17695209db1e3e7826c358baa
data/CHANGELOG.md CHANGED
@@ -7,7 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- <!-- TODO: Remove post_install_message from slk.gemspec before releasing 0.3.0 -->
10
+ ## [0.4.0] - 2026-01-30
11
+
12
+ ### Added
13
+
14
+ - **Windows Support** - slk now runs on Windows
15
+ - Uses `%APPDATA%` and `%LOCALAPPDATA%` for config/cache directories
16
+ - Cross-platform command detection with `Open3.capture3`
17
+ - Proper NTFS permission handling (skips `chmod` on Windows)
18
+ - New `Support::Platform` module for OS-specific behavior
19
+ - CI testing on Windows (Ruby 3.2, 3.3, 3.4, 4.0)
20
+
21
+ ### Changed
22
+
23
+ - New `UserLookup` service consolidates duplicate user name resolution logic
24
+ - Removed ~65 lines of duplicated code from `MentionReplacer` and `MessageFormatter`
25
+
26
+ ## [0.3.0] - 2026-01-16
27
+
28
+ ### Added
29
+
30
+ - `-vv`/`--very-verbose` flag for detailed API debugging with timing and response bodies
31
+ - SSH key validation and token migration when keys change
32
+ - Public key validation (ensures it matches private key)
33
+ - `config unset` command for removing configuration values
34
+ - CI infrastructure with GitHub Actions (Ruby 3.2-4.0, macOS, Ubuntu)
35
+
36
+ ### Changed
37
+
38
+ - Improved error handling throughout with comprehensive tests
39
+ - Better SSH key error messages with public key prompting
40
+ - Cache user lookups to reduce API calls
41
+ - Improved rate limit error messages
42
+
43
+ ### Fixed
44
+
45
+ - Test output no longer leaks to stdout
46
+ - All rubocop offenses resolved
11
47
 
12
48
  ## [0.2.0] - 2025-01-15
13
49
 
@@ -63,5 +99,7 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
63
99
  - Pure Ruby stdlib - no gem dependencies
64
100
  - Ruby 3.2+ with modern features (Data.define, pattern matching)
65
101
 
102
+ [0.4.0]: https://github.com/ericboehs/slk/releases/tag/v0.4.0
103
+ [0.3.0]: https://github.com/ericboehs/slk/releases/tag/v0.3.0
66
104
  [0.2.0]: https://github.com/ericboehs/slk/releases/tag/v0.2.0
67
105
  [0.1.0]: https://github.com/ericboehs/slk/releases/tag/v0.1.0
data/README.md CHANGED
@@ -12,6 +12,22 @@ gem install slk
12
12
 
13
13
  Requires Ruby 3.2+.
14
14
 
15
+ ### Windows
16
+
17
+ ```powershell
18
+ # Install Ruby (if needed) via RubyInstaller or Chocolatey
19
+ winget install RubyInstallerTeam.Ruby.3.3
20
+ # or: choco install ruby
21
+
22
+ # Install slk
23
+ gem install slk
24
+
25
+ # (Optional) Install age for encrypted token storage
26
+ choco install age.portable
27
+ ```
28
+
29
+ Configuration is stored in `%APPDATA%\slk\` on Windows.
30
+
15
31
  ## Setup
16
32
 
17
33
  Run the setup wizard:
@@ -56,10 +72,10 @@ slk dnd off # Disable DND
56
72
  ### Messages
57
73
 
58
74
  ```bash
59
- slk messages #general # Read channel messages
75
+ slk messages general # Read channel messages
60
76
  slk messages @username # Read DM with user
61
- slk messages #general -n 50 # Show 50 messages
62
- slk messages #general --json # Output as JSON
77
+ slk messages general -n 50 # Show 50 messages
78
+ slk messages general --json # Output as JSON
63
79
  ```
64
80
 
65
81
  ### Activity
@@ -86,7 +102,7 @@ Use `--show-messages` (or `-m`) to preview the actual message content for each a
86
102
  ```bash
87
103
  slk unread # Show unread counts
88
104
  slk unread clear # Mark all as read
89
- slk unread clear #general # Mark channel as read
105
+ slk unread clear general # Mark channel as read
90
106
  ```
91
107
 
92
108
  ### Catchup (Interactive Triage)
@@ -155,13 +171,13 @@ Tokens will be stored encrypted in `~/.config/slk/tokens.age`.
155
171
 
156
172
  ## Configuration
157
173
 
158
- Files are stored in XDG-compliant locations:
174
+ Files are stored in XDG-compliant locations (or `%APPDATA%`/`%LOCALAPPDATA%` on Windows):
159
175
 
160
- - **Config**: `~/.config/slk/`
176
+ - **Config**: `~/.config/slk/` (Windows: `%APPDATA%\slk\`)
161
177
  - `config.json` - Settings
162
178
  - `tokens.json` or `tokens.age` - Workspace tokens
163
179
  - `presets.json` - Status presets
164
- - **Cache**: `~/.cache/slk/`
180
+ - **Cache**: `~/.cache/slk/` (Windows: `%LOCALAPPDATA%\slk\`)
165
181
  - `users-{workspace}.json` - User cache
166
182
  - `channels-{workspace}.json` - Channel cache
167
183
 
@@ -177,13 +193,15 @@ ruby -Ilib bin/slk --version
177
193
 
178
194
  # Run tests
179
195
  rake test
196
+ ```
180
197
 
181
- # Build gem
182
- gem build slk.gemspec
198
+ ### Releasing
183
199
 
184
- # Install locally
185
- gem install ./slk-0.1.0.gem
186
- ```
200
+ 1. Update version in `lib/slk/version.rb`
201
+ 2. Update `CHANGELOG.md` (move Unreleased to new version, add date)
202
+ 3. Commit: `git commit -am "Release vX.Y.Z"`
203
+ 4. Release to RubyGems: `rake release`
204
+ 5. Create GitHub Release: `gh release create vX.Y.Z --generate-notes`
187
205
 
188
206
  ## License
189
207
 
data/bin/ci ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ # Run the full CI suite (tests + linting)
3
+ set -e
4
+
5
+ cd "$(dirname "$0")/.."
6
+
7
+ echo "==> Running tests..."
8
+ rake test
9
+
10
+ echo ""
11
+ echo "==> Running rubocop..."
12
+ rubocop
13
+
14
+ echo ""
15
+ echo "==> All checks passed!"
data/bin/coverage ADDED
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/inline'
5
+
6
+ gemfile do
7
+ source 'https://rubygems.org'
8
+ gem 'nokogiri'
9
+ end
10
+
11
+ # Path to the SimpleCov HTML report
12
+ coverage_file = File.join(Dir.pwd, 'coverage', 'index.html')
13
+
14
+ unless File.exist?(coverage_file)
15
+ puts "No coverage report found at #{coverage_file}"
16
+ puts 'Run tests first: COVERAGE=1 bundle exec rake test'
17
+ exit 1
18
+ end
19
+
20
+ # Parse the HTML file
21
+ doc = Nokogiri::HTML(File.read(coverage_file))
22
+
23
+ # Extract overall line coverage
24
+ line_percent = doc.css('.covered_percent span').first&.text&.strip
25
+ total_lines = doc.css('.t-line-summary b').first&.text&.strip
26
+ covered_lines = doc.css('.t-line-summary .green b').first&.text&.strip
27
+ missed_lines = doc.css('.t-line-summary .red b').first&.text&.strip
28
+
29
+ # Extract overall branch coverage (first t-branch-summary is the overall stats)
30
+ overall_branch_summary = doc.css('.t-branch-summary').first
31
+ if overall_branch_summary
32
+ branch_percent = overall_branch_summary.css('span').last.text.strip.gsub(/[()]/, '')
33
+ branch_summary_spans = overall_branch_summary.css('span b')
34
+ overall_total_branches = branch_summary_spans[0]&.text&.strip
35
+ overall_covered_branches = branch_summary_spans[1]&.text&.strip
36
+ overall_missed_branches = branch_summary_spans[2]&.text&.strip
37
+ else
38
+ # No branch coverage available
39
+ branch_percent = nil
40
+ overall_total_branches = nil
41
+ overall_covered_branches = nil
42
+ overall_missed_branches = nil
43
+ end
44
+
45
+ # Extract timestamp
46
+ timestamp = doc.css('.timestamp .timeago').first&.attr('title')
47
+
48
+ puts '## SimpleCov Coverage Report'
49
+ puts "Generated: #{timestamp}"
50
+ puts ''
51
+
52
+ puts "### Line Coverage: #{line_percent}"
53
+ puts " #{covered_lines}/#{total_lines} lines covered"
54
+ puts " #{missed_lines} lines missed"
55
+ puts ''
56
+
57
+ if branch_percent
58
+ puts "### Branch Coverage: #{branch_percent}"
59
+ puts " #{overall_covered_branches}/#{overall_total_branches} branches covered"
60
+ puts " #{overall_missed_branches} branches missed"
61
+ puts ''
62
+ end
63
+
64
+ # Show file-by-file breakdown if there are missed lines or branches
65
+ if missed_lines.to_i.positive? || overall_missed_branches&.to_i&.positive?
66
+ # First, collect all files with missing coverage
67
+ files_with_issues = []
68
+ files_shown = Set.new
69
+
70
+ doc.css('tbody .t-file').each do |row|
71
+ file_name = row.css('.t-file__name a').first&.text&.strip
72
+ line_coverage = row.css('.t-file__coverage').first&.text&.strip
73
+ branch_coverage = row.css('.t-file__branch-coverage').first&.text&.strip
74
+
75
+ # Only include files that aren't 100% covered and haven't been seen yet
76
+ should_skip = files_shown.include?(file_name) ||
77
+ (line_coverage == '100.00 %' && (!branch_coverage || branch_coverage == '100.00 %'))
78
+ next if should_skip
79
+
80
+ files_shown.add(file_name)
81
+ files_with_issues << row
82
+ end
83
+
84
+ total_files_with_issues = files_with_issues.length
85
+ files_to_show = files_with_issues.take(50)
86
+
87
+ puts '### Files with missing coverage:'
88
+ puts ''
89
+ if total_files_with_issues > 50
90
+ puts "Showing top 50 of #{total_files_with_issues} files with missing coverage:"
91
+ puts ''
92
+ end
93
+
94
+ # rubocop:disable Metrics/BlockLength
95
+ files_to_show.each do |row|
96
+ file_name = row.css('.t-file__name a').first&.text&.strip
97
+ line_coverage = row.css('.t-file__coverage').first&.text&.strip
98
+ branch_coverage = row.css('.t-file__branch-coverage').first&.text&.strip
99
+ file_link = row.css('.t-file__name a').first&.attr('href')
100
+
101
+ # Extract detailed line information for this file
102
+ missed_lines = []
103
+ missed_branches = []
104
+ total_file_lines = 0
105
+ covered_file_lines = 0
106
+
107
+ if file_link
108
+ file_id = file_link.gsub('#', '')
109
+ file_section = doc.css("##{file_id}")
110
+
111
+ if file_section.any?
112
+ # Get the actual counts from SimpleCov's summary
113
+ line_summary = file_section.css('.t-line-summary')
114
+ if line_summary.any? # rubocop:disable Metrics/BlockNesting
115
+ summary_text = line_summary.text
116
+ # Extract numbers from text like "13 relevant lines. 12 lines covered and 1 lines missed."
117
+ total_file_lines = Regexp.last_match(1).to_i if summary_text.match(/(\d+)\s+relevant\s+lines/) # rubocop:disable Metrics/BlockNesting
118
+ covered_file_lines = Regexp.last_match(1).to_i if summary_text.match(/(\d+)\s+lines\s+covered/) # rubocop:disable Metrics/BlockNesting
119
+ end
120
+
121
+ # Find missed lines and branches
122
+ file_section.css('li').each do |line_item|
123
+ line_number = line_item.attr('data-linenumber')
124
+ line_class = line_item.attr('class')
125
+
126
+ if line_class&.include?('missed') && !line_class.include?('missed-branch') # rubocop:disable Metrics/BlockNesting
127
+ missed_lines << line_number
128
+ elsif line_class&.include?('missed-branch') # rubocop:disable Metrics/BlockNesting
129
+ missed_branches << line_number
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ # Format the line ranges more clearly
136
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
137
+ def format_line_ranges(lines)
138
+ return '' if lines.empty?
139
+
140
+ ranges = []
141
+ current_range = [lines.first.to_i]
142
+
143
+ lines.map(&:to_i).sort[1..]&.each do |line|
144
+ if line == current_range.last + 1
145
+ current_range << line
146
+ else
147
+ ranges << format_range(current_range)
148
+ current_range = [line]
149
+ end
150
+ end
151
+ ranges << format_range(current_range)
152
+
153
+ "L#{ranges.join(', L')}"
154
+ end
155
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
156
+
157
+ def format_range(range)
158
+ if range.length == 1
159
+ range.first.to_s
160
+ else
161
+ "#{range.first}-#{range.last}"
162
+ end
163
+ end
164
+
165
+ # Get branch counts from the file section
166
+ covered_branches = 0
167
+ total_branches = 0
168
+
169
+ if file_link
170
+ file_id = file_link.gsub('#', '')
171
+ file_section = doc.css("##{file_id}")
172
+ branch_summary = file_section.css('.t-branch-summary')
173
+
174
+ if branch_summary.any?
175
+ branch_spans = branch_summary.css('span b')
176
+ total_branches = branch_spans[0]&.text.to_i
177
+ covered_branches = branch_spans[1]&.text.to_i
178
+ end
179
+ end
180
+
181
+ branch_display = branch_coverage ? "Branch: #{branch_coverage}" : 'Branch: N/A'
182
+ puts " #{file_name} (Line: #{line_coverage}, #{branch_display}):"
183
+
184
+ line_info = "Lines: #{covered_file_lines}/#{total_file_lines}"
185
+ line_info += " (missed: #{format_line_ranges(missed_lines)})" unless missed_lines.empty?
186
+ puts " #{line_info}"
187
+
188
+ if total_branches.positive?
189
+ branch_info = "Branches: #{covered_branches}/#{total_branches}"
190
+ branch_info += " (missed: #{format_line_ranges(missed_branches)})" unless missed_branches.empty?
191
+ puts " #{branch_info}"
192
+ end
193
+
194
+ puts ''
195
+ end
196
+ # rubocop:enable Metrics/BlockLength
197
+ end
198
+
199
+ # Write to GitHub Actions job summary if available
200
+ if ENV['GITHUB_STEP_SUMMARY']
201
+ File.open(ENV['GITHUB_STEP_SUMMARY'], 'a') do |f|
202
+ f.puts ''
203
+ f.puts '## Test Coverage Report'
204
+ f.puts ''
205
+ f.puts "**Line Coverage:** #{line_percent} (#{covered_lines}/#{total_lines} lines)"
206
+ f.puts ''
207
+
208
+ if branch_percent
209
+ f.puts "**Branch Coverage:** #{branch_percent} (#{overall_covered_branches}/#{overall_total_branches} branches)"
210
+ f.puts ''
211
+ end
212
+
213
+ # Add visual indicators
214
+ line_pct_value = line_percent.gsub('%', '').to_f
215
+ branch_pct_value = branch_percent&.gsub('%', '')&.to_f
216
+
217
+ if line_pct_value >= 95 && (!branch_pct_value || branch_pct_value >= 95)
218
+ f.puts 'Coverage meets quality thresholds'
219
+ elsif line_pct_value >= 80 && (!branch_pct_value || branch_pct_value >= 80)
220
+ f.puts 'Coverage is acceptable but could be improved'
221
+ else
222
+ f.puts 'Coverage is below recommended thresholds'
223
+ end
224
+ end
225
+ end
data/bin/test ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # Run the test suite
3
+ set -e
4
+
5
+ cd "$(dirname "$0")/.."
6
+
7
+ rake test "$@"
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Api
5
+ # Wrapper for Slack search.* API endpoints
6
+ # Note: search.messages requires user tokens (xoxc/xoxs), NOT bot tokens (xoxb)
7
+ class Search
8
+ def initialize(api_client, workspace)
9
+ @api = api_client
10
+ @workspace = workspace
11
+ end
12
+
13
+ # Search for messages across channels and DMs
14
+ # @param query [String] Search query with optional modifiers (in:, from:, before:, after:, etc.)
15
+ # @param count [Integer] Number of results per page (max 100)
16
+ # @param page [Integer] Page number (1-indexed)
17
+ # @param sort [String] Sort field: 'score' or 'timestamp'
18
+ # @param sort_dir [String] Sort direction: 'asc' or 'desc'
19
+ # @return [Hash] API response with messages.matches array
20
+ def messages(query:, count: 20, page: 1, sort: 'timestamp', sort_dir: 'desc')
21
+ @api.get(@workspace, 'search.messages', {
22
+ query: query,
23
+ count: [count, 100].min,
24
+ page: page,
25
+ sort: sort,
26
+ sort_dir: sort_dir
27
+ })
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/slk/cli.rb CHANGED
@@ -13,6 +13,7 @@ module Slk
13
13
  'unread' => Commands::Unread,
14
14
  'catchup' => Commands::Catchup,
15
15
  'activity' => Commands::Activity,
16
+ 'search' => Commands::Search,
16
17
  'preset' => Commands::Preset,
17
18
  'workspaces' => Commands::Workspaces,
18
19
  'cache' => Commands::Cache,
@@ -115,10 +116,12 @@ module Slk
115
116
  end
116
117
 
117
118
  def build_runner(args)
118
- verbose = args.include?('-v') || args.include?('--verbose')
119
- output = Formatters::Output.new(verbose: verbose)
119
+ verbose = verbose_mode?(args)
120
+ very_verbose = args.include?('-vv') || args.include?('--very-verbose')
121
+ output = @output || Formatters::Output.new(verbose: verbose)
120
122
  runner = Runner.new(output: output)
121
123
  setup_verbose_logging(runner, output) if verbose
124
+ setup_very_verbose_logging(runner, output) if very_verbose
122
125
  runner
123
126
  end
124
127
 
@@ -128,6 +131,49 @@ module Slk
128
131
  }
129
132
  end
130
133
 
134
+ def setup_very_verbose_logging(runner, output)
135
+ setup_response_header_logging(runner, output)
136
+ setup_request_body_logging(runner, output)
137
+ setup_response_body_logging(runner, output)
138
+ setup_cache_logging(runner, output)
139
+ end
140
+
141
+ def setup_response_header_logging(runner, output)
142
+ runner.api_client.on_response = lambda { |method, code, headers|
143
+ next if headers.empty?
144
+
145
+ elapsed = headers.delete('elapsed_ms')
146
+ req_id = headers.delete('X-Slack-Req-Id')
147
+ rate_parts = headers.map { |k, v| "#{k.sub('X-RateLimit-', '')}=#{v}" }
148
+
149
+ parts = ["#{elapsed}ms"]
150
+ parts << "req=#{req_id}" if req_id
151
+ parts.concat(rate_parts)
152
+ output.debug(" #{method} #{code}: #{parts.join(', ')}")
153
+ }
154
+ end
155
+
156
+ def setup_request_body_logging(runner, output)
157
+ runner.api_client.on_request_body = lambda { |method, body|
158
+ truncated = body.length > 500 ? "#{body[0..500]}..." : body
159
+ output.debug(" #{method} request: #{truncated}")
160
+ }
161
+ end
162
+
163
+ def setup_response_body_logging(runner, output)
164
+ runner.api_client.on_response_body = lambda { |method, body|
165
+ truncated = body.length > 500 ? "#{body[0..500]}..." : body
166
+ output.debug(" #{method} response: #{truncated}")
167
+ }
168
+ end
169
+
170
+ def setup_cache_logging(runner, output)
171
+ runner.cache_store.on_cache_access = lambda { |type, _workspace, key, hit, value|
172
+ status = hit ? "HIT (#{value})" : 'MISS'
173
+ output.debug(" [Cache] #{type} #{key}: #{status}")
174
+ }
175
+ end
176
+
131
177
  def execute_command(command_class, args, runner)
132
178
  command = command_class.new(args, runner: runner)
133
179
  result = command.execute
@@ -136,7 +182,8 @@ module Slk
136
182
  end
137
183
 
138
184
  def verbose_mode?(args)
139
- args.include?('-v') || args.include?('--verbose')
185
+ args.include?('-v') || args.include?('--verbose') ||
186
+ args.include?('-vv') || args.include?('--very-verbose')
140
187
  end
141
188
 
142
189
  def log_api_call_count(runner)
@@ -69,6 +69,7 @@ module Slk
69
69
  when '--no-wrap' then @options[:width] = nil
70
70
  when '--all' then @options[:all] = true
71
71
  when '-v', '--verbose' then @options[:verbose] = true
72
+ when '-vv', '--very-verbose' then @options[:verbose] = @options[:very_verbose] = true
72
73
  when '-q', '--quiet' then @options[:quiet] = true
73
74
  when '--json' then @options[:json] = true
74
75
  when '-h', '--help' then @options[:help] = true
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../support/help_formatter'
4
+ require_relative '../support/platform'
4
5
 
5
6
  module Slk
6
7
  module Commands
@@ -293,7 +294,7 @@ module Slk
293
294
 
294
295
  def open_channel_in_slack(workspace, channel_id)
295
296
  team_id = runner.client_api(workspace.name).team_id
296
- system('open', "slack://channel?team=#{team_id}&id=#{channel_id}")
297
+ Support::Platform.open_url("slack://channel?team=#{team_id}&id=#{channel_id}")
297
298
  success('Opened in Slack')
298
299
  :next
299
300
  end
@@ -402,7 +403,7 @@ module Slk
402
403
  first = thread_mark_data.first
403
404
  team_id = runner.client_api(workspace.name).team_id
404
405
  url = "slack://channel?team=#{team_id}&id=#{first[:channel]}&thread_ts=#{first[:thread_ts]}"
405
- system('open', url)
406
+ Support::Platform.open_url(url)
406
407
  success('Opened in Slack')
407
408
  end
408
409
  end