openclacky 1.5.13 → 1.5.15

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +53 -98
  6. data/lib/clacky/agent.rb +477 -297
  7. data/lib/clacky/agent_config.rb +5 -3
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +7 -0
  10. data/lib/clacky/brand_config.rb +27 -19
  11. data/lib/clacky/cli.rb +92 -12
  12. data/lib/clacky/cli_guidance.rb +48 -0
  13. data/lib/clacky/client.rb +2 -2
  14. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  19. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  20. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  21. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  22. data/lib/clacky/extension/api_extension.rb +18 -6
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/json_ui_controller.rb +4 -2
  25. data/lib/clacky/media/openai_compat.rb +70 -30
  26. data/lib/clacky/message_format/bedrock.rb +6 -1
  27. data/lib/clacky/message_format/open_ai.rb +22 -5
  28. data/lib/clacky/message_format/open_ai_responses.rb +7 -3
  29. data/lib/clacky/plain_ui_controller.rb +1 -1
  30. data/lib/clacky/prompts/base.md +1 -1
  31. data/lib/clacky/providers.rb +44 -13
  32. data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
  33. data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
  34. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
  35. data/lib/clacky/search_config.rb +3 -3
  36. data/lib/clacky/server/channel/channel_manager.rb +25 -7
  37. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  38. data/lib/clacky/server/dir_picker.rb +67 -4
  39. data/lib/clacky/server/git_panel.rb +10 -2
  40. data/lib/clacky/server/http_server.rb +391 -75
  41. data/lib/clacky/server/preview.rb +351 -0
  42. data/lib/clacky/server/session_registry.rb +4 -0
  43. data/lib/clacky/server/web_ui_controller.rb +10 -5
  44. data/lib/clacky/session_manager.rb +52 -1
  45. data/lib/clacky/skill.rb +48 -0
  46. data/lib/clacky/tools/browser.rb +176 -19
  47. data/lib/clacky/tools/web_search.rb +91 -8
  48. data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
  49. data/lib/clacky/ui2/components/input_area.rb +37 -4
  50. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  51. data/lib/clacky/ui2/screen_buffer.rb +1 -0
  52. data/lib/clacky/ui2/ui_controller.rb +72 -12
  53. data/lib/clacky/ui_interface.rb +1 -1
  54. data/lib/clacky/utils/file_processor.rb +19 -7
  55. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  56. data/lib/clacky/utils/model_pricing.rb +118 -68
  57. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  58. data/lib/clacky/version.rb +1 -1
  59. data/lib/clacky/web/app.css +998 -168
  60. data/lib/clacky/web/app.js +19 -0
  61. data/lib/clacky/web/components/chat-navigator.js +489 -202
  62. data/lib/clacky/web/components/code-editor.js +191 -5
  63. data/lib/clacky/web/components/composer.js +3 -1
  64. data/lib/clacky/web/components/datepicker.js +19 -19
  65. data/lib/clacky/web/components/mentions.js +11 -14
  66. data/lib/clacky/web/components/model-picker.js +66 -23
  67. data/lib/clacky/web/components/quote-select.js +341 -0
  68. data/lib/clacky/web/core/aside.js +157 -8
  69. data/lib/clacky/web/core/ext.js +16 -0
  70. data/lib/clacky/web/features/billing/view.js +75 -21
  71. data/lib/clacky/web/features/extensions/store.js +4 -1
  72. data/lib/clacky/web/features/model-tester/store.js +37 -4
  73. data/lib/clacky/web/features/skills/store.js +18 -1
  74. data/lib/clacky/web/features/skills/view.js +128 -7
  75. data/lib/clacky/web/features/workspace/store.js +80 -7
  76. data/lib/clacky/web/features/workspace/view.js +474 -43
  77. data/lib/clacky/web/i18n.js +158 -23
  78. data/lib/clacky/web/index.html +57 -29
  79. data/lib/clacky/web/sessions.js +517 -127
  80. data/lib/clacky/web/settings.js +155 -6
  81. data/lib/clacky/web/utils.js +34 -0
  82. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  83. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  84. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  85. data/lib/clacky/web/ws-dispatcher.js +161 -4
  86. data/lib/clacky.rb +2 -0
  87. metadata +13 -1
@@ -10,12 +10,14 @@ require_relative "components/welcome_banner"
10
10
  require_relative "components/inline_input"
11
11
  require_relative "thinking_verbs"
