slk 0.2.0 → 0.4.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +30 -12
  4. data/bin/ci +15 -0
  5. data/bin/coverage +225 -0
  6. data/bin/test +7 -0
  7. data/lib/slk/api/saved.rb +26 -0
  8. data/lib/slk/api/search.rb +31 -0
  9. data/lib/slk/cli.rb +61 -3
  10. data/lib/slk/commands/activity.rb +7 -13
  11. data/lib/slk/commands/base.rb +3 -1
  12. data/lib/slk/commands/catchup.rb +3 -2
  13. data/lib/slk/commands/config.rb +48 -45
  14. data/lib/slk/commands/help.rb +3 -0
  15. data/lib/slk/commands/later.rb +297 -0
  16. data/lib/slk/commands/messages.rb +63 -14
  17. data/lib/slk/commands/search.rb +223 -0
  18. data/lib/slk/commands/ssh_key_manager.rb +129 -0
  19. data/lib/slk/formatters/activity_formatter.rb +4 -7
  20. data/lib/slk/formatters/attachment_formatter.rb +16 -2
  21. data/lib/slk/formatters/markdown_output.rb +98 -0
  22. data/lib/slk/formatters/mention_replacer.rb +13 -31
  23. data/lib/slk/formatters/message_formatter.rb +18 -33
  24. data/lib/slk/formatters/reaction_formatter.rb +1 -1
  25. data/lib/slk/formatters/saved_item_formatter.rb +144 -0
  26. data/lib/slk/formatters/search_formatter.rb +71 -0
  27. data/lib/slk/formatters/text_processor.rb +48 -0
  28. data/lib/slk/models/saved_item.rb +128 -0
  29. data/lib/slk/models/search_result.rb +115 -0
  30. data/lib/slk/runner.rb +32 -0
  31. data/lib/slk/services/api_client.rb +60 -11
  32. data/lib/slk/services/cache_store.rb +55 -36
  33. data/lib/slk/services/encryption.rb +114 -11
  34. data/lib/slk/services/message_resolver.rb +38 -0
  35. data/lib/slk/services/setup_wizard.rb +3 -3
  36. data/lib/slk/services/target_resolver.rb +27 -4
  37. data/lib/slk/services/token_loader.rb +83 -0
  38. data/lib/slk/services/token_saver.rb +87 -0
  39. data/lib/slk/services/token_store.rb +35 -65
  40. data/lib/slk/services/user_lookup.rb +117 -0
  41. data/lib/slk/support/date_parser.rb +64 -0
  42. data/lib/slk/support/inline_images.rb +94 -10
  43. data/lib/slk/support/platform.rb +34 -0
  44. data/lib/slk/support/xdg_paths.rb +27 -9
  45. data/lib/slk/version.rb +1 -1
  46. data/lib/slk.rb +15 -0
  47. metadata +21 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f633ce48448a37653aed188faa126b5f5d009a3c393128602ee5cb3ac01ff469
4
- data.tar.gz: a9a342b35df6960696c800e6007c3cdab422edd0d3132e82e3bd7f91d4a8aeb4
3
+ metadata.gz: ef8284e736f39c42a65ef336e2d8db1a36f998393070433809db242cf6de0861
4
+ data.tar.gz: 6af8a0cdb8cbc5f35aea41e89ba0221cd0eb7ab5e4fbbf360cc60b2eb750c4d7
5
5
  SHA512:
