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
@@ -1,118 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'token_loader'
4
+ require_relative 'token_saver'
5
+
3
6
  module Slk
4
7
  module Services
5
8
  # Manages workspace tokens with optional encryption
6
9
  class TokenStore
7
- attr_accessor :on_warning
10
+ attr_accessor :on_warning, :on_info, :on_prompt_pub_key
8
11
 
9
12
  def initialize(config: nil, encryption: nil, paths: nil)
10
13
  @config = config || Configuration.new
11
14
  @encryption = encryption || Encryption.new
12
15
  @paths = paths || Support::XdgPaths.new
16
+ @loader = TokenLoader.new(encryption: @encryption, paths: @paths)
17
+ @saver = TokenSaver.new(encryption: @encryption, paths: @paths)
13
18
  @on_warning = nil
19
+ @on_info = nil
20
+ @on_prompt_pub_key = nil
14
21
  end
15
22
 
16
23
  def workspace(name)
17
- tokens = load_tokens
24
+ tokens = @loader.load_auto(@config)
18
25
  data = tokens[name]
19
26
  raise WorkspaceNotFoundError, "Workspace '#{name}' not found" unless data
20
27
 
21
- Models::Workspace.new(
22
- name: name,
23
- token: data['token'],
24
- cookie: data['cookie']
25
- )
28
+ Models::Workspace.new(name: name, token: data['token'], cookie: data['cookie'])
26
29
  end
27
30
 
28
31
  def all_workspaces
29
- load_tokens.map do |name, data|
30
- Models::Workspace.new(
31
- name: name,
32
- token: data['token'],
33
- cookie: data['cookie']
34
- )
32
+ @loader.load_auto(@config).map do |name, data|
33
+ Models::Workspace.new(name: name, token: data['token'], cookie: data['cookie'])
35
34
  end
36
35
  end
37
36
 
38
37
  def workspace_names
39
- load_tokens.keys
38
+ @loader.load_auto(@config).keys
40
39
  end
41
40
 
42
41
  def exists?(name)
43
- load_tokens.key?(name)
42
+ @loader.load_auto(@config).key?(name)
44
43
  end
45
44
 
46
45
  def add(name, token, cookie = nil)
47
- # Validate by constructing a Workspace (will raise ArgumentError if invalid)
48
46
  Models::Workspace.new(name: name, token: token, cookie: cookie)
49
-
50
- tokens = load_tokens
47
+ tokens = @loader.load_auto(@config)
51
48
  tokens[name] = { 'token' => token, 'cookie' => cookie }.compact
52
- save_tokens(tokens)
49
+ @saver.save(tokens, @config.ssh_key)
53
50
  end
54
51
 
55
52
  def remove(name) # rubocop:disable Naming/PredicateMethod
56
- tokens = load_tokens
53
+ tokens = @loader.load_auto(@config)
57
54
  removed = tokens.delete(name)
58
- save_tokens(tokens) if removed
55
+ @saver.save(tokens, @config.ssh_key) if removed
59
56
  !removed.nil?
60
57
  end
61
58
 
62
59
  def empty?
63
- load_tokens.empty?
60
+ @loader.load_auto(@config).empty?
64
61
  end
65
62
 
66
- private
63
+ def migrate_encryption(old_ssh_key, new_ssh_key)
64
+ return if old_ssh_key == new_ssh_key
67
65
 
68
- def load_tokens
69
- if encrypted_file_exists?
70
- decrypt_tokens
71
- elsif plain_file_exists?
72
- JSON.parse(File.read(plain_tokens_file))
73
- else
74
- {}
75
- end
76
- rescue JSON::ParserError => e
77
- raise TokenStoreError, "Tokens file #{plain_tokens_file} is corrupted: #{e.message}"
66
+ tokens = @loader.load(old_ssh_key)
67
+ return if tokens.empty?
68
+
69
+ @encryption.on_prompt_pub_key = @on_prompt_pub_key
70
+ @encryption.validate_key_type!(new_ssh_key) if new_ssh_key
71
+ @saver.save_with_cleanup(tokens, new_ssh_key)
72
+ notify_encryption_change(old_ssh_key, new_ssh_key)
78
73
  end
79
74
 
80
- def save_tokens(tokens)
81
- @paths.ensure_config_dir
75
+ private
82
76
 