12
12
  require_relative "../ui_interface"
13
+ require_relative "../cli_guidance"
13
14
 
14
15
  module Clacky
15
16
  module UI2
16
17
  # UIController is the MVC controller layer that coordinates UI state and user interactions
17
18
  class UIController
18
19
  include Clacky::UIInterface
20
+ include Clacky::CliGuidance
19
21
 
20
22
  attr_reader :layout, :renderer, :running, :inline_input, :input_area
21
23
  attr_accessor :config, :available_models
@@ -31,6 +33,7 @@ module Clacky
31
33
  working_dir: config[:working_dir],
32
34
  mode: config[:mode],
33
35
  model: config[:model],
36
+ reasoning_effort: config[:reasoning_effort],
34
37
  theme: config[:theme]
35
38
  }
36
39
 
@@ -81,6 +84,7 @@ module Clacky
81
84
  working_dir: @config[:working_dir],
82
85
  mode: @config[:mode],
83
86
  model: @config[:model],
87
+ reasoning_effort: @config[:reasoning_effort],
84
88
  tasks: @tasks_count,
85
89
  cost: @total_cost
86
90
  )
@@ -124,6 +128,7 @@ module Clacky
124
128
  working_dir: @config[:working_dir],
125
129
  mode: @config[:mode],
126
130
  model: @config[:model],
131
+ reasoning_effort: @config[:reasoning_effort],
127
132
  tasks: @tasks_count,
128
133
  cost: @total_cost,
129
134
  cost_source: cost_source,
@@ -132,6 +137,16 @@ module Clacky
132
137
  @layout.render_input
133
138
  end
134
139
 
140
+ def refresh_guidance
141
+ return unless @running
142
+ @layout.recalculate_layout
143
+ @layout.render_input
144
+ end
145
+
146
+ def show_user_message(content, created_at: nil, files: [], source: :web, steering: false)
147
+ append_output(@renderer.render_user_message(content, files: files)) if steering
148
+ end
149
+
135
150
  def show_goal_status(goal)
136
151
  @input_area.set_goal(goal)
137
152
  @layout.render_input
@@ -183,6 +198,8 @@ module Clacky
183
198
 
184
199
  # Set callback for user input
185
200
  # @param block [Proc] Callback to execute with user input
201
+ attr_accessor :queue_input_while_running
202
+
186
203
  def on_input(&block)
187
204
  @input_callback = block
188
205
  end
@@ -582,7 +599,7 @@ module Clacky
582
599
 
583
600
  # Show tool result
584
601
  # @param result [String] Formatted tool result
585
- def show_tool_result(result)
602
+ def show_tool_result(result, ui: nil)
586
603
  output = @renderer.render_tool_result(result: result)
587
604
  append_output(output)
588
605
  end
@@ -1271,6 +1288,8 @@ module Clacky
1271
1288
  "",
1272
1289
  theme.format_text("Commands:", :info),
1273
1290
  " #{theme.format_text("/model", :success)} - Quickly switch the current model",
1291
+ " #{theme.format_text("/input-mode", :success)} - View or set steer / interrupt behavior",
1292
+ " #{theme.format_text("/think", :success)} - Set the thinking (reasoning) effort level",
1274
1293
  " #{theme.format_text("/config", :success)} - Configure models, API keys, settings",
1275
1294
  " #{theme.format_text("/goal", :success)} - Set a standing goal for autonomous work",
1276
1295
  " #{theme.format_text("/goal <text>", :dim)} Set a goal and start working toward it",
@@ -1704,6 +1723,11 @@ module Clacky
1704
1723
  intervene_feedback_countdown
1705
1724
  end
1706
1725
 
1726
+ if !@feedback_countdown && [:ctrl_d, :ctrl_g].include?(key)
1727
+ action = key == :ctrl_d ? :remove : :send_now
1728
+ return if request_guidance_action(action)
1729
+ end
1730
+
1707
1731
  result = @input_area.handle_key(key)
1708
1732
 
1709
1733
  # Handle height change first
@@ -1803,6 +1827,7 @@ module Clacky
1803
1827
  working_dir: @config[:working_dir],
1804
1828
  mode: @config[:mode],
1805
1829
  model: @config[:model],
1830
+ reasoning_effort: @config[:reasoning_effort],
1806
1831
  tasks: @tasks_count,
1807
1832
  cost: @total_cost
1808
1833
  )