6
- metadata.gz: 3422fcd166300be8a976c6996107c9e95623a1ee24d6850c365c8da8f14e57e81d089902405b027fc5552006ad38468a326b79a8fdfc49916abef875f36c5b6a
7
- data.tar.gz: 23def7efe41b31edbe1791e8baf603676d4250ff04d92d0ef90932ae8682bcf3a6babdc9c514f40f64e980703afc9d1057bb70ab9a46380b8a038520ba9de160
6
+ metadata.gz: 2c384f28530877909f82a596d4b306ca342989b5720f7cee47ea8157b91f59bd21202101c85e8878a83cb909a3ff954dec6dcabea8d3de2ae72c5d49e6f55882
7
+ data.tar.gz: 1da065c6704edfd5beaf45b2989a9fec1a183061109d5d167467943d7eecaf3b1f7c707e46ae4b517b2e28c3d1edcb23cf37d0f90fda1882408d362be59fc992
data/CHANGELOG.md CHANGED
@@ -7,7 +7,66 @@ 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
+ ### Added
11
+
12
+ - **Ghostty/Kitty terminal support** - Inline emoji images now work in Ghostty and Kitty terminals
13
+ - Uses Kitty graphics protocol with Unicode placeholders for proper tmux support
14
+ - Images clear correctly with `clear` command (no floating artifacts)
15
+ - Converts GIF/JPEG to PNG automatically (macOS only via `sips`)
16
+
17
+ - **`later` command** - View Slack's "Save for Later" items
18
+ - Lists saved messages with content preview
19
+ - Filter by state: `--completed`, `--in-progress`
20
+ - `--counts` for summary statistics (total, overdue, with due dates)
21
+ - `--no-content` to skip fetching message text
22
+ - `--workspace-emoji` for inline custom emoji images
23
+ - `--width N` to wrap text at N columns
24
+ - `--no-wrap` to truncate messages to single line
25
+ - `--json` output includes message content
26
+
27
+ ### Changed
28
+
29
+ - New `TextProcessor` service centralizes text processing (HTML decode, mentions, emoji)
30
+ - New `MessageResolver` service extracted from activity command for reuse
31
+ - Refactored formatters to use shared TextProcessor
32
+
33
+ ## [0.4.0] - 2026-01-30
34
+
35
+ ### Added
36
+
37
+ - **Windows Support** - slk now runs on Windows
38
+ - Uses `%APPDATA%` and `%LOCALAPPDATA%` for config/cache directories
39
+ - Cross-platform command detection with `Open3.capture3`
40
+ - Proper NTFS permission handling (skips `chmod` on Windows)
41
+ - New `Support::Platform` module for OS-specific behavior
42
+ - CI testing on Windows (Ruby 3.2, 3.3, 3.4, 4.0)
43
+
44
+ ### Changed
45
+
46
+ - New `UserLookup` service consolidates duplicate user name resolution logic
47
+ - Removed ~65 lines of duplicated code from `MentionReplacer` and `MessageFormatter`
48
+
49
+ ## [0.3.0] - 2026-01-16
50
+
51
+ ### Added
52
+
53
+ - `-vv`/`--very-verbose` flag for detailed API debugging with timing and response bodies
54
+ - SSH key validation and token migration when keys change
55
+ - Public key validation (ensures it matches private key)
56
+ - `config unset` command for removing configuration values
57
+ - CI infrastructure with GitHub Actions (Ruby 3.2-4.0, macOS, Ubuntu)
58
+
59
+ ### Changed
60
+
61
+ - Improved error handling throughout with comprehensive tests
62
+ - Better SSH key error messages with public key prompting
63
+ - Cache user lookups to reduce API calls
64
+ - Improved rate limit error messages
65
+
66
+ ### Fixed
67
+
68
+ - Test output no longer leaks to stdout
69
+ - All rubocop offenses resolved
11
70
 
12
71
  ## [0.2.0] - 2025-01-15
13
72
 
@@ -63,5 +122,7 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
63
122
  - Pure Ruby stdlib - no gem dependencies
64
123
  - Ruby 3.2+ with modern features (Data.define, pattern matching)
65
124
 
125
+ [0.4.0]: https://github.com/ericboehs/slk/releases/tag/v0.4.0
126
+ [0.3.0]: https://github.com/ericboehs/slk/releases/tag/v0.3.0
66
127
  [0.2.0]: https://github.com/ericboehs/slk/releases/tag/v0.2.0
