slk 0.5.0 → 0.6.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: 54edc7e40c9bcf891e2d2727c4cf50c4f43de73a70051465da12f3a97bc5282b
4
- data.tar.gz: 40bbb5862b982c4e0d87d2a47c18b91a2f0070cac9fe7ad15a68e109114bf977
3
+ metadata.gz: 139ba582e24a6b92cd79493c0000a1479fbeb4ce59c90d883d0bbb603e8ff4c7
4
+ data.tar.gz: 81bdd74fc8a1d9b44480272098f78744297b3bd2b42b39dbc25c7f1fdb891c2f
5
5
  SHA512:
6
- metadata.gz: 96abfcac2a271dccbce0a7b8068d23cf70d62ff68466e2a915fd67474aa9a3b6f648dce14ebaa87718c069993886a309c86490ab865a060586789bf8f8775752
7
- data.tar.gz: ece446478a9dfda1dec3fa5dad6feff79c5f7be24e89f9835d61a0f3684e3d7839a94c673859ac5678a1f0f0c8675d31a9673493e744efcdd67433acda1f8fda
6
+ metadata.gz: f79c404a79de6d32c77e85226b92b65322ff159ae610201d3514abc66fc9f8497a30c29da802b81e0ed2f36cdb3bc805835eee0e85f744f0f323d2620391c483
7
+ data.tar.gz: 6fff7a3b9b11762d179ccd2d22e5a0e4a6e395f52bd4fe89810cb045fed144c989026c07928179efdcd35e8ac3c3c991dab8f90344042cd0475adc8fc1007d7e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-04-27
11
+
12
+ ### Added
13
+
14
+ - **`slk who [target]`** — compact teems-style profile card for self or any user
15
+ - Targets: positional arg accepts `Uxxx`, display name, real name, or email; defaults to self
16
+ - `--full` expands into Contact / People / About me sections matching the Slack web profile
17
+ - `--json` emits the full Profile struct for piping
18
+ - `--refresh` bypasses both the per-run memo and the on-disk meta cache
19
+ - `--all` prints every match in turn; `--pick N` picks the Nth match non-interactively
20
+ - Multi-match disambiguation: prints a numbered list on stderr and prompts on a TTY; non-TTY contexts raise instead of silently picking
21
+ - Renders Slack Connect external users with a stripped layout (`external — <home workspace>`)
22
+ - Marks deactivated accounts with a bold `deactivated account` tag (and `deactivated: true` in JSON)
23
+ - Type-aware custom field rendering: `link` fields use OSC 8 hyperlinks with their `alt` label, `date` fields show "Jun 17, 2024 (1y 10mo ago)", `user` fields resolve one level (name + pronouns + title)
24
+ - **`slk org [target]`** — walks the supervisor chain upward from the target
25
+ - `--depth N` caps traversal (default 5); cycle-safe via seen-set
26
+ - Indented tree with `└─ ├─ │` glyphs; `← you` marker on whichever node is the authenticated user
27
+ - New `Api::Team` wrapper for `team.info` and `team.profile.get`, plus `Api::Users#profile_for(user_id, include_labels:)`
28
+ - `Services::ProfileResolver`, `ProfileBuilder`, `UserMatcher`, `UserPicker`, `WhoTargetResolver`, `MetaCache` services backing the new commands
29
+ - Hidden `slk debug profile <uid>` subcommand for inspecting raw profile/info/schema responses
30
+
31
+ ### Changed
32
+
33
+ - `ApiError` now carries a typed `code` symbol (`:user_not_found`, `:ratelimited`, `:network_error`, `:unauthorized`, `:http_error`, `:invalid_json`, `:missing_scope`); `ApiClient` populates it on every raise
34
+ - `ProfileResolver` only swallows `:user_not_found` from `users.profile.get` (Slack Connect fallback to `users.info`); other API errors propagate so callers can surface them
35
+ - CI matrix now uses `bundler-cache` and runs `bundle exec rake test`; new `coverage` job enforces a 95/95 line/branch SimpleCov threshold
36
+ - Pinned `parallel < 2.0` in dev/test bundle to keep Ruby 3.2 compatible with current rubocop
37
+
38
+ ### Fixed
39
+
40
+ - `slk org` no longer mislabels the wrong node with `← you` when invoked against a teammate — the marker now compares each node against the authenticated user id
41
+
42
+ ## [0.5.0] - 2026-04-13
43
+
44
+ ### Added
45
+
46
+ - **`--fetch-attachments` flag** - Download message files and attachment images to local cache
47
+ - Downloads Slack files (authed) and public attachment images (Giphy, Tenor, etc.)
48
+ - Cached to `~/.cache/slk/files/{workspace}/` with skip-on-rerun
49
+ - Shows copyable local file paths in output: `[File: /path/to/file.png]`
50
+ - Works with `messages`, `thread`, and `--threads` inline replies
51
+ - Summary line when files are present: `9 files not downloaded. Use --fetch-attachments to download.`
52
+ - Follows up to 3 redirect hops with relative URL resolution
53
+
54
+ ### Changed
55
+
56
+ - Added `rubocop` as a dev dependency for linting
57
+
58
+ ### Fixed
59
+
60
+ - **`thread` command** - Extracted `resolve_and_display_thread` to fix rubocop complexity warnings
61
+
10
62
  ## [0.4.2] - 2026-03-01