83
- if @config.ssh_key
84
- # When encryption is configured, always use it - don't silently fall back
85
- @encryption.encrypt(JSON.generate(tokens), @config.ssh_key, encrypted_tokens_file)
86
- FileUtils.rm_f(plain_tokens_file)
77
+ def notify_encryption_change(old_ssh_key, new_ssh_key)
78
+ if new_ssh_key && old_ssh_key
79
+ @on_info&.call('Tokens have been re-encrypted with the new SSH key.')
80
+ elsif new_ssh_key
81
+ @on_info&.call('Tokens have been encrypted with the new SSH key.')
87
82
  else
88
- # Plain text storage (no encryption configured)
89
- File.write(plain_tokens_file, JSON.pretty_generate(tokens))
90
- File.chmod(0o600, plain_tokens_file)
83
+ @on_warning&.call('Tokens are now stored in plaintext.')
91
84
  end
92
85
  end
93
-
94
- def decrypt_tokens
95
- content = @encryption.decrypt(encrypted_tokens_file, @config.ssh_key)
96
- content ? JSON.parse(content) : {}
97
- rescue JSON::ParserError => e
98
- raise TokenStoreError, "Encrypted tokens file is corrupted: #{e.message}"
99
- end
100
-
101
- def encrypted_file_exists?
102
- File.exist?(encrypted_tokens_file)
103
- end
104
-
105
- def plain_file_exists?
106
- File.exist?(plain_tokens_file)
107
- end
108
-
109
- def encrypted_tokens_file
110
- @paths.config_file('tokens.age')
111
- end
112
-
113
- def plain_tokens_file
114
- @paths.config_file('tokens.json')
115
- end
116
86
  end
117
87
  end