67
128
  [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,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Api
5
+ # Thin wrapper for the Slack saved.list API endpoint
6
+ # Used to fetch "Save for Later" items
7
+ class Saved
8
+ def initialize(api_client, workspace)
9
+ @api = api_client
10
+ @workspace = workspace
11
+ end
12
+
13
+ # List saved items
14
+ # @param filter [String] Filter type: 'saved', 'in_progress', 'completed', 'archived'
15
+ # @param limit [Integer] Number of items to return (default: 15)
16
+ # @param cursor [String, nil] Pagination cursor
17
+ # @return [Hash] API response with 'ok' and 'saved_items' keys
18
+ # @raise [ApiError] if the API call fails (network error, auth error, etc.)
19
+ def list(filter: 'saved', limit: 15, cursor: nil)
20
+ params = { filter: filter, limit: limit.to_s }
21
+ params[:cursor] = cursor if cursor
22
+ @api.post_form(@workspace, 'saved.list', params)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -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,8 @@ module Slk
13
13
  'unread' => Commands::Unread,
14
14
  'catchup' => Commands::Catchup,
15
15
  'activity' => Commands::Activity,
16
+ 'later' => Commands::Later,
17
+ 'search' => Commands::Search,
16
18
  'preset' => Commands::Preset,
17
19
  'workspaces' => Commands::Workspaces,
18
20
  'cache' => Commands::Cache,
@@ -24,6 +26,7 @@ module Slk
24
26
  def initialize(argv, output: nil)
25
27
  @argv = argv.dup
26
28
  @output = output || Formatters::Output.new
29
+ @output_injected = !output.nil?
27
30
  end
28
31
 
29
32
  def run
@@ -115,19 +118,73 @@ module Slk
115
118
  end
116
119
 
117
120
  def build_runner(args)
118
- verbose = args.include?('-v') || args.include?('--verbose')
119
- output = Formatters::Output.new(verbose: verbose)
121
+ verbose = verbose_mode?(args)
122
+ very_verbose = args.include?('-vv') || args.include?('--very-verbose')
123
+ markdown = args.include?('--markdown')
124
+ output = @output_injected ? @output : build_output(verbose: verbose, markdown: markdown)
120
125
  runner = Runner.new(output: output)
121
126
  setup_verbose_logging(runner, output) if verbose
127
+ setup_very_verbose_logging(runner, output) if very_verbose
122
128
  runner
123
129
  end
124
130
 
131
+ def build_output(verbose:, markdown:)
132
+ if markdown
133
+ Formatters::MarkdownOutput.new(verbose: verbose)
134
+ else
135
+ Formatters::Output.new(verbose: verbose)
136
+ end
137
+ end
138
+
125
139
  def setup_verbose_logging(runner, output)
126
140
  runner.api_client.on_request = lambda { |method, count|
127
141
  output.debug("[API ##{count}] #{method}")
128
142
  }
129
143
  end
130
144
 
145
+ def setup_very_verbose_logging(runner, output)
146
+ setup_response_header_logging(runner, output)
147
+ setup_request_body_logging(runner, output)
148
+ setup_response_body_logging(runner, output)
149
+ setup_cache_logging(runner, output)
150
+ end
151
+
152
+ def setup_response_header_logging(runner, output)
153
+ runner.api_client.on_response = lambda { |method, code, headers|
154
+ next if headers.empty?
155
+
156
+ elapsed = headers.delete('elapsed_ms')
157
+ req_id = headers.delete('X-Slack-Req-Id')
158
+ rate_parts = headers.map { |k, v| "#{k.sub('X-RateLimit-', '')}=#{v}" }
159
+
160
+ parts = ["#{elapsed}ms"]
161
+ parts << "req=#{req_id}" if req_id
162
+ parts.concat(rate_parts)
163
+ output.debug(" #{method} #{code}: #{parts.join(', ')}")
164
+ }
165
+ end
166
+
167
+ def setup_request_body_logging(runner, output)
168
+ runner.api_client.on_request_body = lambda { |method, body|
169
+ truncated = body.length > 500 ? "#{body[0..500]}..." : body
170
+ output.debug(" #{method} request: #{truncated}")
171
+ }
172
+ end
173
+
174
+ def setup_response_body_logging(runner, output)
175
+ runner.api_client.on_response_body = lambda { |method, body|
176
+ truncated = body.length > 500 ? "#{body[0..500]}..." : body
177
+ output.debug(" #{method} response: #{truncated}")
178
+ }
179
+ end
180
+
181
+ def setup_cache_logging(runner, output)
182
+ runner.cache_store.on_cache_access = lambda { |type, _workspace, key, hit, value|
183
+ status = hit ? "HIT (#{value})" : 'MISS'
184
+ output.debug(" [Cache] #{type} #{key}: #{status}")
185
+ }
186
+ end
187
+
131
188
  def execute_command(command_class, args, runner)
132
189
  command = command_class.new(args, runner: runner)
133
190
  result = command.execute
@@ -136,7 +193,8 @@ module Slk
136
193
  end
137
194
 
138
195
  def verbose_mode?(args)
139
- args.include?('-v') || args.include?('--verbose')
196
+ args.include?('-v') || args.include?('--verbose') ||
197
+ args.include?('-vv') || args.include?('--very-verbose')
140
198
  end
141
199
 
142
200
  def log_api_call_count(runner)
@@ -118,7 +118,7 @@ module Slk
118
118
  output: output,
119
119
  enricher: enricher(workspace),
120
120
  emoji_replacer: runner.emoji_replacer,
121
- mention_replacer: runner.mention_replacer,
121
+ text_processor: runner.text_processor,
122
122
  on_debug: ->(msg) { debug(msg) }
123
123
  )