11
63
 
12
64
  ### Added
@@ -131,6 +183,7 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
131
183
  - Pure Ruby stdlib - no gem dependencies
132
184
  - Ruby 3.2+ with modern features (Data.define, pattern matching)
133
185
 
186
+ [0.5.0]: https://github.com/ericboehs/slk/releases/tag/v0.5.0
134
187
  [0.4.2]: https://github.com/ericboehs/slk/releases/tag/v0.4.2
135
188
  [0.4.0]: https://github.com/ericboehs/slk/releases/tag/v0.4.0
136
189
  [0.3.0]: https://github.com/ericboehs/slk/releases/tag/v0.3.0
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Api
5
+ # Wrapper for Slack team.* API endpoints
6
+ class Team
7
+ def initialize(api_client, workspace)
8
+ @api = api_client
9
+ @workspace = workspace
10
+ end
11
+
12
+ def info(team_id = nil)
13
+ params = team_id ? { team: team_id } : {}
14
+ @api.post_form(@workspace, 'team.info', params)
15
+ end
16
+
17
+ def profile_schema(visibility: nil)
18
+ params = {}
19
+ params[:visibility] = visibility if visibility
20
+ @api.post_form(@workspace, 'team.profile.get', params)
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/slk/api/users.rb CHANGED
@@ -49,6 +49,10 @@ module Slk
49
49
  }
50
50
  end
51
51
 
52
+ def get_presence_for(user_id)
53
+ @api.post_form(@workspace, 'users.getPresence', { user: user_id })
54
+ end
55
+
52
56
  def set_presence(presence) # rubocop:disable Naming/AccessorMethodName
53
57
  @api.post(@workspace, 'users.setPresence', { presence: presence })
54
58
  end
@@ -63,6 +67,12 @@ module Slk
63
67
  @api.post_form(@workspace, 'users.info', { user: user_id })
64
68
  end
65
69
 
70
+ def profile_for(user_id, include_labels: true)
71
+ params = { user: user_id }
72
+ params[:include_labels] = true if include_labels
73
+ @api.post_form(@workspace, 'users.profile.get', params)
74
+ end
75
+
66
76
  def get_prefs # rubocop:disable Naming/AccessorMethodName
67
77
  @api.post(@workspace, 'users.prefs.get')
68
78
  end
data/lib/slk/cli.rb CHANGED
@@ -20,7 +20,10 @@ module Slk
20
20
  'cache' => Commands::Cache,
21
21
  'emoji' => Commands::Emoji,
22
22
  'config' => Commands::Config,
23
- 'help' => Commands::Help
23
+ 'help' => Commands::Help,
24
+ 'debug' => Commands::Debug,
25
+ 'who' => Commands::Who,
26
+ 'org' => Commands::Org
24
27
  }.freeze
25
28
 
26
29
  def initialize(argv, output: nil)