118
88
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Consolidated service for user name resolution
6
+ # Provides ID → name and name → ID lookups with caching
7
+ class UserLookup
8
+ def initialize(cache_store:, workspace:, api_client: nil, on_debug: nil)
9
+ @cache = cache_store
10
+ @api = api_client
11
+ @workspace = workspace
12
+ @on_debug = on_debug
13
+ end
14
+
15
+ # Resolve user ID to display name (most common use case)
16
+ # @param user_id [String] Slack user ID (e.g., "U123ABC")
17
+ # @return [String, nil] Display name or nil if not found
18
+ def resolve_name(user_id)
19
+ return nil if user_id.to_s.empty?
20
+
21
+ cached = @cache.get_user(@workspace.name, user_id)
22
+ return cached if cached
23
+
24
+ fetch_and_cache_name(user_id)
25
+ end
26
+
27
+ # Resolve user ID to display name, handling bots
28
+ # @param user_id [String] Slack user or bot ID
29
+ # @return [String, nil] Display name or nil if not found
30
+ def resolve_name_or_bot(user_id)
31
+ return nil if user_id.to_s.empty?
32
+
33
+ cached = @cache.get_user(@workspace.name, user_id)
34
+ return cached if cached
35
+
36
+ if user_id.start_with?('B')
37
+ fetch_and_cache_bot_name(user_id)
38
+ else
39
+ fetch_and_cache_name(user_id)
40
+ end
41
+ end
42
+
43
+ # Find user ID by display name (reverse lookup)
44
+ # @param name [String] Display name to search for
45
+ # @return [String, nil] User ID or nil if not found
46
+ def find_id_by_name(name)
47
+ return nil if name.to_s.empty?
48
+
49
+ cached = @cache.get_user_id_by_name(@workspace.name, name)
50
+ return cached if cached
51
+
52
+ fetch_id_by_name(name)
53
+ end
54
+
55
+ private
56
+
57
+ def fetch_and_cache_name(user_id)
58
+ return nil unless @api
59
+
60
+ user = fetch_user(user_id)
61
+ return nil unless user
62
+
63
+ @cache.set_user(@workspace.name, user_id, user.best_name, persist: true)
64
+ user.best_name
65
+ rescue ApiError => e
66
+ @on_debug&.call("User lookup failed for #{user_id}: #{e.message}")
67
+ nil
68
+ end
69
+
70
+ def fetch_and_cache_bot_name(bot_id)
71
+ return nil unless @api
72
+
73
+ bots_api = Api::Bots.new(@api, @workspace, on_debug: @on_debug)
74
+ name = bots_api.get_name(bot_id)
75
+ @cache.set_user(@workspace.name, bot_id, name, persist: true) if name
76
+ name
77
+ rescue ApiError => e
78
+ @on_debug&.call("Bot lookup failed for #{bot_id}: #{e.message}")
79
+ nil
80
+ end
81
+
82
+ def fetch_user(user_id)
83
+ users_api = Api::Users.new(@api, @workspace, on_debug: @on_debug)
84
+ response = users_api.info(user_id)
85
+ return nil unless response['ok'] && response['user']
86
+
87
+ Models::User.from_api(response['user'])
88
+ end
89
+
90
+ def fetch_id_by_name(name)
91
+ return nil unless @api
92
+
93
+ users_api = Api::Users.new(@api, @workspace, on_debug: @on_debug)
94
+ users = users_api.list['members'] || []
95
+ user = find_user_by_name(users, name)
96
+ cache_user_from_api(user) if user
97
+ user&.dig('id')
98
+ rescue ApiError => e
99
+ @on_debug&.call("User list lookup failed: #{e.message}")
100
+ nil
101
+ end
102
+
103
+ def find_user_by_name(users, name)
104
+ users.find do |u|
105
+ u['name'] == name ||
106
+ u.dig('profile', 'display_name') == name ||
107
+ u.dig('profile', 'real_name') == name
108
+ end
109
+ end
110
+
111
+ def cache_user_from_api(user_data)
112
+ user = Models::User.from_api(user_data)
113
+ @cache.set_user(@workspace.name, user.id, user.best_name, persist: true)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Slk
6
+ module Support
7
+ # Parses date strings into timestamps for Slack API queries
8
+ # Supports duration formats (1d, 7d, 1w, 1m) and ISO dates (YYYY-MM-DD)
9
+ class DateParser
10
+ DURATION_PATTERN = /\A(\d+)([dwm])\z/i
11
+ ISO_DATE_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/
12
+
13
+ # Parse a date string and return a Unix timestamp
14
+ # @param input [String] duration (1d, 7d, 1w, 1m) or ISO date (YYYY-MM-DD)
15
+ # @return [Integer] Unix timestamp
16
+ # @raise [ArgumentError] if format is invalid
17
+ def self.parse(input)
18
+ new.parse(input)
19
+ end
20
+
21
+ # Parse a date string and return a Slack-formatted timestamp (with microseconds)
22
+ # @param input [String] duration or ISO date
23
+ # @return [String] Slack timestamp like "1234567890.000000"
24
+ def self.to_slack_timestamp(input)
25
+ "#{parse(input)}.000000"
26
+ end
27
+
28
+ def parse(input)
29
+ input = input.to_s.strip
30
+
31
+ case input
32
+ when DURATION_PATTERN
33
+ parse_duration(input)
34
+ when ISO_DATE_PATTERN
35
+ parse_iso_date(input)
36
+ else
37
+ raise ArgumentError, "Invalid date format: #{input}. Use duration (1d, 7d, 1w, 1m) or ISO date (YYYY-MM-DD)"
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def parse_duration(input)
44
+ match = input.match(DURATION_PATTERN)
45
+ amount = match[1].to_i
46
+ unit = match[2].downcase
47
+
48
+ seconds_ago = case unit
49
+ when 'd' then amount * 86_400 # days
50
+ when 'w' then amount * 7 * 86_400 # weeks
51
+ when 'm' then amount * 30 * 86_400 # months (approximate)
52
+ end
53
+
54
+ Time.now.to_i - seconds_ago
55
+ end
56
+
57
+ def parse_iso_date(input)
58
+ Time.parse("#{input} 00:00:00").to_i
59
+ rescue ArgumentError
60
+ raise ArgumentError, "Invalid ISO date: #{input}"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -2,11 +2,17 @@
2
2
 
3
3
  module Slk
4
4
  module Support
5
- # Shared module for inline image display in iTerm2/WezTerm/Mintty terminals
5
+ # Shared module for inline image display in terminals supporting
6
+ # iTerm2 protocol (iTerm2/WezTerm/Mintty) or Kitty graphics protocol (Ghostty/Kitty)
6
7
  # Includes special handling for tmux passthrough sequences
7
8
  module InlineImages
8
- # Check if terminal supports iTerm2 inline image protocol
9
+ # Check if terminal supports any inline image protocol
9
10
  def inline_images_supported?
11
+ iterm2_protocol_supported? || kitty_graphics_supported?
12
+ end
13
+
14
+ # Check if terminal supports iTerm2 inline image protocol
15
+ def iterm2_protocol_supported?
10
16
  # iTerm2, WezTerm, Mintty support inline images
11
17
  # LC_TERMINAL persists through tmux/ssh
12
18
  ['iTerm.app', 'WezTerm'].include?(ENV.fetch('TERM_PROGRAM', nil)) ||
@@ -15,31 +21,88 @@ module Slk
15
21
  ENV['TERM'] == 'mintty'