@@ -1816,15 +1841,18 @@ module Clacky
1816
1841
  # so no ticker thread is left writing into the buffer while we render
1817
1842
  # the new user message. Each handle's finish() renders its own final
1818
1843
  # frame with elapsed time, so the user still sees a summary.
1819
- interrupt_all_progress if progress_active?
1844
+ queuing = guidance_deferred?(data[:text])
1845
+ interrupt_all_progress if progress_active? && !queuing
1820
1846
 
1821
1847
  # Also clear stdout buffer used by Ctrl+O (unrelated to progress, but
1822
1848
  # we don't want stale command output carried across user turns).
1823
- @stdout_lines = nil
1824
- @stdout_partial_tail = false
1849
+ unless queuing
1850
+ @stdout_lines = nil
1851
+ @stdout_partial_tail = false
1852
+ end
1825
1853
 
1826
1854
  # Render user message immediately before running agent
1827
- unless data[:text].empty? && data[:files].empty?
1855
+ unless queuing || (data[:text].empty? && data[:files].empty?)
1828
1856
  output = @renderer.render_user_message(data[:text], files: data[:files])
1829
1857
  append_output(output)
1830
1858
  end
@@ -1871,14 +1899,15 @@ module Clacky
1871
1899
  }
1872
1900
  end
1873
1901
 
1874
- # Add action buttons
1875
- choices << { name: "─" * 50, disabled: true }
1876
- choices << { name: "[+] Add New Model", value: { action: :add } }
1902
+ # Add action buttons (sticky: always visible below the scrollable
1903
+ # model list, so a long model list can't push them off screen)
1904
+ choices << { name: "─" * 50, disabled: true, sticky: true }
1905
+ choices << { name: "[+] Add New Model", value: { action: :add }, sticky: true }
1877
1906
  if current_config.models.length > 0
1878
- choices << { name: "[*] Edit Current Model", value: { action: :edit } }
1879
- choices << { name: "[-] Delete Model", value: { action: :delete } } if current_config.models.length > 1
1907
+ choices << { name: "[*] Edit Current Model", value: { action: :edit }, sticky: true }
1908
+ choices << { name: "[-] Delete Model", value: { action: :delete }, sticky: true } if current_config.models.length > 1
1880
1909
  end
1881
- choices << { name: "[X] Close", value: { action: :close } }
1910
+ choices << { name: "[X] Close", value: { action: :close }, sticky: true }
1882
1911
 
1883
1912
  # Show menu