@@ -140,6 +143,11 @@ module Slk
140
143
  runner.api_client.on_request = lambda { |method, count|
141
144
  output.debug("[API ##{count}] #{method}")
142
145
  }
146
+ runner.api_client.on_response = lambda { |method, code, headers|
147
+ next unless code == 'rate-wait'
148
+
149
+ output.warn("Rate limited on #{method}; sleeping #{headers['sleep_seconds']}s and retrying...")
150
+ }
143
151
  end
144
152
 
145
153
  def setup_very_verbose_logging(runner, output)
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Commands
5
+ # Hidden command for development spikes.
6
+ # Dumps raw JSON from Slack endpoints to validate xoxc compatibility
7
+ # and field shapes before building higher-level features.
8
+ #
9
+ # Not registered in `slk help`.
10
+ class Debug < Base
11
+ def execute
12
+ result = validate_options
13
+ return result if result
14
+
15
+ dispatch_action
16
+ rescue ApiError => e
17
+ error("API error: #{e.message}")
18
+ 1
19
+ end
20
+
21
+ def dispatch_action
22
+ case positional_args
23
+ in ['profile', user] then dump_profile(user)
24
+ in ['profile'] then dump_profile(nil)
25
+ in ['team'] then dump_team
26
+ in ['schema'] then dump_schema
27
+ else unknown_action
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def dump_profile(user_input)
34
+ workspace = runner.workspace(@options[:workspace])
35
+ user_id = resolve_user_id(workspace, user_input)
36
+ users_api = runner.users_api(workspace.name)
37
+ out = {
38
+ 'users.profile.get' => users_api.profile_for(user_id),
39
+ 'users.info' => users_api.info(user_id),
40
+ 'team.profile.get' => runner.team_api(workspace.name).profile_schema
41
+ }
42
+ output.puts(JSON.pretty_generate(out))
43
+ 0
44
+ end
45
+
46
+ def dump_team
47
+ workspace = runner.workspace(@options[:workspace])
48
+ output.puts(JSON.pretty_generate(runner.team_api(workspace.name).info))
49
+ 0
50
+ end
51
+
52
+ def dump_schema
53
+ workspace = runner.workspace(@options[:workspace])
54
+ output.puts(JSON.pretty_generate(runner.team_api(workspace.name).profile_schema))
55
+ 0
56
+ end
57
+
58
+ def resolve_user_id(workspace, user_input)
59
+ return self_user_id(workspace) if user_input.nil? || user_input == 'me'
60
+ return user_input if user_input.match?(/\A[UW][A-Z0-9]+\z/)
61
+
62
+ id = lookup_for(workspace).find_id_by_name(user_input.delete_prefix('@'))
63
+ raise ApiError, "Could not resolve user: #{user_input}" unless id
64
+
65
+ id
66
+ end
67
+
68
+ def lookup_for(workspace)
69
+ Services::UserLookup.new(
70
+ cache_store: cache_store,
71
+ workspace: workspace,
72
+ api_client: api_client,
73
+ on_debug: ->(msg) { output.debug(msg) }
74
+ )
75
+ end
76
+
77
+ def self_user_id(workspace)
78
+ client = Api::Client.new(api_client, workspace)
79
+ response = client.auth_test
80
+ response['user_id']
81
+ end
82
+
83
+ def unknown_action
84
+ error("Unknown debug action: #{positional_args.first.inspect}")
85
+ error('Valid actions: profile [user], team, schema')
86
+ 1
87
+ end
88
+
89
+ protected
90
+
91
+ def help_text
92
+ <<~HELP
93
+ slk debug <action> [args]
94
+
95
+ Hidden development command — dumps raw API responses for spike validation.
96
+
97
+ ACTIONS
98
+ profile [user] Dump users.profile.get + users.info + team.profile.get
99
+ team Dump team.info
100
+ schema Dump team.profile.get
101
+
102
+ USER
103
+ (none) | me Self
104
+ @handle | name Resolved via user cache
105
+ Uxxx Raw user ID
106
+ HELP
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Commands
5
+ # Walk the Slack org chart by following Supervisor custom profile fields.
6
+ # Examples:
7
+ # slk org # self, supervisors up to depth 3
8
+ # slk org @alex # alex's chain
9
+ # slk org Uxxx --depth 5
10
+ # slk org --down # reports (best-effort, requires reindex for completeness)
11
+ class Org < Base
12
+ DEFAULT_DEPTH = 5
13
+
14
+ def execute
15
+ result = validate_options
16
+ return result if result
17
+
18
+ run
19
+ rescue ApiError => e
20
+ error("API error: #{e.message}")
21
+ 1
22
+ end
23
+
24
+ protected
25
+
26
+ def handle_option(arg, args, _remaining)
27
+ case arg
28
+ when '--up' then @options[:direction] = :up
29
+ when '--down' then @options[:direction] = :down
30
+ when '--depth' then @options[:depth] = args.shift.to_i
31
+ else return super
32
+ end
33
+ true
34
+ end
35
+
36
+ def help_text
37
+ <<~HELP
38
+ slk org [target]
39
+
40
+ Walk the Slack org chart by Supervisor field.
41
+
42
+ OPTIONS
43
+ --up Walk supervisors upward (default)
44
+ --down Show direct reports (best-effort, see note)
45
+ --depth N Levels to walk (default #{DEFAULT_DEPTH})
46
+
47
+ NOTES
48
+ --down currently scans cached profiles only. Run `slk who` on a
49
+ user to seed their profile in cache; full crawl support is TODO.
50
+ HELP
51
+ end
52
+
53
+ private
54
+
55
+ def run
56
+ workspace = runner.workspace(@options[:workspace])
57
+ resolver = runner.profile_resolver(workspace.name, refresh: @options[:refresh])
58
+ user_id = resolve_user_id(workspace)
59
+ target = resolver.resolve(user_id)
60
+ @self_user_id = self_user_id(workspace)
61
+
62
+ case @options[:direction] || :up
63
+ when :up then render_up(resolver, target)
64
+ when :down then render_down(target)
65
+ end
66
+ 0
67
+ end
68
+
69
+ def render_up(resolver, target)
70
+ chain = resolver.resolve_chain_up(target.user_id, depth: @options[:depth] || DEFAULT_DEPTH)
71
+ if chain.empty?
72
+ info("No supervisor on #{target.best_name}'s profile.")
73
+ return
74
+ end
75
+
76
+ chain.reverse_each.with_index do |profile, depth|
77
+ render_node(profile, depth, you: profile.user_id == @self_user_id)
78
+ end
79
+ render_node(target, chain.size, you: target.user_id == @self_user_id)
80
+ end
81
+
82
+ def render_down(target)
83
+ warn('slk org --down is best-effort against cached profiles only.')
84
+ warn('Run `slk who <user>` to seed their profile, or wait for `slk org reindex` (TODO).')
85
+ info("Direct reports for #{target.best_name}: lookup not yet wired (Phase 4).")
86
+ end
87
+
88
+ def render_node(profile, depth, you: false)
89
+ prefix = depth.zero? ? '' : "#{' ' * depth}└─ "
90
+ marker = you ? output.bold(' ← you') : ''
91
+ title = profile.title.to_s.empty? ? '' : " — #{output.gray(profile.title)}"
92
+ output.puts("#{prefix}#{profile.best_name}#{title}#{marker}")
93
+ end
94
+
95
+ def resolve_user_id(workspace)
96
+ target = positional_args.first
97
+ return self_user_id(workspace) if target.nil? || target == 'me'
98
+ return target if target.match?(/\A[UW][A-Z0-9]+\z/)
99
+
100
+ Services::UserLookup.new(
101
+ cache_store: cache_store,
102
+ workspace: workspace,
103
+ api_client: api_client,
104
+ on_debug: ->(msg) { output.debug(msg) }
105
+ ).find_id_by_name(target.delete_prefix('@')) ||
106
+ (raise ApiError, "Could not resolve user: #{target}")
107
+ end
108
+
109
+ def self_user_id(workspace)
110
+ cached = cache_store.get_meta(workspace.name, 'self_user_id')
111
+ return cached if cached
112
+
113
+ user_id = Api::Client.new(api_client, workspace).auth_test['user_id']
114
+ cache_store.set_meta(workspace.name, 'self_user_id', user_id) if user_id
115
+ user_id
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Commands
5
+ # Display a Slack user profile (teems-style compact card by default).
6
+ # Examples:
7
+ # slk who # self
8
+ # slk who @alex
9
+ # slk who alice
10
+ # slk who Uxxx --full
11
+ # slk who --json
12
+ class Who < Base
13
+ def execute
14
+ result = validate_options
15
+ return result if result
16
+
17
+ run
18
+ rescue ApiError => e
19
+ error("API error: #{e.message}")
20
+ 1
21
+ end
22
+
23
+ protected
24
+
25
+ def handle_option(arg, args, _remaining)
26
+ case arg
27
+ when '--full' then @options[:full] = true
28
+ when '--no-cache', '--refresh' then @options[:refresh] = true
29
+ when '--all' then @options[:all] = true
30
+ when '--pick' then @options[:pick] = parse_pick(args.shift)
31
+ else return super
32
+ end
33
+ true
34
+ end
35
+
36
+ def help_text
37
+ <<~HELP
38
+ slk who [target]
39
+
40
+ Show a Slack user profile.
41
+
42
+ TARGET
43
+ (none) Self
44
+ @handle | name Resolved via user cache
45
+ Uxxx Raw user ID
46
+
47
+ OPTIONS
48
+ --full Section-grouped layout (Contact, People, About me)
49
+ --json Raw JSON output
50
+ --refresh Bypass cache
51
+ --all Render every match (skips the disambiguation prompt)
52
+ --pick N Auto-select match N when a name resolves to multiple
53
+ HELP
54
+ end
55
+
56
+ private
57
+
58
+ def parse_pick(value)
59
+ Integer(value)
60
+ rescue ArgumentError, TypeError
61
+ raise ApiError, "--pick expects an integer (got #{value.inspect})"
62
+ end
63
+
64
+ def run
65
+ workspace = runner.workspace(@options[:workspace])
66
+ profiles = resolve_profiles(workspace)
67
+ return output_json_profiles(profiles) if @options[:json]
68
+
69
+ render_profiles(profiles)
70
+ 0
71
+ end
72
+
73
+ def resolve_profiles(workspace)
74
+ resolver = Services::WhoTargetResolver.new(
75
+ workspace: workspace, cache_store: cache_store,
76
+ api_client: api_client, output: output, options: @options
77
+ )
78
+ resolver.resolve(positional_args.first).map { |id| load_profile(workspace, id) }
79
+ end
80
+
81
+ def load_profile(workspace, user_id)
82
+ runner.profile_resolver(workspace.name, refresh: @options[:refresh])
83
+ .resolve_with_people(user_id)
84
+ end
85
+
86
+ def render_profiles(profiles)
87
+ profiles.each_with_index do |profile, idx|
88
+ output.puts(output.gray('—' * 40)) if idx.positive?
89
+ render(profile)
90
+ end
91
+ end
92
+
93
+ def render(profile)
94
+ formatter = Formatters::ProfileFormatter.new(
95
+ output: output, emoji_replacer: runner.emoji_replacer
96
+ )
97
+ @options[:full] ? formatter.full(profile) : formatter.compact(profile)
98
+ end
99
+
100
+ def output_json_profiles(profiles)
101
+ payload = profiles.map { |p| profile_payload(p) }
102
+ output_json(profiles.size == 1 ? payload.first : payload)
103
+ 0
104
+ end
105
+
106
+ def profile_payload(profile)
107
+ profile.to_h.merge(
108
+ custom_fields: profile.visible_fields.map(&:to_h),
109
+ sections: profile.sections,
110
+ resolved_users: profile.resolved_users.transform_values(&:to_h)
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end
@@ -18,6 +18,8 @@ module Slk
18
18
 
19
19
  attr_reader :verbose, :quiet
20
20
 
21
+ def color? = @color
22
+
21
23
  def initialize(io: $stdout, err: $stderr, color: nil, verbose: false, quiet: false)
22
24
  @io = io
23
25
  @err = err
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Renders ProfileField values to display strings (date with relative
6
+ # phrasing, link with alt text, type:user dereferenced via resolved_users).
7
+ class ProfileFieldRenderer
8
+ def initialize(output:)
9
+ @output = output
10
+ @hyperlinks = output.respond_to?(:color?) && output.color?
11
+ end
12
+
13
+ def render(field, profile)
14
+ case field.type
15
+ when 'date' then format_date(field.value)
16
+ when 'link' then format_link(field)
17
+ when 'user' then format_user_list(field, profile)
18
+ else format_text(field.value)
19
+ end
20
+ end
21
+
22
+ # Slack stores some text fields (notably free-text profile blocks) as
23
+ # rich_text JSON blocks. Detect and flatten to plain text.
24
+ def format_text(value)
25
+ text = value.to_s
26
+ return text unless text.start_with?('[{') && text.include?('rich_text')
27
+
28
+ flatten_rich_text(JSON.parse(text))
29
+ rescue JSON::ParserError
30
+ text
31
+ end
32
+
33
+ def flatten_rich_text(blocks)
34
+ Array(blocks).flat_map { |block| extract_text_from_block(block) }.join
35
+ end
36
+
37
+ def extract_text_from_block(block)
38
+ return [block.to_s] unless block.is_a?(Hash)
39
+
40
+ return [block['text'].to_s] if block['type'] == 'text'
41
+ return [block['name'] ? ":#{block['name']}:" : ''] if block['type'] == 'emoji'
42
+
43
+ Array(block['elements']).flat_map { |el| extract_text_from_block(el) }
44
+ end
45
+
46
+ def render_user_reference(user_id, profile)
47
+ ref = profile.resolved_users[user_id]
48
+ return user_id unless ref
49
+
50
+ suffix = ref.title.to_s.empty? ? '' : " — #{ref.title}"
51
+ pronouns = ref.pronouns.to_s.empty? ? '' : " #{@output.gray("(#{ref.pronouns})")}"
52
+ "#{ref.best_name}#{pronouns}#{suffix}"
53
+ end
54
+
55
+ private
56
+
57
+ def format_user_list(field, profile)
58
+ field.user_ids.map { |uid| render_user_reference(uid, profile) }.join(', ')
59
+ end
60
+
61
+ def format_date(value)
62
+ date = parse_date(value) or return value
63
+ relative = relative_phrase(date, Date.today)
64
+ formatted = date.strftime('%b %-d, %Y')
65
+ relative ? "#{formatted} (#{relative})" : formatted
66
+ end
67
+
68
+ def parse_date(value)
69
+ Date.parse(value.to_s)
70
+ rescue ArgumentError, TypeError
71
+ nil
72
+ end
73
+
74
+ def relative_phrase(then_date, now_date)
75
+ return nil if then_date >= now_date
76
+
77
+ years, months = years_months_between(then_date, now_date)
78
+ parts = []
79
+ parts << "#{years}y" if years.positive?
80
+ parts << "#{months}mo" if months.positive?
81
+ parts.empty? ? nil : "#{parts.join(' ')} ago"
82
+ end
83
+
84
+ def years_months_between(then_date, now_date)
85
+ months = ((now_date.year - then_date.year) * 12) + (now_date.month - then_date.month)
86
+ months -= 1 if now_date.day < then_date.day
87
+ [months / 12, months % 12]
88
+ end
89
+
90
+ def format_link(field)
91
+ url = field.value.to_s
92
+ label = field.alt.to_s.empty? ? url : field.alt
93
+ return url if label == url
94
+ return "#{label} (#{shorten_url(url)})" unless @hyperlinks
95
+
96
+ # OSC 8 hyperlink — clickable in iTerm2, Ghostty, Wezterm, Kitty, Vte.
97
+ "\e]8;;#{url}\a#{label}\e]8;;\a"
98
+ end
99
+
100
+ def shorten_url(url)
101
+ URI.parse(url).host || url
102
+ rescue URI::InvalidURIError
103
+ url
104
+ end
105
+ end
106
+ end
107
+ end