16
22
  end
17
23
 
24
+ # Check if terminal supports Kitty graphics protocol (Ghostty, Kitty)
25
+ def kitty_graphics_supported?
26
+ ENV['TERM_PROGRAM'] == 'ghostty' ||
27
+ ENV['GHOSTTY_RESOURCES_DIR'] ||
28
+ ENV['TERM']&.include?('kitty') ||
29
+ tmux_client_is_kitty_compatible?
30
+ end
31
+
32
+ # Check if tmux client terminal supports Kitty graphics
33
+ def tmux_client_is_kitty_compatible?
34
+ return false unless in_tmux?
35
+
36
+ @tmux_client_is_kitty_compatible ||= begin
37
+ output = begin
38
+ `tmux display-message -p '\#{client_termname}'`.chomp
39
+ rescue StandardError
40
+ ''
41
+ end
42
+ output.include?('ghostty') || output.include?('kitty')
43
+ end
44
+ end
45
+
18
46
  # Check if running inside tmux
19
47
  def in_tmux?
20
48
  # tmux sets TERM to screen-* or tmux-*
21
49
  ENV['TERM']&.include?('screen') || ENV['TERM']&.start_with?('tmux')
22
50
  end
23
51
 
24
- # Print an inline image using iTerm2 protocol
52
+ # Print an inline image using the appropriate protocol
25
53
  # In tmux, uses passthrough sequence and cursor positioning
26
54
  def print_inline_image(path, height: 1)
27
- data = read_image_data(path)
55
+ data = read_image_data_for_protocol(path)
28
56
  return unless data
29
57
 
30
58
  encoded = [data].pack('m0')
31
- in_tmux? ? print_tmux_image(encoded, height) : print_iterm_image(encoded, height)
59
+
60
+ if kitty_graphics_supported?
61
+ in_tmux? ? print_tmux_kitty_image(encoded, height) : print_kitty_image(encoded, height)
62
+ else
63
+ in_tmux? ? print_tmux_iterm_image(encoded, height) : print_iterm_image(encoded, height)
64
+ end
32
65
  end
33
66
 
34
- def read_image_data(path)
67
+ def read_image_data_for_protocol(path)
35
68
  return nil unless File.exist?(path)
36
69
 
37
- File.binread(path)
70
+ data = File.binread(path)
71
+ return nil unless data
72
+
73
+ # Kitty protocol requires PNG format; convert GIF/JPEG if needed
74
+ if kitty_graphics_supported? && !png_data?(data)
75
+ convert_to_png(path)
76
+ else
77
+ data
78
+ end
38
79
  rescue IOError, SystemCallError
39
80
  nil
40
81
  end
41
82
 
42
- def print_tmux_image(encoded, height)
83
+ def png_data?(data)
84
+ # PNG files start with magic bytes: 137 80 78 71 13 10 26 10
85
+ data[0, 8]&.bytes == [137, 80, 78, 71, 13, 10, 26, 10]
86
+ end
87
+
88
+ def convert_to_png(path)
89
+ # Use sips (macOS) to convert to PNG
90
+ require 'tempfile'
91
+ temp = Tempfile.new(['emoji', '.png'])
92
+ temp.close
93
+
94
+ system('sips', '-s', 'format', 'png', path, '--out', temp.path,
95
+ out: File::NULL, err: File::NULL)
96
+
97
+ return nil unless File.exist?(temp.path) && File.size(temp.path).positive?
98
+
99
+ File.binread(temp.path)
100
+ ensure
101
+ temp&.unlink
102
+ end
103
+
104
+ # iTerm2 protocol methods
105
+ def print_tmux_iterm_image(encoded, height)
43
106
  fmt = "\ePtmux;\e\e]1337;File=inline=1;preserveAspectRatio=0;" \
44
107
  "size=%<size>d;height=%<height>d:%<data>s\a\e\\\n "
45
108
  printf fmt, size: encoded.length, height: height, data: encoded
@@ -49,6 +112,26 @@ module Slk
49
112
  printf "\e]1337;File=inline=1;height=%<height>d:%<data>s\a", height: height, data: encoded
50
113
  end
51
114
 