1884
1913
  result = modal.show(
@@ -1903,7 +1932,7 @@ module Clacky
1903
1932
  if new_model
1904
1933
  # Determine anthropic_format based on provider
1905
1934
  # For Anthropic provider, use Anthropic API format
1906
- anthropic_format = new_model[:provider] == "anthropic"
1935
+ anthropic_format = new_model[:provider] == Clacky::Providers::ANTHROPIC_ID
1907
1936
 
1908
1937
  current_config.add_model(
1909
1938
  model: new_model[:model],
@@ -2121,6 +2150,37 @@ module Clacky
2121
2150
  result # Return selected task_id or nil
2122
2151
  end
2123
2152
 
2153
+ # Show a reasoning-effort picker for the `/think` command.
2154
+ # @param current_effort [String, nil] the agent's current effort level
2155
+ # @return [String, nil] the chosen level ("off"/"low"/.../"max") or nil if cancelled
2156
+ public def show_reasoning_effort_menu(current_effort)
2157
+ modal = Components::ModalComponent.new
2158
+
2159
+ descriptions = {
2160
+ "off" => "Provider default (no effort override)",
2161
+ "low" => "Light reasoning - fastest",
2162
+ "medium" => "Balanced reasoning",
2163
+ "high" => "Deep reasoning",
2164
+ "xhigh" => "Extra deep reasoning",
2165
+ "max" => "Maximum reasoning - slowest"
2166
+ }
2167
+
2168
+ active_index = nil
2169
+ choices = descriptions.keys.each_with_index.map do |level, idx|
2170
+ is_current = current_effort.nil? ? level == "off" : level == current_effort
2171
+ active_index = idx if is_current
2172
+ marker = is_current ? "● " : " "
2173
+ { name: "#{marker}#{level} - #{descriptions[level]}", value: level }
2174
+ end
2175
+
2176
+ modal.show(
2177
+ title: "Thinking Level - Set Reasoning Effort",
2178
+ choices: choices,
2179
+ initial_index: active_index,
2180
+ on_close: -> { @layout.rerender_all }
2181
+ )
2182
+ end
2183
+
2124
2184
  # Show form for editing a model
2125
2185
  # @param model [Hash, nil] Existing model hash or nil for new model
2126
2186
  # @return [Hash, nil] Updated model hash or nil if cancelled
@@ -14,7 +14,7 @@ module Clacky
14
14
  def show_subagent_start(skill: nil, iterations: nil, cost_usd: nil); end
15
15
  def show_subagent_end; end
16
16
  def show_tool_call(name, args); end
17
- def show_tool_result(result); end
17
+ def show_tool_result(result, ui: nil); end
18
18
  def show_tool_stdout(lines); end
19
19
  def show_tool_error(error); end
20
20
  def show_tool_args(formatted_args); end
@@ -299,7 +299,9 @@ module Clacky
299
299
  Zip::InputStream.open(StringIO.new(body)) do |zis|
300
300
  while (entry = zis.get_next_entry)
301
301
  size = entry.size ? " (#{entry.size} bytes)" : ""
302
- lines << "- #{entry.name}#{size}"
302
+ # rubyzip returns names as raw ASCII-8BIT bytes regardless of the zip's
303
+ # UTF-8 flag; convert so the listing never carries binary encoding.
304
+ lines << "- #{Clacky::Utils::Encoding.to_utf8(entry.name)}#{size}"
303
305
  end
304
306
  end
305
307
  lines.join("\n")
@@ -325,7 +327,7 @@ module Clacky
325
327
  tar.each do |entry|
326
328
  kind = entry.directory? ? "[dir] " : ""
327
329
  size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
328
- lines << "- #{kind}#{entry.full_name}#{size}"
330
+ lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
329
331
  end
330
332
  end
331
333
  end
@@ -339,7 +341,7 @@ module Clacky
339
341
  tar.each do |entry|
340
342
  kind = entry.directory? ? "[dir] " : ""
341
343
  size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
342
- lines << "- #{kind}#{entry.full_name}#{size}"
344
+ lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
343
345
  end
344
346
  end
345
347
  end
@@ -359,7 +361,7 @@ module Clacky
359
361
  found_tar = true
360
362
  kind = entry.directory? ? "[dir] " : ""
361
363
  size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
362
- lines << "- #{kind}#{entry.full_name}#{size}"
364
+ lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
363
365
  end
364
366
  end
365
367
  end
@@ -388,7 +390,8 @@ module Clacky
388
390
  end
389
391
  end
390
392
  lines = ["# GZIP Contents\n"]
391
- lines << "- Original filename: #{original_name || "(not recorded)"}"
393
+ display_name = original_name ? Clacky::Utils::Encoding.to_utf8(original_name) : "(not recorded)"
394
+ lines << "- Original filename: #{display_name}"
392
395
  lines << "- Compressed size: #{File.size(path)} bytes"
393
396
  lines << "- Uncompressed size: #{uncompressed} bytes#{uncompressed && uncompressed > 64 * 1024 * 1024 ? " (truncated)" : ""}"
394
397
  lines.join("\n")
@@ -406,7 +409,14 @@ module Clacky
406
409
  FileUtils.mkdir_p(UPLOAD_DIR)
407
410
  safe_name = File.basename(original_path.to_s).gsub(/[\/\:\*?"<>|\x00]/, "_")
408
411
  dest = File.join(UPLOAD_DIR, "#{SecureRandom.hex(8)}_#{safe_name}.preview.md")
409
- File.write(dest, content)
412
+ # File.write transcodes to the default external encoding and raises
413
+ # "\xE8 from ASCII-8BIT to UTF-8" on binary (ASCII-8BIT) content, which
414
+ # can leak in from archive entry names / external parsers.
415
+ text = content.to_s
416
+ unless text.encoding == ::Encoding::UTF_8 && text.valid_encoding?
417
+ text = Clacky::Utils::Encoding.to_utf8(text)
418
+ end
419
+ File.write(dest, text)
410
420
  dest
411
421
  end
412
422
 
@@ -540,7 +550,9 @@ module Clacky
540
550
  LOCAL_IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp].freeze
541
551
  LOCAL_VIDEO_EXTENSIONS = %w[.mp4 .webm .mov].freeze
542
552
  LOCAL_AUDIO_EXTENSIONS = %w[.wav .mp3 .ogg .aac .flac .m4a].freeze
543
- LOCAL_MEDIA_EXTENSIONS = (LOCAL_IMAGE_EXTENSIONS + LOCAL_VIDEO_EXTENSIONS + LOCAL_AUDIO_EXTENSIONS).freeze
553
+ # .pdf: served by /api/local-image so the WebUI file viewer can embed it
554
+ # in an iframe (browser-native PDF rendering, no extra dependency).
555
+ LOCAL_MEDIA_EXTENSIONS = (LOCAL_IMAGE_EXTENSIONS + LOCAL_VIDEO_EXTENSIONS + LOCAL_AUDIO_EXTENSIONS + %w[.pdf]).freeze
544
556
 
545
557
  # Replace local image paths in markdown content with base64 data URLs.
546
558
  #
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "digest"
5
+ require_relative "environment_detector"
6
+
7
+ module Clacky
8
+ module Utils
9
+ # Detects installed macOS applications able to open a given file type.
10
+ #
11
+ # Builds an extension → apps index by scanning application bundles and
12
+ # parsing their Info.plist document declarations (CFBundleDocumentTypes),
13
+ # then applies a static supplement for well-known apps whose cross-format
14
+ # abilities are not declared in the plist — e.g. Keynote opens ppt/pptx
15
+ # without listing them. Non-macOS hosts always answer with an empty list.
16
+ module MacAppDetector
17
+ SEARCH_DIRS = [
18
+ "/Applications",
19
+ "/System/Applications",
20
+ File.expand_path("~/Applications")
21
+ ].freeze
22
+
23
+ # bundle name (without .app) => extensions it handles but does not
24
+ # declare in CFBundleDocumentTypes.
25
+ EXTRA_ABILITIES = {
26
+ "Keynote" => %w[key kth ppt pptx pot pps ppsx],
27
+ "Pages" => %w[pages doc docx dot rtf],
28
+ "Numbers" => %w[numbers xls xlsx csv],
29
+ "Preview" => %w[pdf png jpg jpeg gif tiff tif heic webp svg bmp],
30
+ "Microsoft PowerPoint" => %w[ppt pptx pot pps ppsx],
31
+ "Microsoft Word" => %w[doc docx dot rtf],
32
+ "Microsoft Excel" => %w[xls xlsx csv],
33
+ }.freeze
34
+
35
+ # @param ext [String] file extension, with or without leading dot
36
+ # @return [Array<Hash>] [{ "name" => ..., "path" => bundle path }]
37
+ def self.apps_for_ext(ext)
38
+ return [] unless Utils::EnvironmentDetector.os_type == :macos
39
+
40
+ ext = ext.to_s.downcase.sub(/\A\./, "")
41
+ return [] if ext.empty?
42
+
43
+ index[ext].sort_by { |a| a["name"] }
44
+ end
45
+
46
+ # Resolve an app name or bundle path to a scanned bundle path.
47
+ # @return [String, nil]
48
+ def self.app_path(name_or_path)
49
+ return nil unless Utils::EnvironmentDetector.os_type == :macos
50
+
51
+ all_apps[name_or_path.to_s] || all_apps.key(name_or_path.to_s)
52
+ end
53
+
54
+ # Return the application macOS would use to open `path` (the handler the
55
+ # plain `open` command would pick). Cached per file extension.
56
+ # @param path [String] absolute file path
57
+ # @return [Hash, nil] { "name" => ..., "path" => bundle path }
58
+ def self.default_app_for(path)
59
+ return nil unless Utils::EnvironmentDetector.os_type == :macos
60
+
61
+ ext = File.extname(path).downcase.sub(/\A\./, "")
62
+ return nil if ext.empty?
63
+
64
+ @default_app_cache ||= {}
65
+ return @default_app_cache[ext] if @default_app_cache.key?(ext)
66
+
67
+ bundle = default_app_bundle(path)
68
+ @default_app_cache[ext] =
69
+ if bundle && File.directory?(bundle)
70
+ { "name" => File.basename(bundle, ".app"), "path" => bundle }
71
+ end
72
+ end
73
+
74
+ # Query LaunchServices for the default handler of `path` via NSWorkspace.
75
+ # @return [String, nil] bundle path (…/*.app)
76
+ def self.default_app_bundle(path)
77
+ script = <<~JXA
78
+ (function() {
79
+ ObjC.import("AppKit");
80
+ var url = $.NSURL.fileURLWithPath(#{path.to_json});
81
+ var app = $.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL(url);
82
+ return app ? app.path.js : "";
83
+ })()
84
+ JXA
85
+ out, _err, _status = Open3.capture3("osascript", "-l", "JavaScript", "-e", script)
86
+ result = out.strip
87
+ result.empty? ? nil : result
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ # Convert an application bundle icon (.icns) to a cached 64px PNG via the
93
+ # system `sips` tool. Used by the Web UI "open with" dropdown.
94
+ # @param bundle_path [String] application bundle path (…/*.app)
95
+ # @return [String, nil] path to the cached PNG file
96
+ def self.icon_png(bundle_path)
97
+ return nil unless Utils::EnvironmentDetector.os_type == :macos
98
+
99
+ bundle = bundle_path.to_s
100
+ return nil unless bundle.end_with?(".app") && File.directory?(bundle)
101
+
102
+ cache_dir = File.join(Dir.home, "Library", "Caches", "openclacky", "app-icons")
103
+ cache = File.join(cache_dir, Digest::MD5.hexdigest(bundle) + ".png")
104
+ return cache if File.exist?(cache)
105
+
106
+ info = read_plist(File.join(bundle, "Contents", "Info.plist"))
107
+ return nil unless info
108
+
109
+ icon = info["CFBundleIconFile"].to_s
110
+ return nil if icon.empty?
111
+ icon += ".icns" unless icon =~ /\.icns\z/i
112
+ icns = File.join(bundle, "Contents", "Resources", icon)
113
+ return nil unless File.exist?(icns)
114
+
115
+ require "fileutils"
116
+ FileUtils.mkdir_p(cache_dir)
117
+ ok = system("sips", "-s", "format", "png", "--resampleWidth", "64",
118
+ icns, "--out", cache, out: File::NULL, err: File::NULL)
119
+ return nil unless ok && File.exist?(cache)
120
+ cache
121
+ end
122
+
123
+ # Open a file with a specific installed application (macOS only).
124
+ # @param path [String] absolute file path
125
+ # @param app [String] app name or bundle path, must be a scanned app
126
+ # @return [Boolean, nil] system() result, nil when the app is unknown
127
+ def self.open_with(path, app)
128
+ bundle = app_path(app.to_s)
129
+ return nil unless bundle
130
+ system("open", "-a", bundle, path)
131
+ end
132
+
133
+ def self.all_apps
134
+ return @all_apps if defined?(@all_apps)
135
+
136
+ @all_apps = {}
137
+ SEARCH_DIRS.each do |dir|
138
+ next unless File.directory?(dir)
139
+ Dir.glob(File.join(dir, "*.app")).each do |bundle|
140
+ @all_apps[File.basename(bundle, ".app")] = bundle
141
+ end
142
+ end
143
+ @all_apps
144
+ end
145
+
146
+ def self.index
147
+ return @index if defined?(@index)
148
+
149
+ @index = Hash.new { |h, k| h[k] = [] }
150
+
151
+ all_apps.each do |name, bundle|
152
+ info = read_plist(File.join(bundle, "Contents", "Info.plist"))
153
+ next unless info
154
+ (info["CFBundleDocumentTypes"] || []).each do |dt|
155
+ (dt["CFBundleTypeExtensions"] || []).each do |e|
156
+ add(@index, e.to_s.downcase, name, bundle)
157
+ end
158
+ end
159
+ end
160
+
161
+ EXTRA_ABILITIES.each do |name, exts|
162
+ bundle = all_apps[name]
163
+ next unless bundle
164
+ exts.each { |e| add(@index, e, name, bundle) }
165
+ end
166
+
167
+ @index
168
+ end
169
+
170
+ def self.add(index, ext, name, bundle)
171
+ return if ext.empty?
172
+ list = index[ext]
173
+ list << { "name" => name, "path" => bundle } unless list.any? { |a| a["path"] == bundle }
174
+ end
175
+
176
+ def self.read_plist(path)
177
+ out, _err, _status = Open3.capture3("plutil", "-convert", "json", "-o", "-", path)
178
+ return nil if out.nil? || out.empty?
179
+ require "json"
180
+ JSON.parse(out)
181
+ rescue StandardError
182
+ nil
183
+ end
184
+ end
185
+ end
186
+ end