124
124
  end
@@ -131,20 +131,14 @@ module Slk
131
131
  end
132
132
 
133
133
  def fetch_message(workspace, channel_id, message_ts)
134
- response = fetch_message_history(workspace, channel_id, message_ts)
135
- return nil unless response['ok'] && response['messages']&.any?
136
-
137
- response['messages'].find { |msg| msg['ts'] == message_ts }
138
- rescue ApiError => e
139
- debug("Could not fetch message #{message_ts} from #{channel_id}: #{e.message}")
140
- nil
134
+ message_resolver(workspace).fetch_by_ts(channel_id, message_ts)
141
135
  end
142
136
 
143
- def fetch_message_history(workspace, channel_id, message_ts)
144
- api = runner.conversations_api(workspace.name)
145
- oldest_ts = (message_ts.to_f - 1).to_s
146
- latest_ts = (message_ts.to_f + 1).to_s
147
- api.history(channel: channel_id, limit: 10, oldest: oldest_ts, latest: latest_ts)
137
+ def message_resolver(workspace)
138
+ Services::MessageResolver.new(
139
+ conversations_api: runner.conversations_api(workspace.name),
140
+ on_debug: ->(msg) { debug(msg) }
141
+ )
148
142
  end
149
143
  end
150
144
  # rubocop:enable Metrics/ClassLength
@@ -32,7 +32,7 @@ module Slk
32
32
  end
33
33
 
34
34
  def base_options
35
- { workspace: nil, all: false, verbose: false, quiet: false, json: false, width: default_width }
35
+ { workspace: nil, all: false, verbose: false, quiet: false, json: false, markdown: false, width: default_width }
36
36
  end
37
37
 
38
38
  def formatting_options
@@ -69,8 +69,10 @@ 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
75
+ when '--markdown' then @options[:markdown] = true
74
76
  when '-h', '--help' then @options[:help] = true
75
77
  when '--no-emoji' then @options[:no_emoji] = true
76
78
  when '--no-reactions' then @options[:no_reactions] = 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