115
+ # Kitty graphics protocol methods (Ghostty, Kitty)
116
+ # a=T: transmit and display, q=1: suppress OK response, f=100: PNG, r=rows, m=0: no more chunks
117
+ def print_kitty_image(encoded, height)
118
+ printf "\e_Ga=T,q=1,f=100,r=%<height>d,m=0;%<data>s\e\\", height: height, data: encoded
119
+ end
120
+
121
+ # Kitty graphics with Unicode placeholders for tmux (images clear/scroll with text)
122
+ # See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
123
+ def print_tmux_kitty_image(encoded, _height)
124
+ @kitty_image_id ||= 30
125
+ @kitty_image_id = (@kitty_image_id % 255) + 1
126
+ image_id = @kitty_image_id
127
+
128
+ # tmux passthrough: transmit image with Unicode placeholder mode (U=1), 2 cols x 1 row
129
+ $stdout.print "\ePtmux;\e\e_Ga=T,U=1,q=1,f=100,i=#{image_id},c=2,r=1,m=0;#{encoded}\e\e\\\e\\"
130
+ # Output placeholder cells (U+10EEEE + row/col diacritics) with foreground color = image_id
131
+ $stdout.print "\e[38;5;#{image_id}m\u{10EEEE}\u0305\u0305\u{10EEEE}\u0305\u030D\e[39m"
132
+ $stdout.flush
133
+ end
134
+
52
135
  # Print inline image with name on same line
53
136
  # Handles tmux cursor positioning to keep image and text on same line
54
137
  def print_inline_image_with_text(path, text, height: 1) # rubocop:disable Naming/PredicateMethod
@@ -56,11 +139,12 @@ module Slk
56
139
 
57
140
  print_inline_image(path, height: height)
58
141
 
59
- if in_tmux?
60
- # tmux: image ends with \n + space, cursor on next line
142
+ if in_tmux? && !kitty_graphics_supported?
143
+ # iTerm2 in tmux: image ends with \n + space, cursor on next line
61
144
  # Move up 1 line, right 3 cols (past image), then print text
62
145
  print "\e[1A\e[3C#{text}\n"
63
146
  else
147
+ # Direct output or Unicode placeholders (regular text, just print after)
64
148
  puts " #{text}"
65
149
  end
66
150
 
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Support
5
+ # Cross-platform utilities for OS-specific operations
6
+ module Platform
7
+ module_function
8
+
9
+ def windows?
10
+ Gem.win_platform?
11
+ end
12
+
13
+ def macos?
14
+ RUBY_PLATFORM.include?('darwin')
15
+ end
16
+
17
+ def linux?
18
+ RUBY_PLATFORM.include?('linux')
19
+ end
20
+
21
+ # Open a URL or file with the system's default handler.
22
+ # Uses: open (macOS), start (Windows), xdg-open (Linux)
23
+ def open_url(url)
24
+ if windows?
25
+ system('start', '', url)
26
+ elsif macos?
27
+ system('open', url)
28
+ else
29
+ system('xdg-open', url)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -2,22 +2,40 @@
2
2
 
3
3
  module Slk
4
4
  module Support
5
- # XDG-compliant paths for config and cache directories
5
+ # Cross-platform paths for config and cache directories.
6
+ # Uses XDG Base Directory spec on Unix, APPDATA/LOCALAPPDATA on Windows.
6
7
  class XdgPaths
8
+ WINDOWS = Gem.win_platform?
9
+
7
10
  def config_dir
8
- @config_dir ||= File.join(
9
- ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config')),
10
- 'slk'
11
- )
11
+ @config_dir ||= normalize_path(File.join(default_config_base, 'slk'))
12
12
  end
13
13
 
14
14
  def cache_dir
15
- @cache_dir ||= File.join(
16
- ENV.fetch('XDG_CACHE_HOME', File.join(Dir.home, '.cache')),
17
- 'slk'
18
- )
15
+ @cache_dir ||= normalize_path(File.join(default_cache_base, 'slk'))
16
+ end
17
+
18
+ private
19
+
20
+ def default_config_base
21
+ return ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config')) unless WINDOWS
22
+
23
+ ENV.fetch('APPDATA', File.join(Dir.home, 'AppData', 'Roaming'))
19
24
  end
20
25
 
26
+ def default_cache_base
27
+ return ENV.fetch('XDG_CACHE_HOME', File.join(Dir.home, '.cache')) unless WINDOWS
28
+
29
+ ENV.fetch('LOCALAPPDATA', File.join(Dir.home, 'AppData', 'Local'))
30
+ end
31
+
32
+ # Normalize path separators to forward slashes for Dir.glob compatibility on Windows
33
+ def normalize_path(path)
34
+ WINDOWS ? path.tr('\\', '/') : path
35
+ end
36
+
37
+ public
38
+
21
39
  def config_file(filename)
22
40
  File.join(config_dir, filename)
23
41
  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.2.0'
4
+ VERSION = '0.4.1'
5
5
  end
data/lib/slk.rb CHANGED
@@ -32,6 +32,8 @@ module Slk
32
32
  autoload :User, 'slk/models/user'
33
33
  autoload :Channel, 'slk/models/channel'
34
34
  autoload :Preset, 'slk/models/preset'
35
+ autoload :SearchResult, 'slk/models/search_result'
36
+ autoload :SavedItem, 'slk/models/saved_item'
35
37
  end
36
38
 
37
39
  # Application services for configuration, caching, and API communication
@@ -39,6 +41,8 @@ module Slk
39
41
  autoload :ApiClient, 'slk/services/api_client'
40
42
  autoload :Configuration, 'slk/services/configuration'
41
43
  autoload :TokenStore, 'slk/services/token_store'
44
+ autoload :TokenLoader, 'slk/services/token_loader'
45
+ autoload :TokenSaver, 'slk/services/token_saver'
42
46
  autoload :CacheStore, 'slk/services/cache_store'
43
47
  autoload :PresetStore, 'slk/services/preset_store'
44
48
  autoload :Encryption, 'slk/services/encryption'
@@ -50,11 +54,14 @@ module Slk
50
54
  autoload :UnreadMarker, 'slk/services/unread_marker'
51
55
  autoload :TargetResolver, 'slk/services/target_resolver'
52
56
  autoload :SetupWizard, 'slk/services/setup_wizard'
57
+ autoload :UserLookup, 'slk/services/user_lookup'
58
+ autoload :MessageResolver, 'slk/services/message_resolver'
53
59
  end
54
60
 
55
61
  # Output formatters for messages, durations, and emoji
56
62
  module Formatters
57
63
  autoload :Output, 'slk/formatters/output'
64
+ autoload :MarkdownOutput, 'slk/formatters/markdown_output'
58
65
  autoload :DurationFormatter, 'slk/formatters/duration_formatter'
59
66
  autoload :MentionReplacer, 'slk/formatters/mention_replacer'
60
67
  autoload :EmojiReplacer, 'slk/formatters/emoji_replacer'
@@ -64,6 +71,9 @@ module Slk
64
71
  autoload :ActivityFormatter, 'slk/formatters/activity_formatter'
65
72
  autoload :AttachmentFormatter, 'slk/formatters/attachment_formatter'
66
73
  autoload :BlockFormatter, 'slk/formatters/block_formatter'
74
+ autoload :SearchFormatter, 'slk/formatters/search_formatter'
75
+ autoload :SavedItemFormatter, 'slk/formatters/saved_item_formatter'
76
+ autoload :TextProcessor, 'slk/formatters/text_processor'
67
77
  end
68
78
 
69
79
  # CLI commands implementing user-facing functionality
@@ -77,12 +87,14 @@ module Slk
77
87
  autoload :Unread, 'slk/commands/unread'
78
88
  autoload :Catchup, 'slk/commands/catchup'
79
89
  autoload :Activity, 'slk/commands/activity'
90
+ autoload :Search, 'slk/commands/search'
80
91
  autoload :Preset, 'slk/commands/preset'
81
92
  autoload :Workspaces, 'slk/commands/workspaces'
82
93
  autoload :Cache, 'slk/commands/cache'
83
94
  autoload :Emoji, 'slk/commands/emoji'
84
95
  autoload :Config, 'slk/commands/config'
85
96
  autoload :Help, 'slk/commands/help'
97
+ autoload :Later, 'slk/commands/later'
86
98
  end
87
99
 
88
100
  # Thin wrappers around Slack API endpoints
@@ -96,6 +108,8 @@ module Slk
96
108
  autoload :Threads, 'slk/api/threads'
97
109
  autoload :Usergroups, 'slk/api/usergroups'
98
110
  autoload :Activity, 'slk/api/activity'
111
+ autoload :Search, 'slk/api/search'
112
+ autoload :Saved, 'slk/api/saved'
99
113
  end
100
114
 
101
115
  # Utility classes for paths, parsing, and helpers
@@ -108,5 +122,6 @@ module Slk
108
122
  autoload :UserResolver, 'slk/support/user_resolver'
109
123
  autoload :TextWrapper, 'slk/support/text_wrapper'
110
124
  autoload :InteractivePrompt, 'slk/support/interactive_prompt'
125
+ autoload :DateParser, 'slk/support/date_parser'
111
126
  end
112
127
  end